Guide

Guide Rendering Immediate-mode rendering

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.

Advanced ~5 min read

What you'll learn

  • draw procedural geometry without a scene node via drawGeometry
  • instance thousands of like items as one draw call with RenderBatch
  • know when immediate rendering beats the retained scene graph

Before you start

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 a RenderBatch of 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 RenderBatch uploads 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, cacheAsBitmap, 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.

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.

draw(context) {
    context.backend.clear();

    // 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, tx, c, d, ty.
        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 — the geometry must be 'static' (the default); the batch
// uploads it to the GPU 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:

draw(context) {
    context.backend.clear();

    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);
}

A few rules the batch enforces:

  • The geometry must be usage: 'static' — the GPU buffer is uploaded once and cached by identity. Dynamic or stream geometry is rejected.
  • v1 renders batches with the default mesh material (per-instance tint over the geometry’s vertex colors); a custom RenderBatch.material on the instanced path is not yet supported.
  • An empty batch (count === 0) is a no-op.

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.

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.

Immediate-Mode Rendering Open in Playground View source

Preview is paused until you click Play.

import { Application, Color, Geometry, Matrix, RenderBatch, Scene } from '@codexo/exojs';
import { mountControlPanel, mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(6, 9, 18, 1),
});

// Number of instances drawn in the batched field. The whole field is one
// instanced draw call no matter how large this is.
const FIELD_COUNT = 2400;
// Procedural gears drawn individually with drawGeometry — one draw call each.
const GEAR_COUNT = 14;

// Build a flat-shaded regular polygon as interleaved geometry: position
// (f32 x2) + color (u8 x4, normalized) per vertex, triangle-list. This is the
// exact "standard mesh layout" the immediate path repacks — position, optional
// texcoord, optional color — so an untextured colored shape needs no material.
function polygonGeometry(radius: number, sides: number, fill: Color, center: Color): Geometry {
    const stride = 12; // 2 * f32 (8) + 4 * u8 (4)
    const buffer = new ArrayBuffer(sides * 3 * stride);
    const view = new DataView(buffer);
    let offset = 0;

    const writeVertex = (x: number, y: number, color: Color): void => {
        view.setFloat32(offset + 0, x, true);
        view.setFloat32(offset + 4, y, true);
        view.setUint8(offset + 8, color.r);
        view.setUint8(offset + 9, color.g);
        view.setUint8(offset + 10, color.b);
        view.setUint8(offset + 11, Math.round(color.a * 255));
        offset += stride;
    };

    for (let i = 0; i < sides; i++) {
        const a0 = (i / sides) * Math.PI * 2;
        const a1 = ((i + 1) / sides) * Math.PI * 2;

        // A triangle fan, emitted as a triangle list: center + two rim points.
        writeVertex(0, 0, center);
        writeVertex(Math.cos(a0) * radius, Math.sin(a0) * radius, fill);
        writeVertex(Math.cos(a1) * radius, Math.sin(a1) * radius, fill);
    }

    return 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,
        // 'static' is the default and is required by RenderBatch — its GPU
        // buffer is uploaded once and cached by identity across frames.
        usage: 'static',
    });
}

// Compose a raw world matrix (no node, no parent) from translation, rotation,
// and uniform scale. drawGeometry / RenderBatch take this verbatim as the
// instance's world transform — there is no origin/position/scale to compose.
function composeTransform(out: Matrix, tx: number, ty: number, radians: number, scale: number): Matrix {
    const cos = Math.cos(radians) * scale;
    const sin = Math.sin(radians) * scale;

    // Row-major affine: world = (a*lx + b*ly + x, c*lx + d*ly + y).
    return out.set(cos, -sin, tx, sin, cos, ty);
}

interface Gear {
    geometry: Geometry;
    x: number;
    y: number;
    baseScale: number;
    spin: number;
    tint: Color;
}

interface Spark {
    angle: number;
    radius: number;
    speed: number;
    wobble: number;
    scale: number;
    tint: Color;
}

class ImmediateModeScene extends Scene {
    // The single shared geometry every batched instance draws.
    private sparkGeometry!: Geometry;
    // Reused across frames: clear() keeps the pooled per-instance storage, so a
    // steady-state batch allocates nothing.
    private sparkBatch!: RenderBatch;
    private sparks!: Spark[];
    private gears!: Gear[];
    // One scratch matrix, rewritten per draw — immediate draws are flushed
    // synchronously, so a single matrix is safe to reuse.
    private readonly scratch = new Matrix();
    private elapsed = 0;
    private batched = true;

    private hud!: ReturnType<typeof mountControls>;
    private panel!: ReturnType<typeof mountControlPanel>;

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

        // --- Procedural gears: each drawn with its own drawGeometry call. ---
        const gearPalette = [Color.gold, Color.skyBlue, Color.hotPink, Color.mediumSpringGreen, Color.orange, Color.mediumPurple];

