Custom renderers
Extend rendering with custom passes and backend-specific logic.
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 ShaderFilter 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:
CallbackRenderPass first, raw device last
Reach for CallbackRenderPass for almost all custom draw work — it slots into the normal pipeline and hands you the high-level RenderingContext. Drop to raw backend.device only when no pass primitive can express what you need.
class CustomPassScene extends Scene {
private back!: Sprite;
private front!: Sprite;
private between!: Graphics;
private pipeline!: RenderPipeline;
private angle = 0;
override init(): void {
this.back = new Sprite(this.loader.get('image/hero.png')).setAnchor(0.5).setPosition(280, 300);
this.front = new Sprite(this.loader.get('image/hero.png')).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: Seconds): void {
this.angle += delta * 2.2;
}
override draw(context: RenderingContext): 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):
class MyBackendPass implements BackendRenderPass {
public execute(backend: RenderBackend): void {
// Raw, backend-specific draw work issued through `backend`. A pass that
// owns the whole frame like this normally runs with `autoClear: false`,
// so its own clear is the only one.
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/renderer-sdk';
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.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 + ShaderFilter cover custom rendering needs without reaching for raw GPU APIs.
Renderer registration
Extend the abstract SDK renderers, not the concrete ones
Build a custom renderer by subclassing the abstract base renderers from @codexo/exojs/renderer-sdk (AbstractWebGpuRenderer, AbstractWebGl2Renderer / AbstractWebGl2BatchedRenderer). The engine’s concrete renderers such as WebGl2SpriteRenderer are internal, coupled to private data paths, and not part of the SDK surface — subclassing them will break.
ExoJS resolves a renderer for each drawable type through the RendererRegistry (available from @codexo/exojs/renderer-sdk). 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.
In practice you build a custom renderer by extending one of the abstract base renderers from @codexo/exojs/renderer-sdk — AbstractWebGl2Renderer / AbstractWebGl2BatchedRenderer (WebGL2) or AbstractWebGpuRenderer (WebGPU) — which is exactly how the @codexo/exojs-particles and @codexo/exojs-tilemap packages build theirs. The engine’s built-in concrete renderers (e.g. WebGl2SpriteRenderer) are internal: coupled to internal sprite/mesh data paths and intentionally not part of the SDK surface, so don’t subclass them directly.
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 Mesh — CallbackRenderPass is the simpler and more intentional path.
When to use which
| Mechanism | Use when |
|---|---|
Mesh |
You need custom vertex geometry with the standard pipeline |
MeshMaterial |
You need a custom vertex/fragment shader on a Mesh |
Custom shader filter (ShaderFilter) |
You need a screen-space post-render effect on a drawable |
CallbackRenderPass |
You need to issue draw calls at a specific point in the frame between other draws |
BackendRenderPass |
You need a reusable backend-only command (custom shader / draw logic); bridge it into a pipeline via CallbackRenderPass |
| Direct backend access | You need to bypass ExoJS entirely and work at the GPU API level |
Examples
An arc drawn between two sprites via CallbackRenderPass — procedural geometry in the render graph.
Where to go next
The next chapter, Authoring extensions, shows how to package a custom renderer — along with custom asset handlers and node serializers — into a distributable extension, exactly how the official @codexo/exojs-particles and @codexo/exojs-tiled packages plug into the core.

