46 KiB
IMPORTANT: Common Error When Extracting Shaders from HTML Script Tags: When extracting source from <script type="x-shader/x-fragment">, you must ensure #version is the very first character of the string, with no leading whitespace or newlines:
// WRONG: indentation/newline inside script tag
// <script id="fs">
// #version 300 es <-- leading newline here!
// </script>
const source = document.getElementById('fs').textContent; // contains leading whitespace
// CORRECT: use .trim() or place template string flush with the start
const source = document.getElementById('fs').textContent.trim();
// Or in HTML, place content directly after the tag:
// <script id="fs">#version 300 es
// ...
IMPORTANT: Float Texture Compatibility (Most Critical Issue for Fluid Simulation)
Fluid simulation requires float textures to store velocity (can be negative), pressure, and ink concentration (can exceed 1.0).
IMPORTANT: Must use RGBA16F instead of RGBA32F: Many environments (headless Chrome, SwiftShader, mobile) do not support RGBA32F render targets. Even when the EXT_color_buffer_float extension claims to be available, RGBA32F FBOs may silently fail (framebuffer reports complete but renders all zeros or all ones). RGBA16F + HALF_FLOAT has far better compatibility than RGBA32F, and its precision is more than sufficient for fluid simulation.
const gl = canvas.getContext("webgl2");
if (!gl) { /* error handling */ }
const ext = gl.getExtension("EXT_color_buffer_float");
// IMPORTANT: Continue even if ext is null — some environments support RGBA16F without this extension
function createFloatTexture(w, h) {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
// IMPORTANT: Must use RGBA16F + HALF_FLOAT, do NOT use RGBA32F + FLOAT
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return tex;
}
function createFBO(w, h) {
const tex = createFloatTexture(w, h);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
console.error("FBO incomplete:", status);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return { fbo, tex };
}
Mouse Interaction Implementation
Fluid simulation requires tracking mouse position and drag direction. iMouse uniform convention: xy=current mouse position, z=mouse down flag (>0 means pressed), w=unused. Mouse velocity is calculated from the position difference between current and previous frames:
// IMPORTANT: iMouse convention: xy=current position, z=pressed flag (1.0=down, 0.0=up), w=0
// Mouse velocity is computed via prevMouse on the JS side, passed through a separate uniform
let iMouse = [0, 0, 0, 0]; // [x, y, pressed, 0]
let prevMouse = [0, 0];
let mouseDown = false;
canvas.addEventListener('mousemove', (e) => {
const dpr = Math.min(window.devicePixelRatio, 1.5);
const x = e.clientX * dpr;
const y = canvas.height - e.clientY * dpr; // WebGL Y-axis is flipped
if (mouseDown) {
prevMouse[0] = iMouse[0];
prevMouse[1] = iMouse[1];
iMouse[0] = x;
iMouse[1] = y;
}
});
canvas.addEventListener('mousedown', (e) => {
mouseDown = true;
const dpr = Math.min(window.devicePixelRatio, 1.5);
const x = e.clientX * dpr;
const y = canvas.height - e.clientY * dpr;
iMouse[0] = x; iMouse[1] = y;
iMouse[2] = 1.0; // Flag: mouse pressed
prevMouse[0] = x; prevMouse[1] = y;
});
canvas.addEventListener('mouseup', () => {
mouseDown = false;
iMouse[2] = 0.0; // Flag: mouse released
});
// Pass uniforms in render loop
// iMouse: xy=position, z=pressed flag, w=0
gl.uniform4f(uMouse, iMouse[0], iMouse[1], iMouse[2], 0.0);
// IMPORTANT: Mouse velocity must be clamped, otherwise fast dragging produces huge velocity deltas causing NaN explosion
const mvx = Math.max(-50, Math.min(50, iMouse[0] - prevMouse[0]));
const mvy = Math.max(-50, Math.min(50, iMouse[1] - prevMouse[1]));
gl.uniform2f(uMouseVel, mvx, mvy);
Handling WebGL 2 Unavailability
const gl = canvas.getContext("webgl2");
if (!gl) {
document.body.innerHTML = `
<div style="color:#fff;padding:20px;font-family:sans-serif;">
<h2>WebGL 2 Not Supported</h2>
<p>Fluid simulation requires WebGL 2. Please use a modern browser (Chrome 56+, Firefox 51+, Safari 15+).</p>
</div>
`;
throw new Error('WebGL2 not supported');
}
Real-Time Fluid Simulation
Use Cases
- Real-time 2D fluid effects in ShaderToy/WebGL (smoke, liquids, ink diffusion)
- Interactive fluid: mouse/touch-driven fluid response
- Ink diffusion/curling vortex effects in water: vorticity confinement + high diffusion coefficient + single or multi-color ink
- Multi-color ink mixing: multiple ink colors interpenetrating and blending (requires Buffer B to store RGB ink, see multi-color ink mixing template)
- Decorative fluid backgrounds, particle systems, vortex visualization
- Lava/fire/magma effects: fluid simulation + FBM noise texture + temperature color mapping
- Water surface ripple effects: wave equation + click-generated concentric ripples + interference and damping
- Core: solving simplified Navier-Stokes equations or wave equations in GPU fragment shaders
Core Principles
Incompressible Navier-Stokes equation discretization:
Momentum equation: ∂v/∂t = -(v·∇)v - ∇p + ν∇²v + f
Continuity equation: ∇·v = 0
Term meanings: -(v·∇)v advection, -∇p pressure gradient, ν∇²v viscous diffusion, f external forces.
Zero divergence = incompressibility constraint, achieved by projecting the velocity field through the pressure Poisson equation.
ShaderToy implementation strategy: texture buffer inter-frame feedback, each frame executes: advection → diffusion → external forces → pressure projection. Each pixel stores grid point physical quantities (velocity, pressure, density).
Water Surface Ripple Principles (Wave Equation)
Water surface ripples use the 2D wave equation rather than Navier-Stokes:
∂²h/∂t² = c² * ∇²h - damping * ∂h/∂t
Discretized using Verlet integration: next = speed * (2*curr - prev + laplacian) * damping.
Data encoding: .r = previous frame height (prev), .g = current frame height (curr). Each frame computes the Laplacian to advance the wavefront, with ping-pong buffers alternating read/write.
Implementation Steps
Step 1: Data Encoding & Neighborhood Sampling
// Data layout: .xy=velocity, .z=pressure/density, .w=ink
#define T(p) texture(iChannel0, (p) / iResolution.xy)
vec4 c = T(p); // center
vec4 n = T(p + vec2(0, 1)); // north
vec4 e = T(p + vec2(1, 0)); // east
vec4 s = T(p - vec2(0, 1)); // south
vec4 w = T(p - vec2(1, 0)); // west
Step 2: Discrete Differential Operators
// Laplacian (weighted 3x3 stencil)
const float _K0 = -20.0 / 6.0;
const float _K1 = 4.0 / 6.0;
const float _K2 = 1.0 / 6.0;
vec4 laplacian = _K0 * c
+ _K1 * (n + e + s + w)
+ _K2 * (T(p+vec2(1,1)) + T(p+vec2(-1,1)) + T(p+vec2(1,-1)) + T(p+vec2(-1,-1)));
// Gradient (central difference)
vec4 dx = (e - w) / 2.0;
vec4 dy = (n - s) / 2.0;
// Divergence & Curl
float div = dx.x + dy.y;
float curl = dx.y - dy.x;
Step 3: Semi-Lagrangian Advection
#define DT 0.15 // time step
// Backward trace: sample from upstream, unconditionally stable
vec4 advected = T(p - DT * c.xy);
c.xyw = advected.xyw;
Step 4: Viscous Diffusion
#define NU 0.5 // kinematic viscosity (0.01=water, 1.0=syrup)
#define KAPPA 0.1 // ink diffusion coefficient
c.xy += DT * NU * laplacian.xy;
c.w += DT * KAPPA * laplacian.w;
Step 5: Pressure Projection
#define K 0.2 // pressure correction strength
c.xy -= K * vec2(dx.z, dy.z);
c.z -= DT * (dx.z * c.x + dy.z * c.y + div * c.z);
Step 6: Mouse Interaction
// IMPORTANT: iMouse.z is the mouse-down flag (>0=pressed), not a position coordinate
// iMouseVel is mouse movement velocity, passed via a separate uniform
// IMPORTANT: Must clamp mouseVel to prevent NaN explosion
if (iMouse.z > 0.0) {
vec2 mouseVel = clamp(iMouseVel, vec2(-50.0), vec2(50.0));
float dist2 = dot(p - iMouse.xy, p - iMouse.xy);
float influence = exp(-dist2 / 50.0); // 50.0=influence radius
c.xy += DT * influence * mouseVel;
c.w += DT * influence * 0.5;
}
Step 6b: Vorticity Confinement (Required for Ink Curling Effects)
// IMPORTANT: Ink diffusion/swirl effects require vorticity confinement, otherwise small vortices dissipate quickly leaving only smooth flow
// Vorticity confinement re-injects energy into small-scale vortices, producing characteristic curling textures
#define VORT_STR 0.035 // [0.01=subtle, 0.05=noticeable, 0.1=strong]
float curl_c = dx.y - dy.x;
float curl_n = (T(p + vec2(1,1)).y - T(p + vec2(-1,1)).y) / 2.0
- (T(p + vec2(0,2)).x - T(p).x) / 2.0;
float curl_s = (T(p + vec2(1,-1)).y - T(p + vec2(-1,-1)).y) / 2.0
- (T(p).x - T(p + vec2(0,-2)).x) / 2.0;
float curl_e = (T(p + vec2(2,0)).y - T(p).y) / 2.0
- (T(p + vec2(1,1)).x - T(p + vec2(1,-1)).x) / 2.0;
float curl_w = (T(p).y - T(p + vec2(-2,0)).y) / 2.0
- (T(p + vec2(-1,1)).x - T(p + vec2(-1,-1)).x) / 2.0;
vec2 eta = normalize(vec2(abs(curl_e)-abs(curl_w), abs(curl_n)-abs(curl_s)) + vec2(1e-5));
c.xy += DT * VORT_STR * vec2(eta.y, -eta.x) * curl_c;
Step 7: Automatic Ink Sources (Critical: Ensures Visible Output Without Interaction)
// IMPORTANT: Must have automatic ink sources! Otherwise the screen is completely black without mouse interaction
// IMPORTANT: Ink injection and decay must be balanced! Too-strong injection or too-weak decay causes ink saturation across the entire screen → solid color with no features
// IMPORTANT: Gaussian denominator controls emitter radius — larger denominator means larger emitter!
// Denominator > 300 makes emitter cover most of the screen, ink saturates quickly
// Recommended 100~200, keeping it locally concentrated with visible gradient falloff at distance
float t = iTime;
// Emitter positions should move over time for dynamic effects
vec2 em1 = iResolution.xy * vec2(0.25, 0.5 + 0.2 * sin(t * 0.7));
vec2 em2 = iResolution.xy * vec2(0.75, 0.5 + 0.2 * cos(t * 0.9));
vec2 em3 = iResolution.xy * vec2(0.5, 0.3 + 0.15 * sin(t * 1.3));
// Gaussian influence radius controls locality (smaller denominator = more concentrated, 100~200 is reasonable)
float r1 = exp(-dot(p - em1, p - em1) / 150.0);
float r2 = exp(-dot(p - em2, p - em2) / 150.0);
float r3 = exp(-dot(p - em3, p - em3) / 120.0);
// Inject velocity (rotating, crossing directions make fluid motion more interesting)
c.xy += DT * r1 * vec2(cos(t), sin(t * 1.3)) * 3.0;
c.xy += DT * r2 * vec2(-cos(t * 0.8), sin(t * 0.6)) * 3.0;
c.xy += DT * r3 * vec2(sin(t * 1.1), -cos(t)) * 2.0;
// Inject ink (note: injection amount must balance with INK_DECAY, otherwise screen saturates)
c.w += DT * (r1 + r2 + r3) * 2.0;
Step 8: Boundaries & Stability
// No-slip boundary
if (p.x < 1.0 || p.y < 1.0 ||
iResolution.x - p.x < 1.0 || iResolution.y - p.y < 1.0) {
c.xyw *= 0.0;
}
// IMPORTANT: Ink decay — must use multiplicative decay (e.g., *= 0.99), NOT subtractive decay (-= constant)
// Subtractive decay zeros out quickly at small ink values and decays too slowly at large values, causing saturation
// Multiplicative decay scales proportionally, maintaining contrast at any concentration
c.w *= 0.99; // 1% decay per frame, adjustable [0.98=fast dissipation, 0.995=persistent]
c = clamp(c, vec4(-5, -5, 0.5, 0), vec4(5, 5, 3, 5));
Step 9: Visualization (Image Pass) — General Fluid
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec4 c = texture(iChannel0, uv);
// IMPORTANT: Color base must be bright enough! 0.5+0.5*cos produces [0,1] range bright colors
// Never use vec3(0.02, 0.01, 0.08) or similar near-zero base colors — they become invisible when multiplied by ink
float angle = atan(c.y, c.x);
vec3 col = 0.5 + 0.5 * cos(angle + vec3(0.0, 2.1, 4.2));
// IMPORTANT: Use smoothstep to map ink concentration; upper limit should exceed actual ink range to preserve gradients
float ink = smoothstep(0.0, 2.0, c.w);
col *= ink;
// Pressure highlights
col += vec3(0.05) * clamp(c.z - 1.0, 0.0, 1.0);
// IMPORTANT: Background color must be visible (RGB at least > 5/255 ≈ 0.02), otherwise users think the page is all black
col = max(col, vec3(0.02, 0.012, 0.035));
fragColor = vec4(col, 1.0);
}
Step 9b: Visualization (Image Pass) — Lava/Fire/Magma Effects
Lava/fire requires FBM noise for turbulent textures + temperature color band mapping. Key: Must use FBM noise to distort UV coordinates and temperature values, otherwise the image is too smooth and looks like a plain gradient rather than lava.
// IMPORTANT: FBM noise is the core of lava/fire visualization! Without it the image is a smooth gradient with no lava texture
// These noise functions must be defined in the Image Pass
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f); // smoothstep hermite
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
// IMPORTANT: octaves=4~6 produces sufficient detail; fewer than 3 gives too-coarse textures
float fbm(vec2 p, int octaves) {
float val = 0.0;
float amp = 0.5;
for (int i = 0; i < octaves; i++) {
val += amp * noise(p);
p *= 2.0;
amp *= 0.5;
}
return val;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec4 c = texture(iChannel0, uv);
float t = iTime;
// Use fluid velocity field to distort noise sampling coordinates so noise moves with the fluid
vec2 distortedUV = uv + c.xy * 0.002;
// FBM noise: multi-octave superposition produces turbulent detail
float n1 = fbm(distortedUV * 8.0 + t * 0.3, 5);
float n2 = fbm(distortedUV * 4.0 - t * 0.2 + 5.0, 4);
float ink = smoothstep(0.0, 2.0, c.w);
float speed = length(c.xy);
// Temperature = ink concentration + noise perturbation + speed contribution
// IMPORTANT: Noise perturbation amplitude of 0.2~0.4 produces visible texture without becoming noisy
float temp = ink * 0.7 + n1 * 0.25 + speed * 0.1;
// Second noise layer for cracks/dark veins
temp -= (1.0 - n2) * 0.15 * ink;
temp = clamp(temp, 0.0, 1.0);
// Lava temperature color band: black → dark red → orange → yellow → white-hot
vec3 col;
if (temp < 0.15) {
col = mix(vec3(0.05, 0.0, 0.0), vec3(0.5, 0.05, 0.0), temp / 0.15);
} else if (temp < 0.4) {
col = mix(vec3(0.5, 0.05, 0.0), vec3(1.0, 0.35, 0.0), (temp - 0.15) / 0.25);
} else if (temp < 0.7) {
col = mix(vec3(1.0, 0.35, 0.0), vec3(1.0, 0.75, 0.1), (temp - 0.4) / 0.3);
} else {
col = mix(vec3(1.0, 0.75, 0.1), vec3(1.0, 0.95, 0.7), clamp((temp - 0.7) / 0.3, 0.0, 1.0));
}
// Glow effect: additional additive glow in high-temperature regions
float glow = smoothstep(0.5, 1.0, temp) * 0.4;
col += vec3(1.0, 0.5, 0.1) * glow;
// HDR tone mapping
col = 1.0 - exp(-col * 1.5);
col = max(col, vec3(0.03, 0.005, 0.0));
fragColor = vec4(col, 1.0);
}
Step 9c: Visualization (Image Pass) — Water Surface Ripple Effects
The water ripple Image Pass computes normals from the height field, then applies lighting + environment reflection. Key: Normal perturbation strength must be large enough (50~100), water base color must be bright (blue component > 0.15), and specular highlights must be prominent.
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec2 texel = 1.0 / iResolution.xy;
// IMPORTANT: Sample from wave height field: .g channel stores current frame height
float h = texture(iChannel0, uv).g;
float hn = texture(iChannel0, uv + vec2(0.0, texel.y)).g;
float hs = texture(iChannel0, uv - vec2(0.0, texel.y)).g;
float he = texture(iChannel0, uv + vec2(texel.x, 0.0)).g;
float hw = texture(iChannel0, uv - vec2(texel.x, 0.0)).g;
// IMPORTANT: Normal perturbation factor must be large enough (50~100), otherwise ripples are invisible
// If drop strength is 1.0 and radius is 8~15px, using 80.0 produces clearly visible ripples
vec3 normal = normalize(vec3((hw - he) * 80.0, (hs - hn) * 80.0, 1.0));
vec3 lightDir = normalize(vec3(0.3, 0.5, 1.0));
vec3 viewDir = vec3(0.0, 0.0, 1.0);
vec3 halfVec = normalize(lightDir + viewDir);
float diffuse = max(dot(normal, lightDir), 0.0);
float specular = pow(max(dot(normal, halfVec), 0.0), 64.0);
// IMPORTANT: Water base color must be bright enough! Deep color no darker than vec3(0.02, 0.08, 0.2), shallow color use vec3(0.1, 0.3, 0.6)
vec3 deepColor = vec3(0.02, 0.08, 0.22);
vec3 shallowColor = vec3(0.1, 0.35, 0.65);
vec3 waterColor = mix(deepColor, shallowColor, 0.5 + h * 5.0);
float fresnel = pow(1.0 - max(dot(normal, viewDir), 0.0), 3.0);
vec3 col = waterColor * (0.4 + 0.6 * diffuse);
col += vec3(0.9, 0.95, 1.0) * specular * 2.0;
col += vec3(0.15, 0.25, 0.45) * fresnel * 0.6;
// Caustic effect
float caustic = pow(max(diffuse, 0.0), 8.0) * abs(h) * 5.0;
col += vec3(0.15, 0.35, 0.55) * caustic;
col = max(col, vec3(0.02, 0.06, 0.15));
col = pow(col, vec3(0.95));
fragColor = vec4(col, 1.0);
}
IMPORTANT: Common Fatal Errors
- RGBA32F silently fails in headless/SwiftShader environments: Must use
RGBA16F + HALF_FLOAT - Ink saturates entire screen: Gaussian denominator too large (>300) or decay too weak (>0.995). Fix: denominator 100~200, decay
*= 0.99 - Image Pass colors too dark causing all-black screen: Use
0.5 + 0.5 * cos(...)color base to ensure bright range - Unclamped mouse velocity causing NaN crash: Fast dragging or first-frame clicks produce huge velocity deltas → velocity explosion → NaN propagates across entire screen. Both JS side and shader side must clamp mouseVel to [-50, 50]
- Using single scalar for multi-color ink prevents mixing: A single
c.wcan only do single-color. Multi-color ink requires Buffer B to store RGB three channels (see multi-color ink mixing template) - GLSL strict typing:
vec2 = floatis illegal, must usevec2(float); integers and floats cannot be mixed
Complete Code Template
Setup: Buffer A's iChannel0 points to Buffer A itself (feedback loop).
Standalone HTML JS Skeleton (Ping-Pong Render Pipeline)
Fluid simulation requires framebuffer self-feedback + float textures. The following JS skeleton demonstrates the correct WebGL2 multi-pass ping-pong structure:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fluid Simulation</title>
<style>
/* IMPORTANT: Critical: canvas must fill the viewport, otherwise it may be invisible or clipped */
*{margin:0;padding:0}
html,body{width:100%;height:100%;overflow:hidden;background:#000}
canvas{display:block;width:100%;height:100%}
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
let frameCount = 0;
// IMPORTANT: iMouse convention: [x, y, pressedFlag, 0] — z is the pressed flag (1 or 0), not a coordinate
let mouse = [0, 0, 0, 0];
let prevMouse = [0, 0];
let mouseDown = false;
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2');
if (!gl) {
document.body.innerHTML = '<div style="color:#fff;padding:20px;font-family:sans-serif;"><h2>WebGL 2 not supported</h2></div>';
throw new Error('WebGL2 not supported');
}
const ext = gl.getExtension('EXT_color_buffer_float');
function createShader(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
console.error(gl.getShaderInfoLog(s));
return s;
}
function createProgram(vsSrc, fsSrc) {
const p = gl.createProgram();
gl.attachShader(p, createShader(gl.VERTEX_SHADER, vsSrc));
gl.attachShader(p, createShader(gl.FRAGMENT_SHADER, fsSrc));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS))
console.error(gl.getProgramInfoLog(p));
return p;
}
const vsSource = `#version 300 es
in vec2 pos;
void main(){ gl_Position=vec4(pos,0,1); }`;
// fsBuffer / fsImage: adapt from the Buffer A / Image templates below
// IMPORTANT: Fragment shaders must declare these uniforms:
// uniform sampler2D iChannel0;
// uniform vec2 iResolution;
// uniform float iTime;
// uniform int iFrame;
// uniform vec4 iMouse; // xy=position, z=pressed flag, w=0
// uniform vec2 iMouseVel; // mouse movement velocity (only needed in Buffer pass)
const progBuf = createProgram(vsSource, fsBuffer);
const progImg = createProgram(vsSource, fsImage);
// IMPORTANT: Must use RGBA16F + HALF_FLOAT, do NOT use RGBA32F + FLOAT
// RGBA32F may render all zeros in headless Chrome / SwiftShader even when framebuffer reports complete
function createFBO(w, h) {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return { fbo, tex };
}
let W, H, bufA, bufB;
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
function resize() {
const dpr = Math.min(window.devicePixelRatio, 1.5);
canvas.width = W = Math.floor(innerWidth * dpr);
canvas.height = H = Math.floor(innerHeight * dpr);
bufA = createFBO(W, H);
bufB = createFBO(W, H);
frameCount = 0;
}
addEventListener('resize', resize);
resize();
canvas.addEventListener('mousemove', e => {
const dpr = Math.min(devicePixelRatio, 1.5);
const x = e.clientX * dpr;
const y = H - e.clientY * dpr;
if (mouseDown) {
prevMouse[0] = mouse[0]; prevMouse[1] = mouse[1];
mouse[0] = x; mouse[1] = y;
}
});
canvas.addEventListener('mousedown', e => {
mouseDown = true;
const dpr = Math.min(devicePixelRatio, 1.5);
mouse[0] = e.clientX * dpr;
mouse[1] = H - e.clientY * dpr;
mouse[2] = 1.0; // IMPORTANT: Pressed flag, not a coordinate
prevMouse[0] = mouse[0]; prevMouse[1] = mouse[1];
});
canvas.addEventListener('mouseup', () => {
mouseDown = false;
mouse[2] = 0.0; // IMPORTANT: Released flag
});
// Touch events (mobile)
canvas.addEventListener('touchstart', e => {
e.preventDefault(); mouseDown = true;
const t = e.touches[0], dpr = Math.min(devicePixelRatio, 1.5);
mouse[0] = t.clientX * dpr; mouse[1] = H - t.clientY * dpr;
mouse[2] = 1.0;
prevMouse[0] = mouse[0]; prevMouse[1] = mouse[1];
}, {passive:false});
canvas.addEventListener('touchmove', e => {
e.preventDefault();
const t = e.touches[0], dpr = Math.min(devicePixelRatio, 1.5);
if (mouseDown) {
prevMouse[0] = mouse[0]; prevMouse[1] = mouse[1];
mouse[0] = t.clientX * dpr; mouse[1] = H - t.clientY * dpr;
}
}, {passive:false});
canvas.addEventListener('touchend', () => { mouseDown = false; mouse[2] = 0.0; });
// Cache uniform locations (avoid per-frame lookups)
const uBuf = {
ch0: gl.getUniformLocation(progBuf, 'iChannel0'),
res: gl.getUniformLocation(progBuf, 'iResolution'),
time: gl.getUniformLocation(progBuf, 'iTime'),
frame: gl.getUniformLocation(progBuf, 'iFrame'),
mouse: gl.getUniformLocation(progBuf, 'iMouse'),
mouseVel: gl.getUniformLocation(progBuf, 'iMouseVel')
};
const uImg = {
ch0: gl.getUniformLocation(progImg, 'iChannel0'),
res: gl.getUniformLocation(progImg, 'iResolution'),
time: gl.getUniformLocation(progImg, 'iTime')
};
function render(t) {
t *= 0.001;
// Buffer pass: read bufA → write bufB
gl.useProgram(progBuf);
gl.bindFramebuffer(gl.FRAMEBUFFER, bufB.fbo);
gl.viewport(0, 0, W, H);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, bufA.tex);
gl.uniform1i(uBuf.ch0, 0);
gl.uniform2f(uBuf.res, W, H);
gl.uniform1f(uBuf.time, t);
gl.uniform1i(uBuf.frame, frameCount);
gl.uniform4f(uBuf.mouse, mouse[0], mouse[1], mouse[2], 0.0);
// IMPORTANT: Must clamp mouse velocity! Fast movement or first-frame clicks can produce huge velocity values,
// causing shader velocity explosion → NaN propagation → page crash
const mvx = Math.max(-50, Math.min(50, mouse[0] - prevMouse[0]));
const mvy = Math.max(-50, Math.min(50, mouse[1] - prevMouse[1]));
gl.uniform2f(uBuf.mouseVel, mvx, mvy);
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
[bufA, bufB] = [bufB, bufA];
// Reset prevMouse each frame to avoid velocity accumulation
prevMouse[0] = mouse[0]; prevMouse[1] = mouse[1];
// Image pass: read bufA → screen
gl.useProgram(progImg);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, W, H);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, bufA.tex);
gl.uniform1i(uImg.ch0, 0);
gl.uniform2f(uImg.res, W, H);
gl.uniform1f(uImg.time, t);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
frameCount++;
requestAnimationFrame(render);
}
requestAnimationFrame(render);
</script>
Buffer A (Fluid Computation):
// Grid-Based Euler Fluid Solver — Buffer A
// Data layout: .xy=velocity, .z=pressure/density, .w=ink
// iChannel0 = Buffer A (self-feedback)
#define DT 0.15 // time step [0.05 - 0.3]
#define K 0.2 // pressure correction strength [0.1 - 0.4]
#define NU 0.5 // viscosity coefficient [0.01=water, 1.0=syrup]
#define KAPPA 0.1 // ink diffusion coefficient [0.0 - 0.5]
#define MOUSE_RAD 50.0 // mouse influence radius [10.0 - 200.0]
#define T(p) texture(iChannel0, (p) / iResolution.xy)
void mainImage(out vec4 fragColor, in vec2 p) {
// Initial frames: add slight noise to break symmetry lock
if (iFrame < 10) {
vec2 uv = p / iResolution.xy;
float noise = fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453);
fragColor = vec4(noise * 1e-4, noise * 1e-4, 1.0, 0.0);
return;
}
vec4 c = T(p);
vec4 n = T(p + vec2(0, 1));
vec4 e = T(p + vec2(1, 0));
vec4 s = T(p - vec2(0, 1));
vec4 w = T(p - vec2(1, 0));
vec4 laplacian = (n + e + s + w - 4.0 * c);
vec4 dx = (e - w) / 2.0;
vec4 dy = (n - s) / 2.0;
float div = dx.x + dy.y;
c.z -= DT * (dx.z * c.x + dy.z * c.y + div * c.z);
c.xyw = T(p - DT * c.xy).xyw;
c.xyw += DT * vec3(NU, NU, KAPPA) * laplacian.xyw;
c.xy -= K * vec2(dx.z, dy.z);
// Mouse interaction: iMouse.z is the pressed flag (>0), velocity obtained via iMouseVel uniform
// IMPORTANT: mouseVel must be clamped to prevent NaN explosion (JS side should also clamp — double safety)
if (iMouse.z > 0.0) {
vec2 mouseVel = clamp(iMouseVel, vec2(-50.0), vec2(50.0));
float dist2 = dot(p - iMouse.xy, p - iMouse.xy);
float influence = exp(-dist2 / MOUSE_RAD);
c.xy += DT * influence * mouseVel;
c.w += DT * influence * 0.5;
}
// Vorticity confinement: prevents small vortices from dissipating too quickly, producing curling textures
// IMPORTANT: Ink diffusion/swirl effects (e.g., ink diffusing in water) require vorticity confinement, otherwise curl dissipates quickly leaving only smooth flow
float curl_c = dx.y - dy.x;
float curl_n = (T(p + vec2(1,1)).y - T(p + vec2(-1,1)).y) / 2.0
- (T(p + vec2(0,2)).x - T(p).x) / 2.0;
float curl_s = (T(p + vec2(1,-1)).y - T(p + vec2(-1,-1)).y) / 2.0
- (T(p).x - T(p + vec2(0,-2)).x) / 2.0;
float curl_e = (T(p + vec2(2,0)).y - T(p).y) / 2.0
- (T(p + vec2(1,1)).x - T(p + vec2(1,-1)).x) / 2.0;
float curl_w = (T(p).y - T(p + vec2(-2,0)).y) / 2.0
- (T(p + vec2(-1,1)).x - T(p + vec2(-1,-1)).x) / 2.0;
vec2 eta = vec2(abs(curl_e) - abs(curl_w), abs(curl_n) - abs(curl_s));
eta = normalize(eta + vec2(1e-5));
c.xy += DT * 0.035 * vec2(eta.y, -eta.x) * curl_c;
// IMPORTANT: Automatic ink sources: ensure visible fluid motion without mouse interaction
// Emitter positions must move over time, and Gaussian radius must be small enough to maintain locality
float t = iTime;
vec2 em1 = iResolution.xy * vec2(0.25, 0.5 + 0.2 * sin(t * 0.7));
vec2 em2 = iResolution.xy * vec2(0.75, 0.5 + 0.2 * cos(t * 0.9));
vec2 em3 = iResolution.xy * vec2(0.5, 0.3 + 0.15 * sin(t * 1.3));
float r1 = exp(-dot(p - em1, p - em1) / 150.0);
float r2 = exp(-dot(p - em2, p - em2) / 150.0);
float r3 = exp(-dot(p - em3, p - em3) / 120.0);
c.xy += DT * r1 * vec2(cos(t), sin(t * 1.3)) * 3.0;
c.xy += DT * r2 * vec2(-cos(t * 0.8), sin(t * 0.6)) * 3.0;
c.xy += DT * r3 * vec2(sin(t * 1.1), -cos(t)) * 2.0;
c.w += DT * (r1 + r2 + r3) * 2.0;
// IMPORTANT: Ink decay: must use multiplicative decay, do NOT use subtractive (subtractive causes saturation)
c.w *= 0.99;
c = clamp(c, vec4(-5, -5, 0.5, 0), vec4(5, 5, 3, 5));
if (p.x < 1.0 || p.y < 1.0 ||
iResolution.x - p.x < 1.0 || iResolution.y - p.y < 1.0) {
c.xyw *= 0.0;
}
fragColor = c;
}
Image (Visualization Rendering):
// Fluid Visualization — Image Pass
// iChannel0 = Buffer A
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec4 c = texture(iChannel0, uv);
// IMPORTANT: Color base must be bright enough! 0.5+0.5*cos produces [0,1] range bright colors
// Never use vec3(0.02, 0.01, 0.08) or similar extremely dark base colors — they become invisible when multiplied by ink
float angle = atan(c.y, c.x);
vec3 col = 0.5 + 0.5 * cos(angle + vec3(0.0, 2.1, 4.2));
// IMPORTANT: smoothstep upper limit should cover actual ink range to preserve gradient variation
float ink = smoothstep(0.0, 2.0, c.w);
col *= ink;
// Pressure highlights
col += vec3(0.05) * clamp(c.z - 1.0, 0.0, 1.0);
// IMPORTANT: Background color must be visible (RGB at least > 5/255 ≈ 0.02), otherwise users think the page is all black
col = max(col, vec3(0.02, 0.012, 0.035));
fragColor = vec4(col, 1.0);
}
Water Surface Ripple Complete Template
Water surface ripples use the wave equation rather than Navier-Stokes. Clicks/touches generate concentric ripples that interfere with each other and gradually decay.
IMPORTANT: Water ripple drop injection must be implemented directly in the shader using iMouse, do not use custom uniform arrays to pass click positions — that adds complexity on both JS/GLSL sides and is error-prone (uniform location not found, array length mismatch, etc.).
Water Ripple Buffer Pass (Wave Equation Solver)
// Water Ripple — Buffer Pass (Wave Equation Solver)
// Data encoding: .r = previous frame height (prev), .g = current frame height (curr)
// iChannel0 = self-feedback (ping-pong)
// IMPORTANT: Drop injection is done directly in the shader via iMouse, no custom uniforms needed
void main() {
vec2 p = gl_FragCoord.xy;
vec2 uv = p / iResolution.xy;
if (iFrame < 2) {
fragColor = vec4(0.0);
return;
}
float prev = texture(iChannel0, uv).r;
float curr = texture(iChannel0, uv).g;
vec2 texel = 1.0 / iResolution.xy;
float n = texture(iChannel0, uv + vec2(0.0, texel.y)).g;
float s = texture(iChannel0, uv - vec2(0.0, texel.y)).g;
float e = texture(iChannel0, uv + vec2(texel.x, 0.0)).g;
float w = texture(iChannel0, uv - vec2(texel.x, 0.0)).g;
float laplacian = n + s + e + w - 4.0 * curr;
// Verlet integration: next = 2*curr - prev + c²*laplacian
float speed = 0.45;
float next = 2.0 * curr - prev + speed * laplacian;
// damping: 0.995~0.998 lets ripples propagate several rings before disappearing
float damping = 0.996;
next *= damping;
// IMPORTANT: Mouse click drop injection — directly using iMouse, simple and reliable
// iMouse.z > 0 indicates mouse is pressed
if (iMouse.z > 0.0) {
float dist = length(p - iMouse.xy);
float radius = 12.0;
float strength = 1.5;
next += strength * exp(-dist * dist / (2.0 * radius * radius));
}
// IMPORTANT: Automatic ripples: ensure visible ripples even without interaction
// Use periodic functions of iTime to control auto-drop position and timing
float autoPhase = iTime * 0.5;
float autoPeriod = fract(autoPhase);
// Only inject during phase < 0.05 each cycle (avoid continuous injection)
if (autoPeriod < 0.05) {
float idx = floor(autoPhase);
// Pseudo-random position
vec2 autoPos = iResolution.xy * vec2(
0.2 + 0.6 * fract(sin(idx * 12.9898) * 43758.5453),
0.2 + 0.6 * fract(sin(idx * 78.233) * 43758.5453)
);
float dist = length(p - autoPos);
next += 1.2 * exp(-dist * dist / (2.0 * 10.0 * 10.0));
}
// Boundary absorption
if (p.x < 2.0 || p.y < 2.0 ||
iResolution.x - p.x < 2.0 || iResolution.y - p.y < 2.0) {
next *= 0.0;
}
// IMPORTANT: Output: .r = current frame (becomes next frame's prev), .g = newly computed (becomes next frame's curr)
fragColor = vec4(curr, next, 0.0, 1.0);
}
Water Ripple JS Side
The water ripple JS structure is identical to the fluid simulation skeleton (ping-pong FBO + render loop), with only these differences:
- Buffer pass shader is the wave equation solver (template above)
- Image pass is the water surface lighting renderer (Step 9c)
- No custom uniform arrays needed, drop injection is done entirely in the shader via iMouse
- JS side only needs to pass standard uniforms:
iChannel0, iResolution, iTime, iFrame, iMouse
When dragging the mouse, ripples are continuously injected (because iMouse.z > 0 remains true), and faster dragging produces denser ripples (a natural effect).
Water Ripple Image Pass
See Step 9c above.
Multi-Color Ink Mixing Template (Ink Diffusion in Water / Multi-Color Blending)
When multiple ink colors need to interpenetrate and blend, a single scalar c.w is insufficient. You need two Buffers: Buffer A stores velocity/pressure (same as above), Buffer B stores RGB three-channel ink concentration, sharing the same velocity field for advection.
IMPORTANT: Key for multi-color ink: Buffer B's RGB channels independently store the concentration of each ink color, using Buffer A's velocity field for semi-Lagrangian advection. Different ink colors naturally blend during advection and diffusion.
JS Side Changes (Three Buffer Ping-Pong)
Two sets of ping-pong FBOs are needed: bufA/bufB (velocity field) and bufC/bufD (ink RGB). In the render loop, first render Buffer A (velocity field), then Buffer B (ink advection), and finally the Image pass reads Buffer B for visualization:
// Create additional ink FBO pair
let bufC, bufD;
function resize() {
// ... same as above for bufA/bufB ...
bufC = createFBO(W, H);
bufD = createFBO(W, H);
}
// Buffer B shader needs two input textures:
// iChannel0 = Buffer B self (ink RGB)
// iChannel1 = Buffer A (velocity field)
const uBufInk = {
ch0: gl.getUniformLocation(progBufInk, 'iChannel0'),
ch1: gl.getUniformLocation(progBufInk, 'iChannel1'),
res: gl.getUniformLocation(progBufInk, 'iResolution'),
time: gl.getUniformLocation(progBufInk, 'iTime'),
frame: gl.getUniformLocation(progBufInk, 'iFrame'),
mouse: gl.getUniformLocation(progBufInk, 'iMouse'),
};
function render(t) {
t *= 0.001;
// Pass 1: Buffer A (velocity) — read bufA, write bufB
// ... same as above ...
[bufA, bufB] = [bufB, bufA];
// Pass 2: Buffer B (ink RGB) — read bufC(ink)+bufA(velocity), write bufD
gl.useProgram(progBufInk);
gl.bindFramebuffer(gl.FRAMEBUFFER, bufD.fbo);
gl.viewport(0, 0, W, H);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, bufC.tex); // previous frame ink
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, bufA.tex); // current velocity field
gl.uniform1i(uBufInk.ch0, 0);
gl.uniform1i(uBufInk.ch1, 1);
gl.uniform2f(uBufInk.res, W, H);
gl.uniform1f(uBufInk.time, t);
gl.uniform1i(uBufInk.frame, frameCount);
gl.uniform4f(uBufInk.mouse, mouse[0], mouse[1], mouse[2], 0.0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
[bufC, bufD] = [bufD, bufC];
// Pass 3: Image — read bufC(ink) to screen
gl.useProgram(progImg);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, bufC.tex);
// ...
}
Buffer B — Multi-Color Ink Advection Shader
// Multi-Color Ink — Buffer B (Ink Advection)
// .rgb = concentrations of three ink colors
// iChannel0 = Buffer B self (ink RGB)
// iChannel1 = Buffer A (velocity field, .xy=velocity)
#define DT 0.15
#define INK_KAPPA 0.3 // ink diffusion coefficient (higher than single-color template for faster blending)
#define INK_DECAY 0.995 // ink decay (slower than single-color to maintain richness)
#define TINK(p) texture(iChannel0, (p) / iResolution.xy)
#define TVEL(p) texture(iChannel1, (p) / iResolution.xy)
void mainImage(out vec4 fragColor, in vec2 p) {
if (iFrame < 10) { fragColor = vec4(0.0, 0.0, 0.0, 1.0); return; }
vec2 vel = TVEL(p).xy;
// Semi-Lagrangian advection: backward trace using velocity field
vec3 ink = TINK(p - DT * vel).rgb;
// Diffusion: Laplacian operator
vec3 inkC = TINK(p).rgb;
vec3 inkN = TINK(p + vec2(0,1)).rgb;
vec3 inkE = TINK(p + vec2(1,0)).rgb;
vec3 inkS = TINK(p - vec2(0,1)).rgb;
vec3 inkW = TINK(p - vec2(1,0)).rgb;
vec3 lapInk = inkN + inkE + inkS + inkW - 4.0 * inkC;
ink += DT * INK_KAPPA * lapInk;
// Automatic ink sources: multiple emitters with different colors
float t = iTime;
vec2 em1 = iResolution.xy * vec2(0.25, 0.5 + 0.2 * sin(t * 0.7));
vec2 em2 = iResolution.xy * vec2(0.75, 0.5 + 0.2 * cos(t * 0.9));
vec2 em3 = iResolution.xy * vec2(0.5, 0.3 + 0.15 * sin(t * 1.3));
vec2 em4 = iResolution.xy * vec2(0.5, 0.7 + 0.15 * cos(t * 0.5));
float r1 = exp(-dot(p - em1, p - em1) / 200.0);
float r2 = exp(-dot(p - em2, p - em2) / 200.0);
float r3 = exp(-dot(p - em3, p - em3) / 180.0);
float r4 = exp(-dot(p - em4, p - em4) / 180.0);
// Each emitter injects a different color
ink.r += DT * (r1 * 3.0 + r4 * 1.5); // red/magenta
ink.g += DT * (r2 * 3.0 + r3 * 1.5); // green/cyan
ink.b += DT * (r3 * 3.0 + r1 * 0.8 + r2 * 0.8); // blue/mixed
// Mouse stirring injects white ink (all channels)
if (iMouse.z > 0.0) {
float dist2 = dot(p - iMouse.xy, p - iMouse.xy);
float influence = exp(-dist2 / 80.0);
ink += vec3(DT * influence * 2.0);
}
// Decay + clamp
ink *= INK_DECAY;
ink = clamp(ink, vec3(0.0), vec3(5.0));
// Boundary clear
if (p.x < 1.0 || p.y < 1.0 ||
iResolution.x - p.x < 1.0 || iResolution.y - p.y < 1.0) {
ink = vec3(0.0);
}
fragColor = vec4(ink, 1.0);
}
Step 9d: Visualization (Image Pass) — Multi-Color Ink Mixing
// Multi-Color Ink Visualization — Image Pass
// iChannel0 = Buffer B (ink RGB)
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec3 ink = texture(iChannel0, uv).rgb;
// Use smoothstep to map each channel, preserving concentration gradients
vec3 mapped = smoothstep(vec3(0.0), vec3(2.5), ink);
// Color mapping: map RGB concentrations to actual visible colors
// IMPORTANT: Base colors must be bright, do not use extremely dark values
vec3 col1 = vec3(0.9, 0.15, 0.2); // red ink
vec3 col2 = vec3(0.1, 0.8, 0.3); // green ink
vec3 col3 = vec3(0.15, 0.3, 0.95); // blue ink
vec3 col = col1 * mapped.r + col2 * mapped.g + col3 * mapped.b;
// Mixing regions produce new hues (additive blending naturally creates gradients)
// HDR tone mapping to prevent overexposure
col = 1.0 - exp(-col * 1.2);
// Background color
float totalInk = mapped.r + mapped.g + mapped.b;
vec3 bg = vec3(0.02, 0.015, 0.04);
col = mix(bg, col, smoothstep(0.0, 0.3, totalInk));
fragColor = vec4(col, 1.0);
}
IMPORTANT: The multi-color ink Buffer A velocity field template is identical to the single-color version, except c.w is no longer used for ink (ink is in Buffer B). Buffer A only handles velocity + pressure.
Common Variants
Variant 1: Rotational Self-Advection
Does not use pressure projection; achieves naturally divergence-free advection through multi-scale rotational sampling.
#define RotNum 3
#define angRnd 1.0
const float ang = 2.0 * 3.14159 / float(RotNum);
mat2 m = mat2(cos(ang), sin(ang), -sin(ang), cos(ang));
float getRot(vec2 uv, float sc) {
float ang2 = angRnd * randS(uv).x * ang;
vec2 p = vec2(cos(ang2), sin(ang2));
float rot = 0.0;
for (int i = 0; i < RotNum; i++) {
vec2 p2 = p * sc;
vec2 v = texture(iChannel0, fract(uv + p2)).xy - vec2(0.5);
rot += cross(vec3(v, 0.0), vec3(p2, 0.0)).z / dot(p2, p2);
p = m * p;
}
return rot / float(RotNum);
}
// Multi-scale advection superposition
vec2 v = vec2(0);
float sc = 1.0 / max(iResolution.x, iResolution.y);
for (int level = 0; level < 20; level++) {
if (sc > 0.7) break;
vec2 p = vec2(cos(ang2), sin(ang2));
for (int i = 0; i < RotNum; i++) {
vec2 p2 = p * sc;
float rot = getRot(uv + p2, sc);
v += p2.yx * rot * vec2(-1, 1);
p = m * p;
}
sc *= 2.0;
}
fragColor = texture(iChannel0, fract(uv + v * 3.0 / iResolution.x));
Variant 2: Vorticity Confinement
Adds vorticity confinement force on top of the basic solver, preventing small vortices from dissipating too quickly.
#define VORT_STRENGTH 0.01 // [0.001 - 0.1]
float curl_c = curl_at(uv);
float curl_n = abs(curl_at(uv + vec2(0, texel.y)));
float curl_s = abs(curl_at(uv - vec2(0, texel.y)));
float curl_e = abs(curl_at(uv + vec2(texel.x, 0)));
float curl_w = abs(curl_at(uv - vec2(texel.x, 0)));
vec2 eta = normalize(vec2(curl_e - curl_w, curl_n - curl_s) + 1e-5);
vec2 conf = VORT_STRENGTH * vec2(eta.y, -eta.x) * curl_c;
c.xy += DT * conf;
Variant 3: Viscous Fingering
Rotation-driven self-amplification + Laplacian diffusion, producing reaction-diffusion style organic patterns.
const float cs = 0.25; // curl→rotation scale
const float ls = 0.24; // Laplacian diffusion strength
const float ps = -0.06; // divergence-pressure feedback
const float amp = 1.0; // self-amplification coefficient
const float pwr = 0.2; // curl power exponent
float sc = cs * sign(curl) * pow(abs(curl), pwr);
float ta = amp * uv.x + ls * lapl.x + norm.x * sp + uv.x * sd;
float tb = amp * uv.y + ls * lapl.y + norm.y * sp + uv.y * sd;
float a = ta * cos(sc) - tb * sin(sc);
float b = ta * sin(sc) + tb * cos(sc);
fragColor = clamp(vec4(a, b, div, 1), -1.0, 1.0);
Variant 4: Gaussian Kernel SPH Particle Fluid (Gaussian SPH)
Gaussian kernel function for density and velocity estimation, a grid-based approximation of SPH.
#define RADIUS 7 // search radius [3-10]
vec4 r = vec4(0);
for (vec2 i = vec2(-RADIUS); ++i.x < float(RADIUS);)
for (i.y = -float(RADIUS); ++i.y < float(RADIUS);) {
vec2 v = texelFetch(iChannel0, ivec2(i + fragCoord), 0).xy;
float mass = texelFetch(iChannel0, ivec2(i + fragCoord), 0).z;
float w = exp(-dot(v + i, v + i)) / 3.14;
r += mass * w * vec4(mix(v + v + i, v, mass), 1, 1);
}
r.xy /= r.z + 1e-6;
Variant 5: Lagrangian Vortex Particle Method
Tracks discrete vortex particles, computing the velocity field using the Biot-Savart law.
#define N 20 // N×N particles
#define STRENGTH 1e3*0.25 // vorticity strength scale
vec2 F = vec2(0);
for (int j = 0; j < N; j++)
for (int i = 0; i < N; i++) {
float w = vorticity(i, j);
vec2 d = particle_pos(i, j) - my_pos;
float l = dot(d, d);
if (l > 1e-5)
F += vec2(-d.y, d.x) * w / l;
}
velocity = STRENGTH * F;
position += velocity * dt;
Performance & Composition
Performance tips:
- 5-point cross stencil is fastest; 3x3 (9 samples) is the best accuracy/performance tradeoff
- SPH search radius >7 is extremely slow; use
texelFetchinstead oftextureto skip filtering - Merge multiple steps into a single Pass; inter-frame feedback forms implicit Jacobi iteration
- Multi-step advection (
ADVECTION_STEPS=3) improves accuracy but 3x sampling cost textureLodprovides O(1) multi-scale reads replacing large-radius sampling- Add slight noise (
1e-6) on initial frames to break symmetry lock fract(uv + offset)implements periodic boundaries without branching- Multiply pressure field by
0.9999decay to prevent drift
Composition directions:
- + Normal map lighting: density field → height map → normals → Phong/GGX, liquid metal effects
- + Particle tracing: passive particles update position following the flow field, visualizing streamlines/ink wash
- + Color advection: extra channels store RGB, synchronous semi-Lagrangian advection, colorful blending
- + Audio response: low freq → thrust, high freq → vortex perturbation, music-driven fluid
- + 3D volume rendering: 2D slices packed as 3D voxels, ray marching to render clouds/explosions
Further Reading
Full step-by-step tutorial, mathematical derivations, and advanced usage in reference