Guide

GuideEffectsFilters

Filters

Stack shader and color effects to control final image style.

Intermediate~13 min read

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.

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.

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×1 texture 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²×N unwrapped 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:

examples/guides/filters/wave-filter.ts
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:

examples/guides/filters/glow-filter.ts
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.

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.5 makes every filter in that list render at 0.5.
  • Pixel-valued filter parameters are in logical units, not target texels. BlurFilter.strength covers 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 the resolution argument its apply receives.

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 MeshMaterial for per-vertex effects: displacement, custom lighting, procedural geometry.
  • Use both together: a MeshMaterial on the mesh, plus a filter on the mesh’s parent container.

The next chapter, Custom mesh shaders, covers attaching a MeshMaterial in detail.

Examples

Blur FilterOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, BlurFilter, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Sprite } from '@codexo/exojs';
import { mountControlPanel, mountControls } from '@examples/runtime';

// High-detail, high-contrast content so the blur visibly softens hard edges.
const PIXEL_GRID = assets.technical.filtering.pixelGrid128;

const MAX_STRENGTH = 8;

class BlurFilterScene extends Scene {
  private blur!: BlurFilter;
  private sprite!: Sprite;
  private enabled = true;
  private hud!: ReturnType<typeof mountControls>;
  private panel!: ReturnType<typeof mountControlPanel>;
  private slider!: ReturnType<ReturnType<typeof mountControlPanel>['addSlider']>;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    this.blur = new BlurFilter({ strength: 2 });
    this.sprite = new Sprite(this.loader.get(PIXEL_GRID))
      .setAnchor(0.5)
      .setScale(4.5)
      .setPosition(width / 2, height / 2);
    this.sprite.filters = [this.blur];

    this.hud = mountControls({
      title: 'Blur Filter',
      controls: [
        { keys: 'Strength', action: 'soften the sprite (Gaussian standard deviation)' },
        { keys: 'Filter', action: 'toggle to compare before / after' },
      ],
      status: this.statusText(),
      hint: 'Drag the Strength slider — the live value is shown to its right.',
    });

    this.panel = mountControlPanel({ title: 'Blur' });
    this.slider = this.panel.addSlider({
      label: 'Strength',
      min: 0,
      max: MAX_STRENGTH,
      step: 0.1,
      value: this.blur.strength,
      onChange: value => {
        this.blur.strength = value;
        this.refresh();
      },
    });
    this.panel.addToggle({
      label: 'Filter',
      value: true,
      onChange: on => {
        this.enabled = on;
        this.sprite.filters = on ? [this.blur] : [];
        this.refresh();
      },
    });
  }

  private statusText(): string {
    if (!this.enabled) {
      return 'Filter: OFF (original sprite)';
    }

    return `Strength: ${this.blur.strength.toFixed(1)} px`;
  }

  private refresh(): void {
    this.hud.setStatus(this.statusText());
  }

  override draw(context: RenderingContext): void {
    context.render(this.sprite);
  }
}

const app = new Application({
  scenes: { BlurFilterScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
});

await app.start(BlurFilterScene);

A single BlurFilter with an interactive strength slider — the basic filter pattern.

Bloom FilterOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, BloomFilter, Color, Container, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, type Seconds } from '@codexo/exojs';
import { mountControlPanel, mountControls } from '@examples/runtime';

/**
 * Six discs of rising luminance on a dark backdrop. Only the ones above the
 * threshold glow, so dragging Threshold walks the glow along the row and shows
 * what the filter is actually selecting on.
 */
const DISCS = [
  new Color(40, 46, 70),
  new Color(70, 90, 140),
  new Color(110, 150, 210),
  new Color(160, 210, 255),
  new Color(225, 245, 255),
  new Color(255, 255, 255),
];

const DISC_RADIUS = 48;

class BloomFilterScene extends Scene {
  private bloom!: BloomFilter;
  private stage!: Container;
  private discs!: Graphics;
  private beam!: Graphics;
  private elapsed = 0;
  private hud!: ReturnType<typeof mountControls>;
  private panel!: ReturnType<typeof mountControlPanel>;

