GPU shader effects as a native EDGE element. Metal on iOS, AGSL on Android.
<native:shader name="ripple" animated speed="1.2"
:uniforms="['amplitude' => 14, 'frequency' => 28]"
class="w-full h-64">
<native:image src="{{ $cover }}" />
</native:shader>PHP never touches a pixel. It names a shader function and supplies its uniforms; the GPU runs the effect at display refresh rate. Time is generated natively — nothing about animation crosses the bridge.
composer require nativephp/mobile-shader
php artisan vendor:publish --tag=nativephp-plugins-provider # once, before the first plugin
php artisan native:plugin:register nativephp/mobile-shader
php artisan native:plugin:list # verifyThen rebuild (php artisan native:run ios / android) — native code only
compiles in at build time.
Xcode 26 ships the Metal compiler as a separate downloadable component. Without
it, CompileMetalFile would fail the whole build with an error that never
mentions this plugin.
A copy_assets hook handles that: if the toolchain is missing it removes the
shader source, so the build still succeeds and the app still runs —
<native:shader> just renders its children unchanged. You get a clear warning
telling you what to run:
xcodebuild -downloadComponent MetalToolchain # ~690 MB, once per machineSet NATIVEPHP_SHADER_AUTO_DOWNLOAD_TOOLCHAIN=true to have the hook download it
for you instead. That's opt-in — a build shouldn't start a 690 MB download
without being asked.
| Attribute | Type | Default | Notes |
|---|---|---|---|
name |
string | — | Shader function name. Unknown names render the children unmodified. |
mode |
string | layer |
layer, distortion, or color. |
animated |
bool | true |
false freezes the shader at time = 0 without unmounting it. |
speed |
float | 1.0 |
Multiplier applied to elapsed time. |
uniforms |
array | [] |
Ordered map of uniform name → float. |
Modes map to the SwiftUI effect family and their AGSL equivalents:
- layer — samples the rendered children (warp, melt, refract)
- distortion — displaces sample coordinates only
- color — recolors per pixel, ignoring child geometry
| Name | Mode | Uniforms (in order) | |
|---|---|---|---|
ripple |
layer | amplitude, frequency |
Radial wave |
rippleDistortion |
distortion | amplitude, frequency |
The same wave as a displacement |
pixelate |
layer | cellSize, blend |
Mosaic; blend fades back to the original |
swirl |
distortion | strength, radius |
Twist, unwinding at radius points |
aurora |
color | spread, intensity |
Drifting colour blobs — a background panel |
galaxy |
color | twist, starDensity, intensity, spin |
Spiral nebula, twinkling starfield — a full-bleed backdrop |
shimmer |
color | width, intensity, bounce |
Sweeping band — the skeleton-loading idiom |
shimmer's bounce plays the sweep forward then backward (>= 0.5) instead of
restarting from the leading edge each pass. Its rate halves to match, so a single
pass travels at the same speed either way.
pixelate ignores time — a shader doesn't have to animate.
Because shimmer scales its glow by the incoming alpha, it self-masks — put
only the placeholder shapes inside it and the transparent gaps between them
stay untouched, so each bar shimmers rather than the whole card:
<native:row class="w-full h-32 items-center rounded-2xl bg-theme-surface p-5">
<native:shader name="shimmer" mode="color" animated
:uniforms="['width' => 0.16, 'intensity' => 0.7]" class="w-full h-full">
<native:row class="w-full h-full items-center gap-4">
<native:column class="w-14 h-14 rounded-full bg-theme-outline" />
<native:column class="flex-1 h-3 rounded-full bg-theme-outline" />
</native:row>
</native:shader>
</native:row>Move bg-theme-surface inside the shader and every pixel becomes opaque, so the
entire panel lights up like a spotlight instead. Keeping one shader over the
whole row (rather than one per bar) also gives the band a single coordinate
space, so it crosses the bars in sequence instead of pulsing them in lockstep.
Colour shaders must preserve the incoming alpha. They recolour pixels that
already exist; they cannot paint behind them. Returning a hardcoded 1.0 alpha
turns every transparent pixel opaque, which paints filled boxes around each text
run and squares off rounded corners. So a background like aurora wraps a plain
opaque panel, with the real content layered over it as a sibling:
<native:stack class="w-full h-64">
<native:shader name="aurora" mode="color" animated :uniforms="['spread' => 0.45, 'intensity' => 1.0]" class="w-full h-full">
<native:column class="w-full h-full rounded-2xl bg-theme-surface" />
</native:shader>
<native:column class="w-full h-full items-center justify-center p-6">
<native:text>Readable, on top of the gradient</native:text>
</native:column>
</native:stack>A shader belongs to exactly one mode. On iOS the three effect families take
different Metal signatures — half4 f(float2, SwiftUI::Layer, …) for layer,
float2 f(float2, …) for distortion, half4 f(float2, half4, …) for color — and
a mismatch renders nothing at all rather than erroring. That's why ripple and
rippleDistortion exist as separate entry points over identical math. AGSL has
no such split, so both names map to one source on Android.
Shaders are authored twice — once per platform. They are close enough that porting is mechanical, but it is two files:
-
iOS — add a
[[stitchable]]function toresources/ios/NativeShaders.metal. The renderer always passes(size, time)first, then the element's uniforms in declaration order; Metal shader arguments are positional.Keep everything in that one file. The plugin compiler copies
.swiftsources automatically but not.metal, so the shader file ships through an explicitassetsentry innativephp.json— a second.metalfile would need its own entry, and silently renders nothing if you forget. -
Android — add the AGSL source to
ShaderCataloginresources/android/ShaderRenderer.kt. AGSL uniforms are set by name, so theuniform float …declarations must match the names used in Blade.
Keeping both in sync is the maintenance cost of the element. The names-and-order overlap means a Blade snippet that is correct on one platform is correct on both.
- A blank box means the shader function wasn't found. SwiftUI has no way to
probe
ShaderLibraryfor a name, so a missing or misspelled function makes the effect draw nothing rather than error. If the element reserves its space but renders empty, check thatNativeShaders.metalreachednativephp/ios/NativePHP/Resources/. - Metal is compiled at build time. Shader source cannot be shipped over the
air from PHP, and editing a
.metalfile does not hot-reload — it needs a fullnative:run. (AGSL compiles from a string at runtime, so Android could accept source from PHP; the element deliberately does not, to keep one contract.) - Android requires API 33+ (
RuntimeShader). Below Tiramisu the element renders its children unmodified. - iOS requires 17+ for SwiftUI shader effects. Below that, likewise unmodified.
- Hit-testing does not follow the distortion. Displaced pixels move; touch geometry does not. Fine for decoration, wrong for controls.
- Do not put an animated shader inside a scrolling list.
layerEffectforces an offscreen pass per frame. - Battery. A full-screen animated shader pins the GPU. Bind
animatedto screen state and switch it off when the effect isn't visible.
nativephp/mobile^4.0- iOS 17+ / Android 13+ for the effect itself (older versions degrade gracefully)