Files
skills/shader-dev/techniques/fluid-simulation.md
shihao 6487becf60 Initial commit: add all skills files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:52:49 +08:00

1176 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
**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:
```javascript
// 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.
```javascript
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:
```javascript
// 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
```javascript
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
```glsl
// 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
```glsl
// 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
```glsl
#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
```glsl
#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
```glsl
#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
```glsl
// 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)
```glsl
// 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)
```glsl
// 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
```glsl
// 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
```glsl
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.**
```glsl
// 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.**
```glsl
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
1. **RGBA32F silently fails in headless/SwiftShader environments**: Must use `RGBA16F + HALF_FLOAT`
2. **Ink saturates entire screen**: Gaussian denominator too large (>300) or decay too weak (>0.995). Fix: denominator 100~200, decay `*= 0.99`
3. **Image Pass colors too dark causing all-black screen**: Use `0.5 + 0.5 * cos(...)` color base to ensure bright range
4. **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]**
5. **Using single scalar for multi-color ink prevents mixing**: A single `c.w` can only do single-color. Multi-color ink requires Buffer B to store RGB three channels (see multi-color ink mixing template)
6. **GLSL strict typing**: `vec2 = float` is illegal, must use `vec2(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:
```html
<!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)**:
```glsl
// 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)**:
```glsl
// 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)
```glsl
// 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:
```javascript
// 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
```glsl
// 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
```glsl
// 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.
```glsl
#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.
```glsl
#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.
```glsl
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.
```glsl
#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.
```glsl
#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 `texelFetch` instead of `texture` to 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
- `textureLod` provides 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.9999` decay 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](../reference/fluid-simulation.md)