Masks, the triangle filter and materials
Instance masks
Each instance has an 8-bit mask, 0xff by default, as in DXR. A masked trace skips an instance when
(instanceMask & rayMask) == 0, at the top level, before it enters the instance's tree.
// 8-bit instance masks (default 0xff) and a material index per instance (0-65534).
trees.addInstance({ mesh, objectToWorld: matrix, mask: 0b0000_0011, material: 12 });
// A masked trace skips an instance when (instanceMask & rayMask) == 0.
// hit.material is 12 for this instance's hits, BVH_NO_MATERIAL where none was given.
- In WGSL,
bvhTraceClosestMasked(ray, mask)andbvhTraceAnyMasked(ray, mask). The plainbvhTraceClosestandbvhTraceAnyuse an all-ones ray mask, so an instance with mask 0 is never hit, by the plain functions too. - The test reads the flags word the walk already loads, so it costs one shift, AND and compare per instance leaf, not per triangle.
rayMasks: falsecompiles the mask code out of the same walk: no*Maskedfunctions, no test, every instance visible. Nothing changes size either way.
// No *Masked functions, no mask test in the walk, every instance visible.
const code = traceModuleWgsl({ rayMasks: false });
In three, layers 0-7 become mask bits 0-7 (instanceMaskFromLayers), and rayMaskFromLayers(camera.layers) gives a
ray mask. Layers 8-31 are dropped.
The triangle filter
A triangle whose flags bit 0 (TRIANGLE_FLAG_FILTER) is set asks a WGSL function before its hit is taken:
fn bvhTrianglePasses(candidate: BvhCandidate) -> bool
true means the ray passes through: nothing is recorded and the closest distance stays, so what lies behind is still
found. Unflagged triangles never ask, so a scene pays only for the triangles that need it. Use it for alpha-tested
foliage, glass that casts no shadow, or one-sided surfaces.
The candidate holds instance, triangle (the original index), t, barycentrics, frontFace, query
(BVH_QUERY_CLOSEST or BVH_QUERY_ANY) and rayMask. It is a struct, so fields can be added without breaking filters.
The filter must not trace or query (bvhTraceAny, bvhClosestPoint, bvhIntersects*, bvhShapecast): a nested walk
overwrites state the outer walk keeps in private variables (the node frame of quantized nodes), and the outer walk would
read the rest of its instance wrongly. A shapecast's triangle hook may nest; see
Spatial queries on the GPU.
// Asked only for triangles with TRIANGLE_FLAG_FILTER set. true: the ray passes through.
const triangleFilter = /* wgsl */ `
fn bvhTrianglePasses(candidate: BvhCandidate) -> bool {
// Shadow rays pass through flagged triangles (foliage cards, glass); closest-hit rays still see them.
return candidate.query == BVH_QUERY_ANY;
}`;
const code = traceModuleWgsl({ triangleFilter });
The default filter, DEFAULT_TRIANGLE_FILTER_WGSL, lets nothing through. triangleFilter: null leaves the function
out, for a kernel that declares bvhTrianglePasses itself (to read a texture, for example).
Flag the triangles when you add the mesh:
import { TRIANGLE_FLAG_FILTER, type MeshGeometry } from '@pmndrs/bvh';
const triangleCount = indices.length / 3;
const triangleFlags = new Uint32Array(triangleCount);
for (let triangle = 0; triangle < triangleCount; triangle++) if (isGlass(triangle)) triangleFlags[triangle] = TRIANGLE_FLAG_FILTER;
const window: MeshGeometry = { positions, indices, triangleFlags }; // only bit 0 may be set
const mesh = trees.addMesh(window);
For a scene assembled with buildTraceScene, writeTriangleFilterFlags(scene, (originalIndex, sourceInstance) => ..., scene.meshes[i]) writes the flags into the scene's own buffer after assembly. The CPU walk and the reference tracer
take the same filter as a TypeScript function (TrianglePasses).
Materials
Each instance can carry a material index, 0 to 65534. It comes back as hit.material (and as word 6 of the batch
kernel's 40-byte hit record). BVH_NO_MATERIAL (0xffffffff, NO_MATERIAL in TypeScript) means none was given, and
is what a miss reports.
- It costs no binding and two loads per ray, after the walk.
- A merged static triangle keeps its source's material.
- In three, the sync's
material(mesh, info)callback sets it per object and group.
Per-triangle materials
A mesh can carry a material per triangle instead: MeshInput.triangleMaterials, one value per original triangle,
0 to 65534, or TRIANGLE_MATERIAL_NONE (0xffff) for none. A hit's material is its triangle's when it has one,
else its instance's, else BVH_NO_MATERIAL.
import { TRIANGLE_MATERIAL_NONE } from '@pmndrs/bvh';
// One material per triangle (0-65534), by original triangle; TRIANGLE_MATERIAL_NONE (0xffff) takes the instance's.
const triangleMaterials = new Uint16Array(indices.length / 3).fill(TRIANGLE_MATERIAL_NONE);
triangleMaterials.fill(3, 0, 100); // triangles 0-99 are material 3
const mesh = trees.addMesh({ positions, indices, triangleMaterials });
trees.addInstance({ mesh, objectToWorld: matrix, material: 7 });
// hit.material: 3 on triangles 0-99, 7 (the instance's) on the others.
- Records keep it in flags bits 16-31 (
material + 1), which every builder writes. Indexed scenes keep a 16-bit table per mesh before its header, and merged groups their side table.triangleFlagsstill may set bit 0 only. - A merged triangle takes its source triangle's material, else its source instance's.
- It costs one select after the walk, nothing in the loop.
assembleTraceScenewith prebuilt trees: records scenes report the trees' bits. Indexed scenes takegeometries[i].triangleMaterialswhen it is there, else the tree's records' bits, so both formats report the same; both present and different throws.@pmndrs/bvh/threefills it from a multi-material mesh's groups (Scenes and the sync).
The hit also has normal: the world-space geometric normal of the authored front side, unit length. Interpolated
normals need your own attributes, through hit.triangle and hit.barycentrics.