Taos Engine

Rendering

Volumetric Fluid — Technical Deep Dive

The Volumetric Fluid sample is a GPU grid-based (Eulerian) fluid simulator that renders fire, smoke and water as a participating medium. A 3D "stable fluids" solver advances velocity / density / temperature fields entirely in compute shaders, and a fullscreen ray-march composites the density field as volumetric emission and absorption over a procedural sky and ground. It exercises the engine's render graph (compute and render passes), the RenderContext device / timing plumbing, the Camera + CameraController fly camera, and the shared BloomPass / TonemapPass post-processing passes.


§ 1

Overview#

You fly a free camera (WASD + mouse, no pointer lock) around a single simulation box that sits on a checkerboard ground plane. A control panel on the right switches between four templates and three grid resolutions, and a slider bar at the bottom tunes three live multipliers.

The four templates (all the same solver, re-tuned):

KeyTemplateRender modeEmitterWhat you see
1CampfirefireplumeContinuous hot plume, black-body emission, flicker
2FireballfireburstSpherical bursts re-fired every 3.2 s
3SmokesmokeplumeCool buoyant plume, sun-lit and self-shadowed
4Splashing WaterwaterdripDrops fall under gravity into a churning pool

Controls:

All panel state (template, resolution, three sliders) is persisted to localStorage under the key crafty.fluid.settings and restored on reload.


§ 2

Architecture#

File map for the sample (samples/fluid_test.html, samples/fluid_test.ts, and everything under samples/fluid/):

FileResponsibility
fluid_test.htmlPage shell: canvas, info HUD, the (empty) #panel populated by JS, the slider bar, and the source_viewer.ts include.
fluid_test.tsApp entry. Owns camera, UI, persisted settings, per-frame parameter packing, and wires the compute + render-graph passes each frame.
fluid/presets.tsThe four FluidPreset configs, the fixed GRID resolution, and the world-space box (BOX_MIN / BOX_SIZE).
fluid/fluid_sim.tsFluidSim class — owns the field 3D textures and six compute pipelines, records the solve into its own command buffer.
fluid/fluid_sim.wgslThe six solver compute kernels (advect, vorticity, forces, divergence, pressure, project).
fluid/fluid_render_pass.tsFluidRenderPass — a render-graph Pass that ray-marches the density field into an HDR target.
fluid/fluid_render.wgslVertex (fullscreen triangle) + fragment (volumetric ray-march) shader.

The compute simulation (FluidSim) is deliberately not a render-graph pass. It submits its own command buffer first; the render graph then consumes the density texture FluidSim produced. Only the ray-march, bloom and tonemap go through the graph.


§ 3

The simulation#

3.1 The grid and its fields#

The reference grid is GRID = [64, 96, 64] cells (presets.ts) — tall in Y to give plumes vertical room. It maps to a world-space box BOX_MIN = [-6, 0, -6], BOX_SIZE = [12, 18, 12], sitting on the ground at y = 0.

FluidSim (fluid/fluid_sim.ts) allocates these 3D textures, each dimension: '3d' with TEXTURE_BINDING | STORAGE_BINDING usage:

FieldFormatCountHolds
_velrgba16float2 (ping-pong)velocity xyz
_denrgba16float2 (ping-pong)r = density, g = temperature
_prsr32float2 (ping-pong)pressure
_divr32float1velocity divergence
_vortrgba16float1curl (vorticity) of velocity

Density and temperature share one rgba16float texture (.x / .y), so advection moves both in a single trilinear sample. Pressure and divergence use r32float — scalar fields needing the precision for the Jacobi relaxation.

The host owns the ping-pong: _vi and _di index the slot holding the current velocity / density state. Each step that produces a new field reads slot i and writes slot i ^ 1, then flips the index. _prs ping-pongs internally inside the pressure loop. _div and _vort are single-buffered (written then immediately consumed within the same pass).

3.2 The solver pipeline#

