Signed distance fields
A signed distance field (SDF) stores, at every voxel of a box, the distance to the nearest surface: positive outside,
negative inside. Raymarching, soft collisions, particles that slide along walls, ambient occlusion and mesh-to-volume
effects all read one. SdfGenerator fills one on the GPU from the same scene buffers the walk traces
(SceneTrees.traceBuffers or uploadTraceScene), in every node and triangle format, instanced or merged, with no copy
of the scene.
Per voxel it takes two answers from the spatial queries: the distance to the nearest triangle
(bvhClosestPoint) and whether the voxel's centre is inside (bvhInsideVotes, below).
Fill a texture
import { createSdfTexture, SdfGenerator } from '@pmndrs/bvh';
// One generator per scene format (it compiles the walk with the queries); it serves any scene of that format.
const generator = await SdfGenerator.create(device, {
triangleLeaves: trees.triangleLeaves,
positionLayout: trees.positionLayout,
nodeFormat: trees.nodeFormat,
});
// 96 x 96 x 96 voxel centres over a world-space box.
const grid = { min: [-2, -2, -2], max: [2, 2, 2], resolution: 96 } as const;
const texture = createSdfTexture(device, grid); // r32float, 3d: storage, sampling and copies
const { ms } = await generator.generate(trees.traceBuffers, { texture }, grid);
// The texture holds the distance to the nearest triangle at each voxel centre, negative inside.
Voxel (x, y, z) holds the field at its centre, min + (i + 0.5) * (max - min) / resolution on each axis. So a shader
that samples the texture at uvw = (p - min) / (max - min) with linear filtering reads the field at p (sampling an
r32float texture with filtering needs the device's float32-filterable feature; without it, use nearest or
textureLoad).
generate cuts the grid into slabs of whole z slices, one submit each (voxelsPerSubmit, 2,097,152 by default, a 128³
grid in one), so no single submit runs long enough for the OS to reset the GPU (about 2 s on Windows), and resolves when
the GPU is done. readSdf(device, { texture }, grid) reads a field back to the CPU.
Options
SdfGenerator.create(device, options) compiles in:
output:'texture'(the default; anr32float3D texture withSTORAGE_BINDING, ascreateSdfTexturemakes) or'buffer'(f32in a storage buffer, x fastest, then y, then z).triangleLeaves,positionLayout,nodeFormat: the scene's formats, as forTraceBatchKernel. A generator refuses a scene of another format.rayMasks: default true, so each call may pass amask; false compiles masks out.
Each encode or generate takes:
sign:'parity'(the default),'winding'or'unsigned'(the closest point alone, about a quarter of the work).maxDistance: values are clamped to it, and to minus it inside. The closest-point walk skips boxes farther away, so a narrow band is cheaper. Default: none.mask: only instances whose mask shares a bit with it count.slices(encodeonly):{ start, count }, the z slices of this dispatch;timestampWritesfor GPU timing.
// Into a buffer (x fastest, then y, then z) in your own encoder, from a generator made with output: 'buffer':
// a narrow band, the winding rule, the instances with mask bit 0, and z slices 0-15 only.
const grid = { min: [-2, -2, -2], max: [2, 2, 2], resolution: [64, 64, 32] } as const;
const buffer = device.createBuffer({ size: 64 * 64 * 32 * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
generator.encode(encoder, trees.traceBuffers, { buffer }, grid, { maxDistance: 0.25, sign: 'winding', mask: 0x1, slices: { start: 0, count: 16 } });
The inside test
A distance alone has no sign. The sign comes from an inside test, which the queries module has for your own kernels too:
struct BvhRayCrossings { count: u32, winding: i32 }
fn bvhRayCrossings(origin: vec3f, direction: vec3f) -> BvhRayCrossings
fn bvhInsideVotes(point: vec3f) -> vec2u
fn bvhIsInside(point: vec3f) -> bool
with *Masked forms (a last mask: u32) unless rayMasks: false.
bvhRayCrossingswalks every triangle a ray crosses (t >= 0), with the walk's own watertight triangle test, and returns how many there were and their signed sum: +1 for a triangle the ray leaves through its front side, -1 for one it enters through it.bvhInsideVotescasts three fixed rays (INSIDE_TEST_DIRECTIONS: along no axis or diagonal, into three different octants) and counts how many vote inside:.xby parity (an odd number of crossings),.yby winding (a signed sum other than 0). Two or three votes is inside.bvhIsInsideis the parity vote.
// A signed distance in your own kernel: the closest point, negated where the inside test says inside.
const code = /* wgsl */ `
${traceModuleWgsl({ queries: true })}
fn signedDistance(p: vec3f) -> f32 {
let hit = bvhClosestPoint(p, 1e30);
return select(hit.distance, -hit.distance, bvhIsInside(p));
}
fn insideByWinding(p: vec3f) -> bool {
return bvhInsideVotes(p).y >= 2u; // .x: rays voting inside by parity, .y: by winding (of 3)
}`;
Why ray parity with a vote
The other common choice is the generalized winding number (Jacobson et al. 2013): the sum of the solid angles every triangle subtends at the point. It degrades gracefully on open meshes, but exactly it costs one term per triangle per voxel; the fast form needs a tree of dipole moments per node (Barill et al. 2018), which our nodes don't store. A ray cast walks the tree we already have, with the walk's watertight test, so on a closed mesh a ray can't slip between two triangles that share an edge, and the answer is exact away from the surface. A ray exactly through an edge counts both triangles (the walk's rule); casting three rays in different directions and taking the majority outvotes that ray.
Parity and winding differ where shells overlap or nest:
Meshes that aren't watertight
A gap, a missing face or a crack between two vertices that should be one makes some rays pass through, and each such ray
votes "outside". With three rays in different octants, a point is still found inside unless two of the three leave
through openings: a box without its top keeps its middle inside. A mesh open on a large part of its surface (a terrain
patch, a plane, a cloth) has no inside; use sign: 'unsigned'. Seams are the common trap: three's built-in geometries
repeat their vertices along UV seams, and two copies that round apart leave a crack. Weld positions first
(mergeVertices from three's BufferGeometryUtils, after deleting the other attributes), as the three/sdf example
does.
Cost
A signed voxel walks the tree four times: once for the closest point, which prunes by distance, and three times along
rays that can't stop early (every crossing counts). An unsigned voxel walks it once. Points far from everything are
cheap with maxDistance; the rays are not limited by it.
On the CPU
CpuMeshBvh has the same test (rayCrossings, insideVotes, isPointInside), the same rays and the same watertight
walk in doubles, and generateSdfCpu fills a grid voxel for voxel at the GPU's points: the reference the GPU is checked
against, and a field where there is no GPU. Several trees count as one scene (insideVotesOf adds their crossings up).
import { CpuMeshBvh, generateSdfCpu } from '@pmndrs/bvh';
const tree = await CpuMeshBvh.create(geometry); // world-space positions
const inside = tree.isPointInside([0, 0, 0]); // or isPointInside(point, 'winding'), or insideVotes(point)
// The same field on the CPU, voxel for voxel the GPU's points: a reference, or a field with no GPU.
const field = generateSdfCpu(tree, { min: [-2, -2, -2], max: [2, 2, 2], resolution: 9 });
const center = field[(4 * 9 + 4) * 9 + 4]; // voxel (4, 4, 4) is the box's centre
In three
generateSdfTexture from @pmndrs/bvh/three fills a three Storage3DTexture on the renderer's device from a
SceneTrees (or { buffers: uploadTraceScene(...) }), and gives TSL helpers to read it:
import { generateSdfTexture } from '@pmndrs/bvh/three';
import { positionWorld, vec3 } from 'three/tsl';
import type { MeshBasicNodeMaterial, WebGPURenderer as Renderer } from 'three/webgpu';
const box = new Box3().setFromObject(object).expandByScalar(0.1);
const sdf = await generateSdfTexture(renderer, trees, { box, resolution: 64 }); // the trees after their update
// sdf.texture is a three Storage3DTexture (r32float); sdf.sample(p) reads it at a world position in TSL.
material.colorNode = sdf.sample(positionWorld).lessThan(0).select(vec3(0.3, 0.5, 1), vec3(1, 0.6, 0.3));
// After the scene moved, fill the same texture again: generateSdfTexture(renderer, trees, { box, resolution: 64, texture: sdf.texture }).
The result also has uvw(p) (the texture coordinate of a world position, for texture3D), gpuTexture and grid (for
readSdf or your own WebGPU passes), box, resolution and ms. three makes and owns the texture; the generator only
writes into it. Don't set its needsUpdate afterwards: three would make a new, empty one. The texture filters linearly
where the device has float32-filterable, else nearest.
The three/sdf example raymarches the field and shows a
slice with contours; compare/sdf times it against
three-mesh-bvh's webgpu_sdfGeneration kernel.
Limits
- Formats:
r32floattextures andf32buffers.r16floatisn't a storage format in core WebGPU; write a buffer and convert it in a pass of your own if you need half floats. - Memory: a 128³ field is 8 MiB, 256³ is 64 MiB; a 3D texture's side is limited by
maxTextureDimension3D(2048 by default). - The sign near the surface: within rounding of a triangle, the closest point is still right but the sign may go either way; a voxel that small a distance from the surface reads as about zero anyway.
- Static: a field is a snapshot. After the scene moves, generate it again (into the same texture or buffer).
- The box: voxels outside the scene's reach still get a value (the distance to the nearest triangle, however far, up to
maxDistance); a clamp-to-edge sampler (the three helper's) repeats the edge outside the box.
Correctness
tests/gpu/pages/sdf-oracle compares the GPU field with generateSdfCpu voxel for voxel: welded solids, overlapping and
hollow shells under rotations, non-uniform scales and a mirror, a box without its top and a triangle soup; every node and
triangle format, merged trees, masks on and off, a distance band and unsigned fields. Distances must agree to 4e-6 of the
scene's size (plus the point's distance from the origin), and signs everywhere farther than 1e-5 of the size from the
surface (on the open scene, at most 0.2% of voxels may differ). The texture and buffer outputs must match bit for bit.
See also
- Spatial queries on the GPU: the closest-point query the field is made of.
- Closest point: the same query on the CPU.