**IMPORTANT: Common Error When Extracting Shaders from HTML Script Tags**: When extracting source from `
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:
//
```
**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)