@vshaders/fluid
Stable Fluids simulation kernels as pure WGSL modules: semi-Lagrangian advection, pressure projection (divergence, Jacobi relaxation, gradient subtraction), vorticity confinement, and Gaussian splats. Each kernel computes one pass of the classic Stam splitting; a fluid is a small pass graph, not a single shader, and the entry shaders you own declare the bindings and pass textures in as function arguments. Every module is functions only, no bindings, no entry points, so vgpu's resolver composes and prunes as usual.
npm install @vshaders/fluidConventions
The kernels agree on one grid model; mix them freely as long as your fields do too.
- Fields:
velocityTexstores 2D velocity in.xy(anrg16floattarget works).pressureTex,divergenceTexandcurlTexstore scalars in.x(r16float). Dye is any four-component field. - Units:
uvspans[0, 1]across a field;texelis one grid cell in uv, i.e.1.0 / resolutionof the field being processed. Velocity is stored in grid texels per unit ofdt. Grid spacing is one texel, so all central differences carry the0.5factor and divergence, pressure, and gradients share per-texel units. - Boundaries: integer-coordinate kernels (
*At) read neighborhoods withtextureLoadat coordinates clamped to the texture extent (clamp-to-edge, approximating free-slip walls, with no sampler and no filterability requirement). Sampling kernels expect a linear-filtering, clamp-to-edge sampler for the same boundary behavior. The projection kernels also come in*BoundedAtvariants with reflective container walls; see the boundary conditions below. - Coordinates:
p: vec2iis the integer texel coordinate of the cell being written,vec2i(position.xy)in a fullscreen fragment pass. Out-of-rangepclamps like every other read.
@vshaders/fluid/advect
Semi-Lagrangian advection: to move a field through the flow, look backward along the velocity and take what was there. Linear interpolation at the backtraced point is what makes the scheme unconditionally stable.
| Signature | Description |
|---|---|
bilerp2(tex: texture_2d<f32>, samp: sampler, uv: vec2f) -> vec2f | Bilinear sample of a two-component field (velocity) at uv. samp must filter linearly for true bilinear interpolation. |
bilerp4(tex: texture_2d<f32>, samp: sampler, uv: vec2f) -> vec4f | The four-component variant, for dye. |
advectBacktrace(uv: vec2f, velocity: vec2f, dt: f32, texel: vec2f) -> vec2f | Where the quantity now at uv was one step ago: uv - velocity * dt * texel. velocity is the flow at uv (from bilerp2 on the velocity field); texel converts it into uv displacement. Non-positive texel components contribute no displacement (a degenerate axis advects in place) instead of reversing the trace. Sample the advected field at the result and scale by your dissipation factor. |
import { advectBacktrace, bilerp2, bilerp4 } from "@vshaders/fluid/advect";
struct Uniforms {
resolution: vec2f,
dt: f32,
dissipation: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var srcTex: texture_2d<f32>;
@group(0) @binding(2) var velocityTex: texture_2d<f32>;
@group(0) @binding(3) var linearSampler: sampler;
@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
let texel = 1.0 / max(uniforms.resolution, vec2f(1.0));
let velocity = bilerp2(velocityTex, linearSampler, uv);
let back = advectBacktrace(uv, velocity, uniforms.dt, texel);
return bilerp4(srcTex, linearSampler, back) * uniforms.dissipation;
}@vshaders/fluid/project
The projection that makes the flow incompressible: measure divergence, relax the pressure Poisson equation, subtract the pressure gradient. All kernels run on the velocity grid and are numerically consistent with each other.
| Signature | Description |
|---|---|
divergenceAt(velocityTex: texture_2d<f32>, p: vec2i) -> f32 | Central-difference divergence of the velocity field at p. Write it once per projection into a scalar target. |
jacobiPressureAt(pressureTex: texture_2d<f32>, divergenceTex: texture_2d<f32>, p: vec2i) -> f32 | One Jacobi relaxation step of laplacian(pressure) = divergence: the next pressure at p from the previous iteration and the fixed divergence. Iterate by ping-ponging the pressure texture; 20 iterations is a good default for pointer-splat-scale flows. |
pressureGradientAt(pressureTex: texture_2d<f32>, p: vec2i) -> vec2f | Central-difference gradient of the relaxed pressure. |
subtractPressureGradientAt(velocityTex: texture_2d<f32>, pressureTex: texture_2d<f32>, p: vec2i) -> vec2f | The projected velocity: velocity - pressureGradient at p, approximately divergence-free. Write it back to the velocity field. |
Boundary conditions
The plain kernels read out-of-range neighbors clamped to the edge, under which nothing stops flow at the domain edge: fluid drifts out and the walls are effectively open. Each kernel has a *BoundedAt variant with the same signature that treats the domain edge as a reflective (no-through-flow) container wall instead, using the ghost-cell boundary conditions of Harris, GPU Gems ch. 38. For velocity (divergenceBoundedAt), the off-grid neighbor's velocity is the negation of the center's, so the wall-face velocity (the average of the two) is zero: flow into a wall registers as compression in the divergence, the pressure solve pushes back, and incoming fluid bounces. For pressure (jacobiPressureBoundedAt, pressureGradientBoundedAt, subtractPressureGradientBoundedAt), the off-grid neighbor equals the center, pure Neumann (dp/dn = 0 at walls), so the projection never accelerates flow through a wall.
| Signature | Description |
|---|---|
divergenceBoundedAt(velocityTex: texture_2d<f32>, p: vec2i) -> f32 | Divergence with reflective walls. The wall sits half a texel outside the outermost texel ring; a one-texel grid axis degenerates gracefully (both ghosts cancel, zero difference along that axis). |
jacobiPressureBoundedAt(pressureTex: texture_2d<f32>, divergenceTex: texture_2d<f32>, p: vec2i) -> f32 | One Jacobi step with pure-Neumann walls. |
pressureGradientBoundedAt(pressureTex: texture_2d<f32>, p: vec2i) -> vec2f | Pressure gradient with pure-Neumann walls: zero normal derivative at the boundary. |
subtractPressureGradientBoundedAt(velocityTex: texture_2d<f32>, pressureTex: texture_2d<f32>, p: vec2i) -> vec2f | The contained projection step. |
Use one family per projection: the bounded kernels together for a contained domain, the plain kernels for a free one. Since the signatures match, a contained uniform can select between them per pass (a select on a uniform keeps control flow uniform). On a same-size grid the pressure kernels' Neumann reads coincide numerically with the clamped free reads; the bounded variants make the wall condition explicit and keep a contained projection reading as one family. Advection needs no bounded variant: with a clamp-to-edge sampler a backtrace past a wall lands on the edge value, and the bounded projection keeps wall-normal flow near zero, so backtraces rarely leave the domain in the first place.
@vshaders/fluid/vorticity
Grid advection dissipates small vortices; vorticity confinement measures the curl the grid still has and steers flow back around it.
| Signature | Description |
|---|---|
curlAt(velocityTex: texture_2d<f32>, p: vec2i) -> f32 | Scalar (out-of-plane) curl of the velocity field at p by central differences. Write it into a scalar target before the confinement pass. |
vorticityForceAt(curlTex: texture_2d<f32>, p: vec2i, strength: f32) -> vec2f | The confinement force at p from the curl field: strength * curl along the rotated unit gradient of |curl|. Add force * dt to velocity. Returns zero where the |curl| gradient vanishes (no vortex structure to sharpen) instead of normalizing a zero vector, and for non-positive strength. Typical strength is 5 to 30 on a 128 grid; more is stormier. |
@vshaders/fluid/splat
| Signature | Description |
|---|---|
gaussianSplat(p: vec2f, center: vec2f, radius: f32) -> f32 | The Gaussian falloff exp(-|p - center|² / radius²) used to inject force and dye: 1 at the center, 1/e at distance radius, effectively zero past ~3 radii. p, center and radius must share one coordinate space (correct uv for aspect ratio before calling, or work in pixels). A non-positive radius deposits nothing instead of dividing by zero. Scale the falloff by your force or dye color and add it to the field being splatted. |
Kernels compose into passes
The kernels compose into the classic per-tick pass graph: advect, (splat, confine,) project, as fullscreen fragment passes over ping-pong float targets, one frame() per tick. Each pass is a tiny entry shader that imports one or two kernels and owns its bindings; vgpu composes and validates each entry with exactly the functions it imports.
import { effect, frame, init, pingPong, sampler, target } from "vgpu";
const gpu = await init();
const velocity = pingPong(gpu, 128, 128, { format: "rg16float" });
const pressure = pingPong(gpu, 128, 128, { format: "r16float" });
const divergence = target(gpu, { size: [128, 128], format: "r16float" });
const linear = sampler(gpu, {
minFilter: "linear", magFilter: "linear",
addressModeU: "clamp-to-edge", addressModeV: "clamp-to-edge",
});
// advect, div, jacobi, subtract = effect(gpu, entryShader) for entries
// importing the kernels above.
frame(gpu, (f) => {
advect.set({ srcTex: velocity.read, velocityTex: velocity.read, linearSampler: linear });
f.pass(velocity.write, advect);
velocity.swap();
div.set({ velocityTex: velocity.read });
f.pass(divergence, div);
for (let i = 0; i < 20; i++) {
jacobi.set({ pressureTex: pressure.read, divergenceTex: divergence });
f.pass(pressure.write, jacobi);
pressure.swap();
}
subtract.set({ velocityTex: velocity.read, pressureTex: pressure.read });
f.pass(velocity.write, subtract);
velocity.swap();
});Texture rebinds between passes of one frame take effect per pass; JS-value uniform writes do not (they are frame-global), so per-pass uniform values need one effect instance each.
The fluid effect in this registry is exactly this architecture: nine passes per tick (advection for velocity and dye, pointer splats, curl, vorticity confinement, divergence, a pressure warm-start, iterated Jacobi relaxation, gradient subtraction, display), driven only by the cursor, contained walls by default. Its editor lists every pass with a note on what it computes, alongside the composed import manifest.
Verify
npx vgpu check shaders/your-entry.wgsl --require-validationRuns on the entry shader that imports these modules: it resolves the import graph, validates the composed shader against a real device, and prints its reflection.
Provenance
The method is Jos Stam's "Stable Fluids" (SIGGRAPH 1999) and its game-oriented restatement "Real-Time Fluid Dynamics for Games" (GDC 2003): semi-Lagrangian advection plus pressure projection via Jacobi relaxation. Vorticity confinement is from Fedkiw, Stam, and Jensen, "Visual Simulation of Smoke" (SIGGRAPH 2001). The texture-based GPU formulation (fields as textures, kernels as fragment passes, central differences at one-texel spacing) and the ghost-cell wall conditions follow Mark Harris, "Fast Fluid Dynamics Simulation on the GPU", GPU Gems ch. 38 (2004). The Gaussian splat is the standard injection used throughout that literature. All WGSL in this package is an original implementation from the published equations, no shader code transcribed from any existing fluid demo, with edge-case guards (clamped neighborhoods, zero-gradient confinement, non-positive radius, strength, and texel sizes) in the argument ranges the math leaves undefined. MIT licensed.