Guide

Guide Effects Post-processing

Post-processing

Build multi-pass render flows for bloom, trails, and mirror effects.

Advanced ~5 min read

Before you start

Post-processing

Post-processing means rendering a scene — or parts of one — into off-screen RenderTexture targets, running filters on those targets, and compositing the results back onto the canvas. This is the pattern behind bloom, color grading, motion trails, screen-wide shader effects, and any multi-pass render flow where a filter treats the whole scene as one image.

The building blocks — RenderTexture and Filter — are covered in their own chapters. This chapter is about composition: a RenderPipeline holding an ordered list of passes, so the GPU produces a final frame that is more than the sum of individually drawn sprites. The two stock passes you compose are RenderNodePass (renders a scene subtree) and CallbackRenderPass (runs an arbitrary draw callback); both accept a { target } option that redirects their output into a RenderTexture.

A single post-process pass

The minimal pattern: render your scene into a RenderTexture, apply a filter, display the result. Build the pipeline once in init, then run it each frame with pipeline.execute(context):

import { BlurFilter, CallbackRenderPass, Color, Container, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs';

class PostProcessScene extends Scene {
    private sceneRt!: RenderTexture;
    private blurredRt!: RenderTexture;
    private blur!: BlurFilter;
    private worldLayer!: Container;
    private final!: Sprite;
    private pipeline!: RenderPipeline;

    override init(): void {
        this.sceneRt = new RenderTexture(800, 600);
        this.blurredRt = new RenderTexture(800, 600);
        this.blur = new BlurFilter({ radius: 4, quality: 2 });
        this.worldLayer = new Container();
        this.final = new Sprite(this.blurredRt);

        this.pipeline = new RenderPipeline()
            // 1. Render the full scene into sceneRt
            .addPass(new RenderNodePass(this.worldLayer, { target: this.sceneRt, clear: Color.black }))
            // 2. Apply blur: sceneRt → filter → blurredRt
            .addPass(new CallbackRenderPass((context) => this.blur.apply(context.backend, this.sceneRt, this.blurredRt)))
            // 3. Display the result on the canvas
            .addPass(new RenderNodePass(this.final, { clear: Color.black }));
    }

    override draw(context): void {
        this.pipeline.execute(context);
    }
}

A RenderNodePass renders a scene subtree; with a target it redirects that output into the texture (and clear clears the target first). A CallbackRenderPass runs an arbitrary callback that receives the RenderingContext — here it issues the filter step via context.backend. filter.apply(backend, inputRt, outputRt) reads from one RenderTexture and writes filtered output to another. The pipeline runs its passes in order when you call pipeline.execute(context).

Bloom (lite)

Bloom requires two renderings of the same scene: one at normal brightness (the base), one with only the bright parts tinted and blurred (the glow). The glow is composited additively over the base. Because the same sprite is drawn twice with different tints, each off-screen step is a CallbackRenderPass that sets the tint before rendering:

import { BlendModes, BlurFilter, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs';

class BloomScene extends Scene {
    private baseRt!: RenderTexture;
    private glowRt!: RenderTexture;
    private blurredRt!: RenderTexture;
    private bunny!: Sprite;
    private baseSprite!: Sprite;
    private glowSprite!: Sprite;
    private blur!: BlurFilter;
    private pipeline!: RenderPipeline;

    override init(loader): void {
        this.baseRt = new RenderTexture(800, 600);
        this.glowRt = new RenderTexture(800, 600);
        this.blurredRt = new RenderTexture(800, 600);

        this.bunny = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5);
        this.baseSprite = new Sprite(this.baseRt);
        this.glowSprite = new Sprite(this.blurredRt)
            .setTint(new Color(255, 255, 255, 0.8))
            .setBlendMode(BlendModes.Additive);

        this.blur = new BlurFilter({ radius: 10, quality: 2 });

        this.pipeline = new RenderPipeline()
            // Pass 1: base scene at normal tint, into baseRt
            .addPass(
                new CallbackRenderPass(
                    (context) => {
                        this.bunny.setTint(Color.white);
                        context.render(this.bunny);
                    },
                    { target: this.baseRt, clear: Color.black },
                ),
            )
            // Pass 2: glow scene — same geometry, bright warm tint, into glowRt
            .addPass(
                new CallbackRenderPass(
                    (context) => {
                        this.bunny.setTint(new Color(255, 230, 190));
                        context.render(this.bunny);
                    },
                    { target: this.glowRt, clear: Color.black },
                ),
            )
            // Pass 3: blur the glow
            .addPass(new CallbackRenderPass((context) => this.blur.apply(context.backend, this.glowRt, this.blurredRt)))
            // Composite: base + blurred glow (additive)
            .addPass(new RenderNodePass(this.baseSprite, { clear: Color.black }))
            .addPass(new RenderNodePass(this.glowSprite));
    }

    override draw(context): void {
        this.pipeline.execute(context);
    }
}

The two glow/base passes each set a tint on the shared bunny sprite, then render it — using CallbackRenderPass rather than RenderNodePass precisely because they need to mutate the node before drawing. The key insight: the glow pass renders the same geometry with a bright tint. Only the bright regions survive into the blurred glow — the dark regions have near-zero contribution. The additive blend mode prevents the glow from over-brightening the base.

Filter chains on a render target

Instead of a single filter, pass the output through a chain — one CallbackRenderPass per filter step, bracketed by a scene render and a composite:

import { BlurFilter, CallbackRenderPass, Color, ColorFilter, Graphics, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs';

class FilterChainScene extends Scene {
    private scene!: Graphics;
    private sceneRt!: RenderTexture;
    private tmpRt!: RenderTexture;
    private outRt!: RenderTexture;
    private blur!: BlurFilter;
    private color!: ColorFilter;
    private final!: Sprite;
    private pipeline!: RenderPipeline;

    override init(): void {
        this.scene = new Graphics();
        this.sceneRt = new RenderTexture(800, 600);
        this.tmpRt = new RenderTexture(800, 600);
        this.outRt = new RenderTexture(800, 600);
        this.blur = new BlurFilter({ radius: 6, quality: 2 });
        this.color = new ColorFilter(new Color(140, 190, 255));
        this.final = new Sprite(this.outRt);

        this.pipeline = new RenderPipeline()
            // Render scene → sceneRt
            .addPass(new RenderNodePass(this.scene, { target: this.sceneRt, clear: Color.black }))
            // sceneRt → blur → tmpRt
            .addPass(new CallbackRenderPass((context) => this.blur.apply(context.backend, this.sceneRt, this.tmpRt)))
            // tmpRt → color → outRt
            .addPass(new CallbackRenderPass((context) => this.color.apply(context.backend, this.tmpRt, this.outRt)))
            // Display outRt
            .addPass(new RenderNodePass(this.final, { clear: Color.black }));
    }

    override draw(context): void {
        this.pipeline.execute(context);
    }
}

Each filter.apply() call is one additional render pass. For a chain of N filters you need N RenderTexture instances (or fewer if you reuse them for subsequent steps — the engine reuses render target memory across filter passes internally when filters are on a RenderNode, but explicit filter.apply() calls require explicit texture management).

Trail feedback

Reuse a render target as its own input to create motion trails. The feedback pass deliberately omits clear, so the decayed previous frame accumulates instead of being wiped:

import { CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs';

class TrailScene extends Scene {
    private feedbackRt!: RenderTexture;
    private decay!: Sprite;
    private hero!: Sprite;
    private final!: Sprite;
    private pipeline!: RenderPipeline;

    override init(loader): void {
        this.feedbackRt = new RenderTexture(800, 600);
        this.decay = new Sprite(this.feedbackRt)
            .setTint(new Color(255, 255, 255, 0.93)); // 93% opaque = 7% fade per frame
        this.hero = new Sprite(loader.get(Texture, 'hero')).setAnchor(0.5);
        this.final = new Sprite(this.feedbackRt);

        this.pipeline = new RenderPipeline()
            // Draw the previous frame (at 93% opacity) plus the hero on top — no clear, so it accumulates
            .addPass(
                new CallbackRenderPass(
                    (context) => {
                        context.render(this.decay);
                        context.render(this.hero);
                    },
                    { target: this.feedbackRt },
                ),
            )
            // Display the result
            .addPass(new RenderNodePass(this.final, { clear: Color.black }));
    }

    override draw(context): void {
        this.pipeline.execute(context);
    }
}

Each frame, the previous frame’s image fades slightly (the decay sprite’s alpha=0.93), and the hero’s current position is drawn on top. After several frames, older positions fade away while newer ones remain visible — a self-erasing trail. The same technique works for light streaks, ghost images, and feedback-based shader effects.

Render-into-target passes vs. filter.apply

Two ways to write into a RenderTexture:

MethodWhat it does
RenderNodePass / CallbackRenderPass with { target }Renders a scene subtree (RenderNodePass) or runs a draw callback (CallbackRenderPass) with the target set as active render target. Used for scene rendering.
filter.apply(context.backend, inputRt, outputRt)Runs the filter shader/program on an input texture, producing filtered output. Wrap it in a CallbackRenderPass to place it in the pipeline. Used for post-render passes.

You compose them inside a RenderPipeline: render scenes with a targeted RenderNodePass/CallbackRenderPass, apply filters with filter.apply (from a CallbackRenderPass), display the final result with a RenderNodePass that draws the result sprite onto the active target.

When to use per-node filters vs. post-processing

  • Per-node filter (sprite.filters = [blur]): Filter affects one drawable’s rendered output. Good for that one glowing character in an otherwise sharp scene.
  • Post-processing: Filter affects the entire scene rendered into a target. Good for screen-wide effects — bloom, color grading, CRT simulation.
  • Both together: Per-node filters for individual effects, post-processing chain for scene-wide compositing.

The cost is in passes. A post-processing chain with 3 RTS and 2 filters costs at least 4 passes (scene render + filter A + filter B + final composite). Per-node filters cost 1 pass per filtered node per frame. The render pipeline debugger (Render pipeline debugging) helps you count these.

Examples

Bloom Lite Open in Playground View source
import { Application, BlendModes, BlurFilter, CallbackRenderPass, Color, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

class BloomLiteScene extends Scene {
    private baseRt!: RenderTexture;
    private glowRt!: RenderTexture;
    private blurredRt!: RenderTexture;
    private bunny!: Sprite;
    private baseSprite!: Sprite;
    private glowSprite!: Sprite;
    private blur!: BlurFilter;
    private pipeline!: RenderPipeline;
    private time = 0;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { bunny: 'image/ship-a.png' });
    }

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

        this.baseRt = new RenderTexture(width, height);
        this.glowRt = new RenderTexture(width, height);
        this.blurredRt = new RenderTexture(width, height);
        this.bunny = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setScale(1.9);
        this.baseSprite = new Sprite(this.baseRt);
        this.glowSprite = new Sprite(this.blurredRt).setTint(new Color(255, 255, 255, 0.8)).setBlendMode(BlendModes.Additive);
        this.blur = new BlurFilter({ radius: 10, quality: 2 });

        // The same sprite is drawn twice with different tints (white base, warm glow), so each
        // off-screen step is a callback that sets the tint before rendering.
        this.pipeline = new RenderPipeline()
            .addPass(
                new CallbackRenderPass(
                    (context) => {
                        this.bunny.setTint(Color.white);
                        context.render(this.bunny);
                    },
                    { target: this.baseRt, clear: Color.black },
                ),
            )
            .addPass(
                new CallbackRenderPass(
                    (context) => {
                        this.bunny.setTint(new Color(255, 230, 190));
                        context.render(this.bunny);
                    },
                    { target: this.glowRt, clear: Color.black },
                ),
            )
            .addPass(new CallbackRenderPass((context) => this.blur.apply(context.backend, this.glowRt, this.blurredRt)))
            .addPass(new RenderNodePass(this.baseSprite, { clear: Color.black }))
            .addPass(new RenderNodePass(this.glowSprite));
    }

    override update(delta): void {
        const { width, height } = this.app.canvas;
        this.time += delta.seconds;
        this.bunny.setPosition(width / 2 + Math.cos(this.time * 1.7) * (width * 0.32), height / 2 + Math.sin(this.time * 1.2) * (height * 0.32));
    }

    override draw(context): void {
        this.pipeline.execute(context);
    }

    override destroy(): void {
        // Pipeline cascades destroy() to its passes; the caller-owned targets and blur filter are freed here.
        this.pipeline.destroy();
        this.baseRt.destroy();
        this.glowRt.destroy();
        this.blurredRt.destroy();
        this.blur.destroy();
        super.destroy();
    }
}

app.start(new BloomLiteScene());

A moving bunny with bloom: base pass, glow pass, blur of glow, additive composite.

Post Processing Chain Open in Playground View source
import {
    Application,
    BlurFilter,
    CallbackRenderPass,
    Color,
    ColorFilter,
    Graphics,
    RenderNodePass,
    RenderPipeline,
    RenderTexture,
    Scene,
    Sprite,
} from '@codexo/exojs';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

class PostProcessingChainScene extends Scene {
    private scene!: Graphics;
    private a!: RenderTexture;
    private b!: RenderTexture;
    private c!: RenderTexture;
    private blur!: BlurFilter;
    private color!: ColorFilter;
    private final!: Sprite;
    private pipeline!: RenderPipeline;
    private time = 0;

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

        this.scene = new Graphics();
        this.a = new RenderTexture(width, height);
        this.b = new RenderTexture(width, height);
        this.c = new RenderTexture(width, height);
        this.blur = new BlurFilter({ radius: 6, quality: 2 });
        this.color = new ColorFilter(new Color(140, 190, 255));
        this.final = new Sprite(this.c);

        // Configured once: scene → off-screen, two filter passes, composite to the canvas.
        this.pipeline = new RenderPipeline()
            .addPass(new RenderNodePass(this.scene, { target: this.a, clear: Color.black }))
            .addPass(new CallbackRenderPass((context) => this.blur.apply(context.backend, this.a, this.b)))
            .addPass(new CallbackRenderPass((context) => this.color.apply(context.backend, this.b, this.c)))
            .addPass(new RenderNodePass(this.final, { clear: Color.black }));
    }

    override update(delta): void {
        const { width, height } = this.app.canvas;
        this.time += delta.seconds;
        this.scene.clear();
        this.scene.fillColor = new Color(80, 130, 255);
        this.scene.drawCircle(width / 2 + Math.cos(this.time * 1.6) * (width * 0.32), height / 2 + Math.sin(this.time * 1.8) * (height * 0.32), 78);
        this.scene.fillColor = new Color(255, 170, 90);
        this.scene.drawCircle(width / 2 + Math.cos(this.time * 1.2 + 1) * (width * 0.3), height / 2 + Math.sin(this.time * 1.3 + 0.7) * (height * 0.34), 54);
    }

    override draw(context): void {
        this.pipeline.execute(context);
    }

    override destroy(): void {
        // Pipeline cascades destroy() to its passes; the caller-owned targets and filters it created are freed here.
        this.pipeline.destroy();
        this.a.destroy();
        this.b.destroy();
        this.c.destroy();
        this.blur.destroy();
        this.color.destroy();
        super.destroy();
    }
}

