Custom Shaders
Every BOX mesh uses one ShaderMaterial generated by the Painter package. You
can splice your own GLSL into three points of its fragment shader without
replacing the material.
The ShaderHooks object
interface ShaderHooks {
uniforms?: Record<string, any>;
uvModifier?: string;
colorModifier?: string;
}Declared on the element as strict JSON (double quotes required):
<div
data-mirage-shader='{
"uniforms": { "uTime": 0, "uAmount": 0.05 },
"uvModifier": "resultUv.x += sin(resultUv.y * 10.0 + uTime) * uAmount;",
"colorModifier": "finalColor.rgb = mix(finalColor.rgb, vec3(1.0, 0.2, 0.4), 0.3);"
}'
></div>Where each hook lands
uniform vec2 uSize;
uniform vec4 uBgColor;
// …
#INJECT_DECLARATIONS // ← your uniform declarations
void main() {
vec2 p = (vUv - 0.5) * uMeshSize;
// border-radius clamping…
#INJECT_UV_MODIFIER // ← uvModifier
vec4 baseColor = vec4(uBgColor.rgb, uBgColor.a);
// gradient layer…
#INJECT_BASE_COLOR // ← texture sampling
// SDF box, border, shadow…
#INJECT_COLOR_MODIFIER // ← colorModifier
float finalOpacity = finalColor.a * uOpacity;
if (finalOpacity < 0.001) discard;
gl_FragColor = vec4(finalColor.rgb, finalOpacity);
}uvModifier
Runs before the texture is sampled. Mutate resultUv.
| In scope | Type | Meaning |
|---|---|---|
resultUv | vec2 | The UV used for sampling — write to this |
screenUv | vec2 | Normalized screen position of this fragment |
localUv | vec2 | Element-local UV (non-traveler only) |
p | vec2 | Fragment position in pixels, centered on the mesh |
vUv | vec2 | Raw quad UV, 0…1 |
uSize, uMeshSize | vec2 | Element size, mesh size (mesh includes shadow padding) |
For a traveler, resultUv is initialized from screenUv. For a normal
element it comes from localUv scaled by uTextureRepeat / uTextureOffset,
which is how object-fit: cover behaviour is preserved.
colorModifier
Runs after the box, border and shadow are composited. Mutate finalColor.
| In scope | Type | Meaning |
|---|---|---|
finalColor | vec4 | Composited RGBA — write to this |
p, vUv, resultUv | vec2 | Same as above |
| your uniforms | — | Declared in uniforms |
Do not assign to gl_FragColor here — it is written after your hook runs,
from finalColor. Writing gl_FragColor directly is silently discarded.
Uniform types
Types are inferred from the JSON value:
| JSON value | GLSL type |
|---|---|
0.5 | float |
[0.5, 1.0] | vec2 |
[1, 0, 0] | vec3 |
[1, 0, 0, 1] | vec4 |
{ "type": "sampler2D", "value": null } | that explicit type |
<div data-mirage-shader='{
"uniforms": {
"uTime": 0,
"uMouse": [0.5, 0.5],
"uTint": [1.0, 0.4, 0.8]
},
"colorModifier": "finalColor.rgb *= uTint;"
}'></div>Driving uniforms per frame
Never rewrite the attribute to animate — changing it changes the shader hash,
which makes Mirage dispose and rebuild the mesh. Use updateUniforms():
const el = document.querySelector("#hero") as HTMLElement;
const start = performance.now();
mirage.getTracker().onRender.add(() => {
mirage.updateUniforms(el, {
uTime: (performance.now() - start) / 1000,
});
});
window.addEventListener("pointermove", (e) => {
mirage.updateUniforms(el, {
uMouse: [e.clientX / window.innerWidth, 1 - e.clientY / window.innerHeight],
});
});Recipes
Scanlines
{
"uniforms": { "uDensity": 400 },
"colorModifier": "finalColor.rgb *= 0.85 + 0.15 * step(0.5, fract(vUv.y * uDensity));"
}Animated gradient tint
{
"uniforms": { "uTime": 0 },
"colorModifier": "finalColor.rgb = mix(finalColor.rgb, vec3(0.4 + 0.6 * sin(uTime), 0.3, 0.9), 0.35);"
}Barrel distortion on an image
{
"uniforms": { "uStrength": 0.15 },
"uvModifier": "vec2 c = resultUv - 0.5; resultUv = 0.5 + c * (1.0 + uStrength * dot(c, c));"
}Dissolve
{
"uniforms": { "uProgress": 0 },
"colorModifier": "float n = fract(sin(dot(vUv, vec2(12.9898, 78.233))) * 43758.5453); if (n > uProgress) finalColor.a = 0.0;"
}Gotchas
| Issue | Cause | Fix |
|---|---|---|
| Shader silently ignored | Attribute is not strict JSON | Use double quotes for every key and string |
| Mesh flickers or resets | Attribute rewritten each frame | Use updateUniforms() instead |
uTexture is undefined | Element has no image and no hooks | The texture chunk is only injected when a texture or hooks exist |
| Effect looks offset | Confusing vUv with resultUv | vUv is quad-local, resultUv is what samples the texture |
Debugging
Read the compiled shader off the material. This reaches through private fields, so treat it as a debugging trick rather than API:
// after start() — internal access, may break between versions
const registry = (mirage as any)._engine.registry;
const mesh = registry.get(el);
console.log(mesh.material.fragmentShader);WebGL compile errors appear in the browser console with a line number that maps into this generated source.