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 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 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 (WebGl2ShaderFilter / WebGpuShaderFilter) | 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 section, Shipping, covers getting a finished project to production — troubleshooting common runtime problems, deploying to a host, and migrating across ExoJS versions.