BvhTraceNodes
new BvhTraceNodes(renderer, source) exposes trees to three's node system. The source is a SceneTrees, or any
{ buffers: { nodes, triangles, topLevel }, version?, triangleLeaves?, positionLayout?, nodeFormat? }.
TSL
bvh.traceClosest(origin, direction, tMin = 0, tMax = 1e30) -> BvhTraceHit struct node
bvh.traceAny(origin, direction, tMin = 0, tMax = 1e30) -> bool node
bvh.traceClosestMasked(origin, direction, tMin, tMax, mask) -> BvhTraceHit
bvh.traceAnyMasked(origin, direction, tMin, tMax, mask) -> bool
bvh.closestPoint(point, maxDistance = 1e30) -> BvhClosestPointHit // with queries: true
bvh.closestPointMasked(point, maxDistance, mask) -> BvhClosestPointHit
BvhTraceHit has BvhHit's members: t, instance, triangle, barycentrics, frontFace, material and
normal. Read them with hit.get('t'). The first build has a full
example; the path tracer example traces a bounce loop with it.
With queries: true the module also has the spatial queries. BvhClosestPointHit has
BvhClosestHit's members: distance (negative when nothing is nearer than maxDistance), instance, triangle,
material, barycentrics, point and normal, in world space. From WGSL, call bvhClosestPoint,
bvhIntersectsSphere, bvhIntersectsBox and bvhIntersectsOrientedBox by name; shapecast: wgsl compiles your
own shapecast hooks into the module.
WGSL
import { wgslFn } from 'three/tsl';
import { BvhTraceNodes } from '@pmndrs/bvh/three';
const bvh = new BvhTraceNodes(renderer, trees); // binds SceneTrees' own buffers, no copy
// WGSL: three's wgslFn with the walk as an include, calling it by name.
const shadow = wgslFn(
/* wgsl */ `fn shadow(p: vec3f, l: vec3f, d: f32) -> f32 {
return select(1.0, 0.0, bvhTraceAny(BvhRay(p, 1e-3, l, d)));
}`,
[bvh.module],
);
bvh.module is the walk without bindings: three binds the storage nodes. Call bvhTraceClosest, bvhTraceAny and the
masked forms by name (Tracing in WGSL).
Options
const bvh = new BvhTraceNodes(renderer, trees, {
rayMasks: true, // default: traceClosestMasked and traceAnyMasked exist
anyHitOrder: 'far', // default; 'larger' for occlusion rays from outside the model or across open space
triangleFilter: /* wgsl */ `fn bvhTrianglePasses(candidate: BvhCandidate) -> bool { return candidate.query == BVH_QUERY_ANY; }`,
});
anyHitOrder is the any-hit walk's child order, compiled in: 'far' (the default) is best for rays that start on
surfaces, 'larger' for rays from outside or across open space. Answers are the same either way. On the RTX 3060
Laptop (Windows, headless Chrome 157, 2026-11-13, Sponza and Bistro interior, the median of three runs), 'larger' was
1.11-1.48x 'far' on instanced trees and 1.03-1.07x on merged trees with rays from the air, but 0.80-0.87x on merged
trees with rays from surfaces. The Apple M5 showed the same pattern (1.15-1.42x instanced, 0.78x merged surface rays).
Merged trees are the default, so 'far' stays the default.
The walk is compiled for the source's formats (triangleLeaves, positionLayout, nodeFormat); with indexed leaves
a fourth storage node, bvh.storage.positions, is bound. A SceneTrees names them; so does an upload
(new BvhTraceNodes(renderer, { buffers: uploadTraceScene(device, scene) }) reads them from the buffers). A source of
your own must name all three: the formats are never guessed, and a source without them is refused.
Each frame
Call bvh.update() after every SceneTrees.update that may grow a buffer, and before three uses the nodes again. When
SceneTrees.version changed it swaps in an attribute bound to the new buffer, and three makes new bind groups.
Nothing recompiles.
Waiting for every tree
three-mesh-bvh's await bvh.updateAsync() builds every tree before the scene is traced. Here the builds already run in
the background (GPU first trees, tinybvh in workers), so a frame loop needs nothing extra: its next frame starts them
and each tree is placed when it arrives. For a first load that should be complete before the first traced frame, wait
for it:
import { BvhTraceNodes, SceneTreesObject3DSync, deviceFromRenderer, updateSceneTreesAsync } from '@pmndrs/bvh/three';
import { SceneTrees } from '@pmndrs/bvh';
import type { WebGPURenderer } from 'three/webgpu';
const trees = await SceneTrees.create(deviceFromRenderer(renderer));
const sync = new SceneTreesObject3DSync(trees, scene, { renderer });
const bvh = new BvhTraceNodes(renderer, trees);
// three-mesh-bvh's `await bvh.updateAsync()`: every tree built and placed before the first traced frame.
await bvh.updateAsync({ sync }); // or updateSceneTreesAsync(trees, { sync, nodes: bvh, until: 'refined' })
updateSceneTreesAsync(trees, { sync, nodes, until }) walks the scene (sync.sync()) and waits for its pose pipelines
(sync.ready), runs an update in an encoder of its own (which starts every build), waits for whenBuilt() (or
whenRefined() with until: 'refined': tinybvh's trees, not the GPU's first ones), runs another update (which places
the trees) and rebinds the nodes. bvh.updateAsync({ sync, until }) does the same for a SceneTrees source, and only
rebinds an uploaded one.
Shapecasts with TSL hooks
bvh.getShapecastFn(options) is three-mesh-bvh's getShapecastFn: a shapecast over the whole scene whose box and
triangle tests are three function nodes, a TSL Fn with setLayout or a wgslFn. It returns a TSL function that runs
the cast and returns true when the triangle test stopped it. It needs queries: true.
import { BvhTraceNodes, type BvhTslNode } from '@pmndrs/bvh/three';
import type { SceneTrees } from '@pmndrs/bvh';
import { Fn, If, float, instanceIndex, max, select, storage, uint, vec3, wgslFn } from 'three/tsl';
import { StorageBufferAttribute, type WebGPURenderer } from 'three/webgpu';
const bvh = new BvhTraceNodes(renderer, trees, { queries: true });
// A box test as a TSL Fn with a layout (a pure function of its arguments): the box's score, negative to skip it.
const sphereBounds = Fn(([sphere, boxMin, boxMax]: BvhTslNode[]) => {
const outside = max(max(boxMin.sub(sphere.xyz), sphere.xyz.sub(boxMax)), vec3(0));
const distanceSq = outside.dot(outside);
return select(distanceSq.lessThanEqual(sphere.w.mul(sphere.w)), distanceSq, float(-1));
}).setLayout({ name: 'sphereBounds', type: 'float', inputs: [{ name: 'sphere', type: 'vec4' }, { name: 'boxMin', type: 'vec3' }, { name: 'boxMax', type: 'vec3' }] });
// A triangle test as a wgslFn, which may call the module's helpers: true stops the cast.
const sphereTriangle = wgslFn(
`fn sphereTriangle(sphere: vec4f, a: vec3f, b: vec3f, c: vec3f) -> bool {
let offset = bvhClosestPointToTriangle(sphere.xyz, a, b, c).point - sphere.xyz;
return dot(offset, offset) <= sphere.w * sphere.w;
}`,
[bvh.module],
);
// Does each sphere (centre, radius) touch the scene?
const touches = bvh.getShapecastFn({ name: 'sphereTouches', shape: 'vec4', intersectsBounds: sphereBounds, intersectsTriangle: sphereTriangle });
const sphereNode = storage(spheres, 'vec4', count).toReadOnly();
const out = new StorageBufferAttribute(new Uint32Array(count), 1);
const outNode = storage(out, 'uint', count);
const kernel = Fn(() => {
If(instanceIndex.lessThan(count), () => {
outNode.element(instanceIndex).assign(select(touches(sphereNode.element(instanceIndex)), uint(1), uint(0)));
});
})().compute(count, [64]);
await renderer.computeAsync(kernel);
const touching = new Uint32Array(await renderer.getArrayBufferAsync(out));
shape: the TSL type of the value the cast is called with ('vec4','mat4', aStructTypeNode, ...). Every hook gets it first. Without it, the hooks and the call take no shape.intersectsBounds(shape?, boxMin, boxMax) -> float: a world box's score. Negative skips the box; of two siblings the lower score is entered first.intersectsTriangle(shape?, a, b, c, instance, triangle, material, normal) -> bool, or any leading part of those arguments (only what the hook declares is computed): a triangle in world space, its instance (hit.instance), original triangle, material and the world normal of its authored front side. True stops the cast.keep(shape?, score) -> bool(optional): asked when a box comes off the stack with the score it was pushed with; false skips it. Use it to cull boxes farther than the best answer so far. Default: keep every box.name: the WGSL name of the cast (defaultbvhThreeShapecast<n>, numbered in order of creation). Name your casts to keep the shader text the same across loads.- The call takes
(shape?, mask?);mask(au32, default every instance) only withrayMasks.
The hooks become functions of their own in the shader, and the walk is the trace module's shapecast walk (the one
traceModuleWgsl({ shapecast }) stamps for WGSL hooks, Your own shapecast):
world space, every instance, node and triangle format. Two limits come from three:
- A TSL
Fnwith a layout is cached across shaders, so its body must depend only on its arguments. Pass per-call data as theshape, not as a uniform read in the hook. - TSL hooks can't keep state between calls. A hook that counts or keeps a best distance is a
wgslFnwriting avar<private>(declared in acode()node it includes) or a storage buffer; anotherwgslFnwith the same include reads it after the cast.
The triangle hook may call the module's other walks (bvhTraceAny, bvhClosestPoint, ...); the box and keep hooks
must not. The differences from three-mesh-bvh's: the triangle test gets one world-space triangle at a time rather than a
leaf's index range, and there is no result pointer (the shape goes in by value; results come back through state, as
above).
Checked in Node, with three's own WGSL node builder, that a kernel calling a cast compiles to the expected text, and on a
GPU by the three-shapecast page: a sphere cast against the library's bvhIntersectsSphere, and a counting cast with
private state against a CPU count.
How it binds, and what can break
- The storage nodes are bound to
SceneTrees' ownGPUBuffers, with no copy.BvhTraceNodessets three's backend buffer for each node before three first uses it. That is three's internals, not its API; it is in one place,threeInternalsinsrc/three/trace-nodes.ts. - Checked: once three has used a node,
update()checks that three still binds our buffer. A three version that looks buffers up differently makesupdate()throw, naming the version, rather than read a 16-byte placeholder. - Disposal:
renderer.dispose()doesn't destroySceneTrees' buffers.bvh.dispose()releases the nodes. - Shader size: the walk goes into each shader once, as plain WGSL (one loop, a 64-entry stack), and each TSL helper is one small function. The names are fixed, so the shader text is the same on every load.
- It needs an initialized
WebGPURendereron its WebGPU backend (await renderer.init()).