Shaders
Custom WebGL effects in Phaser 4 — the built-in Filter library, custom GLSL via the Shader game object, and where RenderNodes fit in.
Phaser 3’s Pipeline system is gone in Phaser 4. Three replacements cover almost every use case:
| Want | Use |
|---|---|
| A built-in effect on a sprite or camera | A Filter (obj.filters.internal.addX()) |
| Your own GLSL fragment shader as a quad | The Shader game object |
| A whole new draw step inside the renderer | A custom RenderNode |
Pipelines, setPipeline, setPostPipeline, and PostFXPipeline no longer exist. If you’re porting from v3, see Migrating from Phaser 3 for the mechanical swaps.
Filters: the built-in catalogue
Every game object and every camera exposes a filters property with internal and external filter lists. Internal filters affect the object itself; external filters affect the rendering context (typically full screen).
const camera = this.cameras.main;
// One-line built-in effects.
camera.filters.internal.addBarrel(0.6);
camera.filters.external.addVignette(0.5);
camera.filters.external.addGlow(0xffaa00);
The full set ships with v4: Barrel, Blend, Blocky, Blur, Bokeh, ColorMatrix, CombineColorMatrix, Displacement, Glow, GradientMap, ImageLight, Key, Mask, NormalTools, PanoramaBlur, ParallelFilters, Pixelate, Quantize, Sampler, Shadow, Threshold, TiltShift, Vignette, Wipe. Each addX() call returns a Controller you can tweak or remove later:
const glow = camera.filters.internal.addGlow();
glow.outerStrength = 4;
camera.filters.internal.remove(glow);
Filters compose by stacking — the order you add them is the order they apply.
:::note[Bloom is not a single filter in v4]
v3 had a Bloom FX; in v4 there is no addBloom(). Use Phaser.Actions.AddEffectBloom(target, config?) — it sets up several filters internally via ParallelFilters and returns an object holding references to each, which you can mutate to animate the bloom.
:::
The Shader game object
For arbitrary GLSL, this.add.shader(config, x, y, w, h) draws a single quad with your fragment shader. The constructor takes a ShaderQuadConfig — a single object, not the positional args v3 used.
const fragSource = `
precision mediump float;
uniform vec2 uResolution;
uniform float uTime;
void main() {
vec2 uv = gl_FragCoord.xy / uResolution.xy;
vec3 col = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0.0, 2.0, 4.0));
gl_FragColor = vec4(col, 1.0);
}
`;
create() {
const shader = this.add.shader(
{
name: 'rainbow',
fragmentSource: fragSource,
initialUniforms: { uTime: 0, uResolution: [480, 320] },
setupUniforms: (setUniform) => {
setUniform('uTime', this.time.now / 1000);
setUniform('uResolution', [this.scale.width, this.scale.height]);
},
},
240, 160, 480, 320,
);
}
The key fields:
fragmentSource(orfragmentKeyfor a cache key loaded viathis.load.glsl) — your GLSL.initialUniforms— one-time setup values.setupUniforms(setUniform, drawingContext)— runs every frame. Push per-frame values (time, resolution, mouse, audio level) here.
Phaser ships a default uProjectionMatrix uniform. Shadertoy-style iResolution/iTime automatics from v3 are gone — declare and feed them yourself if you want them.
Loading shaders from files
For non-trivial shaders, ship the GLSL as a separate file:
preload() {
this.load.glsl('rainbow', 'shaders/rainbow.frag');
}
create() {
this.add.shader({ name: 'rainbow', fragmentKey: 'rainbow' }, 240, 160, 480, 320);
}
In v3 the loader needed a 'fragment' | 'vertex' kind. In v4 GLSL is loaded as opaque source; the kind is decided where you construct the Shader.
A worked example: CRT post-effect
A camera-wide CRT filter built from a custom Shader stacked on top of a vignette. The shader samples a RenderTexture of the scene rather than the camera’s framebuffer directly — keeps the effect composable.
const crtFrag = `
precision mediump float;
uniform sampler2D uMainSampler;
uniform vec2 uResolution;
uniform float uTime;
varying vec2 outTexCoord;
void main() {
vec2 uv = outTexCoord;
// Chromatic aberration: sample R/G/B at slightly offset positions.
float aberr = 0.0015;
vec3 col;
col.r = texture2D(uMainSampler, uv + vec2(aberr, 0.0)).r;
col.g = texture2D(uMainSampler, uv).g;
col.b = texture2D(uMainSampler, uv - vec2(aberr, 0.0)).b;
// Scanlines: dim every other pixel row.
float scan = 0.92 + 0.08 * sin(uv.y * uResolution.y * 3.14159);
col *= scan;
// Vignette: darker toward the corners.
vec2 d = uv - 0.5;
col *= 1.0 - dot(d, d) * 0.6;
gl_FragColor = vec4(col, 1.0);
}
`;
create() {
// 1. Render the scene to a texture each frame.
const rt = this.add.renderTexture(0, 0, this.scale.width, this.scale.height).setOrigin(0);
// 2. Draw a Shader quad that samples that texture and applies CRT.
const crt = this.add.shader(
{
name: 'crt',
fragmentSource: crtFrag,
setupUniforms: (setUniform) => {
setUniform('uResolution', [this.scale.width, this.scale.height]);
setUniform('uTime', this.time.now / 1000);
},
},
this.scale.width / 2, this.scale.height / 2,
this.scale.width, this.scale.height,
[rt.texture],
);
// Keep rt in sync each frame.
this.events.on('update', () => {
rt.clear();
rt.draw(this.cameras.main);
rt.render();
});
}
In Phaser 4 RenderTexture buffers commands — you must call render() to flush them (see DynamicTexture / RenderTexture in the migration guide).
Practical notes
mediumpis usually enough.highpdoubles register pressure on some GPUs.- Texture coordinates use GL conventions. Y=0 is at the bottom. Compressed textures must be re-encoded with the Y axis flipped; standard images are handled for you.
- Cost scales with framebuffer size. Halve the resolution and apply your scanline shader to that for cheap retro aesthetics.
- Debug by simplifying. If a shader looks wrong, first replace its body with
gl_FragColor = vec4(outTexCoord, 0.0, 1.0);to verify the UV mapping. - Shaders are stand-alone renders. Each
Shadergame object finishes the current batch and renders by itself. Sparing use is fine; thousands of them are not.
When to reach for a RenderNode instead
A Shader quad costs a draw call. If you need a custom batched effect — say, a sprite type with its own vertex layout — you write a custom RenderNode and register it via RenderConfig#renderNodes at game boot. That’s the bottom layer of the renderer and substantially more code; see Phaser.Renderer.WebGL.RenderNodes in the API reference for the surface area.
For 95% of effects — built-in filters or a Shader game object — you don’t need to.
Related
- Migrating from Phaser 3 — the renderer/shader API changes between v3 and v4.
- Game Objects —
Shaderis one. - Cameras — every camera has a filter list.