        this.gears = [];
        for (let i = 0; i < GEAR_COUNT; i++) {
            const tint = gearPalette[i % gearPalette.length];
            const ringAngle = (i / GEAR_COUNT) * Math.PI * 2;
            const ringRadius = 250 + (i % 3) * 26;
            const sides = 5 + (i % 5);

            this.gears.push({
                geometry: polygonGeometry(40 + (i % 4) * 10, sides, new Color(tint.r, tint.g, tint.b, 1), Color.white),
                x: centerX + Math.cos(ringAngle) * ringRadius,
                y: centerY + Math.sin(ringAngle) * ringRadius,
                baseScale: 0.7 + (i % 3) * 0.18,
                spin: (i % 2 === 0 ? 1 : -1) * (0.4 + (i % 4) * 0.22),
                tint: new Color(tint.r, tint.g, tint.b, 1),
            });
        }

        // --- Instanced spark field: one small quad, FIELD_COUNT instances. ---
        this.sparkGeometry = polygonGeometry(7, 4, Color.white, Color.white);
        this.sparkBatch = new RenderBatch(this.sparkGeometry);

        const sparkPalette = [Color.skyBlue, Color.aquamarine, Color.gold, Color.hotPink, Color.white];

        this.sparks = [];
        for (let i = 0; i < FIELD_COUNT; i++) {
            const tint = sparkPalette[i % sparkPalette.length];

            this.sparks.push({
                angle: (i / FIELD_COUNT) * Math.PI * 2 * 8,
                radius: 30 + (i / FIELD_COUNT) * Math.min(width, height) * 0.46,
                speed: 0.15 + (i % 9) * 0.06,
                wobble: (i % 13) * 0.5,
                scale: 0.6 + (i % 5) * 0.18,
                tint: new Color(tint.r, tint.g, tint.b, 1),
            });
        }

        this.hud = mountControls({
            title: 'Immediate-Mode Rendering',
            controls: [
                { keys: ['Batched'], action: `${FIELD_COUNT} sparks via RenderBatch + drawBatch (1 draw call)` },
                { keys: ['Per-shape'], action: 'each gear via drawGeometry (1 draw call each)' },
            ],
            hint: 'Toggle the spark field between one instanced draw and one-draw-per-spark to compare draw calls.',
        });

        this.panel = mountControlPanel({ title: 'Render path' });
        this.panel.addToggle({
            label: 'Batch sparks',
            value: this.batched,
            onChange: value => {
                this.batched = value;
            },
        });
    }

    override update(delta): void {
        this.elapsed += delta.seconds;
    }

    override draw(context): void {
        const { width, height } = this.app.canvas;
        const centerX = width / 2;
        const centerY = height / 2;
        const time = this.elapsed;

        context.backend.clear();

        // 1) Instanced field. Rebuild the per-instance transforms each frame,
        //    then submit the whole batch as ONE instanced draw call — or, when
        //    toggled off, draw each instance with its own drawGeometry to show
        //    the draw-call cost the batch collapses.
        this.sparkBatch.clear();
        for (const spark of this.sparks) {
            const angle = spark.angle + time * spark.speed;
            const radius = spark.radius + Math.sin(time * 1.3 + spark.wobble) * 14;
            const x = centerX + Math.cos(angle) * radius;
            const y = centerY + Math.sin(angle) * radius;
            const scale = spark.scale + Math.sin(time * 2 + spark.wobble) * 0.2;

            composeTransform(this.scratch, x, y, angle, scale);

            if (this.batched) {
                this.sparkBatch.add(this.scratch, spark.tint);
            } else {
                context.drawGeometry(this.sparkGeometry, this.scratch, { tint: spark.tint });
            }
        }

        if (this.batched) {
            context.drawBatch(this.sparkBatch);
        }

        // 2) Procedural gears on top — one immediate drawGeometry per gear, each
        //    with its own raw transform and tint, no scene node involved.
        for (const gear of this.gears) {
            const scale = gear.baseScale * (1 + Math.sin(time * 1.5 + gear.x * 0.01) * 0.06);

            composeTransform(this.scratch, gear.x, gear.y, time * gear.spin, scale);
            context.drawGeometry(gear.geometry, this.scratch, { tint: gear.tint });
        }

        // drawCalls is the proof: batched → gears + 1, per-shape → gears + sparks.
        const drawCalls = context.stats.drawCalls;
        const path = this.batched ? 'RenderBatch (instanced)' : 'drawGeometry per spark';

        this.hud.setStatus(`${path} · ${FIELD_COUNT} sparks · ${GEAR_COUNT} gears · drawCalls: ${drawCalls}`);
    }

    override unload(): void {
        this.dispose();
    }

    override destroy(): void {
        this.dispose();
    }

    private dispose(): void {
        this.hud?.dispose();
        this.panel?.dispose();
        this.sparkBatch?.destroy();
        this.sparkGeometry?.destroy();

        if (this.gears) {
            for (const gear of this.gears) {
                gear.geometry.destroy();
            }
        }
    }
}

app.start(new ImmediateModeScene());

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.