The GPU layout

Nodes, triangles, parents, instances and the top level, as every builder writes them and every walk reads them.

Every builder writes the same layout, and every walk (WGSL and CPU) reads it. It is part of the API: GPU_LAYOUT_VERSION (now 2) goes up with any change to it, and that is a breaking change. The word positions are exported (GPU_LAYOUT_NODE_WORD, GPU_LAYOUT_TRIANGLE_WORD, ...), so code that reads the buffers needs no magic numbers.

Everything is plain arrays in a few storage buffers. They survive a worker hand-off (transferable ArrayBuffers) and an upload without conversion.

One mesh's tree

Nodes: 64 bytes, two child boxes each (the Aila-Laine layout most GPU BVH2 tracers use). One node read gives both children's boxes, so the walk tests both and visits the nearer first without a second fetch. Node 0 is the root.

word  0-2  leftMin   (f32 x 3)    3  leftIndex
word  4-6  leftMax   (f32 x 3)    7  rightIndex
word  8-10 rightMin  (f32 x 3)   11  leftCount
word 12-14 rightMax  (f32 x 3)   15  rightCount

A child with count 0 is an inner node at index. A child with a count is a leaf: count triangles (1 to 8) starting at triangle index. A tree of one triangle has an empty right child (GPU_LAYOUT_EMPTY_CHILD).

Builders write these 64-byte nodes unless asked for quantized ones. A scene (SceneTrees, buildTraceScene) stores its nodes quantized by default: 32 bytes each, see Variants.

Triangles: 48 bytes each, in tree order. Leaves point at contiguous runs, so there is no index indirection.

word 0-2  v0 (f32 x 3)    3  original triangle index
word 4-6  v1 (f32 x 3)    7  flags
word 8-10 v2 (f32 x 3)   11  padding (a merged triangle's source instance)

The three corners are stored as they are, so a vertex two triangles share has the same bits in both. The watertight triangle test needs that. The original index maps a hit back to your UVs, normals and material groups.

Flags: bit 0 (TRIANGLE_FLAG_FILTER) sends the triangle to the triangle filter. Input flags may set bit 0 only; the builders throw for any other bit. Bit 1 is written by merged static meshes. Bits 16-31 hold the triangle's material + 1 (MeshInput.triangleMaterials, or a merged triangle's source's), 0 for none.

Parents: 4 bytes per node (GPU_LAYOUT_NO_PARENT for the root), in a separate array that only refits read. Static scenes never upload them.

The mesh's box, depth and counts come with the tree: GpuLayoutMeshBvh on the CPU, GpuTreeBuffers on the GPU.

Reading a node from a CPU copy:

import { GPU_LAYOUT_NODE_WORD, GPU_LAYOUT_NODE_WORDS } from '@pmndrs/bvh';

const floats = new Float32Array(layout.nodes);
const words = new Uint32Array(layout.nodes);
const base = node * GPU_LAYOUT_NODE_WORDS; // 16 words, 64 bytes
const leftMin = floats.subarray(base + GPU_LAYOUT_NODE_WORD.leftMin, base + GPU_LAYOUT_NODE_WORD.leftMin + 3);
const leftCount = words[base + GPU_LAYOUT_NODE_WORD.leftCount]; // 0: an inner node at leftIndex
const leftIndex = words[base + GPU_LAYOUT_NODE_WORD.leftIndex]; // a leaf: its first triangle

Instances and the top level

  • Instance record: 64 bytes. The world-to-object transform as a 3x4 matrix, the mesh's root node offset and triangle offset, the instance index, and flags: visible, mirrored, merged, the 8-bit instance mask (bits 8-15) and the material (bits 16-31, material + 1).
  • The top-level tree uses the same 64-byte nodes, over instance world boxes. Its leaves point at instance records.
  • Records and top-level nodes share one buffer, after a 64-byte header. So the walk binds three storage buffers: nodes, triangles and top level. A default device allows 8 per shader stage.

Rays are moved into object space, so moving an object never touches its tree. Mirrored and non-uniform scales work: the object-space direction is not renormalized, so t stays in world units.

The version

// A WGSL walk of your own reads our word positions: refuse a layout it wasn't written for.
const WALK_WRITTEN_FOR = 2;
if (GPU_LAYOUT_VERSION !== WALK_WRITTEN_FOR) throw new Error(`@pmndrs/bvh layout ${GPU_LAYOUT_VERSION}, walk written for ${WALK_WRITTEN_FOR}`);

deserializeLayout refuses bytes from another layout version. A tree is cheap to rebuild, and a wrong word position would only show up as wrong rays.

Variants

Two variants of version 2 trade speed for memory. Each is compiled into the walk, so the walk and the scene must agree.

OptionDefaultOther valueWhat changes
nodeFormat'quantized' (32 B per node)'f32' (64 B per node)32-byte nodes with 16-bit planes on a per-mesh grid, and a 32-byte frame slot before each mesh's root. Boxes round outward, so a decoded box always holds the exact one. The top level stays f32.
triangleLeaves'records' (48 B per triangle)'indexed'16-byte triangles vec4u(i0, i1, i2, id + flags), a 16-byte header per mesh, and the scene's own copy of the positions (a fourth binding), welded by exact bits. positionLayout: 'vec4' (16 B per vertex) or 'packed' (12).

Quantized nodes became the default on 2026-11-13, after the RTX 3060 run. Options default to quantized; data must say what it is. Where the package makes both sides (SceneTrees, buildTraceScene, assembleTraceScene, traceModuleWgsl, TraceBatchKernel.create), a missing nodeFormat means quantized, so a scene and a walk made without the option agree. Buffers handed to a walk name their own formats: uploadTraceScene and trees.traceBuffers do, and a TraceBatchKernel or BvhTraceNodes given buffers that don't is refused. The mesh builders still write 64-byte trees unless asked, and a scene quantizes them as it places them. Indexed leaves stay off: on quantized nodes they cost 5-17% of closest-hit rate on the RTX 3060 and save about 130 MB on Sponza, so they are the option to reach for when memory is tight.

Pass the options to SceneTrees.create, buildTraceScene, the builders' options, traceModuleWgsl and TraceBatchKernel. SceneTrees exposes the values it was made with (trees.triangleLeaves, trees.positionLayout, trees.nodeFormat): compile your own walk from them.

Measured on Sponza (5.74 million triangles), bytes beyond the app's own geometry: 355.5 MiB in the default layout (quantized nodes, records), 448.1 MiB with f32 nodes, 225.5 MiB with indexed leaves as well.

Trace speed, each variant against f32 nodes and records (above 1 is faster):

VariantRTX 3060 LaptopApple M5 (indicative)
Quantized nodesclosest hit 1.02-1.22x, any hit 0.98-1.03x0.93-0.97x
Indexed leaves, vec4 positions0.92-0.97x0.83-0.99x
Indexed leaves, packed positions0.82-0.95x0.75-0.92x

RTX 3060 Laptop: Windows, headless Chrome 157, 2026-11-13, Sponza and Bistro interior surface-ray benches, the median of three runs. Each variant was timed alone. Details in Memory.

Size limits

A default device caps one storage binding at 128 MiB: about 2.7 million triangles at 48 bytes, about 8 million with indexed leaves. Nothing is split across bindings. uploadTraceScene and SceneTrees throw a RangeError that names the limit to request. See the device.

Contributors

The design, with every word position and the reasons behind them, is in docs/internals/design/trace.md, indexed-leaves.md and compressed-nodes.md next to it.