Mesh instancing: InstancedMesh + EXT_mesh_gpu_instancing (#1508) - #1571
Merged
Conversation
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
This was referenced Aug 7, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
WebGLVertexStateandWebGPUPipelineCache.registerVertexLayoutnow take a list of buffer layouts, each with a step mode — WebGPU'sGPUVertexBufferLayout[]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
stepModefor 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 opaquevec4the 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.
InstancedMeshExtends
Mesh(precedent:Sprite3d), so every existing setting works unchanged. Placement is uniform-driven exactly as for a retained mesh:visibleInstanceCountBackends
WebGL compiles
#ifdefvariants 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 declaredVSOutmember is written.glTF
EXT_mesh_gpu_instancingloads with no user code: a node carrying per-instanceTRANSLATION/ROTATION/SCALEaccessors becomes anInstancedMesh— on the static and animated paths alike.ROTATIONis 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":
Camera3dculls ongetBounds(), nevergetBounds3d()— 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.mesh.litis enough).instanceData— read as emissive, so a new instance glowed.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·Sorder, and the axis-bridge question — verbatim model space is correct, including under the mirroring bridge.Verification
tscall clean.WebGLVertexStateis used by every batcher, and the fragment-shader edits touch shaders every 3D scene compiles.🤖 Generated with Claude Code
https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi