Ray batches and the CPU walk
GpuRays: picking and batches
GpuRays is for an app that wants hits on the CPU: picking under the pointer, a batch of visibility tests, a
hundred thousand rays for a tool. It owns the ray, result and readback buffers, runs the package's batch kernel
(the same WGSL walk), reads the results back and decodes them. It traces a SceneTrees or an uploaded scene
(uploadTraceScene's buffers): both are a TraceSource, and traceBuffersOf(source) gives the buffers a source
holds now.
import { GpuRays, packRays, uploadTraceScene, type TraceSceneData } from '@pmndrs/bvh';
// A SceneTrees, or an uploaded scene: both are a TraceSource.
const rays = await GpuRays.create(device, trees);
const uploaded = await GpuRays.create(device, uploadTraceScene(device, data));
// Picking: one ray, its closest hit or null.
const hit = await rays.pick([0, 1, 5], [0, 0, -1]);
if (hit) console.log(hit.instance, hit.triangle, hit.t, hit.material, hit.normal);
// A batch: any number of rays in one dispatch, read back and decoded.
const batch = packRays(Array.from({ length: 4096 }, (_, i) => ({ origin: [i * 0.01, 1, 5], direction: [0, 0, -1], tMin: 0, tMax: 1e30 })));
const closest = await rays.traceClosest(batch);
console.log(`${closest.hits} of ${closest.count} hit, ${closest.gpuMs ?? closest.wallMs} ms`, closest.hit(0));
const shadows = await uploaded.traceAny(batch, { mask: 0x1 });
console.log(`${shadows.blockedCount} blocked`, shadows.blocked(0));
rays.destroy();
- What it compiles: one batch kernel for the source's formats, read from its buffers (records or indexed,
f32or quantized nodes). Options:triangleFilter,anyHitOrder,label, andtimestamps(default true). - Results:
traceClosestgiveshits,hit(i)({ t, instance, triangle, frontFace, barycentrics, material, normal }or null) and the rawrecords;traceAnygivesblockedCount,blocked(i)and the words. Both saygpuMs(timestamps, where the device hastimestamp-query, else null) andwallMs(submit to results on the CPU). - The walk's work:
countWalk(rays, { mode })traces with the counted walk (its own kernel, compiled at the first call) and givescounts(i)(the hit, steps, nodes, instance entries, triangle tests),meanStepsandmaxSteps: a heat map's numbers. - One trace at a time per
GpuRays: calls queue up, andpendingsays one is in flight, so a frame loop can skip a trace rather than build a backlog.retire()frees it once the queued traces are done (a page swapping scenes);destroy()at once. - When it sees the scene: a
SceneTreesis traced as of its last submittedupdate; its buffers are read again at each trace, so a scene whose buffers grew is traced in the new ones. - Limits: a readback per trace, so it suits picking, tools and checks, not a renderer's rays (use
TraceBatchKernelor the WGSL walk in your own kernel and keep the hits on the GPU). Closest-hit records are always the 40-byte ones.
TraceBatchKernel
For rays you have in a buffer (picking, AO samples, visibility tests), the package has its own kernel: one dispatch for any number of rays, group 0 the scene, group 1 the rays and results. One kernel serves any number of scenes.
import { HIT_RECORD_BYTES, packRays, RAY_BYTES, TraceBatchKernel } from '@pmndrs/bvh';
const kernel = await TraceBatchKernel.create(device, {
triangleLeaves: trees.triangleLeaves,
positionLayout: trees.positionLayout,
nodeFormat: trees.nodeFormat,
hitNormal: true, // 40-byte records with material and normal; false: 24 bytes
});
const count = 4096;
const rays = device.createBuffer({ size: count * RAY_BYTES, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
const results = device.createBuffer({ size: count * HIT_RECORD_BYTES, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
const shadowResults = device.createBuffer({ size: count * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
device.queue.writeBuffer(rays, 0, packRays(Array.from({ length: count }, (_, i) => ({ origin: [i * 0.01, 1, 5], direction: [0, 0, -1], tMin: 0, tMax: 1e30 }))));
const encoder = device.createCommandEncoder();
kernel.encode(encoder, trees.traceBuffers, { mode: 'closest', rays, results, count });
kernel.encode(encoder, trees.traceBuffers, { mode: 'any', rays, results: shadowResults, count, mask: 0x1 }); // one u32 per ray, 1 = blocked
device.queue.submit([encoder.finish()]);
- Rays: 32 bytes each (
RAY_BYTES), asBvhRay: origin,tMin, direction,tMax.packRayswrites them from{ origin, direction, tMin, tMax }objects. - Closest-hit records (
hitNormal: true, the default): 40 bytes (HIT_RECORD_BYTES), words inHIT_RECORD_WORD: 0t, 1instance, 2triangle, 3frontFace(0 or 1), 4-5barycentrics, 6material, 7-9normal. - Compact records (
hitNormal: false): 24 bytes (HIT_RECORD_COMPACT_BYTES), words 0-5 only. - Any-hit results: one
u32per ray, 1 when blocked. - Masks: one ray mask per batch (
mask), a uniform; there is no per-ray mask buffer. - Timing: pass
timestampWritesto time the dispatch's pass. - Counters: a kernel made with
counters: trueruns the counted walk and writes oneBvhWalkCounts(WALK_COUNTS_BYTES) per ray in either mode. - Sizes:
encodethrows aRangeErrorwhenraysorresultsholds fewer thancountitems.
The 40-byte record is the default: a hit carries its material and normal unless you say otherwise. It has a cost on NVIDIA. Against the 24-byte record, closest-hit rays traced at 0.88-0.92x on Sponza and 0.93-0.94x on Bistro interior on the RTX 3060 Laptop, a 6-12% cost (Windows, headless Chrome 157, 2026-11-13, surface and air rays, instanced and merged trees, the median of three runs of 5 paired rounds). On the Apple M5 it cost nothing measurable: 0.96-1.15x, medians 1.02-1.11 in favour of the bigger record (indicative). Intel has not run it.
So a shadow or occlusion pass that reads neither material nor normal should pass hitNormal: false, or trace any-hit
rays, which write one word per ray.
batchKernelWgsl(mode, options) gives the kernel's WGSL, for a pipeline of your own.
The CPU walk
createCpuTracer(scene) is the WGSL walk, step for step, in TypeScript, over the same buffers (TraceSceneData, as
buildTraceScene or assembleTraceScene return it). It runs in Node, so tests cover the walk and the layout without a
GPU.
import { buildMeshBvhWithTinyBvh, buildTraceScene, createCpuTracer, createTinyBvhBuilder, IDENTITY_TRANSFORM } from '@pmndrs/bvh';
const tiny = await createTinyBvhBuilder();
const scene = await buildTraceScene([geometry], [{ meshIndex: 0, instanceIndex: 0, objectToWorld: IDENTITY_TRANSFORM }], {
build: (mesh) => buildMeshBvhWithTinyBvh(mesh, tiny),
});
// The WGSL walk, step for step in TypeScript, over the same buffers.
const tracer = createCpuTracer(scene);
const hit = tracer.closest({ origin: [0, 0, 5], direction: [0, 0, -1], tMin: 0, tMax: 1e30 });
const blocked = tracer.any({ origin: [0, 0, 5], direction: [0, 0, -1], tMin: 0, tMax: 1e30 }, { mask: 0x1 });
closest(ray, { mask, side, stats })returns{ t, instanceIndex, triangleIndex, u, v, frontFace, material, normal }or null.any(ray, { mask })returns a boolean.side(CPU only: the WGSL walk has no side option) culls front or back faces:CPU_TRACE_FRONT_SIDE,CPU_TRACE_BACK_SIDE,CPU_TRACE_DOUBLE_SIDE(the default, as the GPU walk).stats(fromnewTraceStats()) counts nodes visited, triangles tested and the deepest stack.createCpuTracer(scene, { triangleFilter, rayMasks })takes the filter as a TypeScript function.
For queries on one mesh (raycasts with sides, shapecasts, closest points), use CpuMeshBvh.
See also
- Tracing in WGSL: the walk these batches call, in your own kernel.
- The oracle rule: the CPU walk as one of three tracers that must agree.
- Builds and tracing: ray rates, and how they were measured.