Immediate-mode rendering
Draw procedural geometry without a scene node using drawGeometry, and instance thousands of like items as a single draw call with RenderBatch.
Immediate-mode rendering
Most rendering in ExoJS is retained: you build a tree of SceneNode objects — sprites, meshes, containers — and the engine walks it every frame, culls it, batches it, and draws it. The tree is the source of truth, and you mutate it between frames.
Immediate-mode rendering is the opposite. You hand a Geometry and a world Matrix straight to the RenderingContext inside Scene.draw, and it draws right then — no node, no parent, no transform composition. Two methods cover it:
drawGeometry— draw one geometry with a raw transform. One draw call per call.drawBatch— draw aRenderBatchof N instances of one geometry as a single instanced draw call.
When to use it
Reach for immediate mode when wrapping each item in a node would be wasteful and the data already lives in a plain array:
- Procedural or data-driven shapes — debug gizmos, generated levels, charts, vector fields, particles you simulate yourself. The geometry is computed, not authored, so there is nothing to retain.
- Many like items as one draw call — thousands of tiles, bullets, grass blades, sparks. A
RenderBatchuploads the geometry once and submits every instance in a single instanced draw, where the scene graph would batch (or fail to batch) per node. - Throwaway frames — when the set of things to draw changes completely every frame, building and tearing down nodes costs more than just drawing.
Stay with the scene graph when you need its services: parenting and transform inheritance, hit-testing, culling, filters, masks, cacheAsTexture, or the editor/serialization tooling. Immediate mode is a deliberate escape hatch, not a replacement — you can freely mix both in the same draw.
Building geometry
A Geometry is interleaved vertex data plus a layout. The immediate path expects the standard mesh layout: a a_position attribute (two floats), and optionally a_texcoord (two floats) and a_color (four bytes, normalized). An untextured, vertex-colored shape needs only position and color — and no material at all, because the default mesh material samples a 1×1 white texture and multiplies it by the vertex color and tint.
import { Geometry } from '@codexo/exojs';
// A solid-color triangle: position (f32 x2) + color (u8 x4) per vertex.
const stride = 12; // 8 bytes position + 4 bytes color
const buffer = new ArrayBuffer(3 * stride);
const view = new DataView(buffer);
const corners = [
[0, -40],
[40, 40],
[-40, 40],
];
corners.forEach(([x, y], i) => {
const base = i * stride;
view.setFloat32(base + 0, x, true);
view.setFloat32(base + 4, y, true);
view.setUint8(base + 8, 120); // r
view.setUint8(base + 9, 200); // g
view.setUint8(base + 10, 255); // b
view.setUint8(base + 11, 255); // a
});
const triangle = new Geometry({
attributes: [
{ name: 'a_position', size: 2, type: 'f32', normalized: false, offset: 0 },
{ name: 'a_color', size: 4, type: 'u8', normalized: true, offset: 8 },
],
vertexData: buffer,
stride,
usage: 'static',
});
Build the geometry once in Scene.init and keep it — it carries no transform, so the same shape is reused at any position. Geometry must use triangle-list topology (the default) for the immediate path; custom per-vertex attributes beyond position/texcoord/color are dropped.
Index width
indices are optional — without them the vertex stream is drawn as a flat triangle list. When you do supply them, the array kind you pass is the width the GPU draws with:
import { Geometry } from '@codexo/exojs';
const attributes = [{ name: 'a_position', size: 2, type: 'f32', normalized: false, offset: 0 } as const];
// The default: cheaper to upload and to keep resident.
const small = new Geometry({ attributes, vertexData: new ArrayBuffer(8 * 8), stride: 8, indices: new Uint16Array([0, 1, 2]) });
// Generated or merged geometry addressing more than 65 536 vertices.
const large = new Geometry({ attributes, vertexData: new ArrayBuffer(8 * 8), stride: 8, indices: new Uint32Array([0, 1, 2]) });
console.log(small.indices?.BYTES_PER_ELEMENT, large.indices?.BYTES_PER_ELEMENT);
Prefer Uint16Array — it is half the index bytes, and almost every hand-authored mesh fits. Reach for Uint32Array where the vertex count genuinely exceeds what a 16-bit index can address: batched tile chunks, trail ribbons, generated terrain, an imported SVG path.
A Uint32Array is never narrowed back for you, even when its values would fit. The declared width is the contract, so a geometry cannot change index width later just because its content changed. A non-indexed mesh is the one case the engine decides for you: its indices are synthesized, so they widen on their own once there are more than 65 536 vertices to address. Both widths may appear in the same frame — each draw binds its own.
Drawing one shape: drawGeometry
drawGeometry(geometry, transform, options?) draws the geometry with transform as its raw world matrix. The matrix is taken verbatim as a, b, c, d, tx, ty — there is no position / rotation / scale / origin composition the way a node would apply. You build the world matrix yourself, which is the point: full control, zero overhead.
override draw(context: RenderingContext): void {
// A row of the same triangle, each at a different position, rotation,
// and scale. Each call is its own flush and its own draw call.
for (let i = 0; i < 5; i++) {
const angle = this.elapsed + i;
const cos = Math.cos(angle) * 1.5;
const sin = Math.sin(angle) * 1.5;
const x = 200 + i * 160;
// Row-major affine: a, b, x, c, d, y.
this.transform.set(cos, -sin, x, sin, cos, 360);
context.drawGeometry(this.triangle, this.transform, { tint: this.tints[i] });
}
}The optional third argument carries a tint (Color) multiplied into the vertex colors, a custom material (must target 'mesh'), and a view override. Each drawGeometry is flushed immediately, so it lands in call order relative to the surrounding render and drawGeometry calls — a shape drawn later layers on top. Because it flushes per call, drawGeometry is best for a handful of shapes; for many like items, batch them.
Drawing many: RenderBatch + drawBatch
A RenderBatch is one geometry plus one material drawn once with N per-instance (transform, tint) pairs — the instanced form of the immediate path. It collapses thousands of like items into a single draw call.
import { Geometry, RenderBatch } from '@codexo/exojs';
// In Scene.init — 'static' is the default and the right choice for a shape
// that never changes: the batch uploads it once and caches it by identity.
const sparkGeometry = new Geometry({
attributes: [{ name: 'a_position', size: 2, type: 'f32', normalized: false, offset: 0 }],
vertexData: new Float32Array([0, 0, 8, 0, 4, 8]),
stride: 8,
usage: 'static',
});
const batch = new RenderBatch(sparkGeometry);
Each frame, rebuild the instances and submit the batch. clear resets the instance count but keeps the pooled per-instance storage, so a steady-state batch allocates nothing across frames. add copies the transform and tint, so you can reuse one scratch Matrix for every instance:
override draw(context: RenderingContext): void {
this.batch.clear();
for (const spark of this.sparks) {
const x = this.centerX + Math.cos(spark.angle) * spark.radius;
const y = this.centerY + Math.sin(spark.angle) * spark.radius;
// One scratch matrix, rewritten and copied into the batch per instance.
this.scratch.set(spark.scale, 0, x, 0, spark.scale, y);
this.batch.add(this.scratch, spark.tint);
}
// Every instance ships as ONE instanced draw call.
context.drawBatch(this.batch);
}Two things worth knowing:
- An empty batch (
count === 0) is a no-op. - Geometry of any
usageworks.'static'is uploaded once and cached by identity, which is what you want for a shape that never changes.'dynamic'or'stream'geometry is re-packed and re-uploaded whenever you callinvalidateon it, so a mesh you rewrite per frame reaches the GPU without rebuilding the batch:
// Rewrite the vertex data in place, then publish the change.
writeWaveVertices(this.ribbon.vertexData as ArrayBuffer, this.elapsed);
this.ribbon.invalidate();
context.drawBatch(this.batch);Call batch.destroy() in Scene.unload/destroy to release the pooled storage. The geometry and any material are owned by you and are not destroyed with the batch.
Custom materials and per-instance data
By default a batch renders through the default mesh material, so the only thing that varies per instance is the transform and the tint. Pass a MeshMaterial instead and you drive every instance with your own shader — and you can feed it your own per-instance values.
Declare those values as instanceAttributes when constructing the batch. The engine interleaves them into one buffer and hands each instance its own slice:
const batch = new RenderBatch(sparkGeometry, sparkMaterial, {
instanceAttributes: [
{ name: 'a_offset', format: 'float32x2' },
{ name: 'a_phase', format: 'float32' },
],
});add takes the values as its third argument, keyed by attribute name, and copies them — exactly like it copies the Matrix and Color. So hoist one scratch object out of the loop rather than allocating a literal per instance, or the batch stops being allocation-free:
const data = { a_offset: [0, 0], a_phase: 0 };
this.batch.clear();
for (const spark of this.sparks) {
data.a_offset[0] = spark.driftX;
data.a_offset[1] = spark.driftY;
data.a_phase = spark.phase;
this.scratch.set(spark.scale, 0, spark.x, 0, spark.scale, spark.y);
this.batch.add(this.scratch, spark.tint, data);
}The shader contract
A batch reaches each instance’s transform and tint through a buffer the engine shares across the whole frame, indexed per instance. Your shader has to read it the same way the engine does — which means knowing the buffer’s exact memory layout, how the affine matrix is unpacked, and how pixel snapping is applied.
You should not have to know any of that, and it is not stable: the tint has already moved into a separate row once, to keep the transform row inside two texels. A shader that had copied the old access would have broken silently, as wrong colours rather than as a compile error.
So the engine exports the contract instead. Insert INSTANCE_TRANSFORM_GLSL after your #version directive and position through exoInstanceClipPosition — it returns clip space, snapping included, because snapping is defined in device pixels and needs the projection and viewport together:
import { INSTANCE_TRANSFORM_GLSL, MeshMaterial, Shader } from '@codexo/exojs';
const sparkMaterial = new MeshMaterial({
shader: new Shader({
glsl: {
vertex: `#version 300 es
${INSTANCE_TRANSFORM_GLSL}
in vec2 a_offset;
in float a_phase;
out vec4 v_tint;
void main() {
vec2 local = a_position + a_offset;
gl_Position = vec4(exoInstanceClipPosition(local, a_nodeIndex), 0.0, 1.0);
v_tint = exoInstanceTint(a_nodeIndex) * (0.5 + 0.5 * sin(a_phase));
}`,
fragment: `#version 300 es
precision mediump float;
in vec4 v_tint;
out vec4 fragColor;
void main() {
fragColor = vec4(v_tint.rgb * v_tint.a, v_tint.a);
}`,
},
}),
});
The constant declares a_position, a_texcoord, a_color and a_nodeIndex for you, along with the uniforms behind the two helpers. Attributes you do not use are stripped when the shader links, which is fine — the batch binds only what survived.
A shader that ignores the contract is rejected on the first drawBatch, naming what it is missing. It is not quietly rendered another way: falling back would turn one instanced draw into one draw call per instance, and you would only ever find that in a profiler. The check reads the linked program, so it cannot run any earlier than the first draw.
Targeting WebGPU too
Give the same Shader a wgsl body built on INSTANCE_TRANSFORM_WGSL, which exposes the same two helpers. Two differences follow from WGSL itself:
- It has no global vertex inputs, so your entry point declares them in an input struct and passes
nodeIndexto the helpers explicitly. The GLSL helpers take it as a parameter too, so both shader bodies read alike. - It has no name-based vertex binding, so declaration order fixes the location: the Nth declared instance attribute is
@location(7 + N), starting fromFIRST_INSTANCE_ATTRIBUTE_LOCATION. Locations 0–2 carry the geometry and 6 carries the node index.
struct VertexInput {
@location(0) position: vec2<f32>,
@location(6) nodeIndex: u32,
@location(7) offset: vec2<f32>, // first declared instanceAttribute
@location(8) phase: f32, // second
};
Proving the draw call: RenderStats
The payoff is visible in the per-frame counters. context.stats.drawCalls reports the number of GPU draw calls issued this frame. A batched field of 2,400 sparks adds exactly one to that count; drawing the same 2,400 sparks with one drawGeometry each adds 2,400. Read it at the end of draw to surface it in a HUD or the debug overlay:
const drawCalls = context.stats.drawCalls;
hud.setStatus(`sparks via RenderBatch - drawCalls: ${drawCalls}`);Worked example
The example draws procedural gears with one drawGeometry call each and a field of 2,400 sparks as a single RenderBatch draw. Toggle the spark field between the instanced batch and one drawGeometry per spark and watch the live drawCalls readout jump from a handful to thousands — the whole reason the batch exists.
Where to go next
Immediate mode shares the mesh vertex layout with Custom mesh shaders — pass a MeshMaterial to drawGeometry’s options to drive the immediate path with your own GLSL/WGSL. To measure where the scene graph itself becomes the bottleneck before reaching for immediate mode, see the Performance chapter and its stress examples.