app.start(new PostProcessingChainScene());

Two animating circles rendered into a RenderTexture, then blurred, then tinted, then displayed.

Render Pipeline Open in Playground View source
import {
    Application,
    BlurFilter,
    CallbackRenderPass,
    Color,
    Container,
    Graphics,
    RenderNodePass,
    RenderPipeline,
    RenderTexture,
    Scene,
    Sprite,
} from '@codexo/exojs';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

// A composable frame, configured once: the world renders off-screen, a blur step turns it into its
// blurred version, a composite step draws that to the screen, and a nested UI pipeline overlays a HUD.
// The blur step toggles on and off via `pass.enabled`; the off-screen targets track the canvas size.
class RenderPipelineScene extends Scene {
    private world!: Container;
    private orb!: Graphics;
    private sceneRt!: RenderTexture;
    private blurredRt!: RenderTexture;
    private composite!: Sprite;
    private blur!: BlurFilter;
    private blurPass!: CallbackRenderPass;
    private hud!: Container;
    private hudBar!: Graphics;
    private frame!: RenderPipeline;
    private detachResize: (() => void) | null = null;
    private time = 0;

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

        this.sceneRt = new RenderTexture(width, height);
        this.blurredRt = new RenderTexture(width, height);
        this.composite = new Sprite(this.blurredRt);
        this.blur = new BlurFilter({ radius: 8, quality: 2 });