fluid_sim.wgsl is a single module with six @compute entry points, all @workgroup_size(4, 4, 4) (the constant WG = 4 in fluid_sim.ts; dispatch is ceilDiv(grid, 4) per axis). The order each step() records is:

advect  →  vorticity  →  forces/emit  →  divergence  →  pressure (×N)  →  project

This is a classic Stam "stable fluids" loop. Each kernel is wired by its own explicit GPUBindGroupLayout (LayoutSet), so the WGSL binding list (@binding(0)–@binding(11)) is a superset — any one entry point only touches the subset its layout declares.

1. Advection — cs_advect. Semi-Lagrangian: for cell c, trace backward back = pos - velocity * dt and trilinearly resample both velocity and density at that point. Unconditionally stable for any dt.

WGSL
let back = pos - loadVel(c) * dt;
let newVel = sampleVelField(back);
let newDen = sampleDenField(back);   // r = density, g = temperature

sampleVelField / sampleDenField convert cell-center coordinates to texture UVW with (p + 0.5) / grid and use a linear-filtering, clamp-to-edge sampler.

2. Vorticity — cs_vorticity. Computes the curl of the (advected) velocity field via central differences of the six neighbors and stores it per cell into _vort. This is precomputed so the forces pass can apply vorticity confinement cheaply.

3. Forces & emission — cs_forces. Everything that changes the fields outside advection and projection:

4. Divergence — cs_divergence. Central-difference divergence of the post-force velocity into _div: 0.5 * ((r-l) + (u-d) + (f-b)).

5. Pressure — cs_pressure. One Jacobi relaxation step of the pressure Poisson equation:

WGSL
prsOut = (l + r + d + u + b + f - div) / 6.0;

The host runs this pipeline _iterations times in a loop, ping-ponging _prs[0] ↔ _prs[1] via two pre-built bind groups (pingA / pingB). The iteration count is forced even (iterations + (iterations & 1)) so the final relaxed pressure always lands back in _prs[0], which the projection pass then reads. iterationsFor() in fluid_test.ts scales it with grid size: round(20 * gridX / GRID[0]) — 20 at Medium, ~15 at Low, ~30 at High — because a bigger grid needs more relaxation sweeps to propagate pressure across it.

6. Projection — cs_project. Subtracts the pressure gradient to make the velocity field divergence-free:

WGSL
var vel = loadVel(c) - 0.5 * vec3<f32>(r - l, u - d, f - b);

It then enforces boundary conditions: zero normal velocity on the four side walls (x and z extremes), and a floor that fluid cannot sink through (if (c.y == 0) { vel.y = max(vel.y, 0.0); }). The top is left open. Reads of out-of-domain cells everywhere in the shader go through clampCoord, giving Neumann-style "no flux through the wall" sampling.

3.3 The SimParams uniform#

fluid_test.ts packs SIM_PARAM_FLOATS = 28 floats (7 × vec4, 112 bytes) each frame and FluidSim.step() uploads them. The WGSL SimParams struct maps them as:

vec4Contents
gridgrid x, y, z, _
timingdt, time, vorticityStrength, _
emitterPosemitter center xyz (grid space), radius
emitterVelinjected velocity xyz, radial burst speed
emitterrate, temperature, shape (0=plume/1=sphere), active
forcesbuoyancy, weight, gravity, cooling
dissipationdensityDissipation, velocityDissipation, ambientTemp, _

3.4 Reset and resize#

reset() destroys and re-allocates every field texture (all zeroed by WebGPU) — used on template switch and R. resize() additionally updates gridX/Y/Z, the dispatch dimensions, and the pressure iteration count, so the grid can change resolution at runtime.


§ 4

Presets#

presets.ts defines four FluidPreset objects. Each is the same solver tuned differently, plus a renderMode for the ray-marcher. Key differentiators:

