fluid@0.1.0
Fluid
A real simulation, not a moving texture: velocity and dye fields advected every frame, pressure projection keeping the flow divergence-free, vorticity confinement feeding the swirls. The fluid moves only with the cursor — every pointer event splats raw force and dye into the field, and an idle canvas settles back to the plain background.
Controls
Install
One command copies the composition into your project (with the @vshaders namespace registered in components.json; the plain URL https://vshaders.com/r/fluid.json works with no setup). npm dependencies install alongside.
npx shadcn@latest add @vshaders/fluidFiles that land in your tree: shaders/fluid/advect.wgsl, shaders/fluid/splat.wgsl, shaders/fluid/curl.wgsl, shaders/fluid/vorticity.wgsl, shaders/fluid/divergence.wgsl, shaders/fluid/clear-pressure.wgsl, shaders/fluid/jacobi-pressure.wgsl, shaders/fluid/gradient-subtract.wgsl, shaders/fluid/display.wgsl, lib/fluid-runner.ts, lib/shared-gpu.ts, components/fluid.tsx, wgsl-env.d.ts.
The shaders import WGSL modules from npm, resolved by vgpu's loader. In next.config.ts, wire @vgpu/wgsl/loader-webpack for *.wgsl under both turbopack.rules and the webpack() hook, and keep the installed wgsl-env.d.ts at your project root so .wgsl imports typecheck. This item ships its own runner (lib/fluid-runner.ts) instead of lib/run-effect.ts. Loader setup: https://vgpu.sh/docs. Effect gallery and sources: https://vshaders.com. Color params are linear-light RGB, not sRGB hex/255: convert hex through the sRGB transfer function, or use Copy params in the live editor (https://vshaders.com/effects/fluid) for exact values.
Passes
Rendered as 9 shader passes into offscreen textures each frame, in this order; only the display pass draws to the canvas.
advect.wgsl · Semi-Lagrangian advection; carries velocity, then dye, through the flow.
splat.wgsl · Gaussian splats along raw pointer strokes: force into velocity, palette-colored dye.
curl.wgsl · Scalar curl of the velocity field.
vorticity.wgsl · Vorticity confinement; feeds curl back as a swirl-preserving force.
divergence.wgsl · Divergence of the forced velocity field.
clear-pressure.wgsl · Warm-starts the solve by decaying last frame's pressure.
jacobi-pressure.wgsl · One Jacobi relaxation step; runs iterations times each frame.
gradient-subtract.wgsl · Subtracts the pressure gradient; the flow comes out divergence-free.
display.wgsl · Samples the dye field and converts to sRGB; the only pass that draws to the canvas.
Imports
What the passes compose, deduplicated across all of them; every module resolves from npm.
import { advectBacktrace, bilerp2, bilerp4 } from "@vshaders/fluid/advect";
import { curlAt, vorticityForceAt } from "@vshaders/fluid/vorticity";
import { mixOklab } from "@vshaders/color/oklab";
import { linearToSrgb3 } from "@vgpu/wgsl-std/color";
import { hash2 } from "@vgpu/wgsl-std/hash";
import { divergenceAt, divergenceBoundedAt, subtractPressureGradientAt, subtractPressureGradientBoundedAt, jacobiPressureAt, jacobiPressureBoundedAt } from "@vshaders/fluid/project";
import { gaussianSplat } from "@vshaders/fluid/splat";
import { cosinePalette } from "@vshaders/color/palette";
Source
The display pass, fluid/display.wgsl; the other passes follow below. Copy them and they are yours.
// fluid · display — the record's face: the only pass that renders to the
// canvas. Samples the dye field (linear light, palette-colored at
// injection by splat.wgsl), rolls the shoulders off with a soft exponential
// tone map, shades the dye with a gradient-derived normal so the plumes
// read as lit smoke instead of flat glow (the technique from Pavel
// Dobryakov's WebGL-Fluid-Simulation display shader, MIT), sinks the edges
// into the backdrop through an OKLab-mixed vignette, and encodes to sRGB
// exactly once at the end. A time-jittered grain of ±0.5/255 breaks up
// banding in the dark gradients.
import { bilerp4 } from "@vshaders/fluid/advect";
import { mixOklab } from "@vshaders/color/oklab";
import { linearToSrgb3 } from "@vgpu/wgsl-std/color";
import { hash2 } from "@vgpu/wgsl-std/hash";
struct Uniforms {
// Resolution of the canvas, physical pixels.
resolution: vec2f,
// Seconds; jitters the banding grain.
time: f32,
// Exposure of the soft tone map: higher lifts faint dye into view sooner.
exposure: f32,
// Backdrop the dye glows over, linear light. Keep it dark: the dye is
// additive, and the vignette mixes toward a deeper shade of this color.
backgroundColor: vec3f,
// Pseudo-3D smoke shading: the dye's screen-space gradient acts as a
// surface normal under a headlight, so dense fronts pick up shadowed
// edges and the plumes read volumetric. 0 = flat, 1 = full effect.
shading: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var dyeTex: texture_2d<f32>;
@group(0) @binding(2) var linearSampler: sampler;
@fragment
fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
let res = max(uniforms.resolution, vec2f(1.0));
// Edge shaping, in two stages on the density's peak channel, applied as
// ONE scale factor to all channels (per-channel math would rim the
// silhouette with whichever channel dies last).
//
// The Gaussian splat falls off gently, and the exponential tone map lifts
// even its mid-falloff into visible brightness — rendered naively, every
// plume sits inside a huge soft halo. The reference look has none: black
// ground right up to the smoke. A density floor alone can't fix that (it
// only clips the far tail), so:
// 1. toe: densities under 0.06 render as nothing, killing the far haze;
// 2. gamma 1.7 on what survives: crushes the Gaussian's mid-falloff —
// the halo — while the dense core, far above 1, is left saturating.
let raw = max(bilerp4(dyeTex, linearSampler, uv).rgb, vec3f(0.0));
let level = max(raw.r, max(raw.g, raw.b));
let shaped = pow(max(level - 0.06, 0.0) * 1.1, 1.7);
let dye = raw * (shaped / max(level, 1e-4));
// Soft shoulder: accumulating dye saturates toward white instead of
// clipping channel by channel.
let exposure = max(uniforms.exposure, 0.0);
var tone = vec3f(1.0) - exp(-dye * exposure);
// Gradient shading over the tone-mapped field: the brightness gradient
// tilts a unit normal away from the viewer, and a headlight at +z darkens
// steep fronts (never brightens — diffuse tops out at 1). Neighbors are
// tone-mapped too so the gradient lives in the displayed range.
let texel = 1.0 / max(vec2f(textureDimensions(dyeTex)), vec2f(1.0));
let dyeL = max(bilerp4(dyeTex, linearSampler, uv - vec2f(texel.x, 0.0)).rgb, vec3f(0.0));
let dyeR = max(bilerp4(dyeTex, linearSampler, uv + vec2f(texel.x, 0.0)).rgb, vec3f(0.0));
let dyeT = max(bilerp4(dyeTex, linearSampler, uv - vec2f(0.0, texel.y)).rgb, vec3f(0.0));
let dyeB = max(bilerp4(dyeTex, linearSampler, uv + vec2f(0.0, texel.y)).rgb, vec3f(0.0));
let gx = length(vec3f(1.0) - exp(-dyeR * exposure)) - length(vec3f(1.0) - exp(-dyeL * exposure));
let gy = length(vec3f(1.0) - exp(-dyeB * exposure)) - length(vec3f(1.0) - exp(-dyeT * exposure));
let normal = normalize(vec3f(gx, gy, length(texel)));
let diffuse = clamp(normal.z + 0.7, 0.7, 1.0);
tone *= mix(1.0, diffuse, clamp(uniforms.shading, 0.0, 1.0));
let background = clamp(uniforms.backgroundColor, vec3f(0.0), vec3f(1.0));
var color = background + tone;
// Vignette, mixed in OKLab so the falloff into the page ink stays neutral
// instead of graying out mid-ramp.
let centered = (uv - vec2f(0.5)) * vec2f(res.x / res.y, 1.0);
let vignette = smoothstep(1.35, 0.45, length(centered));
color = mixOklab(background * 0.35, color, 0.3 + 0.7 * vignette);
let grain = hash2(position.xy + fract(uniforms.time) * 289.0).x - 0.5;
let encoded = linearToSrgb3(clamp(color, vec3f(0.0), vec3f(1.0))) + grain / 255.0;
return vec4f(clamp(encoded, vec3f(0.0), vec3f(1.0)), 1.0);
}
fluid/advect.wgsl
// fluid · advect — semi-Lagrangian advection of one field through the
// velocity field. Serves two pass instances per tick: velocity advecting
// itself (srcTex = velocityTex = the velocity field) and dye advecting
// through velocity, each with its own uniforms (JS uniform writes are
// frame-global, so per-pass values need per-pass effect instances).
import { advectBacktrace, bilerp2, bilerp4 } from "@vshaders/fluid/advect";
struct Uniforms {
// Resolution of this pass's render target (the field being advected).
// Declared by every pass per the standard interface; the backtrace itself
// works through the velocity grid below.
resolution: vec2f,
// Resolution of the velocity grid. Velocity is stored in *its* texels per
// unit of dt, so displacement in uv converts through the velocity texel —
// the dye field is finer than the sim grid and must not rescale the flow.
velocityResolution: vec2f,
// Timestep in seconds, clamped by the runner (tab restores can't explode).
dt: f32,
// Retention factor per 60Hz frame, applied frame-rate independently as
// pow(dissipation, dt * 60): 1 keeps everything, lower values fade.
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 velTexel = 1.0 / max(uniforms.velocityResolution, vec2f(1.0));
let velocity = bilerp2(velocityTex, linearSampler, uv);
let back = advectBacktrace(uv, velocity, max(uniforms.dt, 0.0), velTexel);
let keep = pow(clamp(uniforms.dissipation, 0.0, 1.0), max(uniforms.dt, 0.0) * 60.0);
return bilerp4(srcTex, linearSampler, back) * keep;
}
fluid/splat.wgsl
// fluid · splat — injects force or dye along the stroke segment from
// prevCenter to center: the Gaussian falloff is evaluated at the closest
// point on the segment, so fast pointer motion paints a continuous capsule
// instead of a trail of beads. One shader serves every splat instance per
// tick (pointer velocity, pointer dye, idle emitters), each instance with
// its own uniforms: colorMix 0 adds splatValue as-is (velocity, xy into an
// rg target), colorMix 1 adds a cosine-palette color scaled by splatValue.x
// (dye). Works in linear light; the dye field stays linear until display.
import { gaussianSplat } from "@vshaders/fluid/splat";
import { cosinePalette } from "@vshaders/color/palette";
struct Uniforms {
// Resolution of this pass's render target (the field being splatted).
resolution: vec2f,
// Seconds; drives the palette index of dye splats.
time: f32,
// Gaussian 1/e radius, in short-side uv units (aspect-corrected below).
radius: f32,
// Stroke endpoints in uv of the field; equal for a stationary splat.
center: vec2f,
prevCenter: vec2f,
// Velocity instances: xy = force in velocity-grid texels per unit dt.
// Dye instances: x = dye amount multiplying the palette color.
splatValue: vec3f,
// 0 = raw splatValue (velocity), 1 = palette-colored dye.
colorMix: f32,
// Cosine palette shape (linear light) and its time scale/offset.
paletteBias: vec3f,
paletteSpeed: f32,
paletteAmplitude: vec3f,
paletteShift: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var srcTex: texture_2d<f32>;
@fragment
fn fs_main(@builtin(position) position: vec4f, @location(0) uv: vec2f) -> @location(0) vec4f {
let res = max(uniforms.resolution, vec2f(1.0));
// Aspect correction: distances in short-side units on both axes, so the
// splat is round and `radius` means the same thing at any aspect ratio.
let aspect = res / min(res.x, res.y);
let p = uv * aspect;
let a = uniforms.prevCenter * aspect;
let b = uniforms.center * aspect;
// Closest point on the stroke segment a→b; a zero-length segment (guarded
// against dividing by zero) degrades to the point splat at `center`.
let ab = b - a;
let lenSq = dot(ab, ab);
var nearest = b;
if (lenSq > 1e-10) {
let t = clamp(dot(p - a, ab) / lenSq, 0.0, 1.0);
nearest = a + ab * t;
}
// gaussianSplat guards non-positive radius by depositing nothing.
let falloff = gaussianSplat(p, nearest, uniforms.radius);
let paletteIndex = uniforms.time * uniforms.paletteSpeed + uniforms.paletteShift;
let paletteColor = max(
cosinePalette(
paletteIndex,
uniforms.paletteBias,
uniforms.paletteAmplitude,
vec3f(1.0),
vec3f(0.0, 0.33, 0.67),
),
vec3f(0.0),
);
let amount = mix(uniforms.splatValue, paletteColor * uniforms.splatValue.x, clamp(uniforms.colorMix, 0.0, 1.0));
let last = max(vec2i(textureDimensions(srcTex)) - vec2i(1), vec2i(0));
let base = textureLoad(srcTex, clamp(vec2i(position.xy), vec2i(0), last), 0);
return vec4f(base.rgb + falloff * amount, 1.0);
}
fluid/curl.wgsl
// fluid · curl — scalar (out-of-plane) curl of the velocity field, written
// once per tick into an r16float target for the vorticity-confinement pass.
// The point curl is tent-filtered over the 3x3 neighborhood before it is
// stored: the confinement force normalizes the gradient of |curl|, and raw
// per-texel curl makes that direction flip texel to texel, injecting
// texel-scale divergence the collocated projection mathematically cannot
// remove (its wide Laplacian is blind at the Nyquist frequency). Filtered
// curl keeps the vortex structure and moves the force onto scales the
// Jacobi solve actually reaches.
// The kernel reads textureLoad neighborhoods clamped to the extent, so no
// sampler is needed and the boundary behaves as clamp-to-edge.
import { curlAt } from "@vshaders/fluid/vorticity";
struct Uniforms {
// Resolution of this pass's render target (the sim grid). Declared by
// every pass per the standard interface; the kernel clamps its own reads.
resolution: vec2f,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var velocityTex: texture_2d<f32>;
@fragment
fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let p = vec2i(position.xy);
// 3x3 tent filter (4-2-1)/16 over the point curl.
var filtered = 4.0 * curlAt(velocityTex, p);
filtered += 2.0 * curlAt(velocityTex, p + vec2i(1, 0));
filtered += 2.0 * curlAt(velocityTex, p - vec2i(1, 0));
filtered += 2.0 * curlAt(velocityTex, p + vec2i(0, 1));
filtered += 2.0 * curlAt(velocityTex, p - vec2i(0, 1));
filtered += curlAt(velocityTex, p + vec2i(1, 1));
filtered += curlAt(velocityTex, p + vec2i(1, -1));
filtered += curlAt(velocityTex, p + vec2i(-1, 1));
filtered += curlAt(velocityTex, p + vec2i(-1, -1));
return vec4f(filtered / 16.0, 0.0, 0.0, 1.0);
}
fluid/vorticity.wgsl
// fluid · vorticity — vorticity confinement (Fedkiw, Stam, Jensen 2001):
// adds a force that steers flow back around the vortices grid advection
// smears out, keeping the motion swirly instead of syrupy. Reads the curl
// field written by curl.wgsl and the current velocity; writes velocity.
import { vorticityForceAt } from "@vshaders/fluid/vorticity";
struct Uniforms {
// Resolution of this pass's render target (the sim grid). Declared by
// every pass per the standard interface; the kernel clamps its own reads.
resolution: vec2f,
// Timestep in seconds; the confinement force integrates as force * dt.
dt: f32,
// Confinement strength; 5–30 is typical on a 128 grid, more is stormier.
// The kernel returns zero force for non-positive strength.
curlStrength: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var velocityTex: texture_2d<f32>;
@group(0) @binding(2) var curlTex: texture_2d<f32>;
@fragment
fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let p = vec2i(position.xy);
let last = max(vec2i(textureDimensions(velocityTex)) - vec2i(1), vec2i(0));
let velocity = textureLoad(velocityTex, clamp(p, vec2i(0), last), 0).xy;
let force = vorticityForceAt(curlTex, p, uniforms.curlStrength);
return vec4f(velocity + force * max(uniforms.dt, 0.0), 0.0, 1.0);
}
fluid/divergence.wgsl
// fluid · divergence — central-difference divergence of the velocity field,
// written once per tick into an r16float target. It is the right-hand side
// of the pressure Poisson equation the Jacobi passes relax.
//
// `contained` picks the boundary condition (uniform, so control flow stays
// uniform): 1 treats the domain edge as a reflective container wall (the
// off-grid velocity neighbor is the negation of the center's, so flow into
// a wall registers as compression); 0 keeps the free clamped-edge reads,
// under which fluid drifts out at the edges.
import { divergenceAt, divergenceBoundedAt } from "@vshaders/fluid/project";
struct Uniforms {
// Resolution of this pass's render target (the sim grid). Declared by
// every pass per the standard interface; the kernel clamps its own reads.
resolution: vec2f,
// 1 = contained (reflective walls), 0 = free. Set by the runner.
contained: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var velocityTex: texture_2d<f32>;
@fragment
fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let p = vec2i(position.xy);
let divergence = select(
divergenceAt(velocityTex, p),
divergenceBoundedAt(velocityTex, p),
uniforms.contained > 0.5,
);
return vec4f(divergence, 0.0, 0.0, 1.0);
}
fluid/clear-pressure.wgsl
// fluid · clear pressure — scales last tick's relaxed pressure down and
// hands it to the Jacobi passes as their starting guess (a warm start:
// pressure fields are strongly correlated frame to frame, so this buys the
// iterations back). A dedicated pass because JS uniform writes are
// frame-global: the decay cannot be a first-iteration uniform on the
// Jacobi effect.
struct Uniforms {
// Resolution of this pass's render target (the sim grid). Declared by
// every pass per the standard interface.
resolution: vec2f,
// Fraction of the previous pressure kept as the warm start; 0 restarts
// the relaxation from scratch every tick.
pressureDecay: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var pressureTex: texture_2d<f32>;
@fragment
fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let last = max(vec2i(textureDimensions(pressureTex)) - vec2i(1), vec2i(0));
let pressure = textureLoad(pressureTex, clamp(vec2i(position.xy), vec2i(0), last), 0).x;
return vec4f(pressure * clamp(uniforms.pressureDecay, 0.0, 1.0), 0.0, 0.0, 1.0);
}
fluid/jacobi-pressure.wgsl
// fluid · jacobi pressure — one Jacobi relaxation step of the pressure
// Poisson equation laplacian(p) = divergence. The runner encodes this pass
// N times per tick over a ping-pong r16float pair, rebinding pressureTex to
// the read half before each pass (texture rebinds take effect per pass;
// N is the `iterations` runner param). Jacobi converges fast on the
// high-frequency divergence pointer splats create and slowly on
// domain-scale modes — which is why N is tunable.
//
// `contained` picks the boundary condition (uniform, so control flow stays
// uniform): 1 uses the bounded kernel's explicit pure-Neumann walls (the
// off-grid pressure neighbor equals the center), matching the contained
// divergence and gradient passes; 0 keeps the free clamped-edge reads.
import { jacobiPressureAt, jacobiPressureBoundedAt } from "@vshaders/fluid/project";
struct Uniforms {
// Resolution of this pass's render target (the sim grid). Declared by
// every pass per the standard interface; the kernel clamps its own reads.
resolution: vec2f,
// 1 = contained (reflective walls), 0 = free. Set by the runner.
contained: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var pressureTex: texture_2d<f32>;
@group(0) @binding(2) var divergenceTex: texture_2d<f32>;
@fragment
fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let p = vec2i(position.xy);
let pressure = select(
jacobiPressureAt(pressureTex, divergenceTex, p),
jacobiPressureBoundedAt(pressureTex, divergenceTex, p),
uniforms.contained > 0.5,
);
return vec4f(pressure, 0.0, 0.0, 1.0);
}
fluid/gradient-subtract.wgsl
// fluid · gradient subtract — the projection step that makes the flow
// incompressible: subtracts the gradient of the relaxed pressure from the
// velocity field, leaving it approximately divergence-free. Writes the
// projected velocity back into the velocity ping-pong pair.
//
// `contained` picks the boundary condition (uniform, so control flow stays
// uniform): 1 uses the bounded kernel's pure-Neumann pressure walls, the
// gradient half of the reflective-wall projection (pair with the contained
// divergence and Jacobi passes); 0 keeps the free clamped-edge reads.
import { subtractPressureGradientAt, subtractPressureGradientBoundedAt } from "@vshaders/fluid/project";
struct Uniforms {
// Resolution of this pass's render target (the sim grid). Declared by
// every pass per the standard interface; the kernel clamps its own reads.
resolution: vec2f,
// 1 = contained (reflective walls), 0 = free. Set by the runner.
contained: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var velocityTex: texture_2d<f32>;
@group(0) @binding(2) var pressureTex: texture_2d<f32>;
@fragment
fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let p = vec2i(position.xy);
let projected = select(
subtractPressureGradientAt(velocityTex, pressureTex, p),
subtractPressureGradientBoundedAt(velocityTex, pressureTex, p),
uniforms.contained > 0.5,
);
return vec4f(projected, 0.0, 1.0);
}