~3 min read

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:

WantUse
A built-in effect on a sprite or cameraA Filter (obj.filters.internal.addX())
Your own GLSL fragment shader as a quadThe Shader game object
A whole new draw step inside the rendererA 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 (or fragmentKey for a cache key loaded via this.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

  • mediump is usually enough. highp doubles 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 Shader game 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.

  • Migrating from Phaser 3 — the renderer/shader API changes between v3 and v4.
  • Game ObjectsShader is one.
  • Cameras — every camera has a filter list.