Serialization and baking

Build trees or whole scenes once (in Node, at build time), save the bytes, check them, and load them later with no build at all.

Two kinds of file, one header:

  • A tree: serializeLayout(layout) writes one mesh's tree as one ArrayBuffer; deserializeLayout(bytes) reads it back. Load it into a scene you assemble, into SceneTrees, or into a CPU tree.
  • A scene: serializeTraceScene(scene) writes a whole assembled scene (every tree, the top level, instances, materials); deserializeTraceScene(bytes) reads it back, ready to upload with no build and no assembly.

readSerializedHeader(bytes) reads either file's 64-byte header without loading it. tinybvh's WebAssembly needs no GPU, so a Node script can bake everything ahead of time.

Bake in Node

// bake.ts: node bake.ts  (Node 22+; tinybvh's WebAssembly needs no GPU)
import { writeFile } from 'node:fs/promises';
import { createTinyBvhBuilder, serializeLayout, type MeshInput } from '@pmndrs/bvh';

const tiny = await createTinyBvhBuilder();
for (const [index, mesh] of meshes.entries()) {
    const { layout } = tiny.buildLayout(mesh, { maxLeafPrimitives: 8 });
    // One ArrayBuffer: a 64-byte header, then nodes, triangles and parents as the GPU reads them.
    // With the geometry, the header keeps its hash, and loading the tree next to other geometry is refused.
    await writeFile(path(index), new Uint8Array(serializeLayout(layout, { geometry: mesh })));
}

{ geometry } is optional. It stores a 64-bit hash of the positions and indices (meshGeometryHash) in the header, and changes nothing else in the file.

Load in the browser

import { assembleTraceScene, deserializeLayout, uploadTraceScene, type TraceSceneInstance } from '@pmndrs/bvh';

// deserializeLayout refuses bytes from another GPU_LAYOUT_VERSION (or format version): rebuild those.
const trees = await Promise.all(urls.map(async (url) => deserializeLayout(await (await fetch(url)).arrayBuffer())));
const scene = assembleTraceScene(trees, instances); // instances name their tree by meshIndex
const buffers = uploadTraceScene(device, scene); // nodes, triangles, topLevel: bind them to the walk

A loaded tree is a GpuLayoutMeshBvh, the same thing any builder returns. Use it wherever a tree goes: assembleTraceScene (above), a build function for buildTraceScene, SceneTrees.placeTree (below), GpuMeshRefit.setTree, or new CpuMeshBvh(layout).

Check a file before loading it

