Memory
All figures are MiB (2^20 bytes), exact byte counts of buffers unless marked as formulas. They come from
tools/memory-breakdown.ts (Node, no GPU), checked against SceneTrees.memory on the scene-trees-sponza page on the
Apple M5. Your own scene: read trees.memory (resident, transient, peak).
Against three-mesh-bvh
Two numbers per library:
- Beyond geometry: bytes the BVH adds to the positions and indices the app already holds. three-mesh-bvh reuses the positions and reorders the geometry's own index in place. Our triangle records copy the corners, so every byte of ours counts.
- Total in memory: that plus the app's geometry, counted once.
- Per triangle, we add 64.9 bytes (16.9 of nodes, 48 of record; 81.8 with
f32nodes); three-mesh-bvh adds 9.3 (CENTER) to 20.0 (SAH leaf 4). - Our quantized nodes are 32 bytes with two child boxes and leaves of about 1.9 triangles (tinybvh's SAH); three-mesh-bvh's are 32 bytes with one box and leaves of about 7. Small leaves are part of why the walk is fast.
- The comparison counts what each library adds. An app that also keeps the geometry on the GPU for rendering holds it either way.
Where ours go
Sponza, SceneTrees settled (all tinybvh trees, copy mode, uncapped), with f32 nodes, the default before
2026-11-13: 470.0 MiB on the GPU.
With the default quantized nodes, the nodes take half: scene-trees-sponza measured 360.8 MiB settled against 458.6
with f32 nodes, both under the default 256 MiB cap (Apple M5).
The load's peak
The GPU build is the biggest number. One H-PLOC batch of all of Sponza holds 1,482.4 MiB (inputs 101.3, scratch 745.6,
outputs 635.5), and SceneTrees.memory.peak measured 1,482.7. The default build-peak cap
(256 MiB batches, one after another) takes the peak to 613.2 MiB with the default quantized nodes (711.0 with f32
nodes), with the first trees no later (Apple M5, indicative).
Quantized scenes have H-PLOC write the quantized nodes itself, in its emit pass (gpuQuantizeOnEmit, on by default
since 2026-11-15): a batch holds 32-byte nodes and a frame slot per mesh instead of 64-byte nodes, and no quantize pass
runs as the trees are copied in. The trees are the same bit for bit. One batch of all of Sponza goes from 1,482.6 to
1,307.3 MiB (RTX 3060 Laptop), and the whole build from 41.6 to 35.1 ms. Under the default cap the peak is the same
(613.2 against 613.5 MiB): the cap fills each batch to the same bytes, with more meshes in it. Deforming meshes' builds stay f32 (their
refit needs the copy). gpuQuantizeOnEmit: false brings back the quantize pass.
Other memory
- Workers: each tinybvh worker keeps a 64 MiB WebAssembly heap; the default pool of up to 4 holds 256 MiB of CPU
memory while it lives (
idleTimeoutMsends idle workers). Inputs are copied in (one mesh per worker at a time), results transferred out. - Deforming meshes: the GPU refit's buffers (32 bytes of box per triangle, plus topology and scratch) and, with
the default quantized nodes, a 64-byte
f32copy of the mesh's nodes that the refit runs on (a pass packs it into the scene). So a deforming mesh holds 96 bytes per node: its 32 in the scene and the 64-byte copy. Static scenes pay none of it. - Merged static instances (on by default, for instances added with
static: true): the same resident bytes as instanced, plus a CPU plan of 161.6 MiB on Sponza while the merged mesh builds. - CPU trees (
CpuMeshBvh): 64-byte nodes and 48-byte triangle records by default, each record a copy of its corners.corners: 'positions'keeps 16-byte indexed triangles and reads corners from the source's own positions, with no copy: 32 bytes less per triangle (175.3 MiB less for all of Sponza's 5.74M triangles; the furnished room's tree 21.0 to 12.4 MB). Same answers bit for bit; query times level with records within a loaded CPU's noise, refits 0.76-0.95x the time (bench/cpu-corners.ts, indicative). The source must stay alive and unchanged between refits. three'sBvhBoundsTreekeeps an indexed tree (a file orlayout) this way;computeBoundsTreebuilds records. gpuTreeOutputs: 'adopt': no copy at load, but the node buffer keeps one node per triangle: 613.6 MiB settled on Sponza against 470.0 (bothf32), unlesstrimWhenRefinedpacks it afterwards (448.1). Every format adopts since 2026-11-14: a quantized scene adopts batches whose nodes H-PLOC wrote quantized ('adopt'turns ongpuQuantizeOnEmit), and an indexed scene's builds leave each mesh's 16-byte header free before its triangles (132 headers, 2.1 KiB on Sponza), which the scene writes at the adoption. Not measured on Sponza yet (scene-trees-sponzavariantsquantized-adoptandquantized-indexed-adopt).- The app's own positions (
positionsBuffer, indexed scenes): the walk binds the app's positions buffer instead of the scene's copy, which is then never made: 45.2 MiB less on Sponza (vec4; 33.9 packed). The app keeps every mesh's vertices asf32in that one buffer, in the walk's stride, each mesh from thevertexBaseit givesaddMesh; the scene then doesn't weld (the welded copy was 2.96M vertices against the app's 3.11M), merge static instances or take deforming meshes. See Scene trees.
Levers
Options that keep the layout
These change where bytes live, not what the walk reads: the same trees and the same hits as without them. Since
2026-11-15 they are on by default, except the trim with copy (it would save about 10 MiB for a copy of every node).
Figures from tools/memory-breakdown.ts, Sponza and Bistro exterior; the RTX 3060 measured the same peaks.
Pass the other value to SceneTrees.create(device, { ... }) to turn one off, or idleTimeoutMs in its workers
options (or to createBvhWorkerPool). A trim moves trees: version changes, so bind groups are made again, and placements read from
meshStatus before it are stale. GpuMeshRefit users can share boxes themselves with GpuRefitBoxPool, and keep a
deforming mesh's CPU copy small with compactPoseLayout (which refitLayoutToPose takes).
Speeds are against f32 nodes, records and the 40-byte record. RTX 3060 Laptop: Windows, headless Chrome 157,
2026-11-13, Sponza and Bistro interior surface-ray benches, the median of three runs, each lever timed alone.
Quantized nodes became the default after that run (faster closest hits on NVIDIA, half the node bytes). Indexed leaves
wait for a run with both on together. A shadow or occlusion pass that reads no material or normal should pass
hitNormal: false (or trace any-hit rays, which write one word). Design notes and every table behind this page:
docs/internals/design/memory.md.