Memory

Where the bytes go, against three-mesh-bvh with fair accounting, and the levers that cut them.

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.
Sponza (5.74M triangles)Beyond geometryTotal
Ours, default layout (quantized nodes 92.6 + triangles 263.0 + top level)355.5456.8
Ours, f32 nodes (the default before 2026-11-13)448.1549.4
Ours, quantized nodes and indexed leaves (indexed is off by default)225.5326.8
three-mesh-bvh 0.9.18, defaults (CENTER, leaf 10)51.2152.6
three-mesh-bvh, SAH, leaf 4109.3210.7
Bistro exterior (2.83M triangles)Beyond geometryTotal
Ours, default layout169.9235.7
Ours, f32 nodes210.1275.9
Ours, quantized and indexed115.3181.1
three-mesh-bvh, defaults26.392.1
three-mesh-bvh, SAH, leaf 453.4119.2
  • Per triangle, we add 64.9 bytes (16.9 of nodes, 48 of record; 81.8 with f32 nodes); 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.

MiBShare
Triangle records, 48 B x 5.74M263.056.0%
Nodes in use, 64 B x 3.03M185.139.4%
Node buffer growth room21.84.6%
Top level0.1

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 (idleTimeoutMs ends 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 f32 copy 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's BvhBoundsTree keeps an indexed tree (a file or layout) this way; computeBoundsTree builds 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 (both f32), unless trimWhenRefined packs it afterwards (448.1). Every format adopts since 2026-11-14: a quantized scene adopts batches whose nodes H-PLOC wrote quantized ('adopt' turns on gpuQuantizeOnEmit), 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-sponza variants quantized-adopt and quantized-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 as f32 in that one buffer, in the walk's stride, each mesh from the vertexBase it gives addMesh; 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

LeverDefaultSaves on SponzaTrace speed: RTX 3060 Laptop; Apple M5 (indicative)
Build-peak cap (gpuMaxBatchBytes)on, 256 MiB771 MiB of peaknone measured on first trees (M5)
Quantized nodes (nodeFormat: 'quantized')on92.6 MiB residentclosest 1.02-1.22x, any 0.98-1.03x; M5 0.93-0.97x. SAH +0.01-0.66%
Indexed leaves (triangleLeaves: 'indexed')off130 MiB resident (141 with packed positions)0.92-0.97x (vec4), 0.82-0.95x (packed); M5 0.83-0.99x, 0.75-0.92x
Indexed leaves on quantized nodesoff (quantized only)130.1 MiB resident against quantized records (366.5 to 236.4, uncapped)closest 0.83-0.95x (vec4), 0.77-0.87x (packed); M5 not measured yet
Quantized nodes from H-PLOC's emit (gpuQuantizeOnEmit)on175.3 MiB of an uncapped batch; none under the default capnone on traces (same trees); the build 41.6 to 35.1 ms
The app's positions (positionsBuffer, indexed only)off45.2 MiB resident (vec4)none (the same walk)
Compact hit records (hitNormal: false)off16 bytes per ray in a batchthe 40-byte record costs 6-12% (0.88-0.94x); M5 0.96-1.15x
Smaller leaves (maxLeafPrimitives)8nothing: tinybvh picks about 1.9 per leaf anyway (8 against 4 saved 0.5 MiB)

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.

OptionDefaultWhat it doesSaves on Sponza (Bistro exterior)
SceneTrees.trim(), trimWhenRefinedon with 'adopt', off with 'copy'packs the node buffer to its trees once nothing is building165.5 MiB settled with gpuTreeOutputs: 'adopt' (92.2); 5.3 with copy and the default cap (1.4)
parents: 'refit''refit' ('always' to keep them)no parents output in the GPU builds, no parents arrays for meshes that don't deform3.7 MiB of peak with the default cap, 21.9 uncapped; 11.6 MiB of CPU during the load
deformingLayouts: 'compact''compact' ('full')a deforming mesh keeps its nodes, parents and 4 bytes per triangle for setPoseCpu, not its whole tree241.0 MiB of CPU if every mesh deformed (118.7); half of each deforming mesh's copy
refitBoxes: 'shared''shared' ('per-mesh')deforming meshes' refits share one box buffer, sized for the largest168.7 MiB of GPU if every mesh deformed (83.8)
quantizeJobs: 'batched''batched' ('per-tree')one quantize pass per update instead of one per GPU tree (only when gpuQuantizeOnEmit is off, or for tinybvh's f32 trees)a few KiB; 132 passes and 264 small buffers become 7 and 14 (1,296 and 2,592 become 3 and 6)
Worker pool idleTimeoutMs30 s for the pool a SceneTrees makes; Infinity for pools you makeends workers left idle that long256 MiB of CPU once the pool is idle (4 workers of 64 MiB)

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.