Skip to content

Mesh instancing: InstancedMesh + EXT_mesh_gpu_instancing (#1508) - #1571

Merged
obiot merged 1 commit into
masterfrom
mesh-instancing-1508
Aug 7, 2026
Merged

Mesh instancing: InstancedMesh + EXT_mesh_gpu_instancing (#1508)#1571
obiot merged 1 commit into
masterfrom
mesh-instancing-1508

Conversation

@obiot

@obiot obiot commented Aug 7, 2026

Copy link
Copy Markdown
Member

Closes #1508 — the last engine item on the #1556 tracker.

Draw one geometry many times in a single call, on both GPU backends. Cost scales with the number of instances rather than with instances × vertices.

The new Instanced Forest example renders 100 000 trees from one 52-vertex geometry in one drawElementsInstanced, at 60 fps on WebGL and WebGPU. Baked out as separate meshes that would be 5.2M vertices; as instance records the asset is 3.1 MB and the GPU holds one geometry.

The enabling refactor

Both backends assumed a single vertex buffer. WebGLVertexState and WebGPUPipelineCache.registerVertexLayout now take a list of buffer layouts, each with a step mode — WebGPU's GPUVertexBufferLayout[] shape. Attributes in an "instance" group advance once per instance (vertexAttribDivisor / stepMode: "instance").

Existing callers are untouched by construction: divisors are issued only for instance groups (a fresh VAO already has every divisor at 0), and WebGPU omits stepMode for vertex groups since "vertex" is the default — so every existing pipeline descriptor and cache key stays byte-identical. Pinned by tests.

The record — 48 B bare, 80 B loaded

A row-major 3×4 affine transform rather than a full mat4: the bottom row is always (0,0,0,1), so storing it would waste 16 bytes and an attribute slot per instance. Two opt-in slots follow — a colour multiplied into the mesh tint, and an opaque vec4 the built-in shading reads as emissive and a custom mesh shader may read as a wind phase, an atlas offset, a random seed.

Slot locations are pinned rather than sequential, so omitting the colour slot does not renumber the data slot — which is what lets one derived WGSL module serve every variant.

InstancedMesh

Extends Mesh (precedent: Sprite3d), so every existing setting works unchanged. Placement is uniform-driven exactly as for a retained mesh:

Change Cost
Moving / rotating / re-tinting the group one uniform — no upload
Moving one instance one record
visibleInstanceCount nothing — the draw is just shorter

Backends

WebGL compiles #ifdef variants per declared slot combination, lazily — a scene with no instanced mesh compiles none.

WGSL has no preprocessor and a module carries both entry points in one file, so the WebGPU variant is derived from the ordinary module: head and fragment stage verbatim, vertex stage replaced, one substitution folding the per-instance slot into the emissive term. That keeps the lighting loop and the std140 Light3dBlock — which must agree byte-for-byte with the uniform packer — in exactly one place. The derivation throws if the source drifts from what it substitutes, and asserts every declared VSOut member is written.

glTF

EXT_mesh_gpu_instancing loads with no user code: a node carrying per-instance TRANSLATION/ROTATION/SCALE accessors becomes an InstancedMesh — on the static and animated paths alike. ROTATION is accepted as float or normalized byte/short (the encoding exporters use for large scatters; the shipped asset uses it, so the de-normalization path is exercised for real). Malformed input is rejected or clamped rather than producing NaN records: mismatched counts, wrong accessor types, sparse accessors, unsigned quaternions.

Reviewed

Three independent reviews of this branch found bugs the tests could not, because the suite asserted CPU records and GL call counts — structurally blind to "the right call, made with the wrong state":

  • The group was frustum-culled by the prototype's box. Camera3d culls on getBounds(), never getBounds3d() — so a scatter over thousands of units presented an ~80 px sphere and the entire forest vanished when the group origin left the frustum.
  • draw() ignored the viewport, taking the GPU path under a 2D camera and leaking the anchor into the shared view matrix.
  • A custom shader could leave the transform rows unbound — a singular matrix collapses the mesh to a point, permanently and silently.
  • One dirty span was drained by whichever batcher drew first, freezing instances at stale transforms for the other (toggling mesh.lit is enough).
  • Recycled slots inherited the dead instance's instanceData — read as emissive, so a new instance glowed.
  • The Canvas fallback placed instances in raw model units, collapsing the scatter and mirroring it in Y.
  • One animation clip anywhere in an asset dropped all instancing.

All fixed, each with a regression test that fails against the code as reviewed. The reviews also cleared the parts I was least sure of: the quaternion→basis maths, T·R·S order, and the axis-bridge question — verbatim model space is correct, including under the mirroring bridge.

Verification

  • Suite: 227 files, 5694 passed / 9 skipped. eslint, biome, build, examples tsc all clean.
  • Regression sweep: Night City, glTF Animated Model, 3D Mesh, glTF Scene, Platformer, Shader Effects — 6 examples × both backends, zero console errors. Chosen for blast radius: WebGLVertexState is used by every batcher, and the fragment-shader edits touch shaders every 3D scene compiles.
  • The forest asset is script-generated (deterministic, seeded) rather than Blender-exported; the 7 integrity tests pin the asset's contract, not the generator, so a Blender re-export satisfying them passes unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi

Draw one geometry many times in a single call, on both GPU backends. Cost
scales with the number of instances rather than with instances × vertices:
the new Instanced Forest example renders 100 000 trees from one 52-vertex
geometry in one `drawElementsInstanced`, at 60 fps on WebGL and WebGPU.

The enabling refactor
---------------------
Both backends assumed a single vertex buffer. `WebGLVertexState` and the
WebGPU pipeline cache now take a LIST of buffer layouts, each with a step
mode — the `GPUVertexBufferLayout[]` shape. Attributes in an "instance"
group advance once per instance (`vertexAttribDivisor` / `stepMode`).
Single-buffer callers are untouched: divisors are issued only for instance
groups, and WebGPU omits `stepMode` for vertex groups, so every existing
descriptor and pipeline key stays byte-identical.

The record
----------
A row-major 3×4 affine transform (48 B) — the bottom row of an affine
matrix is always (0,0,0,1), so a full mat4 would waste 16 bytes and an
attribute slot per instance. Two opt-in slots follow: a colour multiplied
into the mesh tint, and an opaque vec4 the built-in shading reads as
emissive and a custom shader may read as anything. Slot locations are
PINNED rather than sequential, so omitting one does not renumber the rest —
which is what lets one derived WGSL module serve every variant.

InstancedMesh
-------------
Extends Mesh, so every existing setting works unchanged. Placement is
uniform-driven as it is for a retained mesh: moving the whole group
re-uploads nothing, moving one instance re-uploads one record, and
`visibleInstanceCount` shortens the draw without touching the buffer.

Backends
--------
WebGL compiles `#ifdef` variants per declared slot combination, lazily.
WGSL has no preprocessor and a module carries both stages in one file, so
the WebGPU variant is DERIVED from the ordinary module — head and fragment
stage verbatim, vertex stage replaced — which keeps the lighting loop and
the std140 light block in exactly one place. The derivation throws if the
source drifts from what it substitutes.

glTF
----
`EXT_mesh_gpu_instancing` loads with no user code: a node carrying
per-instance TRANSLATION / ROTATION / SCALE accessors becomes an
InstancedMesh, on the static and animated paths alike. ROTATION is accepted
as float or normalized byte/short; malformed input (mismatched counts,
wrong accessor types, sparse accessors, unsigned quaternions) is rejected
or clamped rather than producing NaN records.

Reviewed
--------
Three independent reviews of this branch found bugs the tests could not:
the group was frustum-culled by the PROTOTYPE's box (Camera3d culls on
getBounds(), never getBounds3d(), so a wide scatter vanished wholesale);
`draw()` ignored the viewport and took the GPU path under a 2D camera; a
custom shader could leave the transform rows unbound, collapsing the mesh
to a point; one dirty span was drained by whichever batcher drew first,
freezing instances for the other; recycled slots inherited the dead
instance's emissive; and the Canvas fallback placed instances in raw model
units. All fixed, each with a regression test that fails against the code
as reviewed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
Copilot AI lite review requested due to automatic review settings August 7, 2026 04:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit ed2f8d3 into master Aug 7, 2026
6 checks passed
@obiot
obiot deleted the mesh-instancing-1508 branch August 7, 2026 04:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3D: mesh instancing (drawElementsInstanced for repeated geometry)

2 participants