SDF (Signed Distance Fields)
The SDF module bakes a triangle mesh into a signed distance field — a 3D
texture where each texel stores the distance to the nearest surface, negative
inside the mesh and positive outside. The bake runs as a single GPU compute
dispatch. The result, an SdfVolume, is consumed
today by the particle system (surface spawning, attraction, collision,
sticking) and is general enough for ray marching, soft shadows, or collision
queries.
Source lives in src/sdf/.
import { SdfVolume, computeAabb, bakeSdfCpu } from 'taos/sdf/index.js';
What an SdfVolume is#
class SdfVolume {
readonly texture: GPUTexture; // 3D r32float
readonly view: GPUTextureView; // '3d' view
readonly sampler: GPUSampler; // nearest (see note below)
readonly resolution: [number, number, number]; // texel grid dims
readonly worldMin: [number, number, number]; // AABB min, world space
readonly worldMax: [number, number, number]; // AABB max, world space
get extent(): [number, number, number]; // worldMax - worldMin
destroy(): void; // releases the GPU texture
}
The field is paired with an axis-aligned bounding box in world coordinates;
samplers map a world position into the [worldMin, worldMax] box to look up
distance. The texture is r32float, which core WebGPU can't filter without the
float32-filterable feature, so the volume ships a nearest sampler and
consumers do manual trilinear interpolation in their shaders.
Baking#
Two static factories, both in sdf_volume.ts.
From a Mesh — SdfVolume.fromMesh()#
The convenient path. The mesh must have been created with keepData: true
so its CPU vertex/index data is retained.
import { Mesh } from 'taos/assets/mesh.js';
const torus = Mesh.createTorus(device, /* ... */, { keepData: true });
const sdf = SdfVolume.fromMesh(ctx, torus, {
resolution: 64, // number (cube) or [x, y, z]; default 64
padding: 0.15, // fractional AABB padding per axis; default 0.1
worldOffset: [0, 1.6, 0], // translate positions before baking — see below
});
worldOffset matters: the particle pass samples the SDF in world space, so
if the mesh is drawn at Mat4.translation(0, 1.6, 0), bake with
worldOffset: [0, 1.6, 0] so the field's AABB lands where the mesh is rendered.
From raw arrays — SdfVolume.bakeFromMesh()#
When you already have packed positions and indices (no Mesh):
const sdf = SdfVolume.bakeFromMesh(ctx, {
positions, // Float32Array, tightly packed vec3 (3 floats/vertex)
indices, // Uint32Array, triangle list (length % 3 === 0)
resolution: 48, // default 64
padding: 0.2, // default 0.1
});
Both factories submit the compute dispatch and return immediately. The
texture is usable once the device consumes the submission (normal WebGPU
semantics) — no manual fence needed when you hand it to a particle pass in the
same frame. Bake cost is O(width × height × depth × triangleCount); comfortable
to ~10k triangles at 64³.
CPU math helpers#
For tests, tooling, or CPU-side queries, sdf_math.ts
exports the same math the bake shader uses:
import { computeAabb, closestPointOnTri, bakeSdfCpu } from 'taos/sdf/index.js';
const aabb = computeAabb(positions, 0.25);
// { worldMin, worldMax }, expanded 25% per axis
const r = closestPointOnTri([0.25, 0.25, 1], [0,0,0], [1,0,0], [0,1,0]);
// { point: [0.25, 0.25, 0], dist2: 1 } (Ericson's closest-point-on-triangle)
const grid = bakeSdfCpu(positions, indices, [32, 32, 32], aabb);
// Float32Array, row-major (x fastest, then y, then z) — the reference baker
Using an SDF with particles#
The most common consumer. A ParticleGraphConfig
takes one optional sdf field; the SDF-aware emitter shape and modifiers all
share it.
import { ParticleFeature } from 'taos/particles/index.js';
const config = {
emitter: {
// spawn particles on the baked surface, with outward-normal velocity
shape: { kind: 'sdf_surface', thickness: 0.0 },
// ...rate, lifetime, etc.
},
modifiers: [
{ type: 'sdf_attractor', strength: 7.0, offset: 0.0 }, // pull toward the surface
{ type: 'sdf_collision', bounce: 0.55, friction: 0.18, kill: false }, // bounce off the volume
// or: { type: 'sdf_stick', threshold: 0.01 } // stick on contact
],
renderer: { /* ... */ },
sdf, // ← bind the baked volume here
};
const particles = new ParticleFeature({
config,
emitterTransform: () => Mat4.translation(0, 1.6, 0),
});
engine.addFeature(particles);
The SDF binding is required when the graph uses sdf_surface,
sdf_attractor, sdf_collision, or sdf_stick, and ignored otherwise. Exactly
one SDF per system — all SDF modifiers in one graph share it. The pass can move
the field at runtime (ParticlePass.setSdfTransform) without re-baking, so an
animated emitter transform repositions the field for free.
The complete, runnable demo — three meshes (torus / sphere / fox), togglable attractor / collision / stick / surface-spawn — is samples/sdf_particles.ts.
Gotchas#
keepData: trueis mandatory forfromMesh. Without retained CPU data it throws. UsebakeFromMeshwith your own arrays otherwise.worldOffsetmust match where the mesh is drawn, or the field and the rendered surface won't line up.indicesmust be a non-empty triangle list (length % 3 === 0) or the bake throws.- You own the texture. Call
sdf.destroy()when done to free GPU memory. - Nearest sampling only — the texture is
r32float; do trilinear blending in your own shader (the particle builder'ssdf_sampleis the reference). - One SDF per particle system. Multiple fields means multiple systems. Note also that combining block collision + SDF + splits in one particle graph exceeds WebGPU's 4-bind-group limit and the builder will throw.
See also#
- Quick-Start Guide — engine, features, and the render graph
- samples/sdf_particles.ts — the SDF particle demo
- Chapter 9 — Particle System — the particle graph in depth