Guide

Guide Debugging & Performance Custom renderers

Custom renderers

Extend rendering with custom passes and backend-specific logic.

Advanced ~4 min read

Custom renderers

“Custom rendering” in ExoJS means inserting your own draw logic into the frame — either as a pass between normal draws, or as direct GPU work that bypasses the engine’s renderer system entirely. The extension surface is intentionally small: two public mechanisms, plus the existing Mesh/MeshMaterial/custom shader filter primitives covered in earlier chapters.

The distinction: a custom renderer controls when and how things are drawn. A MeshMaterial controls what shader an existing Mesh uses. A custom shader filter (WebGl2ShaderFilter or WebGpuShaderFilter) controls a screen-space post-render effect on a drawable’s output. You reach for custom renderers when none of those primitives fit — you need to issue draw calls at a specific point in the frame, or you need to bypass ExoJS drawable types entirely.

CallbackRenderPass

CallbackRenderPass wraps an arbitrary draw callback as one RenderPass and slots it into a RenderPipeline between other passes. The callback receives the RenderingContext — the same high-level object your scene’s draw method gets. For low-level draws (rendering a Graphics directly, immediate-mode geometry), reach through context.backend to the active RenderBackend:

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

class CustomPassScene extends Scene {
    private back!: Sprite;
    private front!: Sprite;
    private between!: Graphics;
    private pipeline!: RenderPipeline;
    private angle = 0;

    override init(loader): void {
        this.back = new Sprite(loader.get(Texture, 'hero')).setAnchor(0.5).setPosition(280, 300);
        this.front = new Sprite(loader.get(Texture, 'hero')).setAnchor(0.5).setPosition(520, 300);
        this.between = new Graphics();

        this.pipeline = new RenderPipeline()
            .addPass(new RenderNodePass(this.back, { clear: Color.black })) // draws behind the pass
            .addPass(
                new CallbackRenderPass((context) => {
                    this.between.clear();
                    this.between.lineWidth = 8;
                    this.between.lineColor = new Color(130, 240, 170);
                    this.between.drawArc(400, 300, 120, this.angle, this.angle + Math.PI * 1.3);
                    this.between.render(context.backend); // low-level draw via context.backend
                }),
            ) // draws between sprites
            .addPass(new RenderNodePass(this.front)); // draws on top
    }

    override update(delta): void {
        this.angle += delta.seconds * 2.2;
    }

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

The pipeline runs its passes in order, so the callback’s draws land exactly where you place the pass — here, between the two sprite passes. Use CallbackRenderPass for procedural geometry (arcs, connectors, debug lines between objects), for rendering non-scene-graph content, or for inserting a filter step at a specific point in the frame.

Low-level backend passes

Beneath the high-level, context-aware pass tree sits BackendRenderPass — an interface for a single backend-only command. Its one method, execute(backend), receives the RenderBackend directly (no camera, not a frame phase). Implement it when a custom pass needs raw backend access — custom shaders, backend-specific draw logic — and run it via backend.execute(pass):

import { CallbackRenderPass, RenderPipeline } from '@codexo/exojs';
import type { BackendRenderPass, RenderBackend } from '@codexo/exojs';

class MyBackendPass implements BackendRenderPass {
    public execute(backend: RenderBackend): void {
        // raw, backend-specific draw work issued through `backend`
        backend.clear();
    }
}

const backendPass = new MyBackendPass();

// Bridge a BackendRenderPass into a high-level RenderPipeline by wrapping it in a
// CallbackRenderPass and running it through context.backend:
const pipeline = new RenderPipeline().addPass(new CallbackRenderPass((context) => context.backend.execute(backendPass)));

A RenderPipeline composes high-level RenderPass objects (RenderNodePass, CallbackRenderPass, nested pipelines), not BackendRenderPass directly. To run a BackendRenderPass inside a pipeline, wrap it in a CallbackRenderPass and call context.backend.execute(pass) — the bridge shown above. For most custom rendering you never need this layer; CallbackRenderPass is the intended escape hatch.

Direct backend access

When you need full GPU control — raw pipeline creation, custom vertex buffers, compute dispatches — access WebGpuBackend directly through app.backend. This is the escape hatch: you bypass ExoJS drawable types and renderer pipelines entirely, working at the WebGPU API level:

import { WebGpuBackend } from '@codexo/exojs';

class CustomTriangleRenderer {
    constructor(backend) {
        if (!(backend instanceof WebGpuBackend)) {
            throw new Error('This requires a WebGPU backend.');
        }
        this._device = backend.device;     // GPUDevice
        this._format = backend.format;     // GPUTextureFormat
        this._context = backend.context;   // GPUCanvasContext

        // Create your own pipeline, vertex buffers, command encoders...
        this._pipeline = this._device.createRenderPipeline({ /* ... */ });
        this._vertexBuffer = this._device.createBuffer({ /* ... */ });
    }

