Ray batches and the CPU walk

GpuRays for picking and batches, TraceBatchKernel for many rays in one dispatch, its hit records, and the same walk in TypeScript.

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, f32 or quantized nodes). Options: triangleFilter, anyHitOrder, label, and timestamps (default true).
  • Results: traceClosest gives hits, hit(i) ({ t, instance, triangle, frontFace, barycentrics, material, normal } or null) and the raw records; traceAny gives blockedCount, blocked(i) and the words. Both say gpuMs (timestamps, where the device has timestamp-query, else null) and wallMs (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 gives counts(i) (the hit, steps, nodes, instance entries, triangle tests), meanSteps and maxSteps: a heat map's numbers.
  • One trace at a time per GpuRays: calls queue up, and pending says 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 SceneTrees is traced as of its last submitted update; 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 TraceBatchKernel or 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), as BvhRay: origin, tMin, direction, tMax. packRays writes them from { origin, direction, tMin, tMax } objects.
  • Closest-hit records (hitNormal: true, the default): 40 bytes (HIT_RECORD_BYTES), words in HIT_RECORD_WORD: 0 t, 1 instance, 2 triangle, 3 frontFace (0 or 1), 4-5 barycentrics, 6 material, 7-9 normal.
  • Compact records (hitNormal: false): 24 bytes (HIT_RECORD_COMPACT_BYTES), words 0-5 only.
  • Any-hit results: one u32 per ray, 1 when blocked.
  • Masks: one ray mask per batch (mask), a uniform; there is no per-ray mask buffer.
  • Timing: pass timestampWrites to time the dispatch's pass.
  • Counters: a kernel made with counters: true runs the counted walk and writes one BvhWalkCounts (WALK_COUNTS_BYTES) per ray in either mode.
  • Sizes: encode throws a RangeError when rays or results holds fewer than count items.

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 (from newTraceStats()) 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