A file records what it is and the versions it was written with. readSerializedHeader(bytes) reads them from the header alone:

  • kind: 'tree' or 'scene';
  • formatVersion and layoutVersion;
  • current: true when this package loads the file. When it's false, reason says why, in the words deserializeLayout would throw;
  • the formats (triangleLeaves, nodeFormat, and a scene's positionLayout);
  • the counts, and a tree's bounds and geometryHash.

It throws only for bytes that are neither kind of file. isSerializedCurrent(bytes) is the yes or no, and never throws. To fall back instead of failing:

import { createTinyBvhBuilder, deserializeLayout, readSerializedHeader, type GpuLayoutMeshBvh, type MeshInput } from '@pmndrs/bvh';

/** The baked tree at `url`, or a fresh build of `mesh` when the file is from another version of the package. */
export async function loadOrRebuild(url: string, mesh: MeshInput): Promise<GpuLayoutMeshBvh> {
    const bytes = await (await fetch(url)).arrayBuffer();
    // The 64-byte header alone: the kind of file, the versions it was written with, and whether this package reads it.
    const header = readSerializedHeader(bytes);
    if (header.kind === 'tree' && header.current) return deserializeLayout(bytes, { geometry: mesh });
    // A stale bake (header.reason says why): build it again, or fail the deploy.
    console.warn(`${url}: ${header.reason ?? 'a scene file, not a tree'}; building instead`);
    const tiny = await createTinyBvhBuilder();
    return tiny.buildLayout(mesh, { maxLeafPrimitives: 8 }).layout;
}

A stale file is refused when it loads, but only someone who opens the page would see the error. Check the committed or deployed bakes in CI as well:

import { readdir, readFile } from 'node:fs/promises';
import { isSerializedCurrent } from '@pmndrs/bvh';

// In CI: every committed bake must be one this package loads, or the deploy ships files it refuses.
const stale: string[] = [];
for (const name of (await readdir(folder)).filter((file) => file.endsWith('.bvh'))) {
    if (!isSerializedCurrent(await readFile(`${folder}/${name}`))) stale.push(name);
}
if (stale.length) throw new Error(`bake these again: ${stale.join(', ')}`);

Scene files

A scene file holds everything uploadTraceScene needs:

  • every mesh tree, and the merged static groups;
  • the top level, with each instance's transform, mask and material;
  • the triangles, with their per-triangle materials and filter flags;
  • an indexed scene's positions;
  • the formats.

Loading it builds and assembles nothing.

import { buildTraceScene, serializeTraceScene } from '@pmndrs/bvh';

const tiny = await createTinyBvhBuilder();
// Every tree, merged static instances, the top level, instance and triangle materials, the formats.
const scene = await buildTraceScene(meshes, instances, { build: (mesh) => tiny.buildLayout(mesh, { maxLeafPrimitives: 8 }).layout });
await writeFile(path, new Uint8Array(serializeTraceScene(scene)));
import { deserializeTraceScene } from '@pmndrs/bvh';

// No build and no assembly: the buffers go to the GPU as they were baked.
const scene = deserializeTraceScene(await (await fetch(url)).arrayBuffer());
const buffers = uploadTraceScene(device, scene); // compile the walk for scene.nodeFormat and scene.triangleLeaves

Any TraceSceneData can be saved: from assembleTraceScene or buildTraceScene, in any node or triangle format, with merged static instances, or cut into clusters (clusterTraceScene; the placements are kept, the clusters statistics are not). A loaded scene writes the same bytes again, and traces the same hits as the scene it was saved from.

A scene file is a snapshot. Its top level is baked, so its instances can't move, and its merged meshes hold their instances' transforms. For a scene whose instances move, bake one tree per mesh, then assemble them at load or place them in SceneTrees.

Baked trees in SceneTrees

SceneTrees builds a tree for each mesh it's given, unless the app places one first. A baked file per mesh, placed with placeTree before the first update, means no build starts at all: neither the GPU's nor tinybvh's.

import { SceneTrees, type MeshGeometry } from '@pmndrs/bvh';

const trees = await SceneTrees.create(device);
for (const [index, geometry] of geometries.entries()) {
    const mesh = trees.addMesh(geometry);
    const bytes = await (await fetch(urls[index])).arrayBuffer();
    // Checked against the geometry (its hash, when the bake has one); placed before the first update, so no build starts.
    trees.placeTree(mesh, deserializeLayout(bytes, { geometry }));
    trees.addInstance({ mesh, objectToWorld: matrix });
}
// The next update places every tree: trees.counts.onAppTrees === geometries.length.

The tree must have been baked from the geometry given to addMesh. With a hash in the file, deserializeLayout(bytes, { geometry }) checks this. placeTree also requires:

  • the scene's triangle format;
  • f32 trees for deforming meshes (a quantized scene quantizes f32 trees, but an f32 scene refuses quantized ones);
  • weld: false for indexed trees in an indexed scene;
  • a depth within gpuMaxDepth.

A deforming mesh's rebuilds still replace its baked tree (Scene trees).

Indexed bakes

An indexed tree holds 16-byte triangles of vertex indices, not positions. That makes the file small, but it only works next to the exact positions and indices it was baked from. Hand them over wherever the tree goes:

// An indexed tree holds vertex indices, not positions: it only works next to the geometry it was baked from.
const tree = deserializeLayout(await (await fetch(url)).arrayBuffer(), { geometry });
const scene = assembleTraceScene([tree], instances, {}, { geometries: [geometry] }); // the scene copies the positions
const buffers = uploadTraceScene(device, scene);
const cpu = new CpuMeshBvh(tree, { source: geometry }); // the CPU walk reads them from the geometry
// SceneTrees takes it too: SceneTrees.create(device, { triangleLeaves: 'indexed', weld: false }), then placeTree.

new CpuMeshBvh(tree, { source: geometry }) expands the tree to 48-byte records once; add corners: 'positions' to keep its 16-byte triangles and read every corner from the geometry's positions instead (the same answers, CPU raycast). In three, deserializeBoundsTree(bytes, geometry) keeps an indexed file indexed.

deserializeLayout(bytes, { geometry }) checks that the geometry fits the tree:

  • the triangle count matches;
  • every vertex index falls within the geometry's vertices;
  • the hash matches, if the file was written with { geometry }.

Without the hash, positions that moved but kept their count would still load, and would trace wrong triangles. Bake indexed trees with their geometry. An indexed scene file carries its positions, so it needs no geometry.

The format

header, 16 words (64 bytes, little-endian)
    0      magic 'PBVH' (a scene file: 'PBVS')
    1      format version (SERIALIZED_FORMAT_VERSION, now 1)
    2      GPU layout version (GPU_LAYOUT_VERSION, now 2)
    3      nodeCount
    4      triangleCount
    5      maxDepth
    6      leafCount
    7      formats: bits 0-7 triangles (0 records, 1 indexed), bits 8-15 nodes (0 f32, 1 quantized)
    8-13   bounds: minX, minY, minZ, maxX, maxY, maxZ (f32)
    14-15  the geometry's hash (0 and 0: written without one)
nodes      nodeCount x 64 bytes (f32), or (nodeCount + 1) x 32 (quantized: the frame slot, then the nodes)
triangles  triangleCount x 48 bytes (records) or x 16 (indexed)
parents    nodeCount x 4 bytes

serializedLayoutBytes(layout) gives the size before writing. The arrays are copied byte for byte, as the GPU reads them. WebGPU buffers are little-endian, as is every machine that runs WebGPU. Words 14-15 were 0 before the hash: older files load as files with no hash, and older readers ignore the words.

A scene file has its own magic and format version, and the same words 0-2:

header, 16 words (64 bytes, little-endian)
    0      magic 'PBVS'
    1      format version (SERIALIZED_SCENE_FORMAT_VERSION, now 1)
    2      GPU layout version (GPU_LAYOUT_VERSION, now 2)
    3      formats: bits 0-7 triangles, bits 8-15 nodes (as a tree's), bits 16-23 positions (0 vec4, 1 packed)
    4      meshes          5  merged static groups      6  top-level instance records
    7      vertices        8  trace stack entries the scene needs
    9-14   byte sizes: description, nodes, triangles, top level, recordOrder (entries), positions
    15     0
description  JSON: each mesh's and merged group's placement, the top level's node count, depth and skipped instances
nodes, triangles, top level, recordOrder (u32), positions: the scene's buffers, byte for byte

Files from another version are refused, not converted. That holds for the byte format and for the layout version. A tree is cheap to rebuild, and a wrong word position would only show up as wrong rays. Bake again when GPU_LAYOUT_VERSION changes, which is a breaking release. A scene that needs a deeper trace stack than the walk has is refused too.

CPU trees

import { CpuMeshBvh } from '@pmndrs/bvh';

const tree = await CpuMeshBvh.create(input);
const bytes = tree.serialize();
// The layout is checked when it is loaded (children in range, every node reached once),
// so corrupt bytes throw instead of hanging a walk.
const loaded = CpuMeshBvh.deserialize(bytes, { source: input });

In three, geometry.boundsTree.serialize() and deserializeBoundsTree(bytes, geometry) do the same (The tree object). These are our layout's bytes.

three-mesh-bvh's format

To hand trees to three-mesh-bvh, or read ones it stored, use its own serialized format: serializeThreeMeshBvh(tree, { index }) writes its SerializedBVH ({ version: 1, roots, index, indirectBuffer }, indirect mode), and meshBvhFromThreeMeshBvh(data, { positions, indices }) reads one into our layout, keeping its shape and making its boxes exact. In three, MeshBVH.serialize(tree) and MeshBVH.deserialize(data, geometry) take it, the latter our bytes too (The tree object). It isn't for baking: it holds no triangle records, so loading it builds our layout from the geometry (no SAH build, but a pass over every triangle).

Bake at build time

The trees are built once, when the app is built, and shipped as files next to the models. There are two ways to do it, and neither needs a new package:

  • A script. The Node snippet above, run before the bundler: "prebuild": "node scripts/bake.ts" writes the .bvh files into public/, and the page fetches them as it would a texture. The repo's examples:bake is such a script.
  • A Vite plugin. The same calls in generateBundle, so vite build emits the trees with the other assets (the dev server doesn't run generateBundle: run the script once, or serve the file from configureServer).
// vite.config.ts: bake at build time; the trees are emitted next to the app's other assets.
import { createTinyBvhBuilder, serializeLayout, type MeshInput } from '@pmndrs/bvh';
import type { Plugin } from 'vite';

/** `meshes` reads the app's models and returns each one's positions and indices, keyed by file name. */
export function bakeTrees(meshes: () => Promise<Record<string, MeshInput>>): Plugin {
    return {
        name: 'bake-trees',
        async generateBundle() {
            const tiny = await createTinyBvhBuilder();
            for (const [name, mesh] of Object.entries(await meshes())) {
                const { layout } = tiny.buildLayout(mesh, { maxLeafPrimitives: 8 });
                this.emitFile({ type: 'asset', fileName: `trees/${name}.bvh`, source: new Uint8Array(serializeLayout(layout)) });
            }
        },
    };
}

A tree file is one mesh's tree, and a tree must be loaded with the geometry it was built from. Meshes that share a tree are baked once and placed by many instances (meshIndex). A scene file holds a whole assembled scene.

What invalidates a bake

Bake again when any of these change:

  • The geometry: positions, indices, triangle order, or the per-triangle material numbers and filter flags. A records file carries the triangles, flags included, so its only link to the source mesh is that it was built from it. An indexed file holds no positions at all, and is only good next to the exact positions and indices it was built from. The geometry hash catches a changed position or index, but not changed flags or materials.
  • The mesh space: a baked tree is in the mesh's own space. Moving or scaling the mesh object changes nothing, but a transform baked into the positions is part of the file (a glTF merged to world space, as examples:bake does).
  • A scene file's instances: their transforms, masks and materials are part of the file.
  • The options: maxLeafPrimitives, triangleLeaves, nodeFormat, and the builder (a tinybvh tree and an H-PLOC tree of one mesh differ). The formats are in the header, so a reader sees what it was given; the other options only change the tree.
  • GPU_LAYOUT_VERSION, SERIALIZED_FORMAT_VERSION or SERIALIZED_SCENE_FORMAT_VERSION: a breaking release. The file is refused, not converted.

A newer tinybvh may build a different (equally valid) tree from the same mesh. An old bake still loads, and traces the same hits. A refit changes the tree's boxes, but it's applied at load and never baked, so bake the rest pose.

The bake CLI

pnpm examples:bake bakes a model with tinybvh in Node and writes a tree or a scene, with no GPU involved:

pnpm examples:bake                                  # the default torus knot -> examples/node/bake/fixtures/torus-knot.bvh
pnpm examples:bake model.glb --out public/model.bvh  # a glTF's meshes, merged into one world-space mesh
pnpm examples:bake model.glb --leaf 4 --quantized   # smaller leaves, 32-byte nodes
pnpm examples:bake model.glb --indexed              # 16-byte indexed triangles: load next to the same geometry
pnpm examples:bake --scene                          # the default scene -> examples/node/bake/fixtures/knots.scene.bvh
pnpm examples:bake model.glb --scene                # one tree per glTF mesh, its nodes as instances -> model.scene.bvh

It reads .glb and .gltf files, but not Draco, meshopt or quantized positions. It prints the counts, the build time, and the kind, formats and versions it wrote.

  • Tree files carry their geometry's hash.
  • --scene marks every instance static (a scene file's instances can't move anyway), so meshes used once merge into one world-space mesh. --no-merge keeps them as instances.
  • --f32 gives 64-byte nodes. A scene's default is quantized, a tree's is f32.

An example

node/bake is the CLI above (cli.ts, bake.ts, model.ts) and a page that loads either kind of file with no build at all. The page:

  1. reads the header with readSerializedHeader;
  2. loads the file with deserializeLayout or deserializeTraceScene;
  3. uploads it, and traces it with the WGSL walk in the shared ray-cast view.

The overlay's builtBy says baked tree: torus-knot.bvh or baked scene: <name>. It also shows the fetch and deserialize times, and the header's versions next to this package's.

An indexed tree loads next to the default knot, or next to the glTF named by ?model= (merged to world space as the CLI does it). The page refuses it next to anything else.

?bake=<url> (try fixtures/knots.scene.bvh) or a dropped .bvh loads another file. The smoke run checks three files: the default tree, an indexed tree baked in the page, and the committed scene file. It compares each against the CPU walk, and the committed files byte for byte against fresh bakes.