Merged static instances

One world-space tree over the instances that never move, for faster rays from surfaces. On by default for instances marked static.

Every instance box a ray enters costs a transform, a stack marker and a descent into that mesh's tree. In scenes made of one instance per mesh (architecture, Sponza, Bistro) the boxes overlap heavily, and rays that start on surfaces pay for it: on Sponza a surface point sits inside 5.8 instance boxes, and a ray enters 9.8 instances, 8.1 of them without a hit. Merging puts the instances that never move into one world-space tree.

mergeStaticInstances is on by default (SceneTrees and buildTraceScene), but nothing merges unless you mark it: only instances added with static: true are candidates. A scene that marks nothing is the same as before. mergeStaticInstances: false keeps static instances as ordinary ones.

const trees = await SceneTrees.create(device); // mergeStaticInstances is on by default
room.forEach((geometry, index) => {
    const mesh = trees.addMesh(geometry);
    // static: never moves. Merged at the first update when its mesh has no other instance.
    trees.addInstance({ mesh, objectToWorld: matrices[index], static: true });
});

What merges

An instance with static: true, whose mesh no other instance uses, with an invertible transform, visible and contributing to GI, added before the first update. Merged instances are grouped by mask; each group becomes one mesh with one identity-transform instance. Everything else stays a normal instance in the same top level.

Meshes used more than once never merge: copying them would cost memory per copy.

Hits stay exact

On rays with one nearest triangle, hit.instance is the source instance, hit.triangle the triangle's index in its source mesh, hit.frontFace the source's authored side (mirrored sources included), and hit.material the source's material. The ids use words the layout already had, so merging adds no memory to the resident scene.

Near-ties are the exception. Merged triangles are stored in world space, so t agrees with the instanced scene to rounding, not bit for bit. Where two triangles lie at the same distance, the merged walk may pick the other one, and so another instance. The surface-ray benches measured it: 99.949-99.996% of closest hits named the same instance and triangle (RTX 3060 Laptop, Sponza and Bistro interior).

Static means static

Since merging is on by default, static: true now means "merged" for any instance whose mesh it doesn't share. Before 2026-11-13 it changed nothing unless mergeStaticInstances was turned on. Check what you mark:

  • SceneTrees: moving, hiding, re-masking or removing a merged instance throws.
  • SceneTreesObject3DSync (/three): changes to a merged object (removal, new geometry, draw range, groups or materials) are skipped with one warning per object, and rays keep seeing it as merged.
  • mergeStaticGeometry (and buildTraceScene) throw if two static instances that would merge share an instanceIndex: a hit names its source by it.

mergeStaticInstances: false keeps every instance an ordinary one.

The source transforms are baked into the merged geometry. In SceneTrees, moving, hiding, re-masking or removing a merged instance throws. With buildTraceScene, only assembling again notices that a merged source moved; nothing checks between assemblies. Never refit a merged range: its corners are in world space.

Measured

Merged over instanced, 3 runs of 5 paired rounds each per GPU (the median over runs of each run's paired median; pages trace-bench-surface-sponza and trace-bench-surface-bistro-interior, rows in bench/results/trace.csv):

SceneGPUSurface rays, closestSurface rays, anyAir grid, closestAir grid, any
SponzaRTX 3060 Laptop1.67x1.48x1.44x1.24x
SponzaApple M5 Pro (indicative)1.85x (1.83-1.93)1.36x (1.33-1.44)1.49x1.20x
Bistro interiorRTX 3060 Laptop1.20x1.16x1.19x1.35x
Bistro interiorApple M5 Pro (indicative)1.22x (1.21-1.24)1.11x (1.10-1.11)1.19x1.31x

RTX 3060 Laptop: Windows, headless Chrome 157, 2026-11-13. Apple M5 Pro: Chrome 157, under a bench lock. On the RTX 3060, closest hits named the instanced tree's instance and triangle on 99.949-99.996% of rays, and any hits blocked exactly the same rays. On Sponza, steps per surface ray drop from 113.8 to 70.3, and instance entries from 9.8 to 1.0. The option became the default after the RTX 3060 run.

Costs

  • Build time. One big build replaces many small ones, and one build can't spread over the worker pool. tinybvh took 1.53 s for merged Sponza (Node, M5 Pro), against 1.25 s for the per-mesh builds summed (54 ms for the largest). SceneTrees builds the merged mesh on the GPU first, like any mesh, and tinybvh's in a worker.
  • CPU memory while it builds. The merge plan holds world-space positions and indices plus 11 bytes per triangle of source ids: 161.6 MiB on Sponza, until the tree is built.
  • One batch. A merged Sponza is one GPU batch of 5.7 million triangles, whatever the build-peak cap.
  • Precision. Merged triangles are stored in world space as f32. At 10 km from the origin the f32 step is about 1 mm. Merge only what sits near the origin, or re-centre the world first.

Without SceneTrees

buildTraceScene(meshes, instances, { build }) builds and assembles in one call, merging the instances marked static (mergeStaticInstances: false turns it off). The lower level is mergeStaticGeometry(meshes, instances), which returns a plan whose groups you build with any builder and pass to assembleTraceScene(..., { merged: { plan, meshes } }).