Taos Engine ▦ Taos: API Documentation

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 MeshSdfVolume.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: true is mandatory for fromMesh. Without retained CPU data it throws. Use bakeFromMesh with your own arrays otherwise.
  • worldOffset must match where the mesh is drawn, or the field and the rendered surface won't line up.
  • indices must 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's sdf_sample is 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#