Phaser 4 Filters, Masks, Lights and the Beam Renderer
Phaser 4 replaced the v3 renderer wholesale. FX and masks — two separate, mutually incompatible systems in v3 — are now one system called Filters, available on every game object and every camera with no exceptions.
If you are converting v3 code, start at the conversion table at the bottom. If you are writing new code, start here.
The one rule that catches everyone
Game objects need enableFilters() first. Cameras do not.
// Game object — filters is null until you enable it
sprite.enableFilters();
sprite.filters!.internal.addGlow(0xff00ff, 4, 0, 1);
// Camera — filters is always there
this.cameras.main.filters.external.addVignette(0.5, 0.5, 0.5, 0.5);
Calling sprite.filters.internal.addBlur() on a fresh sprite throws, because filters
is null. This is the single most common Phaser 4 effects error, and the message
(Cannot read properties of null) does not point at the cause.
Internal vs external
Every filter list has two halves, and picking the wrong one is the second most common mistake.
filters.internal |
filters.external |
|
|---|---|---|
| Space | The object's own space | The rendering context, usually full screen |
| Moves with the object | Yes | No |
| Resolution | The object's size | Full canvas |
| Cost | Cheaper | More expensive |
| Use for | Glow on a sprite, masking a panel | Screen-wide colour grading, vignette |
// A glow that follows the enemy: internal.
enemy.enableFilters();
enemy.filters!.internal.addGlow(0xff0000, 8, 0, 1);
// A full-screen red wash when the player is hurt: external, on the camera.
this.cameras.main.filters.external.addColorMatrix();
Some objects cannot define an internal space and fall back to external automatically:
objects with no width or height, and Shape objects whose stroke extends past their
reported bounds. If an internal filter on a shape behaves like a screen effect, that is
why.
The filter catalogue
Every one is an add* call on filters.internal or filters.external:
| Filter | What it does |
|---|---|
addBlur(quality, x, y, strength) |
Gaussian blur |
addGlow(color, outerStrength, innerStrength, scale) |
Outline glow |
addShadow(x, y, decay, power, color, samples, intensity) |
Drop shadow |
addBokeh(radius, amount, contrast) |
Depth-of-field style blur |
addPixelate(amount) |
Pixelation |
addBlocky(amount) |
Blocky quantisation |
addVignette(x, y, radius, strength) |
Darkened edges |
addBarrel(amount) |
Pinch / expand lens distortion |
addDisplacement(texture, x, y) |
Displace by a texture — heat haze, water |
addColorMatrix() |
Colour grading (see below) |
addCombineColorMatrix() |
Blend two colour matrices |
addGradientMap(...) |
Remap luminance through a gradient |
addThreshold(...) |
Alpha/colour cutoff |
addQuantize(...) |
Reduce the colour count |
addMask(source, invert, viewCamera, viewTransform, scaleFactor) |
Masking |
addKey(...) |
Chroma key |
addBlend(...) |
Blend modes beyond WebGL's four native ones |
addTiltShift(...) |
Tilt-shift |
addPanoramaBlur(...) |
Panoramic blur |
addSampler(...) |
Sample from another texture |
addImageLight(...) |
Image-based lighting |
addNormalTools(...) |
Normal map manipulation |
addParallelFilters(...) |
Run two filter chains and combine them |
addCustom(...) |
Your own shader |
ColorMatrix has a nested property
The colour methods sit on a colorMatrix sub-property, not on the filter:
const cm = sprite.filters!.internal.addColorMatrix();
cm.colorMatrix.sepia(); // correct
cm.colorMatrix.grayscale();
cm.colorMatrix.night(0.3);
// cm.sepia(); // wrong — this was the v3 shape
Composite effects live in Actions
Some v3 FX were rebuilt as Actions, because they need several filters working together:
Phaser.Actions.AddEffectBloom(target); // was the Bloom FX
Phaser.Actions.AddEffectShine(target); // was the Shine FX
Phaser.Actions.AddMaskShape(target); // was the Circle FX
Masking
BitmapMask no longer exists. createGeometryMask() and createBitmapMask() are not
methods on game objects. GeometryMask survives as a class but only the deprecated
Canvas renderer uses it — under Beam it does nothing at all.
There are two replacements, and the choice is about shape:
Rectangular clip → a camera viewport
Scroll panels, minimaps, split screen. Cheaper than a filter, and pixel-exact.
const panelCam = this.cameras.add(x, y, width, height);
panelCam.setScroll(scrollX, scrollY);
panelCam.ignore(this.children.list.filter(c => c !== panelContent));
this.cameras.main.ignore(panelContent);
There is no
camera.setScissor()in Phaser 4. If you find that call in a guide, the method does not exist — usesetViewport().
Any other shape → the Mask filter
const shape = this.add.graphics();
shape.fillStyle(0xffffff).fillCircle(64, 64, 64);
content.enableFilters();
content.filters!.internal.addMask(shape); // GameObject or texture key
content.filters!.internal.addMask(shape, true); // inverted: hide inside, show outside
The mask source can be a texture key or a game object. Given an object, Phaser renders it
to a DynamicTexture and uses that — so animated masks work, and so do masks made from
filter output. scaleFactor trades mask precision against memory; if you set it, scale
the mask source to match.
Stencils — persistent masking (4.2.0+)
For a mask that stays put across many objects and frames, a Stencil is cheaper than
re-running a mask filter. Unlike v3 stencil masks these are universal and persistent, and
accept anything that draws pixels as a source.
const stencil = this.add.stencil();
stencil.add(this.add.circle(400, 300, 120, 0xffffff));
Modes: addLayer, subtractLayer, clear, clearRegion, with add and subtract each
invertible. this.add.stencilreference() re-renders an existing stencil with different
settings, which is how you reuse or remove stencil geometry.
Regular alpha does nothing inside a stencil, so alpha is controlled by
stencilAlphaStrategy instead (see below). If stencilInvert appears to do nothing, be
on 4.2.1 or later — it was broken by an alpha bug in 4.2.0.
Tinting
Phaser 4 separated the tint colour from the tint mode. In v3 you set tintFill = true;
that property does not exist any more.
sprite.setTint(0xff0000);
sprite.setTintMode(Phaser.TintModes.FILL); // was tintFill = true
Modes: MULTIPLY (default), FILL, ADD, SCREEN, OVERLAY, HARD_LIGHT,
MULTIPLY_TWO.
MULTIPLY_TWO (4.2.0+) uses a second tint colour per corner, which makes gradient
tints possible without a shader — fire, ice, inversion:
sprite.setTint(0xff2200).setTint2(0xffee00).setTintMode(Phaser.TintModes.MULTIPLY_TWO);
Mesh2D and Tile support a constant tint2. TilemapGPULayer does not support tinting
on tiles at all.
Shapes have no tint at all.
Rectangle,Arc,Triangleand the otherShapeobjects do not implement the Tint component —setTint()andsetTintMode()are not methods on them, and the compiler will say so. ChangefillColorinstead:rect.fillColor = 0xff0000. This bites most often when prototyping with coloured rectangles and then wondering why the damage flash pattern does not compile.
The damage flash, which every action game needs:
hit() {
this.sprite.setTint(0xffffff).setTintMode(Phaser.TintModes.FILL);
this.scene.time.delayedCall(80, () => this.sprite.clearTint());
}
Prefer this over a white-flash filter — it is one draw call and no framebuffer.
Lighting
this.lights.enable().setAmbientColor(0x333333);
const torch = this.lights.addLight(x, y, 200, 0xffaa33, 2);
Objects must opt into lighting; see references/lighting.md for the component and for
normal-map setup.
Cone lights (4.2.0+)
Flashlights, vision cones, headlights, searchlights. They run through the same lighting shader — no mask, no second camera, no rendering the scene twice:
const beam = this.lights.addConeLight(
x, y,
300, // radius
0xffffff, // colour
2, // intensity
rotation, // radians — the direction the cone points
0.2, // inner angle, radians
0.6, // outer angle, radians
);
beam.setConeRotation(Phaser.Math.Angle.Between(px, py, mx, my));
beam.setConeAngles(0.15, 0.5);
beam.disableCone(); // back to a normal point light
Any object already lit by Phaser lighting is lit by a cone light. That makes stealth vision cones and torch mechanics far cheaper than the v3 approaches.
Performance
Filters are framebuffer operations. Each one is a render pass, and passes are what cost.
- Prefer tint over a filter for flashes and colour changes. A tint is part of the batch; a filter is a separate pass.
- Prefer one camera filter over N object filters. Twenty enemies each with a glow is twenty extra passes. If they can share a screen-space effect, do that instead.
- Internal beats external where both work — internal renders at the object's size, external at full canvas.
- Filters never render with mipmaps. Do not expect mipmap-based softening from a filter output (4.1.0 fixed the opposite bug, where they incorrectly did).
- Enable filters once, not per frame.
enableFilters()increate(), then adjust the controller's properties inupdate(). Callingadd*every frame stacks filters until the frame budget is gone — an easy accidental leak, and it looks like a performance bug rather than a logic one. - Alpha strategies use
discard, which is inefficient by design.ditherandthresholdexist for effects and for stencils, not as a performance lever.
Verify any of this with the playtest harness rather than by eye — filter cost shows up as
a frame-rate cliff under load, which a single reading misses and a sample step catches:
{ action: 'sample', expression: `game.loop.actualFps`,
duration: 3000, interval: 100, expect: { stat: 'min', atLeast: 50 } }
Read headless FPS as a comparison, never as a number. The harness runs WebGL through SwiftShader, and filters are precisely what software rendering is worst at: a scene with a glow, a vignette, a colour matrix and a lit particle emitter measures around 12–20fps headless while running at 60 on any real GPU. Compare a run against the previous run on the same machine; do not set an absolute threshold for a filter-heavy scene and do not report a headless number as the game's performance.
Converting from Phaser 3
| v3 | v4 |
|---|---|
sprite.preFX.addGlow() |
sprite.enableFilters(); sprite.filters!.internal.addGlow() |
sprite.postFX.addBlur() |
sprite.enableFilters(); sprite.filters!.external.addBlur() |
camera.postFX.addVignette() |
camera.filters.external.addVignette() (no enable step) |
new BitmapMask(...), setMask() |
filters.internal.addMask(source) |
createGeometryMask() |
A camera viewport, or addMask() |
sprite.tintFill = true |
sprite.setTintMode(Phaser.TintModes.FILL) |
setPipeline('X') |
Render nodes — customRenderNodes, RenderConfig#renderNodes |
| Bloom FX | Phaser.Actions.AddEffectBloom() |
| Shine FX | Phaser.Actions.AddEffectShine() |
| Circle FX | Phaser.Actions.AddMaskShape() |
| Gradient FX | the Gradient game object |
colorMatrixFilter.sepia() |
filter.colorMatrix.sepia() |
Mesh / Plane |
Mesh2D (4.2.0+) |
Run the migration grep before assuming your code is clean:
grep -rn "preFX\|postFX\|BitmapMask\|createGeometryMask\|tintFill\|setPipeline\|setScissor" src/
Additional Resources
Reference Files
references/filters-api.md— Every filter with its full parameter list, worked examples, and the internal/external decision per effect.references/lighting.md— The lighting model, normal maps, cone lights, theLightingcomponent, and the performance envelope.references/renderer-and-shaders.md— Beam, render nodes, custom shaders,Mesh2D,SpriteGPULayer,Stencil,CustomContext, and alpha strategies.
Related
skills/phaser-migrate/— the full v3→v4 change list and the release notesskills/phaser-particles/— particle effects, which usually pair with theseskills/phaser-analyze/references/performance-playbook.md— when effects cost frames