From 3db735b9a98d44ad22ec846d14c4c45e13a4dbac Mon Sep 17 00:00:00 2001 From: Exoridus Date: Mon, 22 Jun 2026 18:40:39 +0200 Subject: [PATCH] docs(examples): immediate-mode rendering example + guide (A5) --- examples/examples.json | 15 ++ .../immediate-mode-rendering.js | 195 ++++++++++++++ .../immediate-mode-rendering.ts | 248 ++++++++++++++++++ .../guide/rendering/immediate-mode.mdx | 158 +++++++++++ site/src/lib/guide-structure.ts | 12 + 5 files changed, 628 insertions(+) create mode 100644 examples/geometry-graphics/immediate-mode-rendering.js create mode 100644 examples/geometry-graphics/immediate-mode-rendering.ts create mode 100644 site/src/content/guide/rendering/immediate-mode.mdx diff --git a/examples/examples.json b/examples/examples.json index 4f927645..4d588cc5 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -681,6 +681,21 @@ "graphics", "rendering" ] + }, + { + "slug": "immediate-mode-rendering", + "path": "geometry-graphics/immediate-mode-rendering.js", + "language": "typescript", + "title": "Immediate-Mode Rendering", + "description": "Draw procedural geometry without a scene graph: gears via drawGeometry and a 2,400-spark field as a single instanced RenderBatch draw call, with a live drawCalls readout.", + "backend": "core", + "level": "advanced", + "tags": [ + "rendering", + "geometry", + "instancing", + "performance" + ] } ], "getting-started": [ diff --git a/examples/geometry-graphics/immediate-mode-rendering.js b/examples/geometry-graphics/immediate-mode-rendering.js new file mode 100644 index 00000000..55c92fc1 --- /dev/null +++ b/examples/geometry-graphics/immediate-mode-rendering.js @@ -0,0 +1,195 @@ +// Auto-generated from immediate-mode-rendering.ts — edit the .ts source, not this file. +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, sides, fill, center) { + 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, y, color) => { + 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, tx, ty, radians, scale) { + 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); +} +class ImmediateModeScene extends Scene { + // The single shared geometry every batched instance draws. + sparkGeometry; + // Reused across frames: clear() keeps the pooled per-instance storage, so a + // steady-state batch allocates nothing. + sparkBatch; + sparks; + gears; + // One scratch matrix, rewritten per draw — immediate draws are flushed + // synchronously, so a single matrix is safe to reuse. + scratch = new Matrix(); + elapsed = 0; + batched = true; + hud; + panel; + init() { + 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; + }, + }); + } + update(delta) { + this.elapsed += delta.seconds; + } + draw(context) { + 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}`); + } + unload() { + this.dispose(); + } + destroy() { + this.dispose(); + } + dispose() { + 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()); diff --git a/examples/geometry-graphics/immediate-mode-rendering.ts b/examples/geometry-graphics/immediate-mode-rendering.ts new file mode 100644 index 00000000..a9739dcc --- /dev/null +++ b/examples/geometry-graphics/immediate-mode-rendering.ts @@ -0,0 +1,248 @@ +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; + private panel!: ReturnType; + + 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()); diff --git a/site/src/content/guide/rendering/immediate-mode.mdx b/site/src/content/guide/rendering/immediate-mode.mdx new file mode 100644 index 00000000..87902bba --- /dev/null +++ b/site/src/content/guide/rendering/immediate-mode.mdx @@ -0,0 +1,158 @@ +--- +title: 'Immediate-mode rendering' +description: 'Draw procedural geometry without a scene node using drawGeometry, and instance thousands of like items as a single draw call with RenderBatch.' +--- + +import ExamplePreview from '../../../components/ExamplePreview.astro'; + +# Immediate-mode rendering + +Most rendering in ExoJS is _retained_: you build a tree of [`SceneNode`](/ExoJS/en/api/scene-node/) 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`](/ExoJS/en/api/geometry/) and a world [`Matrix`](/ExoJS/en/api/matrix/) straight to the [`RenderingContext`](/ExoJS/en/api/rendering-context/) inside `Scene.draw`, and it draws right then — no node, no parent, no transform composition. Two methods cover it: + +- [`drawGeometry`](/ExoJS/en/api/rendering-context/) — draw **one** geometry with a raw transform. One draw call per call. +- [`drawBatch`](/ExoJS/en/api/rendering-context/) — draw a [`RenderBatch`](/ExoJS/en/api/render-batch/) 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. + +```ts +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. + +```js +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`](/ExoJS/en/api/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`](/ExoJS/en/api/render-batch/) 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. + +```js +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: + +```js +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: + +```js no-check +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](/ExoJS/en/guide/effects/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](/ExoJS/en/guide/debugging/performance/) chapter and its stress examples. diff --git a/site/src/lib/guide-structure.ts b/site/src/lib/guide-structure.ts index 78d78387..e7fd4161 100644 --- a/site/src/lib/guide-structure.ts +++ b/site/src/lib/guide-structure.ts @@ -354,6 +354,18 @@ const RAW_PARTS: ReadonlyArray = [ prerequisites: ['rendering/sprites'], apiLinks: ['drawable', 'sprite', 'view'], }, + { + slug: 'immediate-mode', + level: 'advanced', + learningGoals: [ + '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', + ], + prerequisites: ['rendering/graphics'], + examples: ['geometry-graphics/immediate-mode-rendering'], + apiLinks: ['rendering-context', 'render-batch', 'geometry', 'matrix', 'color'], + }, ], }, {