  override init(): void {
    const { width, height } = this.app;

    this.bloom = new BloomFilter({ threshold: 0.6, intensity: 1.6, strength: 10, levels: 3 });
    this.discs = new Graphics();
    this.beam = new Graphics();
    // One filter over the whole stage rather than one per shape: bloom reads
    // the composed image, so a highlight next to another highlight glows as one
    // light rather than as two that happen to overlap.
    this.stage = new Container();
    this.stage.addChild(this.discs);
    this.stage.addChild(this.beam);
    this.stage.filters = [this.bloom];

    const spacing = width / (DISCS.length + 1);

    for (let index = 0; index < DISCS.length; index++) {
      this.discs.fillColor = DISCS[index]!;
      this.discs.drawCircle(spacing * (index + 1), height * 0.38, DISC_RADIUS);
    }

    this.hud = mountControls({
      title: 'Bloom Filter',
      controls: [
        { keys: 'Threshold', action: 'luminance a pixel needs before it glows' },
        { keys: 'Intensity', action: 'how much of the extracted highlight is added back' },
        { keys: 'Strength', action: 'how far the glow spreads, in logical units' },
        { keys: 'Levels', action: 'halvings before the blur - wider and cheaper, or tighter' },
      ],
      status: this.statusText(),
      hint: 'The discs get brighter from left to right. Raise Threshold and watch the glow retreat along the row.',
    });

    this.panel = mountControlPanel({ title: 'Bloom' });
    this.panel.addSlider({
      label: 'Threshold',
      min: 0,
      max: 1,
      step: 0.05,
      value: this.bloom.threshold,
      onChange: value => {
        this.bloom.threshold = value;
        this.refresh();
      },
    });
    this.panel.addSlider({
      label: 'Intensity',
      min: 0,
      max: 4,
      step: 0.1,
      value: this.bloom.intensity,
      onChange: value => {
        this.bloom.intensity = value;
        this.refresh();
      },
    });
    this.panel.addSlider({
      label: 'Strength',
      min: 0,
      max: 32,
      step: 1,
      value: this.bloom.strength,
      onChange: value => {
        this.bloom.strength = value;
        this.refresh();
      },
    });
    this.panel.addSlider({
      label: 'Levels',
      min: 1,
      max: 5,
      step: 1,
      value: this.bloom.levels,
      onChange: value => {
        this.bloom.levels = value;
        this.refresh();
      },
    });
  }

  override update(delta: Seconds): void {
    this.elapsed += delta;
  }

  private statusText(): string {
    const { threshold, intensity, strength, levels } = this.bloom;

    return `threshold ${threshold.toFixed(2)} · intensity ${intensity.toFixed(1)} · strength ${strength.toFixed(0)} · levels ${levels}`;
  }

  private refresh(): void {
    this.hud.setStatus(this.statusText());
  }

  override draw(context: RenderingContext): void {
    const { width, height } = this.app;
    // A sweeping white bar: a moving highlight shows the glow tracking it,
    // which a still image cannot.
    const x = width * (0.5 + 0.42 * Math.sin(this.elapsed * 0.7));

    this.beam.clear();
    this.beam.fillColor = Color.white;
    this.beam.drawRoundedRectangle(x - 14, height * 0.62, 28, height * 0.24, 14);

    context.render(this.stage);
  }
}

const app = new Application({
  scenes: { BloomFilterScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(10, 12, 20),
});

await app.start(BloomFilterScene);

Six discs of rising luminance under one BloomFilter, with live threshold, intensity, strength and level sliders.

Filter StackOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, BlurFilter, Color, ColorMatrixFilter, FixedResolutionCanvasSizing, type RenderingContext, Scene, ShaderFilter, Sprite } from '@codexo/exojs';
import { mountControlPanel, mountControls } from '@examples/runtime';

const PRIMARY_RAMP = assets.technical.color.primaryRamp;

const glsl = `#version 300 es
precision mediump float; uniform sampler2D uTexture; in vec2 vUv; out vec4 fragColor;
void main(){ vec4 c=texture(uTexture,vUv); fragColor=vec4(c.rgb*vec3(1.0,0.9,1.2),c.a);} `;
const wgsl = `@group(0) @binding(1) var uTexture:texture_2d<f32>; @group(0) @binding(2) var uSampler:sampler; @fragment fn fragmentMain(@location(0) vUv:vec2<f32>)->@location(0) vec4<f32>{ let c=textureSample(uTexture,uSampler,vUv); return vec4<f32>(c.rgb*vec3<f32>(1.0,0.9,1.2),c.a);} `;

class FilterStackScene extends Scene {
  private sprite!: Sprite;
  private blur!: BlurFilter;
  private tint!: ColorMatrixFilter;
  private custom!: ShaderFilter;
  // The filter chain is applied in this fixed order; each entry can be toggled.
  private active = { blur: true, tint: true, custom: true };
  private hud!: ReturnType<typeof mountControls>;
  private panel!: ReturnType<typeof mountControlPanel>;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    this.sprite = new Sprite(this.loader.get(PRIMARY_RAMP))
      .setAnchor(0.5)
      .setScale(4)
      .setPosition(width / 2, height / 2);
    this.blur = new BlurFilter({ strength: 2 });
    this.tint = new ColorMatrixFilter().tint(new Color(140, 210, 255));
    this.custom = new ShaderFilter({ glsl: { fragment: glsl }, wgsl });
    this.rebuild();

    this.hud = mountControls({
      title: 'Filter Stack',
      controls: [{ keys: 'Blur / Tint / Custom', action: 'toggle each layer independently' }],
      status: this.statusText(),
      hint: 'Filters compose in order: Blur → Tint → Custom. Toggle any subset.',
    });

