Taos Engine · Modules

Modules

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/.

TypeScript
import { SdfVolume, computeAabb, bakeSdfCpu } from 'taos/sdf/index.js';

What an SdfVolume is#

TypeScript
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.

TypeScript
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):

TypeScript
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:

TypeScript
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.

TypeScript
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#

See also#