        this.world = new Container();
        this.orb = new Graphics();
        this.world.addChild(this.orb);

        this.hud = new Container();
        this.hudBar = new Graphics();
        this.hud.addChild(this.hudBar);

        // A filter step: read the off-screen scene, write its blurred version.
        this.blurPass = new CallbackRenderPass((context) => this.blur.apply(context.backend, this.sceneRt, this.blurredRt), {
            label: 'blur',
        });

        // The HUD is its own nested pipeline, rendered in screen space.
        const ui = new RenderPipeline({ label: 'ui' }).addPass(new RenderNodePass(this.hud, { view: screenView, label: 'hud' }));

        // world → off-screen → blur → composite → UI overlay. A RenderPipeline is itself a RenderPass,
        // so `ui` nests directly.
        this.frame = new RenderPipeline({ label: 'frame' })
            .addPass(new RenderNodePass(this.world, { target: this.sceneRt, clear: Color.black, label: 'world' }))
            .addPass(this.blurPass)
            .addPass(new RenderNodePass(this.composite, { clear: Color.black, label: 'composite' }))
            .addPass(ui);

        // Keep the caller-owned off-screen targets matched to the canvas, then cascade resize into the
        // pipeline (effect passes would rebuild their scratch here).
        const handleResize = (width, height): void => {
            this.sceneRt.setSize(width, height);
            this.blurredRt.setSize(width, height);
            this.frame.resize(width, height);
        };
        this.app!.onResize.add(handleResize);
        this.detachResize = () => this.app!.onResize.remove(handleResize);
    }

    override update(delta): void {
        const { width, height } = this.app!.canvas;
        this.time += delta.seconds;

        // `enabled` lives on the pass — flip it and the composer skips the step next frame.
        this.blurPass.enabled = Math.floor(this.time / 2.5) % 2 === 0;

        this.orb.clear();
        this.orb.fillColor = new Color(90, 150, 255);
        this.orb.drawCircle(width / 2 + Math.cos(this.time) * (width * 0.32), height / 2 + Math.sin(this.time * 1.3) * (height * 0.32), 90);

        this.hudBar.clear();
        this.hudBar.fillColor = this.blurPass.enabled ? new Color(120, 230, 150) : new Color(230, 120, 120);
        this.hudBar.drawRectangle(20, 20, 200, 16);
    }

    override draw(context): void {
        this.frame.execute(context);
    }

    override destroy(): void {
        this.detachResize?.();
        // Cascades destroy() to every pass; the pipeline never destroys caller-owned nodes or targets.
        this.frame.destroy();
        this.sceneRt.destroy();
        this.blurredRt.destroy();
        this.blur.destroy();
        super.destroy();
    }
}

app.start(new RenderPipelineScene());

A composable frame built once from a RenderPipeline: world rendered off-screen, a toggleable blur step (pass.enabled), a composite, and a nested UI pipeline overlaying a HUD in screen space.

Where to go next

The next chapter, Custom mesh shaders, covers attaching custom GLSL or WGSL shaders to Mesh drawables — the geometry-space complement to the screen-space filter and post-processing techniques covered here.