Choosing a builder
Measured on Sponza (5,744,384 triangles, 132 meshes), RTX 3060 Laptop, headless Chrome 156 on Windows:
How these were taken is on Builds and tracing.
The chooser
import { createMeshLayoutBuilder, type MeshInput } from '@pmndrs/bvh';
const builder = await createMeshLayoutBuilder(device); // compiles the GPU kernels once
console.log(builder.kind, builder.fallbackReason); // 'gpu-hploc' and null, or 'tinybvh' and why
const results = await builder.buildLayouts(meshes, { maxLeafPrimitives: 8 });
for (const { layout, builtBy, fallbackReason } of results) {
// layout.nodes, layout.triangles and layout.parents are ArrayBuffers in the GPU layout.
console.log(builtBy, fallbackReason, layout.nodeCount, layout.maxDepth);
}
This path reads each GPU-built tree back to the CPU (one small readback per batch) and frees the GPU copy. To keep
GPU trees on the GPU, use SceneTrees, or a GPU builder directly.
const lbvhFirst = await createMeshLayoutBuilder(device, { prefer: 'gpu-lbvh' });
const cpuOnly = await createMeshLayoutBuilder(device, { prefer: 'tinybvh' }); // for comparisons
const capped = await createMeshLayoutBuilder(device, { maxBatchBytes: 256 * 1024 * 1024 }); // lower build peak
// cpuOnly.fallbackReason is 'the CPU builder was asked for', but cpuOnly.fallback is false: show only real fallbacks.
const shown = cpuOnly.fallback ? cpuOnly.fallbackReason : null;
Setup and GPU time
The factory compiles the GPU builder's pipelines before it resolves. With prepare: false it doesn't, and
builder.prepare() (or the first build) does, so a page can time or schedule the setup apart from the builds.
onGpuBatch reports each GPU batch once its trees are on the GPU: its meshes, its compute pass in milliseconds from
timestamp queries (null without timestamp-query; Chrome rounds them to 0.1 ms unless WebGPU's developer features are
on), and the wall time from its submit. It is never called for tinybvh.
const builder = await createMeshLayoutBuilder(device, { prepare: false });
const setupStart = performance.now();
await builder.prepare(); // the pipelines, once: timed apart from any build
console.log(`setup ${(performance.now() - setupStart).toFixed(0)} ms`);
const results = await builder.buildLayouts(meshes, {
// Once per GPU batch, when its trees are on the GPU (before the readback): never for tinybvh.
onGpuBatch: ({ meshes: inBatch, gpuMs, wallMs }) => console.log(`${inBatch.length} meshes: ${gpuMs ?? 'no timestamp-query'} ms on the GPU, ${wallMs} ms submit to done`),
});
To time the builders yourself, each GPU builder's createBatch(...).encode(encoder, timestampWrites) records a batch's
one compute pass with your timestamp writes, and buildMany(meshes, options, timestampWrites) does the same in its own
submit (resolve the query set after it resolves).
tinybvh
import { createTinyBvhBuilder } from '@pmndrs/bvh';
const tiny = await createTinyBvhBuilder(); // loads the WebAssembly once
const { layout, buildMs } = tiny.buildLayout({ positions, indices }, { maxLeafPrimitives: 8 });
buildLayout runs on the calling thread. A big mesh blocks it for a second or more, so in a browser use
@pmndrs/bvh/worker (Workers). In Node it needs no GPU: see
Serialization and baking.
Memory. Each builder has its own WebAssembly memory, which starts at 64 MiB and grows to a build's peak: about
230 bytes per triangle (tinybvh's working arrays, the input copies and the output; one 5.7M-triangle mesh: 1,349 MiB).
A WebAssembly memory never shrinks, so after a build that left it past releaseAboveBytes (default 256 MiB,
TINYBVH_RELEASE_ABOVE_BYTES) the builder drops its instance and starts a fresh one on the next build; the old memory
is freed when the garbage collector collects it (createTinyBvhBuilder({ releaseAboveBytes: Infinity }) keeps one
instance). tiny.heapBytes, tiny.lastBuildHeapBytes and tiny.releases show what it holds.
GPU H-PLOC
import { GpuHplocBuilder, planGpuHplocBatches, type GpuMeshGeometry } from '@pmndrs/bvh';
const builder = new GpuHplocBuilder(device);
await builder.prepare(); // compiles the kernels, asynchronously
// One batch builds many meshes with the same dispatches. planGpuHplocBatches splits
// a scene that doesn't fit the device's buffers (or maxBatchBytes) into several.
const { batches, tooBig } = planGpuHplocBatches(device, meshes, { maxBatchBytes: 256 * 1024 * 1024 });
for (const batch of batches) {
const trees = await builder.buildMany(batch.map((index) => meshes[index]));
// trees.nodes, trees.triangles and trees.parents are GPU buffers the batch's meshes share.
for (const mesh of trees.meshes) {
if (mesh.problem) continue; // refused (too deep, for example): build this one with tinybvh
// mesh.nodeOffset and mesh.triangleOffset say where its tree sits in those buffers.
}
trees.destroy(); // when nothing reads them any more
}
console.log('for tinybvh:', tooBig);
- One batch builds many meshes with the same dispatches. All of a batch's trees share three buffers.
- Every GPU build reads a few stats words back (nothing per vertex), then the trees are ready.
- A mesh's tree is refused (
mesh.problemset) on an index past the end of the positions, a word one GPU thread hands to another still unwritten after a bounded wait, a merge that found no pair (NaN boxes), or a tree too deep. Build that mesh with tinybvh. The chooser andSceneTreesdo this for you. readLayouts(trees)copies trees to the CPU, which a refit'ssetTreeandserializeLayoutneed.
Options (HplocOptions), besides the contract's:
mortonCodes:'cubic-30'(default) or'tinybvh-63'(tinybvh's 63-bit codes).tieBreak:'pairs'(default) or'tinybvh'. On equal merged areas tinybvh's rule chains the tree: 3,000 identical triangles give depth 128, twice the walk's stack.'pairs'keeps it balanced.
With tinybvh's codes and tie rule, the CPU twin (buildHplocOnCpu) builds exactly the tree of tinybvh's
BuildHPLOC. The GPU builds the same trees on every subgroup size (RTX 32 lanes, Intel 8-16).
GPU LBVH
import { GpuLbvhBuilder } from '@pmndrs/bvh';
if (GpuLbvhBuilder.isSupported(device)) {
const builder = new GpuLbvhBuilder(device);
await builder.prepare();
const trees = await builder.buildMany(meshes);
const layouts = await builder.readLayouts(trees); // CPU copies, for a refit's setTree or a file
trees.destroy();
console.log(layouts.map((layout) => layout?.nodeCount));
}
Same API as H-PLOC. Its CPU twin is buildLbvhOnCpu.
Trees from the GPU, used on the CPU
A tree from H-PLOC or the LBVH lives on the GPU. Refits order nodes by level on the CPU, so a GPU-built tree that will
be refit is read back once with readLayouts after its build. Its shape doesn't change while the mesh deforms, so
this is once per build, never per frame. SceneTrees does it for its deforming meshes.
Next: Fallbacks and limits.