    this.panel = mountControlPanel({ title: 'Layers' });
    this.panel.addToggle({
      label: 'Blur',
      value: this.active.blur,
      onChange: on => {
        this.active.blur = on;
        this.rebuild();
      },
    });
    this.panel.addToggle({
      label: 'Tint',
      value: this.active.tint,
      onChange: on => {
        this.active.tint = on;
        this.rebuild();
      },
    });
    this.panel.addToggle({
      label: 'Custom',
      value: this.active.custom,
      onChange: on => {
        this.active.custom = on;
        this.rebuild();
      },
    });
  }

  private rebuild(): void {
    const filters = [];

    if (this.active.blur) filters.push(this.blur);
    if (this.active.tint) filters.push(this.tint);
    if (this.active.custom) filters.push(this.custom);

    this.sprite.filters = filters;
    this.hud?.setStatus(this.statusText());
  }

  private statusText(): string {
    const labels = [this.active.blur && 'Blur', this.active.tint && 'Tint', this.active.custom && 'Custom'].filter(Boolean);

    return labels.length > 0 ? `Active: ${labels.join(' → ')}` : 'Active: none (original sprite)';
  }

  override draw(context: RenderingContext): void {
    context.render(this.sprite);
  }
}

const app = new Application({
  scenes: { FilterStackScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
});

await app.start(FilterStackScene);

Three filters chained on one sprite — blur, tint, and a custom shader — demonstrating filter ordering and composition.

Displacement FilterOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, DisplacementFilter, FixedResolutionCanvasSizing, type RenderingContext, ScaleModes, Scene, type Seconds, Sprite, Texture, WrapModes } from '@codexo/exojs';
import { mountControlPanel, mountControls } from '@examples/runtime';

const SHIP = assets.demo.textures.shipA;
const MAP_SIZE = 256;

// The displacement map: red is the horizontal direction, green the vertical,
// both decoded from [0, 1] to [-1, 1], so flat (0.5, 0.5) grey means "stay
// put". Two sine waves at right angles make the classic water ripple; scrolling
// the sampling offset moves the ripple without redrawing the map.
const rippleMap = (): Texture => {
  const canvas = document.createElement('canvas');

  canvas.width = MAP_SIZE;
  canvas.height = MAP_SIZE;

  const context = canvas.getContext('2d');

  if (context === null) throw new Error('2D canvas context unavailable.');

  const image = context.createImageData(MAP_SIZE, MAP_SIZE);

  for (let y = 0; y < MAP_SIZE; y++) {
    for (let x = 0; x < MAP_SIZE; x++) {
      const offset = (y * MAP_SIZE + x) * 4;
      const u = (x / MAP_SIZE) * Math.PI * 2;
      const v = (y / MAP_SIZE) * Math.PI * 2;

      image.data[offset] = Math.round((Math.sin(v * 3) * 0.5 + 0.5) * 255);
      image.data[offset + 1] = Math.round((Math.sin(u * 2) * 0.5 + 0.5) * 255);
      image.data[offset + 2] = 0;
      image.data[offset + 3] = 255;
    }
  }

  context.putImageData(image, 0, 0);

  // Repeat, so a scrolling sampling offset never runs off the edge of the map.
  return new Texture(canvas, { scaleMode: ScaleModes.Linear, wrapMode: WrapModes.Repeat, generateMipMap: false });
};

class DisplacementFilterScene extends Scene {
  private ripple!: DisplacementFilter;
  private map!: Texture;
  private sprite!: Sprite;
  private scrolling = true;
  private hud!: ReturnType<typeof mountControls>;
  private panel!: ReturnType<typeof mountControlPanel>;

  override init(): void {
    const { width, height } = this.app;

    this.map = rippleMap();
    this.ripple = new DisplacementFilter({ map: this.map, scale: 24 });
    this.sprite = new Sprite(this.loader.get(SHIP))
      .setAnchor(0.5)
      .setScale(10)
      .setPosition(width / 2, height / 2);
    this.sprite.filters = [this.ripple];

    this.hud = mountControls({
      title: 'Displacement Filter',
      hint: 'Each fragment reads its colour from a direction stored in a map texture, so the sprite ripples like a reflection on water.',
      status: this.statusText(),
    });

    this.panel = mountControlPanel({ title: 'Ripple' });
    this.panel.addSlider({
      label: 'Scale',
      min: 0,
      max: 80,
      step: 1,
      value: this.ripple.scaleX,
      onChange: value => {
        this.ripple.setScale(value);
        this.refresh();
      },
    });
    this.panel.addToggle({
      label: 'Scroll',
      value: this.scrolling,
      onChange: on => {
        this.scrolling = on;
      },
    });
  }

  override update(delta: Seconds): void {
    if (!this.scrolling) return;

    this.ripple.offsetU += delta * 0.08;
    this.ripple.offsetV += delta * 0.13;
    this.refresh();
  }

  private statusText(): string {
    return `scale ${this.ripple.scaleX.toFixed(0)} · map offset ${this.ripple.offsetU.toFixed(2)}, ${this.ripple.offsetV.toFixed(2)}`;
  }

  private refresh(): void {
    this.hud.setStatus(this.statusText());
  }

  override draw(context: RenderingContext): void {
    context.render(this.sprite);
  }
}

const app = new Application({
  scenes: { DisplacementFilterScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(18, 30, 46),
  loader: {
    basePath: 'assets/',
  },
});

await app.start(DisplacementFilterScene);

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.