SceneTrees
SceneTrees keeps every mesh tree and the top level of a scene in three storage buffers on one GPUDevice: the
buffers the walk binds. It works with any engine; three's sync drives it from a scene graph.
import { SceneTrees, type MeshGeometry } from '@pmndrs/bvh';
const trees = await SceneTrees.create(device, {
onTree: (event) => console.log(`mesh ${event.mesh}: ${event.builtBy} tree after ${event.ms.toFixed(0)} ms`, event.replaced ? '(swap)' : ''),
});
const mesh = trees.addMesh(chair); // one tree per mesh, shared by its instances
for (const objectToWorld of chairMatrices) trees.addInstance({ mesh, objectToWorld }); // column-major 4x4
// Every frame (or whenever the scene changed):
const encoder = device.createCommandEncoder();
trees.update(encoder);
device.queue.submit([encoder.finish()]);
addMesh(geometry)returns a mesh handle. The geometry is read at the nextupdateand copied into a worker (transferGeometry: truemoves it instead).addInstance({ mesh, objectToWorld, mask, material, visible, static, instanceIndex })returns an instance handle.instanceIndex(default: the handle) comes back ashit.instance.update(encoder)does everything in the order the walk needs: starts builds, places finished trees, applies changes, refits deforming meshes and refits the top level. Record it before anything traces in that encoder.
Fast first trees and swaps
On a device with subgroups, every mesh an update brings is built twice:
- On the GPU, with H-PLOC, in batches (one for the whole scene unless the build-peak cap splits it). Rays see these trees a few frames later.
- With tinybvh, in the worker pool, biggest mesh first. Each tinybvh tree replaces the mesh's GPU tree as it arrives. Only that mesh's range and its table entry change, so nothing recompiles and the top level keeps its shape.
A mesh's first tree is whichever arrives first. Without subgroups, or with gpuFirstTrees: false, every mesh waits for
tinybvh: the declared fallback.
Sponza (132 meshes, 5.74 million triangles) on the Apple M5 (Chrome, scene-trees-sponza page, indicative): every
mesh had a GPU tree 100-190 ms after the first update, depending on the run and the cap, and every tinybvh tree was in
about 285-370 ms after it (trees.timeline). On the three
load-gltf example (405 meshes, 3.7 million triangles), first trees took about 150-230 ms and
every tinybvh tree about 600 ms after the first update.
frame(); // an update starts the builds
await trees.whenBuilt(); // every mesh with instances has a tree (GPU or tinybvh)
frame(); // the next update places them
await trees.whenRefined(); // every GPU tree has been replaced by tinybvh's
frame();
Builds start in update, never in addMesh: a mesh added since the last update counts as waiting, so whenBuilt()
and whenRefined() never resolve if no update comes. Call update once after adding meshes, as frame() does above,
then await. A frame loop that already runs needs nothing extra; a page that awaits before its first frame hangs.
onTree reports every placement: the mesh, builtBy ('gpu' or 'tinybvh'), replaced ('gpu' for a swap),
fallbackReason and the milliseconds since the update that started it.
Build events
onTree says when a tree is placed. onBuild says what happens before: for each mesh (or merged group) and each
builder ('gpu', 'tinybvh'), the build is 'requested' in the update that brings the mesh, 'started' when its GPU
batch is submitted (a capped batch can wait in the queue first) or a worker takes the tinybvh build, and 'finished'
with an outcome:
'built': a tree came back. It is placed at a later update (onTree), unless a better one came first (tinybvh's before the GPU's).'refused': no usable tree (too big for a GPU batch, deeper thangpuMaxDepth, a batch that failed), with areason.'failed': tinybvh threw.'cancelled': the mesh was removed while it built.
Each event has the milliseconds since the request, the triangle count, and rebuild for a deforming mesh's rebuild. A
finished GPU build has gpuMs: the batch's pass from timestamps with timestamps: true, shared by the batch's meshes,
else null. A build that ends before it starts skips 'started'. Nothing is reported after dispose().
const trees = await SceneTrees.create(device, {
// Each mesh's build, per builder: 'requested' (in an update), 'started', 'finished' (with its outcome).
onBuild: (event) => {
const who = event.mesh !== null ? `mesh ${event.mesh}` : `merged group ${event.group}`;
if (event.phase !== 'finished') console.log(who, event.builder, event.phase);
else console.log(who, event.builder, event.outcome, `${event.ms.toFixed(0)} ms`, event.reason ?? '', event.gpuMs ?? '');
},
// Placed: what rays see from the next frame.
onTree: (event) => console.log(`mesh ${event.mesh}: ${event.builtBy} tree placed`),
});
GPU time
Two numbers, both from timestamp queries (the device needs timestamp-query):
- An update's GPU time.
update(encoder, { timestampWrites })writesbeginningOfPassWriteIndexin a tiny compute pass before the first command it records andendOfPassWriteIndexin one after the last: tree copies, quantize and identity passes, refits, growth copies and the top-level refit. You resolve the query set. Both passes are recorded however the update ends, so the pair is never half written. GPU builds are not in it: they go in submits of their own. - The GPU builds. With
timestamps: true, each H-PLOC batch's pass is timed, andcounts.gpuBuildTimestampMssums them (null until one is timed).counts.gpuBuildMsstays the wall time from each batch's submit to its stats.
// timestamps: each GPU build batch's pass, summed in counts.gpuBuildTimestampMs (needs timestamp-query).
const trees = await SceneTrees.create(device, { timestamps: true });
const querySet = device.createQuerySet({ type: 'timestamp', count: 2 });
const resolved = device.createBuffer({ size: 16, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
const read = device.createBuffer({ size: 16, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
const encoder = device.createCommandEncoder();
// A timestamp before and after everything the update records (copies, refits, the top level).
trees.update(encoder, { timestampWrites: { querySet, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 } });
encoder.resolveQuerySet(querySet, 0, 2, resolved, 0);
encoder.copyBufferToBuffer(resolved, 0, read, 0, 16);
device.queue.submit([encoder.finish()]);
await read.mapAsync(GPUMapMode.READ);
const [start, end] = new BigInt64Array(read.getMappedRange());
console.log(`update: ${Number(end - start) / 1e6} ms on the GPU; builds so far: ${trees.counts.gpuBuildTimestampMs ?? '-'} ms`);
GPU trees only
With refine: false, a device with subgroups keeps its GPU trees: tinybvh builds only the meshes the GPU gave no
usable tree (too big for a batch, deeper than gpuMaxDepth, a batch that failed), as the declared fallback, and their
fallbackReason says why. It suits scenes that change often, where tinybvh's better trees would arrive too late to
matter, and tools that compare the GPU builder alone. Each mesh's geometry is kept until its GPU tree is in (for that
fallback). Without subgroups, or with gpuFirstTrees: false, tinybvh builds every mesh either way.
// GPU trees only: tinybvh builds just the meshes the GPU refuses (and every mesh without subgroups).
const trees = await SceneTrees.create(device, { refine: false });
Trees you built
placeTree(mesh, layout) places a tree the app made (on a worker's own device, from a file, with any builder that
writes the GPU layout) as the mesh's tree from the next update. It is final, as tinybvh's is: given before the first
update that brings the mesh, no build starts for it; given later, a tinybvh build still running is cancelled and a GPU
tree that comes after it is dropped. A deforming mesh's rebuilds still replace it. onTree, meshStatus and
counts.onAppTrees say 'app'.
const trees = await SceneTrees.create(device);
const mesh = trees.addMesh(crate);
// A tree you built (another device, a file, any builder of the GPU layout) from the same geometry:
// given before the first update, the mesh starts no builds; later, its builds are cancelled or dropped.
trees.placeTree(mesh, crateLayout);
trees.addInstance({ mesh, objectToWorld: matrix });
// The next update places it: trees.meshStatus(mesh).builtBy === 'app'.
The limits: the tree must be built from the geometry given to addMesh (with its triangle flags and materials), in the
scene's triangle format; f32 nodes are quantized for a quantized scene, but a quantized tree needs a quantized scene;
a deforming mesh needs an f32 tree; an indexed scene welds each mesh's vertices unless weld: false (or the mesh
deforms), and refuses an indexed tree then; and the tree must be within gpuMaxDepth. Each refusal is a RangeError.
The layout's arrays are read at the next update.
Trees baked to files go the same way: placeTree(mesh, deserializeLayout(bytes, { geometry })), which checks the file
against the mesh's geometry first (Baked trees in SceneTrees).
Rebinding when a buffer grows
The node and triangle buffers grow by doubling. A growth replaces the GPUBuffer and bumps trees.version. Make your
bind groups again when it changes; nothing recompiles.
let boundVersion = -1;
const afterUpdate = () => {
if (trees.version !== boundVersion) {
rebind(trees.buffers); // a buffer grew: make the bind groups again (nothing recompiles)
boundVersion = trees.version;
}
};
trees.traceBuffers gives the buffers in the shape TraceBatchKernel and BvhTraceNodes take, with the scene's
formats.
Options
const trees = await SceneTrees.create(device, {
gpuFirstTrees: true, // H-PLOC trees first on a device with subgroups (default)
gpuMaxBatchBytes: 256 * 1024 * 1024, // the build-peak cap (default); Infinity: one batch
workers: { size: 4 }, // the tinybvh worker pool, or a pool of your own
topLevel: 'auto', // the GPU top level with subgroups, the CPU one without (default)
mergeStaticInstances: true, // the default: static instances merge at the first update
triangleLeaves: 'records', // the default; or 'indexed' (16-byte triangles)
nodeFormat: 'quantized', // the default; or 'f32' (64-byte nodes)
});
Your own positions buffer
An indexed scene (triangleLeaves: 'indexed') keeps a copy of every mesh's vertices for the walk, buffers.positions
(45.2 MiB on Sponza with vec4 positions). An app that already keeps all its vertices in one f32 storage buffer can
have the walk bind that buffer instead, with positionsBuffer, and the copy is never made:
- the buffer holds every mesh's vertices in the walk's stride (
positionLayout: 'vec4', 16 bytes per vertex, thewunused; or'packed', 12), each mesh's from thevertexBasegiven toaddMesh(geometry, { vertexBase }), with exactly the bits ofgeometry.positions(the trees are built from the geometry, the walk reads the buffer); - it needs
STORAGEusage, and must stay alive and unchanged while the scene uses it; - the scene doesn't weld (
weldis false; the trees name the app's own vertices), doesn't merge static instances (mergeStaticInstancesis false: a merged group's world-space vertices aren't in the buffer) and takes no deforming meshes (their refit writes each pose into the scene's copy). Asking for any of these throws.
trees.buffers.positions is then your buffer, and memory.positions is 0. A three.js app can't use it today: three
keeps each attribute in a buffer of its own, without storage usage.
What it holds
console.log(trees.gpuFirstTrees); // { enabled, reason }: why GPU first trees are off, if they are
console.log(trees.counts.onGpuTrees, trees.counts.onTinybvhTrees, trees.counts.swaps);
console.log(trees.timeline.firstTreesMs, trees.timeline.tinybvhTreesMs);
console.log((trees.memory.resident / 2 ** 20).toFixed(1), 'MiB resident,', (trees.memory.peak / 2 ** 20).toFixed(1), 'MiB peak');
counts: meshes, instances, trees on the GPU and from tinybvh, swaps, GPU batches and build times.memory: bytes of every buffer, in use and allocated, resident and transient, and thepeaksince creation.meshStatus(mesh),instanceStatus(instance),groupStatus(group)for one mesh, instance or merged group.timeline: when every mesh first had a tree, and when every tinybvh tree was in.
SceneTrees keeps no CPU copy of its trees. For CPU picking on the same scene, build a
CPU scene too.
dispose() frees its buffers and the worker pool it made.