Filters
Stack shader and color effects to control final image style.
Filters
A Filter is a post-render effect applied to a single drawable’s output. Every RenderNode — sprites, containers, graphics, meshes, text — carries a filters array. Each filter in that array transforms the node’s rendered pixels before the result composites into the parent.
Filters are the right tool for per-node visual effects: soften an avatar with blur, tint a background container, apply a CRT shader to a whole scene layer. They are not screen-wide post-processing — that belongs to Post-processing, which combines RenderTexture targets with filter chains. The relationship: a filter is the building block; post-processing is the composition technique.
Every filter is another render pass
Each filter you attach re-renders that node’s output once more per frame — a three-filter chain is three extra passes. Treat filters like draw calls and keep the stack short.
Attaching filters
Filters go on the drawable. The array is processed in order — filter 0 receives the node’s raw render, filter 1 receives filter 0’s output, and so on:
import { BlurFilter, Color, ColorMatrixFilter, Sprite } from '@codexo/exojs';
let sprite: Sprite;
const blur = new BlurFilter({ strength: 3 });
const tint = new ColorMatrixFilter().tint(new Color(140, 210, 255));
sprite.filters = [blur, tint];
Set filters to an empty array to clear the chain. Assigning a new array replaces the old one — you don’t need to manually remove individual filters. An empty array means no extra render passes.
The Render pipeline debugging chapter covers pass-count inspection and reduction strategies.
Find which node is spending passes
The RenderPassInspector counts the live passes each frame. When your frame budget slips, it points straight at the node whose filter stack is responsible.
Bake static filtered subtrees with `cacheAsTexture`
Set cacheAsTexture = true on a filtered subtree that doesn’t change each frame: the chain runs once, then draws as a single cached texture instead of re-running every filter every frame.
BlurFilter
A separable Gaussian blur: the input is swept along X into a scratch target, then that scratch is swept along Y into the output, each sweep sampling the whole kernel in one draw. Chaining the two sweeps is what makes the kernel isotropic — it reaches diagonally, not only along the axes:
import { BlurFilter, Sprite } from '@codexo/exojs';
declare const sprite: Sprite;
declare const tween: { progress: number };
const blur = new BlurFilter({ strength: 4 });
sprite.filters = [blur];
// Live — animate the strength without reconstructing anything
blur.strength = tween.progress * 6;
strength is the Gaussian standard deviation in logical units and clamps to >= 0 (0 = no blur). It is the same quantity CSS blur() and Pixi’s strength take, so a value carried over from either produces the same blur here.
The tap count follows the strength on its own — there is nothing to match by hand. quality is an optional cap on the taps one sweep may take per side, for trading smoothness against texture fetches on a weak device; capping does not shorten the blur, it widens the taps’ spacing inside the same kernel.
A blur reaches strength * 3 logical units outside the drawable it is applied to, on every edge — the point the Gaussian is truncated at — and that extra extent is part of what gets rendered; see Effects can change a drawable’s extent.
ColorMatrixFilter
One affine color transform of everything the filter is handed — RGBA' = M·RGBA + bias, carried as a 4×5 row-major matrix. Brightness, contrast, saturation, inversion, sepia and flat tinting are all the same matrix, so they are conveniences that concatenate onto it rather than a filter class each:
import { Color, ColorMatrixFilter, Sprite } from '@codexo/exojs';
declare const sprite: Sprite;
const grade = new ColorMatrixFilter().grayscale().brightness(1.1);
sprite.filters = [grade];
// Live — every convenience concatenates and invalidates, no rebuild needed
grade.reset().tint(new Color(255, 160, 120));
// Or hand it a matrix directly: swap red and blue, leave alpha alone
grade.matrix = [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0];
The transform runs on straight alpha — the shader divides the premultiplied sample by its alpha, transforms, and multiplies it back — so a half-transparent edge grades the same way an opaque pixel does.
For a plain per-drawable multiply, prefer Drawable.tint: it costs no render target at all. Reach for this filter when the transform is more than a multiply, or when it has to cover a whole subtree as one image.
DropShadowFilter
A soft, offset silhouette of the filtered node drawn behind it. The shadow is the input’s coverage flattened to color (its alpha is the opacity), blurred by blur, and composited at offsetX/offsetY under the unchanged source; shadowOnly leaves the source out, which turns the same filter into a glow or a detached shadow:
import { Color, DropShadowFilter, Sprite } from '@codexo/exojs';
declare const label: Sprite;
declare const orb: Sprite;
label.filters = [new DropShadowFilter({ offsetX: 2, offsetY: 3, blur: 3 })];
// A coloured glow: no offset, wide blur, saturated colour, drawn on its own
// behind the sprite.
const glow = new DropShadowFilter({ offsetX: 0, offsetY: 0, blur: 12, color: new Color(80, 200, 255, 0.8) });
orb.filters = [glow];
All lengths are logical units, so the shadow keeps its on-screen size at every pixel ratio and filter resolution, and the filter declares the extra reach it needs through getOutputBounds, so a shadow is never clipped by the sprite’s own bounds. Every setter (offsetX, offsetY, blur, quality, color, shadowOnly) invalidates, so the shadow can be animated.
BloomFilter
A soft glow around the bright parts of the image. Pixels whose Rec. 709 luminance passes threshold are extracted through a soft knee, carried down a chain of levels halvings, blurred by strength on the smallest of them, carried back up, and added on top of the unchanged input:
import { BloomFilter, Container } from '@codexo/exojs';
declare const world: Container;
const bloom = new BloomFilter({ threshold: 0.7, intensity: 1.4, strength: 12 });
world.filters = [bloom];
// Live - every setter invalidates, so the glow can be animated or tuned
bloom.intensity = 2;
Only the excess over the threshold glows, not the whole pixel, so a scene keeps its own colours instead of washing out. The soft knee puts threshold in the middle of the transition rather than at a hard cut, which is why light a little below it still contributes a little glow.
The glow is added light, not coverage: it carries no alpha of its own. A halo spreading onto the scene behind it can only brighten that scene, never dim it, and a half-transparent subject comes back with exactly the alpha it went in with — which is what lets a glowing sprite stay a fade target.
strength is the Gaussian standard deviation in logical units, exactly as BlurFilter defines it. levels is purely a cost knob: the same strength covers the same distance at every setting, and each halving buys that distance at a quarter of the fill rate, at the price of a softer and coarser base as the blur runs on fewer texels. A node too small to take every halving takes as many as it can. The filter declares the reach it needs — strength * 3 for the blur plus what the halving chain spreads on its own — so a glow is never clipped by the subject’s own bounds.
Everything in the chain is eight-bit sRGB; there is no HDR and no tone mapping. The extraction leaves headroom for an intensity of roughly 1 / (1 - threshold) before the glow saturates to white, past which the effect stops getting brighter and starts getting flatter. Colour grading is LutFilter’s job, before or after this one.
LutFilter
Maps every pixel through a Look-Up Table texture. Two modes:
- RGB 1D LUT (
'rgb1d'):N×1texture holding three independent per-channel curves — red graded through the LUT’s red channel, green through green, blue through blue. Levels/curves-style grading, color ramps, posterisation. - 3D LUT (color grading):
N²×Nunwrapped cube texture with trilinear interpolation. Cinematic color grading, film stock emulation, tone mapping.
import { LutFilter } from '@codexo/exojs';
// From a DaVinci/OBS/Photoshop-exported PNG strip
const lutTexture = LutFilter.fromImage(myPngImage);
const filter = new LutFilter({ mode: '3d', size: 17 }).setLut(lutTexture);
sprite.filters = [filter];
// Switch LUTs live without rebuilding the filter
filter.setLut(differentLutTexture);
LutFilter.fromImage(image) wraps an HTMLImageElement or HTMLCanvasElement as a texture with LUT-appropriate defaults (linear filtering, clamp-to-edge, no mipmaps). identityLut1D() and identityLut3D() create no-op identity textures for testing. The setLut() method swaps textures at runtime — instant, no shader recompilation.
DisplacementFilter
Warps the filtered node by a direction read out of a texture — heat haze, water refraction, glass, shockwaves. The map’s red channel drives the horizontal direction and its green channel the vertical, both decoded from [0, 1] to [-1, 1], so a flat (0.5, 0.5) grey displaces nothing:
import { DisplacementFilter, Sprite, Texture } from '@codexo/exojs';
declare const water: Sprite;
declare const rippleMap: Texture;
const haze = new DisplacementFilter({ map: rippleMap, scale: 24 });
water.filters = [haze];
// From the scene's update: scroll the map to animate the distortion.
haze.offsetV += delta * 0.1;
scale is the maximum displacement in logical units (one number for both axes, or [x, y]), so the distortion keeps its on-screen size at every pixel ratio. offsetU/offsetV move where the map is sampled, in the map’s own UV units — give the map WrapModes.Repeat for a scroll that never runs off its edge. The filter reports the reach through getOutputBounds, so a subject at rest keeps the room its distortion needs; a fragment displaced past the edge of that domain comes out transparent rather than smearing the border texel.
Custom shader filters
ShaderFilter takes a fragment shader source per language and an optional uniforms map. It renders a fullscreen quad and executes the shader against the filter input texture, picking the source the active backend speaks:
private waveFilter = new ShaderFilter({
glsl: {
fragment: `
#version 300 es
precision mediump float;
uniform sampler2D uTexture;
uniform float uTime;
in vec2 vUv;
out vec4 fragColor;
void main() {
vec2 uv = vUv;
uv.y += sin(uv.x * 12.0 + uTime * 3.0) * 0.03;
fragColor = texture(uTexture, uv);
}
`,
},
wgsl: `
struct Uniforms { uTime: f32 };
@group(0) @binding(1) var uTexture: texture_2d<f32>;
@group(0) @binding(2) var uSampler: sampler;
@group(1) @binding(0) var<uniform> uniforms: Uniforms;
@fragment
fn fragmentMain(@location(0) vUv: vec2<f32>) -> @location(0) vec4<f32> {
var uv = vUv;
uv.y += sin(uv.x * 12.0 + uniforms.uTime * 3.0) * 0.03;
return textureSample(uTexture, uSampler, uv);
}
`,
uniforms: { uTime: 0 },
});
override init(): void {
this.sprite.filters = [this.waveFilter];
}
override update(delta: Seconds): void {
this.time += delta;
this.waveFilter.setUniform('uTime', this.time);
}Both sources are optional on their own, but a filter is only portable when it carries both: backend: 'auto' decides which backend an application ends up on, and a filter missing that backend’s language throws ShaderFilterBackendError the moment it is attached — before it compiles anything. Ask filter.supports(backendType) if you want to check first.
The WGSL side is one module. Name the fragment entry point fragmentMain; a module without a @vertex stage gets the default fullscreen-quad vertex stage (vertexMain) prepended, and one that declares its own must name it vertexMain and emit @location(0) vUv: vec2<f32>.
WGSL user uniforms live in @group(1): every non-texture uniform packs into one buffer at @binding(0), each in a 16-byte slot in the order you passed them, and textures follow from @binding(1), each with its sampler in the next slot. The GLSL side binds by name instead, with texture uniforms taking slots 1..N.
Both auto-bind uTexture (the filter input), uResolution (output dimensions) and uOrientation (the v-axis sign, below) — you don’t declare these in your fragment source unless you want to read them. Write custom uniforms with setUniform(name, value) or setUniforms({ ... }): they are flushed before each pass AND they invalidate the nodes rendering the filter, which writing into the read-only uniforms view could not do. Accepted value types: number, [n, n]/[n, n, n]/[n, n, n, n] tuples, Float32Array, Int32Array, Texture, and RenderTexture.
Sampling and the v axis
vUv addresses the filter input in texel space: sampling uTexture at vUv reproduces the input unchanged, whatever the effect domain looks like. The two backends store that domain the other way up, though — a WebGL2 render texture bottom-up, a WebGPU one top-down — so v runs downwards through the effect on one and upwards on the other. A shader that offsets along v therefore moves its content in opposite directions on the two backends unless it says which way is down.
uOrientation is the sign that relates the two: +1 where v grows along the effect domain’s y axis (downwards) and -1 where it grows against it. Multiply the v component of any directional offset by it and one source behaves identically everywhere:
// Read the texel `dy` below this one, on either backend.
vec4 below = texture(uTexture, vUv + vec2(0.0, dy * uOrientation));
@group(0) @binding(3) var<uniform> uOrientation: f32;
let below = textureSample(uTexture, uSampler, vUv + vec2<f32>(0.0, dy * uOrientation));
Offsets that are not directional — a radial blur kernel, a symmetric neighbourhood, anything that only recolours its own texel — need nothing. The same sign also maps vUv onto a texture sampled alongside the input (a displacement or mask map, whose own row 0 is its top on both backends): 0.5 + (vUv.y - 0.5) * uOrientation is that texture’s v.
Composition and layering
Filters on a Container apply to the container’s entire rendered subtree — every child is drawn into an off-screen target first, then the filter chain processes that target. A blur on a container blurs all children together, not individually:
import { BlurFilter, Container, Sprite } from '@codexo/exojs';
declare const hero: Sprite;
declare const enemy: Sprite;
const world = new Container();
world.filters = [new BlurFilter({ strength: 2 })];
world.addChild(hero); // hero is drawn into container's RT, then blurred
world.addChild(enemy); // enemy is drawn into same RT, then blurred
Filters on individual children inside an unfiltered container each get their own pass. The cost model: one filtered container with N children costs 1 + filter_count passes (children batch into one RT). N individually filtered children cost N * (1 + filter_count) passes.
For static subtrees that don’t change every frame, set container.cacheAsTexture = true. The filter chain bakes once and subsequent frames skip the per-frame re-rendering — one texture draw instead of the full subtree + filters.
Effects can change a drawable’s extent
A drawable’s own bounds are not necessarily the bounds of what it finally looks like. An effect may reach outside what it was given — a blur is the obvious case — and the renderer has to reserve room for that before it captures anything.
Every filter answers one question: given these logical bounds, what bounds can I produce? ExoJS asks each filter in the chain in turn, with the previous filter’s answer as its input, and allocates the capture domain from the resulting rectangle:
import { BlurFilter, Sprite } from '@codexo/exojs';
declare const sprite: Sprite; // 100 x 50 logical units
// 12 units of reach (3 x 4), then 6 more (3 x 2) out of that result: 18 units
// on every edge, so the capture domain is 136 x 86 and the composite lands
// 18 units up-left.
sprite.filters = [new BlurFilter({ strength: 4 }), new BlurFilter({ strength: 2 })];
Filters that only recolour what they are handed — ColorMatrixFilter, LutFilter, most custom shader filters — preserve their input bounds and need to do nothing. A custom filter that samples away from its own fragment should override getOutputBounds so its result is not clipped:
class GlowFilter extends Filter {
public constructor(public spread: number) {
super();
}
public override getOutputBounds(input: ReadonlyRectangle, output: Rectangle): void {
output.set(input.x - this.spread, input.y - this.spread, input.width + this.spread * 2, input.height + this.spread * 2);
}
public apply(): void {
// ...the glow passes
}
}The edges move independently, so an effect that only reaches one way — a drop shadow — declares exactly that, and one that reduces its output declares that instead.
The bounds are always in logical units, never device pixels. Rendering the same chain on a pixelRatio: 2 surface allocates twice the texels on each axis; the blur’s 12-unit reach is still 12 units.
A clip still cuts
Effects expand what a drawable looks like; clip is how you take that back. A node with clip = true confines its final, filtered output to its clip region, blur tail included. That is intentional, not a bug — reach for it when a filtered node has to stay inside a panel.
Mutating a filter after attaching it is enough on its own. blur.strength = 12 tells every node the filter is attached to that its output is stale, so a cacheAsTexture node re-bakes at the new extent; you never have to remove and re-add the filter. A custom filter with state of its own should call this.invalidate() after a change that affects what it draws.
Filter resolution
A filter renders into an off-screen target, and that target has a resolution: device pixels per logical unit. By default ('inherit') it matches the surface the result is composited into, so a filtered subtree is exactly as sharp as everything around it — on a pixelRatio: 2 display the target is twice the logical size on each axis.
Lower it for a filter whose output has no fine detail to lose. A blur is the obvious case: at half resolution it costs a quarter of the fragments and is hard to tell apart.
import { BlurFilter, Container } from '@codexo/exojs';
const blur = new BlurFilter({ strength: 4 });
const world = new Container();
blur.resolution = 0.5; // quarter the fill cost, low-frequency output anyway
world.filters = [blur];
Two things to know before reaching for it:
- A chain shares one target size, so the whole chain runs at the lowest resolution any of its filters asks for. One filter at
0.5makes every filter in that list render at0.5. - Pixel-valued filter parameters are in logical units, not target texels.
BlurFilter.strengthcovers the same on-screen distance at every resolution and every device pixel ratio; the filter scales it into texels itself. A custom filter that offsets by pixels has to do the same with theresolutionargument itsapplyreceives.
Very large filtered subtrees are clamped: if bounds × resolution would exceed the device’s maximum texture size, the resolution drops until it fits rather than the frame failing.
Filters vs. mesh materials
A filter transforms a drawable’s rendered pixels — it operates on the 2D output, in screen texture space. A MeshMaterial replaces the drawable’s vertex and fragment stages entirely — it operates in geometry space. The distinction matters:
- Use a filter for post-render effects: blur, tint, color grade, CRT scanlines, vignette.
- Use a
MeshMaterialfor per-vertex effects: displacement, custom lighting, procedural geometry. - Use both together: a
MeshMaterialon the mesh, plus a filter on the mesh’s parent container.
The next chapter, Custom mesh shaders, covers attaching a MeshMaterial in detail.
Examples
A single BlurFilter with an interactive strength slider — the basic filter pattern.
Six discs of rising luminance under one BloomFilter, with live threshold, intensity, strength and level sliders.
Three filters chained on one sprite — blur, tint, and a custom shader — demonstrating filter ordering and composition.
A procedural ripple map warping a sprite, with a live scale slider and a scrolling sampling offset.
Where to go next
The next chapter, Particles, covers the data-oriented particle system — spawn modules, update modules, distributions, and CPU/GPU auto-routing.



