- **Key**: Multi-pass rendering requires creating framebuffers and textures, switching the render target from screen to texture
### WebGL2 Multi-Pass Rendering Complete Template
Below is a complete standalone HTML template demonstrating how to set up WebGL2 double buffering (ping-pong) for physics simulation:
**IMPORTANT: WebGL2 ping-pong core rule: The texture bound to the write-target framebuffer must never simultaneously serve as input for any iChannel.** Violating this rule causes undefined behavior (typically all-black/all-zero output).
For simulations requiring "current frame" and "previous frame" two time steps (such as the wave equation), use **dual-channel encoding**: R channel stores current height, G channel stores previous frame height. This way only one buffer is read from (iChannel0 = currentBuf), writing to another buffer (nextBuf), avoiding read-write conflicts.
```html
GPU Physics
```
**IMPORTANT: Common errors**:
1. **RGBA8 signed value truncation (fatal)**: Environments like SwiftShader that don't support `EXT_color_buffer_float` fall back to RGBA8 where values are clamped to [0,1]. Simulations requiring negative values (like the wave equation) will all zero out and produce a static image. **Must** use encode/decode functions: store as `v * 0.5 + 0.5`, read as `v * 2.0 - 1.0`, switching at runtime via `uniform int useFloatTex`. See the `encode()`/`decode()` functions in the template above
2. **Ping-pong read-write conflict (fatal)**: When rendering to a framebuffer, the texture bound to that framebuffer cannot simultaneously serve as input. The wave equation uses dual-channel encoding (R=current, G=previous) requiring only one input buffer; cloth/particle systems read getpos/getvel both from iChannel0
3. **Cloth rendering must use world coordinate projection (fatal)**: The cloth Image Pass cannot use `uv * vec2(SIZX, SIZY)` to map screen UV directly to grid ID. It must iterate over mesh faces, project vertex world coordinates to screen space via `worldToScreen()`, and perform triangle rasterization
4. **Smoke brightness insufficient (fatal)**: Beer-Lambert absorption must be >=3.0, background color >=`vec3(0.06, 0.07, 0.10)`, smoke base color >=`vec3(0.35)`, add gamma correction `pow(col, vec3(0.85))`, source density >=3.0, density decay >=0.9995
5. **GLSL reserved words**: `active`, `input`, `output`, `filter`, `sample`, `buffer`, `shared` cannot be used as variable names
6. **viewport/iResolution**: Buffer pass uses simulation resolution, Image pass uses screen resolution. Cloth Image Pass getpos/getvel must use `iSimResolution`
7. **GLSL type & math safety**: Cannot write `float / vec2`; `normalize(vec3(0))` produces NaN — check `length(v) > 0.0001` before calling
8. **GLSL nested functions forbidden**: Functions cannot be defined inside other functions
9. **JS variable declarations**: Ping-pong variables inside for loops must use `let`; in substeps, pass `iFrame` as `frame * substeps + substep`
# GPU Physics Simulation Skill
## Use Cases
- Real-time physics simulation: waves, fluid smoke, cloth, rigid body collision, particle fluids
- Interactive physics effects: mouse force fields, ripples, pushing/pulling rigid bodies
- Scientific visualization: chaotic attractors, vortex dynamics, ship wave dispersion
- Iterative computations requiring "previous frame → next frame" state persistence
## Core Principles
The core paradigm of GPU physics simulation is **Buffer Feedback**: physical state is stored in texture buffers, each frame reads the previous frame's state → computes → writes back, with each pixel processed independently in parallel.
### Key Mathematical Tools
```
Discrete Laplacian: ∇²f ≈ f(x+1,y) + f(x-1,y) + f(x,y+1) + f(x,y-1) - 4·f(x,y)
Semi-Lagrangian advection: f_new(x) = f_old(x - v·dt)
Spring force: F = k · (|Δx| - L₀) · normalize(Δx)
Damping force: F = c · dot(normalize(Δx), Δv) · normalize(Δx)
Vorticity confinement: curl = ∂v_x/∂y - ∂v_y/∂x
```
### Architecture Patterns
| Layer | Responsibility | ShaderToy Implementation |
|-------|---------------|--------------------------|
| **State Storage** | Encode physical quantities into textures | Buffer RGBA channels |
| **Solver** | Read old state → compute forces → integrate → write new state | Buffer Pass (can be chained iteratively) |
| **Rendering** | Visualize physical state | Image Pass |
## Implementation Steps
### Step 1: Ping-Pong Double Buffering (Correct WebGL2 Implementation)
**IMPORTANT: Key difference between ShaderToy and WebGL2**: In ShaderToy, Buffer A/B are two independent passes with separate write targets, so `iChannel0=self, iChannel1=other` doesn't conflict. But in WebGL2 with a single shader program doing ping-pong, the write-target texture cannot be read simultaneously.
**Solution: Dual-channel encoding** — R channel stores current height, G channel stores previous frame height, requiring only one input buffer:
```glsl
// WebGL2 Wave Equation Buffer Pass
// IMPORTANT: Only iChannel0 (reads currentBuf), writes to nextBuf (must be different!)
// IMPORTANT: encode/decode ensures signed values aren't truncated under RGBA8 (no float textures)
uniform int useFloatTex;
float decode(float v) { return useFloatTex == 1 ? v : v * 2.0 - 1.0; }
float encode(float v) { return useFloatTex == 1 ? v : v * 0.5 + 0.5; }
void main() {
vec2 uv = vUv;
vec2 texel = 1.0 / iResolution;
vec2 raw = texture(iChannel0, uv).xy;
float current = decode(raw.x);
float previous = decode(raw.y);
float left = decode(texture(iChannel0, uv - vec2(texel.x, 0.0)).x);
float right = decode(texture(iChannel0, uv + vec2(texel.x, 0.0)).x);
float down = decode(texture(iChannel0, uv - vec2(0.0, texel.y)).x);
float up = decode(texture(iChannel0, uv + vec2(0.0, texel.y)).x);
float laplacian = left + right + down + up - 4.0 * current;
float next = 2.0 * current - previous + 0.25 * laplacian;
next *= 0.995;
next *= min(1.0, float(iFrame));
fragColor = vec4(encode(next), encode(current), 0.0, 1.0);
}
```
Corresponding JS render loop (binds only one input texture):
```js
const currentBuf = (frame % 2 === 0) ? bufA : bufB;
const nextBuf = (frame % 2 === 0) ? bufB : bufA;
gl.bindFramebuffer(gl.FRAMEBUFFER, nextBuf.fb); // write to nextBuf
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, currentBuf.tex); // read from currentBuf
gl.uniform1i(gl.getUniformLocation(progBuffer, 'iChannel0'), 0);
gl.uniform1i(gl.getUniformLocation(progBuffer, 'useFloatTex'), useFloat ? 1 : 0);
// IMPORTANT: Do NOT bind iChannel1 to nextBuf/otherBuf!
```
### Step 2: Interaction-Driven (External Force Injection)
```glsl
// Insert external forces before the wave equation computation (add to next)
float force = 0.0;
if (iMouse.z > 0.0)
{
vec2 fragCoord = vUv * iResolution;
force = smoothstep(4.5, 0.5, length(iMouse.xy - fragCoord));
}
else
{
// Procedural raindrops
vec2 fragCoord = vUv * iResolution;
float t = iTime * 2.0;
vec2 pos = fract(floor(t) * vec2(0.456665, 0.708618)) * iResolution;
float amp = 1.0 - step(0.05, fract(t));
force = -amp * smoothstep(2.5, 0.5, length(pos - fragCoord));
}
// Add external force after wave equation
next += force;
```
### Step 3: Height Field Rendering (Image Pass)
```glsl
// IMPORTANT: Image Pass also needs decode
uniform int useFloatTex;
float decode(float v) { return useFloatTex == 1 ? v : v * 2.0 - 1.0; }
void main()
{
vec2 uv = vUv;
vec2 texel = 1.0 / iResolution;
float left = decode(texture(iChannel0, uv - vec2(texel.x, 0.0)).x);
float right = decode(texture(iChannel0, uv + vec2(texel.x, 0.0)).x);
float down = decode(texture(iChannel0, uv - vec2(0.0, texel.y)).x);
float up = decode(texture(iChannel0, uv + vec2(0.0, texel.y)).x);
vec3 normal = normalize(vec3((right - left) * 8.0, (up - down) * 8.0, 1.0));
vec3 light = normalize(vec3(0.2, -0.5, 0.7));
float diffuse = max(dot(normal, light), 0.0);
float spec = pow(max(-reflect(light, normal).z, 0.0), 32.0);
vec3 waterTint = vec3(0.05, 0.15, 0.3);
vec3 color = waterTint * (0.6 + 0.5 * diffuse) + vec3(1.0) * spec * 0.6;
fragColor = vec4(color, 1.0);
}
```
### Step 4: Chained Multi-Buffer Iteration (Fluid Solver)
Buffer A/B/C share the solver from a Common pass, iterating 3 times per frame:
```glsl
// === Common Pass ===
#define dt 0.15
#define viscosityThreshold 0.64
#define vorticityThreshold 0.25
vec4 fluidSolver(sampler2D field, vec2 uv, vec2 step,
vec4 mouse, vec4 prevMouse)
{
float k = 0.2, s = k / dt;
vec4 c = textureLod(field, uv, 0.0);
vec4 fr = textureLod(field, uv + vec2(step.x, 0.0), 0.0);
vec4 fl = textureLod(field, uv - vec2(step.x, 0.0), 0.0);
vec4 ft = textureLod(field, uv + vec2(0.0, step.y), 0.0);
vec4 fd = textureLod(field, uv - vec2(0.0, step.y), 0.0);
vec3 ddx = (fr - fl).xyz * 0.5;
vec3 ddy = (ft - fd).xyz * 0.5;
float divergence = ddx.x + ddy.y;
vec2 densityDiff = vec2(ddx.z, ddy.z);
c.z -= dt * dot(vec3(densityDiff, divergence), c.xyz);
vec2 laplacian = fr.xy + fl.xy + ft.xy + fd.xy - 4.0 * c.xy;
vec2 viscosity = viscosityThreshold * laplacian;
vec2 densityInv = s * densityDiff;
vec2 uvHistory = uv - dt * c.xy * step;
c.xyw = textureLod(field, uvHistory, 0.0).xyw;
vec2 extForce = vec2(0.0);
if (mouse.z > 1.0 && prevMouse.z > 1.0)
{
vec2 drag = clamp((mouse.xy - prevMouse.xy) * step * 600.0, -10.0, 10.0);
vec2 p = uv - mouse.xy * step;
extForce += 0.001 / dot(p, p) * drag;
}
c.xy += dt * (viscosity - densityInv + extForce);
c.xy = max(vec2(0.0), abs(c.xy) - 5e-6) * sign(c.xy);
// Vorticity confinement
c.w = (fd.x - ft.x + fr.y - fl.y);
vec2 vorticity = vec2(abs(ft.w) - abs(fd.w), abs(fl.w) - abs(fr.w));
vorticity *= vorticityThreshold / (length(vorticity) + 1e-5) * c.w;
c.xy += vorticity;
c.y *= smoothstep(0.5, 0.48, abs(uv.y - 0.5));
c.x *= smoothstep(0.5, 0.49, abs(uv.x - 0.5));
c = clamp(c, vec4(-24.0, -24.0, 0.5, -0.25), vec4(24.0, 24.0, 3.0, 0.25));
return c;
}
// === Buffer A / B / C ===
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
vec2 uv = fragCoord / iResolution.xy;
vec2 stepSize = 1.0 / iResolution.xy;
vec4 prevMouse = textureLod(iChannel0, vec2(0.0), 0.0);
fragColor = fluidSolver(iChannel0, uv, stepSize, iMouse, prevMouse);
if (fragCoord.y < 1.0) fragColor = iMouse; // store mouse state
}
```
### Step 5: Particle Data Layout (Cloth/N-Body)
Texture regions are partitioned to store different attributes:
```glsl
#define SIZX 128.0
#define SIZY 64.0
// IMPORTANT: Cloth/particle systems: getpos/getvel both read from iChannel0 (not iChannel1)
// Because iChannel0 = currentBuf (read-only), write target is nextBuf (separate buffer)
// IMPORTANT: Use +0.5 to sample texel centers (not +0.01)
vec3 getpos(vec2 id) {
return texture(iChannel0, (id + 0.5) / iResolution.xy).xyz;
}
vec3 getvel(vec2 id) {
return texture(iChannel0, (id + 0.5 + vec2(SIZX, 0.0)) / iResolution.xy).xyz;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
vec2 fc = floor(fragCoord);
vec2 c = fc;
c.x = fract(c.x / SIZX) * SIZX;
vec3 pos = getpos(c);
vec3 vel = getvel(c);
// ... physics computation ...
fragColor = vec4(fc.x >= SIZX ? vel : pos, 0.0);
}
```
### Step 6: Spring-Damper Constraints
```glsl
const float SPRING_K = 0.15;
const float DAMPER_C = 0.10;
const float GRAVITY = 0.0022;
vec3 pos, vel, ovel;
vec2 c;
void edge(vec2 dif)
{
if ((dif + c).x < 0.0 || (dif + c).x >= SIZX ||
(dif + c).y < 0.0 || (dif + c).y >= SIZY) return;
float restLen = length(dif);
vec3 posdif = getpos(dif + c) - pos;
vec3 veldif = getvel(dif + c) - ovel;
float plen = length(posdif);
if (plen < 0.0001) return;
vec3 dir = posdif / plen;
vel += dir * clamp(plen - restLen, -1.0, 1.0) * SPRING_K;
vel += dir * dot(dir, veldif) * DAMPER_C;
}
// Call 12 edges: 4 nearest neighbors + 4 diagonal + 4 skip
// edge(vec2(0,1)); edge(vec2(0,-1)); edge(vec2(1,0)); edge(vec2(-1,0));
// edge(vec2(1,1)); edge(vec2(-1,-1));
// edge(vec2(0,2)); edge(vec2(0,-2)); edge(vec2(2,0)); edge(vec2(-2,0));
// edge(vec2(2,-2)); edge(vec2(-2,2));
```
### Step 7: N-Body Vortex Particles (Biot-Savart)
```glsl
#define N 20
#define Nf float(N)
#define MARKERS 0.90
float STRENGTH = 1e3 * 0.25 / (1.0 - MARKERS) * sqrt(30.0 / Nf);
#define tex(i,j) texture(iChannel1, (vec2(i,j) + 0.5) / iResolution.xy)
#define W(i,j) tex(i, j + N).z
void mainImage(out vec4 O, vec2 U)
{
vec2 T = floor(U / Nf);
U = mod(U, Nf);
vec2 F = vec2(0.0);
for (int j = 0; j < N; j++)
for (int i = 0; i < N; i++)
{
float w = W(i, j);
vec2 d = tex(i, j).xy - O.xy;
d = (fract(0.5 + d / iResolution.xy) - 0.5) * iResolution.xy;
float l = dot(d, d);
if (l > 1e-5)
F += vec2(-d.y, d.x) * w / l;
}
O.zw = STRENGTH * F;
O.xy += O.zw * dt;
O.xy = mod(O.xy, iResolution.xy);
}
```
### Step 8: Global State Storage (Specific Pixel)
```glsl
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
// Pixel (0,0) stores global state (e.g., Lorenz attractor position)
if (floor(fragCoord) == vec2(0, 0))
{
if (iFrame == 0) {
fragColor = vec4(0.1, 0.001, 0.0, 0.0);
} else {
vec3 state = texture(iChannel0, vec2(0.0)).xyz;
for (float i = 0.0; i < 96.0; i++) {
vec3 deriv;
deriv.x = 10.0 * (state.y - state.x);
deriv.y = state.x * (28.0 - state.z) - state.y;
deriv.z = state.x * state.y - 8.0/3.0 * state.z;
state += deriv * 0.016 * 0.2;
}
fragColor = vec4(state, 0.0);
}
return;
}
// Other pixels: accumulate trajectory distance field
vec3 last = texture(iChannel0, vec2(0.0)).xyz;
float d = 1e6;
for (float i = 0.0; i < 96.0; i++) {
vec3 next = Integrate(last, 0.016 * 0.2);
d = min(d, dfLine(last.xz * 0.015, next.xz * 0.015, uv));
last = next;
}
float c = 0.5 * smoothstep(1.0 / iResolution.y, 0.0, d);
vec3 prev = texture(iChannel0, fragCoord / iResolution.xy).rgb;
fragColor = vec4(vec3(c) + prev * 0.99, 0.0);
}
```
## Complete Code Template
2D wave simulation: double buffering + mouse interaction + procedural raindrops + height field water surface rendering.
**Ping-Pong setup**: bufA and bufB alternate; the shader only reads from iChannel0 (currentBuf) and writes to nextBuf. R=current height, G=previous frame height.
```glsl
// === Buffer Pass (Wave Equation) ===
// IMPORTANT: Only uses iChannel0 = currentBuf (read-only), writes to nextBuf
// IMPORTANT: encode/decode ensures signed values aren't truncated under RGBA8 (SwiftShader compatible)
uniform int useFloatTex;
float decode(float v) { return useFloatTex == 1 ? v : v * 2.0 - 1.0; }
float encode(float v) { return useFloatTex == 1 ? v : v * 0.5 + 0.5; }
#define DAMPING 0.995
#define WAVE_SPEED 0.25
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
vec2 uv = fragCoord / iResolution.xy;
vec2 texel = 1.0 / iResolution.xy;
float current = decode(texture(iChannel0, uv).x);
float previous = decode(texture(iChannel0, uv).y);
float left = decode(texture(iChannel0, uv - vec2(texel.x, 0.0)).x);
float right = decode(texture(iChannel0, uv + vec2(texel.x, 0.0)).x);
float down = decode(texture(iChannel0, uv - vec2(0.0, texel.y)).x);
float up = decode(texture(iChannel0, uv + vec2(0.0, texel.y)).x);
float force = 0.0;
if (iMouse.z > 0.0) {
force = smoothstep(4.5, 0.5, length(iMouse.xy - fragCoord));
} else {
float t = iTime * 2.0;
vec2 pos = fract(floor(t) * vec2(0.456665, 0.708618)) * iResolution.xy;
float amp = 1.0 - step(0.05, fract(t));
force = -amp * smoothstep(2.5, 0.5, length(pos - fragCoord));
}
float laplacian = left + right + down + up - 4.0 * current;
float next = 2.0 * current - previous + WAVE_SPEED * laplacian;
next += force;
next *= DAMPING;
next *= min(1.0, float(iFrame));
fragColor = vec4(encode(next), encode(current), 0.0, 0.0);
}
```
```glsl
// === Image Pass ===
// IMPORTANT: Also needs decode to correctly read signed height values
uniform int useFloatTex;
float decode(float v) { return useFloatTex == 1 ? v : v * 2.0 - 1.0; }
#define SPECULAR_POWER 32.0
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
vec2 uv = fragCoord / iResolution.xy;
vec2 texel = 1.0 / iResolution.xy;
float left = decode(texture(iChannel0, uv - vec2(texel.x, 0.0)).x);
float right = decode(texture(iChannel0, uv + vec2(texel.x, 0.0)).x);
float down = decode(texture(iChannel0, uv - vec2(0.0, texel.y)).x);
float up = decode(texture(iChannel0, uv + vec2(0.0, texel.y)).x);
vec3 normal = normalize(vec3((right - left) * 8.0, (up - down) * 8.0, 1.0));
vec3 light = normalize(vec3(0.3, 0.6, 0.8));
vec3 viewDir = vec3(0.0, 0.0, 1.0);
float diffuse = max(dot(normal, light), 0.0);
vec3 reflectDir = reflect(-light, normal);
float spec = pow(max(dot(reflectDir, viewDir), 0.0), SPECULAR_POWER);
float h = decode(texture(iChannel0, uv).x);
vec3 deepColor = vec3(0.02, 0.06, 0.15);
vec3 shallowColor = vec3(0.05, 0.18, 0.30);
vec3 waterBase = mix(deepColor, shallowColor, clamp(abs(h) * 3.0, 0.0, 1.0));
float fresnel = pow(1.0 - max(dot(normal, viewDir), 0.0), 3.0);
vec3 skyColor = vec3(0.4, 0.55, 0.75);
vec3 color = waterBase * (0.6 + 0.5 * diffuse);
color = mix(color, skyColor, fresnel * 0.3);
color += vec3(1.0, 0.95, 0.85) * spec * 0.6;
fragColor = vec4(color, 1.0);
}
```
## Common Variants
### Variant 1: Euler Fluid (Smoke/Ink)
Smoke/ink simulation requires a complete Buffer Pass (fluid solver) + Image Pass (volume rendering). Buffer stores xy=velocity, z=density, w=curl.
```glsl
// === Buffer Pass (Fluid Solver) ===
// Requires 3 chained buffer iterations (A→B→C→Image), each buffer executes the same fluidSolver
#define dt 0.15
#define viscosityCoeff 0.64
#define vorticityCoeff 0.25
vec4 fluidSolver(sampler2D field, vec2 uv, vec2 step, vec4 mouse, vec4 prevMouse) {
float k = 0.2, s = k / dt;
vec4 c = textureLod(field, uv, 0.0);
vec4 fr = textureLod(field, uv + vec2(step.x, 0.0), 0.0);
vec4 fl = textureLod(field, uv - vec2(step.x, 0.0), 0.0);
vec4 ft = textureLod(field, uv + vec2(0.0, step.y), 0.0);
vec4 fd = textureLod(field, uv - vec2(0.0, step.y), 0.0);
vec3 ddx = (fr - fl).xyz * 0.5;
vec3 ddy = (ft - fd).xyz * 0.5;
float divergence = ddx.x + ddy.y;
vec2 densityDiff = vec2(ddx.z, ddy.z);
c.z -= dt * dot(vec3(densityDiff, divergence), c.xyz);
vec2 laplacian = fr.xy + fl.xy + ft.xy + fd.xy - 4.0 * c.xy;
vec2 viscosity = viscosityCoeff * laplacian;
vec2 densityInv = s * densityDiff;
// Semi-Lagrangian advection
vec2 uvHistory = uv - dt * c.xy * step;
c.xyw = textureLod(field, uvHistory, 0.0).xyw;
// Buoyancy (key for smoke: higher density means stronger upward force)
float buoyancy = 0.15 * c.z;
c.y += buoyancy * dt;
// Wind force (horizontal offset)
c.x += 0.02 * sin(uv.y * 6.28 + float(iFrame) * 0.02) * c.z * dt;
// Mouse/procedural source injection
vec2 extForce = vec2(0.0);
float densitySource = 0.0;
if (mouse.z > 1.0 && prevMouse.z > 1.0) {
vec2 drag = clamp((mouse.xy - prevMouse.xy) * step * 600.0, -10.0, 10.0);
vec2 p = uv - mouse.xy * step;
float influence = 0.001 / (dot(p, p) + 1e-6);
extForce += influence * drag;
densitySource += influence * 0.5;
} else {
// Procedural bottom smoke sources (multi-point + wide range, ensuring dense visibility)
float srcStrength = 0.0;
for (float si = -1.0; si <= 1.0; si += 1.0) {
float srcX = 0.5 + si * 0.12 + 0.08 * sin(float(iFrame) * 0.013 + si * 2.0);
vec2 srcPos = vec2(srcX, 0.06);
float d = length(uv - srcPos);
srcStrength += smoothstep(0.12, 0.0, d) * 3.5;
}
densitySource += srcStrength;
extForce.y += srcStrength * 0.4;
}
c.xy += dt * (viscosity - densityInv + extForce);
c.z = max(c.z + densitySource * dt, 0.0);
// Vorticity confinement (preserves smoke detail and curling structures)
c.w = (fd.x - ft.x + fr.y - fl.y);
vec2 vortGrad = vec2(abs(ft.w) - abs(fd.w), abs(fl.w) - abs(fr.w));
vortGrad *= vorticityCoeff / (length(vortGrad) + 1e-5) * c.w;
c.xy += vortGrad;
c.y *= smoothstep(0.5, 0.48, abs(uv.y - 0.5));
c.x *= smoothstep(0.5, 0.49, abs(uv.x - 0.5));
c.z *= 0.9995; // density decay (closer to 1.0 = denser and more persistent smoke)
c = clamp(c, vec4(-24.0, -24.0, 0.0, -0.25), vec4(24.0, 24.0, 5.0, 0.25));
return c;
}
void main() {
vec2 uv = vUv;
vec2 stepSize = 1.0 / iResolution;
vec4 prevMouse = textureLod(iChannel0, vec2(0.0), 0.0);
fragColor = fluidSolver(iChannel0, uv, stepSize, iMouse, prevMouse);
if (floor(vUv * iResolution).y < 1.0) fragColor = iMouse;
}
```
```glsl
// === Image Pass (Smoke Rendering) ===
// Reads density (z channel) and velocity (xy channels) from buffer, renders dense layered smoke + light scattering
// IMPORTANT: Smoke brightness key: absorption coefficient must be large enough (>=3.0), background not too dark, lightTransmit must not over-attenuate
void main() {
vec2 uv = vUv;
vec4 data = texture(iChannel0, uv);
float density = data.z;
vec2 vel = data.xy;
// Multi-layer sampling for added depth (accumulate density from nearby pixels)
float layeredDensity = density;
vec2 texel = 1.0 / iResolution;
for (float i = 1.0; i <= 4.0; i += 1.0) {
float scale = i * 3.0;
layeredDensity += texture(iChannel0, uv + vec2(texel.x * scale, 0.0)).z * 0.4;
layeredDensity += texture(iChannel0, uv - vec2(texel.x * scale, 0.0)).z * 0.4;
layeredDensity += texture(iChannel0, uv + vec2(0.0, texel.y * scale)).z * 0.4;
layeredDensity += texture(iChannel0, uv - vec2(0.0, texel.y * scale)).z * 0.4;
}
layeredDensity /= 4.0;
// Beer-Lambert absorption (denser regions are more opaque)
float absorption = 1.0 - exp(-layeredDensity * 3.5);
// Light scattering: accumulate density from light direction for simple ray marching
vec2 lightDir2D = normalize(vec2(0.3, 1.0));
float lightAccum = 0.0;
for (float s = 1.0; s <= 8.0; s += 1.0) {
vec2 sampleUV = uv + lightDir2D * texel * s * 5.0;
lightAccum += texture(iChannel0, sampleUV).z;
}
float lightTransmit = exp(-lightAccum * 0.25);
// Velocity field drives color variation (faster flow regions are brighter)
float speed = length(vel);
// Smoke color: gray-white tones, affected by lighting
vec3 smokeBase = mix(vec3(0.35, 0.32, 0.30), vec3(0.85, 0.82, 0.78), lightTransmit);
smokeBase += vec3(1.0, 0.85, 0.6) * lightTransmit * absorption * 0.5;
smokeBase += vec3(0.3, 0.2, 0.1) * speed * 3.0;
// Background gradient (blue-gray, bright enough to contrast with smoke)
vec3 bg = mix(vec3(0.06, 0.07, 0.10), vec3(0.15, 0.18, 0.25), uv.y);
vec3 col = mix(bg, smokeBase, absorption);
// Bottom light source glow
float glow = smoothstep(0.3, 0.0, uv.y) * 0.4;
col += vec3(1.0, 0.6, 0.2) * glow * (0.5 + 0.5 * absorption);
// Gamma correction to ensure smoke visibility
col = pow(col, vec3(0.85));
fragColor = vec4(col, 1.0);
}
```
Smoke simulation requires 3 chained buffer iterations (same fluidSolver) for enhanced convergence. JS side creates bufA/bufB/bufC, executing A→B→C→Image each frame.
### Variant 2: Cloth Simulation (Mass-Spring-Damper)
Cloth simulation requires 2 buffers for ping-pong alternating read/write, with a JS render loop using a for loop to execute multiple substeps (e.g., 4 steps). Data structure:
- Left half of texture [0, SIZX) stores position xyz
- Right half of texture [SIZX, 2*SIZX) stores velocity xyz
- **Note**: When using substep loops, buffer variables in the render function must use `let` to allow reassignment within the loop
- **Key**: Image Pass `getpos`/`getvel` functions must use the simulation resolution (`iSimResolution`) for UV calculation, not the screen resolution
```glsl
// WebGL2-adapted cloth simulation Buffer Pass
// IMPORTANT: getpos/getvel both read from iChannel0! iChannel0 = currentBuf (read-only), writes to nextBuf
#define SIZX 128.0
#define SIZY 64.0
const float SPRING_K = 0.15;
const float DAMPER_C = 0.10;
const float GRAVITY = 0.0022;
vec3 pos, vel, ovel;
vec2 c;
vec3 getpos(vec2 id) {
return texture(iChannel0, (id + 0.5) / iResolution.xy).xyz;
}
vec3 getvel(vec2 id) {
return texture(iChannel0, (id + 0.5 + vec2(SIZX, 0.0)) / iResolution.xy).xyz;
}
void edge(vec2 dif) {
if ((dif + c).x < 0.0 || (dif + c).x >= SIZX ||
(dif + c).y < 0.0 || (dif + c).y >= SIZY) return;
vec3 posdif = getpos(dif + c) - pos;
vec3 veldif = getvel(dif + c) - ovel;
float restLen = length(dif);
float plen = length(posdif);
if (plen < 0.0001) return;
vec3 dir = posdif / plen;
vel += dir * clamp(plen - restLen, -1.0, 1.0) * SPRING_K;
vel += dir * dot(dir, veldif) * DAMPER_C;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 fc = floor(fragCoord);
c = fc;
c.x = fract(c.x / SIZX) * SIZX;
// iFrame should pass the global frame count: frame * SUBSTEPS + substep
if (iFrame < 4) {
vec2 p = vec2(c.x / SIZX, c.y / SIZY);
vec3 initialPos = vec3(p.x * 1.6 - 0.3, -p.y * 0.8 + 0.6, 0.0);
fragColor = vec4(fc.x >= SIZX ? vec3(0.0) : initialPos, 0.0);
return;
}
pos = getpos(c);
vel = getvel(c);
ovel = vel;
edge(vec2(0,1)); edge(vec2(0,-1)); edge(vec2(1,0)); edge(vec2(-1,0));
edge(vec2(1,1)); edge(vec2(-1,-1));
edge(vec2(0,2)); edge(vec2(0,-2)); edge(vec2(2,0)); edge(vec2(-2,0));
vel.y -= GRAVITY;
vec3 ballPos = vec3(0.35, 0.3, 0.0);
float ballRadius = 0.15;
vec3 toBall = pos - ballPos;
float distToBall = length(toBall);
if (distToBall < ballRadius && distToBall > 0.0001) {
vec3 pushDir = toBall / distToBall;
pos = ballPos + pushDir * ballRadius;
vel -= pushDir * dot(pushDir, vel);
}
if (c.y == 0.0) {
pos = vec3(fc.x * 0.85 / SIZX, 0.0, 0.0);
vel = vec3(0.0);
}
pos += vel;
fragColor = vec4(fc.x >= SIZX ? vel : pos, 0.0);
}
```
#### Cloth Rendering Pass (Image Pass) Complete Template
**IMPORTANT: Cloth rendering core principle**: After physics simulation, cloth particle world positions (pos.xy) will deviate from their initial grid positions (due to gravity, collisions, etc.). The Image Pass must render based on particles' **actual world positions** projected to the screen — you cannot use `uv * vec2(SIZX, SIZY)` to directly map screen UV to grid ID (that would produce scattered dots/fragments rather than a continuous cloth surface).
Correct approach: iterate over all cloth mesh cells, project each cell's 4 vertex world coordinates to screen space, determine if the current pixel falls within that quad, then interpolate shading.
```glsl
// IMPORTANT: Key: must pass additional uniform vec2 iSimResolution (simulation resolution)
// getpos/getvel use iSimResolution, not iResolution
// Rendering method: iterate cloth mesh, project world coordinates to screen coordinates
#define SIZX 128.0
#define SIZY 64.0
in vec2 vUv;
out vec4 fragColor;
uniform sampler2D iChannel0;
uniform vec2 iResolution;
uniform vec2 iSimResolution;
uniform float iTime;
vec3 getpos(vec2 id) {
return texture(iChannel0, (id + 0.5) / iSimResolution).xyz;
}
vec3 getvel(vec2 id) {
return texture(iChannel0, (id + 0.5 + vec2(SIZX, 0.0)) / iSimResolution).xyz;
}
vec2 worldToScreen(vec3 p) {
return vec2(p.x * 0.5 + 0.5, 1.0 - (p.y * 0.5 + 0.5));
}
vec3 calcNormal(vec2 cell) {
vec3 pL = getpos(vec2(max(cell.x - 1.0, 0.0), cell.y));
vec3 pR = getpos(vec2(min(cell.x + 1.0, SIZX - 1.0), cell.y));
vec3 pD = getpos(vec2(cell.x, max(cell.y - 1.0, 0.0)));
vec3 pU = getpos(vec2(cell.x, min(cell.y + 1.0, SIZY - 1.0)));
vec3 tanX = pR - pL;
vec3 tanY = pU - pD;
vec3 normal = cross(tanX, tanY);
float nlen = length(normal);
if (nlen < 0.0001) return vec3(0.0, 0.0, -1.0);
normal /= nlen;
if (normal.z > 0.0) normal = -normal;
return normal;
}
float cross2d(vec2 a, vec2 b) { return a.x * b.y - a.y * b.x; }
bool pointInTriangle(vec2 p, vec2 a, vec2 b, vec2 c, out vec3 bary) {
float d00 = dot(b - a, b - a);
float d01 = dot(b - a, c - a);
float d11 = dot(c - a, c - a);
float d20 = dot(p - a, b - a);
float d21 = dot(p - a, c - a);
float denom = d00 * d11 - d01 * d01;
if (abs(denom) < 1e-10) return false;
float v = (d11 * d20 - d01 * d21) / denom;
float w = (d00 * d21 - d01 * d20) / denom;
float u = 1.0 - v - w;
bary = vec3(u, v, w);
return u >= -0.01 && v >= -0.01 && w >= -0.01;
}
void main() {
vec2 uv = vUv;
vec2 fragCoord = vUv * iResolution;
vec3 bgTop = vec3(0.05, 0.08, 0.15);
vec3 bgBot = vec3(0.02, 0.03, 0.08);
vec3 bg = mix(bgBot, bgTop, uv.y);
vec3 col = bg;
float closestZ = 1e6;
vec3 ballPos = vec3(0.35 + sin(iTime * 0.6) * 0.15, 0.3 + cos(iTime * 0.4) * 0.1, 0.0);
float ballRadius = 0.12;
vec2 ballScreen = worldToScreen(ballPos);
float ballDist = length(uv - ballScreen);
if (ballDist < ballRadius * 0.6) {
float shade = smoothstep(ballRadius * 0.6, ballRadius * 0.15, ballDist);
vec3 ballColor = vec3(0.95, 0.35, 0.2);
vec2 bnXY = (uv - ballScreen) / (ballRadius * 0.6);
float bnZ = sqrt(max(0.0, 1.0 - dot(bnXY, bnXY)));
vec3 bn = normalize(vec3(bnXY, bnZ));
float bdiff = max(dot(bn, normalize(vec3(0.5, 0.8, 1.0))), 0.0);
float bspec = pow(max(dot(normalize(bn + normalize(vec3(0.5, 0.8, 1.0))), vec3(0.0, 0.0, 1.0)), 0.0), 32.0);
col = ballColor * (0.3 + 0.7 * bdiff) + vec3(1.0) * bspec * 0.4;
closestZ = ballPos.z - ballRadius;
}
for (float cy = 0.0; cy < SIZY - 1.0; cy += 1.0) {
for (float cx = 0.0; cx < SIZX - 1.0; cx += 1.0) {
vec3 p00 = getpos(vec2(cx, cy));
vec3 p10 = getpos(vec2(cx + 1.0, cy));
vec3 p01 = getpos(vec2(cx, cy + 1.0));
vec3 p11 = getpos(vec2(cx + 1.0, cy + 1.0));
vec2 s00 = worldToScreen(p00);
vec2 s10 = worldToScreen(p10);
vec2 s01 = worldToScreen(p01);
vec2 s11 = worldToScreen(p11);
vec2 bboxMin = min(min(s00, s10), min(s01, s11));
vec2 bboxMax = max(max(s00, s10), max(s01, s11));
if (uv.x < bboxMin.x - 0.01 || uv.x > bboxMax.x + 0.01 ||
uv.y < bboxMin.y - 0.01 || uv.y > bboxMax.y + 0.01) continue;
vec3 bary;
vec2 cellId = vec2(cx, cy);
bool hit = false;
float interpZ = 0.0;
if (pointInTriangle(uv, s00, s10, s01, bary)) {
hit = true;
interpZ = bary.x * p00.z + bary.y * p10.z + bary.z * p01.z;
} else if (pointInTriangle(uv, s10, s11, s01, bary)) {
hit = true;
interpZ = bary.x * p10.z + bary.y * p11.z + bary.z * p01.z;
}
if (hit && interpZ < closestZ) {
closestZ = interpZ;
vec3 normal = calcNormal(cellId);
vec3 lightDir = normalize(vec3(0.5, 0.8, 1.0));
float diff = max(dot(normal, lightDir), 0.0);
float diffBack = max(dot(-normal, lightDir), 0.0);
vec3 halfDir = normalize(lightDir + vec3(0.0, 0.0, 1.0));
float spec = pow(max(dot(normal, halfDir), 0.0), 32.0);
float stretch = length(getvel(cellId));
vec3 clothColor1 = vec3(0.25, 0.55, 0.95);
vec3 clothColor2 = vec3(0.95, 0.35, 0.45);
vec3 clothColor = mix(clothColor1, clothColor2, clamp(stretch * 10.0, 0.0, 1.0));
vec2 gridFrac = fract(vec2(cx, cy) * 0.125);
float checker = step(0.5, fract(gridFrac.x + gridFrac.y));
clothColor *= 0.85 + 0.15 * checker;
col = clothColor * (0.3 + 0.6 * diff + 0.25 * diffBack) + vec3(1.0) * spec * 0.35;
}
}
}
fragColor = vec4(col, 1.0);
}
```
**IMPORTANT: Cloth rendering performance note**: The above template uses a double loop to iterate all mesh faces for triangle rasterization. For a 128x64 mesh this is about 8000 quads per frame. If GPU performance is insufficient, reduce mesh resolution (e.g., SIZX=64, SIZY=32) or use `texelFetch` instead of `texture` for speed. Another approach is to partition the cloth into blocks (e.g., 4x4), each with an independent bounding box for early culling.
#### Cloth Simulation Complete HTML Template (Multi-Substep Iteration)
**IMPORTANT: Key notes (must-read for cloth template)**:
1. **No read-write conflict**: In the Buffer Pass, iChannel0 is bound to currentBuf (read-only), the write target is nextBuf (separate buffer). getpos/getvel both read from iChannel0
2. **iSimResolution uniform**: Image Pass must have `uniform vec2 iSimResolution` passing `(SIM_W, SIM_H)`, and `getpos`/`getvel` internally use `iSimResolution` for UV calculation
3. **iFrame value passing**: In substep loops, iFrame should pass `frame * SUBSTEPS + substep`, ensuring the initialization condition `iFrame < SUBSTEPS` only triggers on the first frame
4. **Substeps use 2-buffer ping-pong + JS for loop**: Do not use 4 buffers; use 2 buffers alternating at the JS level
```html
GPU Cloth Simulation
```
### Variant 3: Rigid Body Physics Engine (Box2D-lite on GPU)
```glsl
// Structured memory addressing: struct mapped to consecutive pixels
int bodyAddress(int b_id) {
return pixel_count_of_Globals + pixel_count_of_Body * b_id;
}
Body loadBody(sampler2D buff, int b_id) {
int addr = bodyAddress(b_id);
vec4 d0 = texelFetch(buff, address2D(res, addr), 0);
vec4 d1 = texelFetch(buff, address2D(res, addr+1), 0);
b.pos = d0.xy; b.vel = d0.zw;
b.ang = d1.x; b.ang_vel = d1.y;
}
// Contact impulse solver
float v_n = dot(dv, contact.normal);
float dp_n = contact.mass_n * (-v_n + contact.bias);
dp_n = max(0.0, dp_n);
body.vel += body.inv_mass * dp_n * contact.normal;
```
### Variant 4: N-Body Vortex Particles
```glsl
// Biot-Savart kernel: v = w * (-dy, dx) / |d|²
for (int j = 0; j < N; j++)
for (int i = 0; i < N; i++) {
float w = W(i, j);
vec2 d = tex(i, j).xy - pos;
d = (fract(0.5 + d / res) - 0.5) * res; // periodic boundary
float l = dot(d, d);
if (l > 1e-5) F += vec2(-d.y, d.x) * w / l;
}
```
### Variant 5: 3D SPH Particle Fluid
```glsl
// 2D texture mapping for 3D grid
vec2 dim2from3(vec3 p3d) {
float ny = floor(p3d.z / SCALE.x);
float nx = floor(p3d.z) - ny * SCALE.x;
return vec2(nx, ny) * size3d.xy + p3d.xy;
}
// SPH pressure force + friction + surface tension
float pressure = max(rho / rest_density - 1.0, 0.0);
float SPH_F = force_coef_a * GD(d, 1.5) * pressure;
float Friction = 0.45 * dot(dir, dvel) * GD(d, 1.5);
float F = surface_tension * GD(d, surface_tension_rad);
p.force += force_k * dir * (F + SPH_F + Friction) * irho / rest_density;
```
## Performance & Composition
### Performance Tips
- Use `texelFetch` instead of `texture` to skip filtering; precompute `1.0/iResolution.xy`
- N-Body: limit N to 20~30; passive marker particles (90%) skip force computation
- Cloth multi-substep: use 2 buffers + JS for loop (do not use 4-buffer chain)
- Adaptive precision: use larger time steps for distant regions
- Data packing: bit operations for compression (5-bit exponent + 3x9-bit components)
- Stability: `clamp` to prevent explosion, `smoothstep` for soft boundaries, damping 0.95~0.999
### Composition Patterns
- **Physics + post-processing**: wave refraction/caustics, fluid advection ink coloring, cloth ray tracing
- **Physics + SDF rendering**: `sdBox`/`length-radius` to render rigid bodies/particles
- **Physics + volume rendering**: density field trilinear interpolation → ray marching → lighting + shadows
- **Multi-system coupling**: fluid driving rigid bodies, cloth collision bodies, particle↔field mutual driving (SPH/Biot-Savart)
- **Physics + audio**: spectrum energy mapped as external force, low frequency drives large scale, high frequency drives small scale
## Further Reading
Full step-by-step tutorial, mathematical derivations, and advanced usage in [reference](../reference/simulation-physics.md)