    draw() {
        const encoder = this._device.createCommandEncoder();
        const pass = encoder.beginRenderPass({
            colorAttachments: [{
                view: this._context.getCurrentTexture().createView(),
                clearValue: { r: 0, g: 0, b: 0, a: 1 },
                loadOp: 'clear',
                storeOp: 'store',
            }],
        });
        pass.setPipeline(this._pipeline);
        pass.setVertexBuffer(0, this._vertexBuffer);
        pass.draw(3);
        pass.end();
        this._device.queue.submit([encoder.finish()]);
    }
}

The scene’s draw method would call this._triangleRenderer.draw() instead of context.backend.clear() / context.render(sprite). The custom renderer owns the entire render pass and does not interact with ExoJS drawables.

Direct backend access is deliberately unabstracted — you are writing raw WebGPU code. On WebGL2 backends, a different renderer class would use backend.context (WebGL2RenderingContext) and branch by backend.backendType. For most projects, MeshMaterial + CallbackRenderPass + custom shader filters (WebGl2ShaderFilter / WebGpuShaderFilter) cover custom rendering needs without reaching for raw GPU APIs.

Renderer registration

ExoJS resolves a renderer for each drawable type through the RendererRegistry. You can register a custom renderer for an existing drawable type via backend.rendererRegistry.registerRenderer(drawableConstructor, renderer). A renderer implements connect(backend), disconnect(), render(drawable), and flush() — these map to GPU resource acquisition, release, per-drawable recording, and batch submission respectively.

Registering a custom renderer is an advanced extension point. For most custom rendering needs — procedural geometry between draws, a single custom shape that doesn’t fit MeshCallbackRenderPass is the simpler and more intentional path.

When to use which

MechanismUse when
MeshYou need custom vertex geometry with the standard pipeline
MeshMaterialYou need a custom vertex/fragment shader on a Mesh
Custom shader filter (WebGl2ShaderFilter / WebGpuShaderFilter)You need a screen-space post-render effect on a drawable
CallbackRenderPassYou need to issue draw calls at a specific point in the frame between other draws
BackendRenderPassYou need a reusable backend-only command (custom shader / draw logic); bridge it into a pipeline via CallbackRenderPass
Direct backend accessYou need to bypass ExoJS entirely and work at the GPU API level

Examples

Custom Render Pass Open in Playground View source
import { Application, CallbackRenderPass, Color, Graphics, RenderNodePass, RenderPipeline, 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 CustomRenderPassScene extends Scene {
    private back!: Sprite;
    private front!: Sprite;
    private between!: Graphics;
    private pipeline!: RenderPipeline;
    private angle = 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.back = new Sprite(loader.get(Texture, 'bunny'))
            .setAnchor(0.5)
            .setPosition(width / 2 - 200, height / 2)
            .setScale(2.2)
            .setTint(new Color(120, 170, 255));
        this.front = new Sprite(loader.get(Texture, 'bunny'))
            .setAnchor(0.5)
            .setPosition(width / 2 + 200, height / 2)
            .setScale(2.2)
            .setTint(new Color(255, 180, 120));
        this.between = new Graphics();

        // A callback pass slots procedural geometry between two scene nodes — same frame order
        // as the imperative version, now a named, inspectable step.
        this.pipeline = new RenderPipeline()
            .addPass(new RenderNodePass(this.back, { clear: Color.black }))
            .addPass(
                new CallbackRenderPass((context) => {
                    const { width: w, height: h } = this.app.canvas;
                    this.between.clear();
                    this.between.lineWidth = 10;
                    this.between.lineColor = new Color(130, 240, 170);
                    this.between.drawArc(w / 2, h / 2, 120, this.angle, this.angle + Math.PI * 1.3);
                    this.between.render(context.backend);
                }),
            )
            .addPass(new RenderNodePass(this.front));
    }

    override update(delta): void {
        this.angle += delta.seconds * 2.2;
    }

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

app.start(new CustomRenderPassScene());

An arc drawn between two sprites via CallbackRenderPass — procedural geometry in the render graph.

Where to go next

The next section, Shipping, covers getting a finished project to production — troubleshooting common runtime problems, deploying to a host, and migrating across ExoJS versions.