ParamCampfireFireballSmokeWater
renderModefirefiresmokewater
emitterModeplumeburstplumedrip
buoyancy2214100
weight0.90.60.40
gravity00030
cooling1.150.90.60
densityDissipation0.380.50.120.32
velocityDissipation0.220.50.20.12
vorticity91363
emitterRadius9866
emitterHeight936880
emitterRate2.7161.924
emitterTemp1.752.00.90
emitterVelY1679−22
emitterRadialSpeed02700
cycleSeconds03.200.85
densityScale981116
emissiveStrength2.73.100
tintwarm graydark graylight grayblue

Reading the table:

GRID, BOX_MIN, BOX_SIZE are also exported here so both the sim and the renderer agree on the domain.


§ 5

Rendering#

5.1 The pass#

FluidRenderPass (fluid/fluid_render_pass.ts) is a render-graph Pass<void, FluidRenderOutputs>. Its create() builds a render pipeline with a fullscreen-triangle vertex shader, a 256-byte uniform buffer, a linear sampler, and a bind-group layout of { uniform, texture_3d<float>, sampler }. The volume texture is not a graph resource — it is owned by FluidSim and bound directly in the execute callback via setVolume(sim.densityView).

addToGraph creates a transient FluidHDR texture (HDR_FORMAT = rgba16float, full canvas size), declares it as a cleared attachment, and in the execute callback binds the uniform + volume + sampler and issues enc.draw(3) — one fullscreen triangle.

5.2 The ray-march#

fluid_render.wgsl's fs_main does the volumetric integration:

  1. Ray reconstruction. The fullscreen triangle's NDC is unprojected with invViewProj at depths 0 and 1; ro is the camera position, rd the normalized world-space ray.

  2. Background. skyColor(rd) is a vertical gradient with a sharp sun disc (pow(dot, 250)) and soft halo (pow(dot, 6)). If the ray points down, it intersects the y = 0 plane and shades groundColor — a checkerboard with distance fade and a Lambert term from the sun. bgDist records that hit.

  3. Box intersection. intersectBox is a slab test returning (tNear, tFar). If the ray misses, or the box is fully behind the ground, it returns just the background.

  4. March. STEP_COUNT = 64 primary steps between tEnter and tExit = min(boxFar, bgDist), step size dh. The entry point is jittered by a static hash12(frag.pos.xy) dither to hide slice banding without TAA. The loop accumulates front-to-back and early-outs when transmittance < 0.01.

  5. Per-sample shading. Density field.x becomes extinction sigma = density * densityScale. The shading branches on renderMode:

    • Fire (mode 0): radiance = fireRamp(temperature) * emissiveStrength * density + scatter * sigma, where fireRamp is a 5-stop HDR black-body ramp (values up to ~3.2, so the hot core blows out past 1.0 for the bloom pass). scatter adds a little ambient + sun-lit smoke around the flame.
    • Smoke (mode 1): a light-scattering medium — radiance = (ambient + sunColor * shadow) * tint * sigma, where shadow comes from lightMarch.
    • Water (mode 2): the same scattering term, plus when the march first crosses a dense iso-surface (density > 0.4) it shades a one-off glossy splash highlight: a gradient-of-density surface normal feeds a Blinn-style spec (pow(dot(n,h), 56)) and a Fresnel-weighted sky reflection.
  6. Self-shadowing — lightMarch. From each marched point it casts a short secondary ray of LIGHT_STEPS = 4 samples toward the sun, accumulates density, and returns exp(-sum * dh * densityScale * shadowDensity) — the light that survives to the sample. This gives smoke and water volumetric self-shadowing.

  7. Composite. accum + bg * transmittance — the integrated volume over the remaining background visibility.

5.3 The RenderParams uniform#

fluid_test.ts packs RENDER_PARAM_FLOATS = 44 floats (a mat4 + 7 × vec4, 176 bytes; the buffer itself is 256 bytes). The mat4 is the camera's inverse view-projection; the trailing vec4s carry camPos/time, boxMin/renderMode, boxSize/STEP_COUNT, sunDir/densityScale, sunColor/LIGHT_STEPS, tint/emissiveStrength, and misc (SHADOW_DENSITY, AMBIENT). Fixed scene constants in fluid_test.ts: SUN_DIR = [0.413, 0.731, 0.543], SUN_COLOR = [1.15, 1.05, 0.9], AMBIENT = 0.09, SHADOW_DENSITY = 1.0.

