Merged static instances
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(andbuildTraceScene) throw if two static instances that would merge share aninstanceIndex: 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):
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).
SceneTreesbuilds 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 } }).