#217
Scene: instancedMesh with typed instance schema and onBeforeRender
Summary
instancedMesh() reserves the API surface for GPU instancing with a typed per-instance schema
and a full onBeforeRender hook contract available on every node. This issue specs the API;
implementation is an explicit fast-follow after the MVP (program protocol + renderTree + lighting)
lands — reserve the surface now so the ABI's location 8-11 reservation (see the program-protocol
sub-issue) has a concrete consumer.
See the parent issue for the full boundary/context.
Typed instance schema → typed writer
const forest = instancedMesh(pine(), barkProgram, {
count: 500,
instance: { tint: "float32x3", sway: "float32" }, // schema → typed writer, locations 12+
});
forest.instances.set(17, { matrix: empty.worldMatrix, sway: 0.5 }); // partial update is OK
forest.count = 350; // shrink without reallocating- Schema fields compile to a typed writer (no manual
DataViewoffset math for authors). - Writes are staged CPU-side with dirty-range tracking; one upload per frame regardless of how
many
instances.set()calls happened that frame. instanceMatrixalways occupieslocation 8-11(see the core-ABI sub-issue); schema fields start atlocation 12.
onBeforeRender — full timing contract (applies to any node, not just instanced meshes)
Fires during the walk, in DFS order, at the moment the node is visited, with that node's own
worldMatrixand all ancestor matrices already resolved.
- Reading
worldMatrixof any node (visited yet or not) is valid — matrices are lazy.- Writing GPU data (e.g.
instances.set(...)) takes effect this same frame.- Writing the transform of a node already visited earlier in this walk takes effect next frame (its matrix for this frame is already resolved/consumed).
- Nodes pruned by
visible: falsenever fireonBeforeRenderat all.
Use case: empties driving instances
const spawnPoints = group(); // "empties" — nodes with no geometry, pure transform carriers
for (let i = 0; i < 350; i++) spawnPoints.add(group({ position: randomPointOnTerrain() }));
forest.onBeforeRender = () =>
spawnPoints.children.forEach((empty, i) =>
forest.instances.set(i, { matrix: empty.worldMatrix }));Edge cases
// (a) writing a transform on a node visited EARLIER in this same walk: takes effect next frame.
world.onBeforeRender = () => { someEarlierSibling.position = [1, 0, 0]; }; // next frame, not this one
// (b) a node pruned by visibility never fires onBeforeRender:
hiddenRig.visible = false;
hiddenRig.onBeforeRender = () => { throw new Error("never called while pruned"); };
// (c) partial instance update leaves untouched slots as-is:
forest.instances.set(3, { sway: 1.2 }); // matrix for instance 3 keeps its previous value
// (d) shrinking count doesn't reallocate or touch retained slot data beyond the new count:
forest.count = 350; // slots 350-499 keep their staged data but stop being drawnTwo GPU paths
// path 1 (pack-friendly): instanceMatrix at locations 8-11; the vertex std detects it and
// multiplies automatically — zero custom WGSL needed for the common case.
const forest = instancedMesh(pine(), barkProgram, { count: 500, instance: { tint: "float32x3" } });
// path 2 (custom / GPU-driven): storage buffer + @builtin(instance_index), authored with
// { shader } for full control — e.g. compute-driven culling/placement.
const gpuForest = instancedMesh(pine(), program({ shader: instanceStorageShaderWGSL }), {
count: 100_000,
storage: true, // opts out of the CPU staging path entirely
});Batching levels
- Level 1 — free today: meshes that share a fingerprint (see the program-protocol sub-issue) already share one compiled pipeline with zero extra API surface.
- Level 2 —
mergeGeometriesutil: an explicit CPU pre-process/authoring-time utility, orthogonal to instancing itself (tracked in the follow-ups sub-issue). - Level 3 — dynamic/indirect batching + culling: phase 3, GPU-driven; low-level indirect draw already exists as a primitive to build on (tracked in the follow-ups sub-issue).
Future sugar (not this issue)
forest.instances.follow(nodes); // sugar over the onBeforeRender pattern aboveAcceptance
-
instancedMesh(geometry, program, { count, instance })typed schema compiles to a typed writer with no manual offset math. -
instances.set(index, partial)supports partial updates; staged CPU-side with dirty-range tracking; exactly one GPU upload per frame regardless ofset()call count. -
countcan shrink without reallocating the underlying buffer. -
onBeforeRendertiming contract (documented above) holds for instanced meshes AND plain nodes/empties, verified with a DFS-order test. -
instanceMatrixreserved atlocation 8-11; schema fields start atlocation 12, verified against the core-ABI sub-issue's reservation. - Path 1 (vertex-std auto-detect) and path 2 (
storage: true/ custom{ shader }) both documented with a working example. - Explicitly scoped as fast-follow: this issue defines the contract; landing it is sequenced after the program-protocol and renderTree sub-issues.
Activity
0No activity yet.