5.4 Post-processing#

After FluidRenderPass produces the HDR target, the shared BloomPass blooms it (threshold 1.1, knee 0.6, strength 0.55 for fire vs 0.18 otherwise — so fire glows much harder), and TonemapPass applies exposure 1.1 with the ACES curve to the canvas backbuffer.


§ 6

Editing / interaction#

The control panel is built procedurally in fluid_test.ts into the #panel div: a Template section (one button per PRESET_ORDER entry), a Resolution row of three buttons, and Trigger / Reset action buttons. refreshButtons() keeps the on/off CSS classes and the mode description in sync.

Emitter. computeEmitter(elapsed) builds the per-frame EmitterState (position, radius, velocity, rate, temperature, shape, active) from the active preset. All grid-space quantities are multiplied by scale (see §7). Behavior by emitterMode:

T / Trigger sets emitStart = -1e9. Because computeEmitter re-fires when elapsed - emitStart >= cycleSeconds, that huge negative value forces an immediate burst/drip on the next frame.

R / Reset calls selectPreset(currentKey), which calls sim.reset() (zeros all fields) and forces an immediate emit.

Sliders are multipliers layered on top of the active preset, read fresh each frame in frame():

SliderRangeEffect
Speed0–2xScales dt: dt = min(deltaTime, 1/30) * speedMul.
Opacity0.3–2xScales the ray-march densityScale (densityScale * opacityMul).
Detail0–2xScales the simulation vorticity strength.

Resolution buttons call setResolution(i), which sets activeGrid, recomputes scale = activeGrid[0] / GRID[0], calls sim.resize(...) with a new iteration count, and forces an emit. The three resolutions are Low [48,72,48], Medium GRID = [64,96,64], High [96,144,96].


§ 7

Frame flow#

frame() in fluid_test.ts, once per requestAnimationFrame:

  1. ctx.update() advances frame timing; on a canvas resize it returns true and cache.trimUnused() is called. The FPS HUD shows ctx.fps and the grid.
  2. cameraController.update(...) then camera.updateRender(ctx); ctx.activeCamera = camera.
  3. Read the three slider multipliers; clamp dt to 1/30 s and scale by speed.
  4. computeEmitter(elapsed) → pack the 28-float simParams → sim.step(simParams). FluidSim.step() writes the uniform, records all six kernels (with the pressure loop) into a FluidSimEncoder compute pass, and submits its own command buffer immediately.
  5. Pack the 44-float renderParams; fluidRenderPass.setVolume(sim.densityView) and updateParams(...). Update bloom and tonemap params.
  6. Build the render graph for the frame:
TypeScript
const graph = new RenderGraph(ctx, cache);
const backbuffer = graph.setBackbuffer('canvas');
const volume = fluidRenderPass.addToGraph(graph);
const bloom = bloomPass.addToGraph(graph, { hdr: volume.hdr });
tonemapPass.addToGraph(graph, { hdr: bloom.result, backbuffer });
const compiled = graph.compile();
graphViz.setGraph(graph, compiled);
void graph.execute(compiled);

So each frame is two submissions: first the compute solve (outside the graph), then the graph's single command buffer for ray-march → bloom → tonemap. The render graph never sees the simulation; it only consumes the density texture the compute pass already produced.

The scale factor. Because the templates are tuned for the Medium grid, fluid_test.ts multiplies every cell-space quantity by scale = activeGrid[0] / GRID[0] before packing: emitter position/radius/velocity, buoyancy, weight, gravity and vorticity. World-space rendering quantities (BOX_MIN, BOX_SIZE) do not scale — the box is the same size at every resolution; only the cell count (and thus detail and cost) changes.


§ 8

Notable techniques and gotchas#