Lava Buster — GPU Particle-Fluid Blaster (Three.js / GLSL)
Goal
Build a full-screen WebGL background that looks like molten lava. Thousands of glowing particles are continuously blasted out from the exact center of the screen, swirl and stream outward like incandescent fluid, and react to the cursor: moving the mouse aims the field and holding the mouse button down injects a spinning vortex of force around the pointer. The whole thing is a multi-pass GLSL simulation running in ping-pong float render targets — glowing orange filaments (rgb 1.0, 0.3, 0.1) on pure black, brightest where the flow curls. Minimal nav/footer text overlays sit on top. The star of this piece is the shader simulation, not any DOM animation.
Tech
- Vanilla HTML / CSS / JS with ES module imports, bundled by Vite (
npm). three(npm) is the ONLY JS dependency. The entire effect is a hand-written multi-pass
fragment-shader simulation on THREE.WebGLRenderTargets.
- No GSAP, no ScrollTrigger, no SplitText, no Lenis, no CustomEase. There is zero tween-library
code. All motion is a bare requestAnimationFrame loop advancing custom GLSL shaders. Do not reach for any animation library — the "easing" is entirely the physics inside the shaders.
- Ship the GLSL as exported template strings from a
shaders.jsmodule (bufferAShader,
bufferBShader, imageShader) and import them into script.js.
- Font: "Akkurat Mono" with a generic
monospacefallback for the overlay labels.
Layout / HTML
Dead simple. The body holds only two fixed text overlays; the <canvas> is created and appended by JS (renderer.domElement), so it is NOT in the HTML.
<body>
<nav>
<div class="nav-items"><a href="#">Motionprompts</a></div>
<div class="nav-items"><a href="#">/Experiment 0381</a></div>
</nav>
<footer>
<p>Unlock Source Code with PRO</p>
<p>Link in Description</p>
</footer>
<script type="module" src="./script.js"></script>
</body>
Styling
- Reset:
* { margin:0; padding:0; box-sizing:border-box }. - body:
width:100vw; height:100vh; overflow:hidden; font-family:"Akkurat Mono". No background
color is needed — the WebGL canvas fills the viewport and the shader paints pure black behind the glowing particles.
- nav & footer: `position:fixed; left:0; width:100vw; padding:2em; display:flex;
justify-content:space-between; z-index:2. nav { top:0 }, footer { bottom:0 }. They must sit ABOVE the canvas (z-index:2; the canvas defaults to z-index:auto`).
- a, p:
text-decoration:none; text-transform:uppercase; color:#fff; font-size:12px. .nav-items:nth-child(2)(and the footer's right item): right-aligned
(display:flex; justify-content:flex-end) with opacity:0.5.
- The canvas from
renderer.setSize(innerWidth, innerHeight)gets inlinewidth/heightstyles that
cover the full viewport; leave it at default stacking so the overlays float on top.
The star effect — multi-pass GLSL particle-fluid simulation (be exact)
This is a direct GLSL port (Shadertoy-style multi-buffer pipeline). Reproduce the passes, the exact uniform wiring, and every constant. There are five render passes per frame plus a final blit.
Renderer, camera, scene
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight); renderer.setPixelRatio(devicePixelRatio); document.body.appendChild(renderer.domElement).
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)— shared by every pass.RESOLUTION_SCALE = 2. Simulation grid size is
size = new THREE.Vector2(round(innerWidth*2), round(innerHeight*2)) — i.e. the sim runs at 2× CSS resolution, independent of devicePixelRatio. size is passed to every buffer as iResolution and drives all gl_FragCoord math.
- A final
THREE.Sceneholds one full-screenMesh(new PlaneGeometry(2, 2))whose material has an
explicit passthrough vertex+fragment shader (below) to blit the composited texture to screen.
Render targets (float, ping-pong)
Every buffer target is new THREE.WebGLRenderTarget(size.x, size.y, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat, type: FloatType }). Float type is required for solver stability (HalfFloatType is an acceptable fallback if full-float is not renderable on the device).
Create a createBuffer(size, fragmentShader) helper returning { scene, target, material, mesh } where material is a THREE.ShaderMaterial with only a fragmentShader (no vertexShader — rely on Three's default ShaderMaterial vertex shader; the frag passes use gl_FragCoord.xy directly, no varyings). Its uniforms are:
iChannel0: null, iChannel1: null, iChannel2: null,
iResolution: size (the shared Vector2),
iMouse: mousePosition (a shared THREE.Vector4),
iTime: 0, iFrame: 0
The mesh is Mesh(PlaneGeometry(2,2), material) added to the buffer's own scene.
A createDoubleBuffer(size, shader) returns { read, write, swap() } where read/write are each a createBuffer and swap() exchanges them. Build:
bufferA= double buffer usingbufferAShader(particle integrator).bufferB,bufferC,bufferD= three separate double buffers, all usingbufferBShader
(density/pressure field). (C and D are part of the original pipeline; C feeds a branch test and D is fed but unused — create all three to stay identical.)
imageBuffer= a singlecreateBufferusingimageShader(final color composite).
Shared GLSL macros (top of bufferAShader, bufferBShader, imageShader)
#define size iResolution.xy
#define SAMPLE(a, p, s) texture((a), (p)/s) // sample by pixel coord, normalized by size
float gauss(vec2 x, float r){ return exp(-pow(length(x)/r, 2.0)); }
const float radius = 2.0;
Pass 1 — bufferAShader (particle position + velocity integrator)
Each texel stores one particle: U.xy = position (in sim pixels), U.zw = velocity. Uniforms used: iChannel0 (self, previous A), iChannel1 (bufferB field), iResolution, iMouse, iTime, iFrame. Constants (with #define SPEED and #define BLASTER both active):
#define dt 8.5 // SPEED branch
#define P 0.01 // SPEED branch
#define particle_density 1.0
#define minimal_density 0.8
Helpers:
// nearest-particle gather: adopt a neighbor's particle if it lands closer to this cell (toroidal)
void Check(inout vec4 U, vec2 pos, vec2 dx){
vec4 Unb = SAMPLE(iChannel0, pos+dx, size);
vec2 rpos1 = mod(pos - Unb.xy + size*0.5, size) - size*0.5;
vec2 rpos2 = mod(pos - U.xy + size*0.5, size) - size*0.5;
if(length(rpos1) < length(rpos2)) U = Unb;
}
vec4 B(vec2 pos){ return 5.0 * SAMPLE(iChannel1, pos, size); } // bufferB field, x5
main:
vec2 pos = gl_FragCoord.xy;
vec4 U = SAMPLE(iChannel0, pos, size);
// gather from all 8 neighbors so particles migrate between cells:
Check(U,pos,vec2(-1,0)); Check(U,pos,vec2(1,0)); Check(U,pos,vec2(0,-1)); Check(U,pos,vec2(0,1));
Check(U,pos,vec2(-1,-1)); Check(U,pos,vec2(1,1)); Check(U,pos,vec2(1,-1)); Check(U,pos,vec2(-1,1));
U.xy = mod(U.xy, size);
// respawn a particle at this cell if the gathered one is too far (toroidal dist > 1/0.8 = 1.25):
if(length(mod(pos - U.xy + size*0.5, size) - size*0.5) > 1.0/minimal_density) U.xy = pos;
vec2 ppos = U.xy;
// pressure = gradient of bufferB's .z channel around the particle:
vec2 pressure = vec2(B(ppos+vec2(1,0)).z - B(ppos+vec2(-1,0)).z,
B(ppos+vec2(0,1)).z - B(ppos+vec2(0,-1)).z);
// --- CURSOR VORTEX (only while mouse button is held: iMouse.z > 0) ---
if(iMouse.z > 0.0){
float k = gauss(ppos - iMouse.xy, 25.0); // gaussian, radius 25 px around cursor
U.zw = U.zw*(1.0-k) + k*0.2*vec2(cos(0.02*iTime*dt), sin(0.02*iTime*dt)); // blend toward a spinning velocity
}
// --- CENTER BLASTER (always on): continuous emission from screen center ---
U.zw += 0.002*vec2(cos(0.01*iTime*dt), sin(0.01*iTime*dt)) * gauss(ppos - size*vec2(0.5,0.5), 8.0) * dt;
U.zw = U.zw * 0.9995; // velocity damping
U.zw += P * pressure * dt; // accelerate down the pressure gradient
vec2 velocity = U.zw; // (a "0.*B(ppos).xy +" term is present but zeroed)
U.xy += dt * velocity; // integrate position
U.xy = mod(U.xy, size); // wrap toroidally
// initial grid seed (frame 0); in practice particles are seeded by the respawn line above too:
if(iFrame < 1.0){
if(mod(pos, vec2(1.0/particle_density)).x < 1.0 && mod(pos, vec2(1.0/particle_density)).y < 1.0)
U = vec4(pos, 0.0, 0.0);
}
gl_FragColor = U;
Key feel: the two angular terms — 0.02*iTime*dt (cursor) and 0.01*iTime*dt (blaster) — make the injected velocity slowly rotate over time, so the fountain sweeps and the cursor swirl spins. The *dt (8.5) makes it energetic; 0.9995 damping keeps it from exploding.
Pass 2/3/4 — bufferBShader (density + pressure/wave field), used by bufferB, bufferC, bufferD
Uniforms: iChannel0 (bufferA particles, current-frame write), iChannel1 (self, previous), iResolution. Helpers:
vec4 B(vec2 pos){ return SAMPLE(iChannel1, pos, size); } // self, previous state
vec3 pdensity(vec2 pos){
vec4 p = SAMPLE(iChannel0, pos, size); // a particle's velocity(zw)+position(xy)
return vec3(p.zw, gauss(pos - p.xy, 0.7*radius)); // (vel.x, vel.y, density falloff of that particle)
}
const vec2 damp = vec2(0.0, 0.01); // present, unused
const vec2 ampl = vec2(0.1, 1.0);
main:
vec2 pos = gl_FragCoord.xy;
vec3 density = pdensity(pos);
vec4 u;
u.xyz = 0.5 * density; // x,y = half velocity; z temporarily half density
float div = B(pos+vec2(1,0)).x - B(pos-vec2(1,0)).x + B(pos+vec2(0,1)).y - B(pos-vec2(0,1)).y;
// diffuse (blur+decay) the z,w channels from the 4 neighbors, then inject divergence & density:
u.zw = (1.0-0.001)*0.25*(B(pos+vec2(0,1)) + B(pos+vec2(1,0)) + B(pos-vec2(0,1)) + B(pos-vec2(1,0))).zw;
u.zw += ampl*vec2(div, density.z); // z += 0.1*div ; w += density
gl_FragColor = u;
This is a lightweight wave/pressure propagation: u.z (the pressure the particle pass reads back as B(...).z) diffuses across neighbors and is driven by the velocity-field divergence; u.x,u.y carry the smoothed velocity used to compute divergence and vorticity.
Pass 5 — imageShader (final color composite → imageBuffer)
Uniforms: iChannel0 (bufferA particles, current write), iChannel1 (bufferB field, current write), iChannel2 (bufferC field, current write). Same B/pdensity helpers as above.
vec2 pos = gl_FragCoord.xy;
vec3 density = pdensity(pos); // from iChannel0
vec4 blur = SAMPLE(iChannel1, pos, size); // bufferB
float vorticity = B(pos+vec2(1,0)).y - B(pos-vec2(1,0)).y
- B(pos+vec2(0,1)).x + B(pos-vec2(0,1)).x; // curl of the velocity field
vec4 fragColor;
if(texture2D(iChannel2, vec2(38,2)/256.0).x > 0.5){
// debug/alt branch — in practice this texel is < 0.5, so this is NOT taken:
fragColor = vec4(2.0*density.z*(7.0*abs(density.xyy)+vec3(0.2,0.1,0.1)), 1.0);
fragColor = vec4(10.0*abs(density.xyy) + 30.0*vec3(0,0,abs(blur.z)), 1.0);
} else {
// ACTIVE RENDER PATH — molten-lava glow proportional to vorticity:
float l1 = 490.0 * abs(vorticity);
float l2 = 1.0 - l1;
fragColor = vec4(vec3(1.0, 0.3, 0.1)*l1 + 0.0*vec3(0.1,0.1,0.1)*l2, 1.0);
}
gl_FragColor = fragColor;
So the visible image is vec3(1.0, 0.3, 0.1) (molten orange) × 490 × |vorticity| on black: particles glow hot where the flow curls hardest, fading to black in calm regions.
Final blit shader (the screen Mesh material — explicit vertex + fragment)
// vertex
varying vec2 vUv;
void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }
// fragment
uniform sampler2D tDiffuse; varying vec2 vUv;
void main(){ gl_FragColor = texture2D(tDiffuse, vUv); }
Input handling (shared mousePosition = new THREE.Vector4())
mousemove(onwindow):mousePosition.x = event.clientX * RESOLUTION_SCALE;
mousePosition.y = (innerHeight - event.clientY) * RESOLUTION_SCALE (Y flipped into GL space, scaled by 2). This is iMouse.xy in sim pixels.
mousedown:mousePosition.z = 1(arms the cursor vortex).mouseup:mousePosition.z = 0.- The same
mousePositionVector4 is passed by reference as theiMouseuniform of every buffer, so
updates are picked up live.
Per-frame loop (animate() via requestAnimationFrame) — exact order & wiring
time = performance.now() * 0.001; frame++; // frame starts at 0
// 1) bufferA (particles): iChannel0 = bufferA.read.tex, iChannel1 = bufferB.read.tex,
// iTime = time, iFrame = frame → render to bufferA.write
// 2) bufferB (field): iChannel0 = bufferA.write.tex, iChannel1 = bufferB.read.tex → bufferB.write
// 3) bufferC (field): iChannel0 = bufferA.write.tex, iChannel1 = bufferC.read.tex → bufferC.write
// 4) bufferD (field): iChannel0 = bufferA.write.tex, iChannel1 = bufferD.read.tex → bufferD.write
// 5) imageBuffer: iChannel0 = bufferA.write.tex, iChannel1 = bufferB.write.tex,
// iChannel2 = bufferC.write.tex → render to imageBuffer.target
// 6) setRenderTarget(null); finalQuad.tDiffuse = imageBuffer.target.texture; render final scene to screen
// 7) bufferA.swap(); bufferB.swap(); bufferC.swap(); bufferD.swap();
Each pass = point the buffer's material at the right input textures, renderer.setRenderTarget(buffer.write.target), renderer.render(buffer.write.scene, camera). Only bufferA receives fresh iTime/iFrame each frame (bufferBShader ignores them). There are no tween durations, eases, delays or staggers anywhere — timing is purely per-frame integration with dt = 8.5 and the 0.9995 / 0.999 decay factors.
Resize
On window resize: recompute width = round(innerWidth*2), height = round(innerHeight*2); renderer.setSize(innerWidth, innerHeight); for every double buffer call read.target.setSize and write.target.setSize and iResolution.value.copy(newSize); do the same for imageBuffer.
Assets / images
None. There are zero image assets — the entire visual is generated in the shaders (black background, orange vorticity glow). No textures, no sprites, no fonts beyond the overlay labels.
Behavior notes
- Desktop, pointer-driven, full-screen background. It runs continuously and forever: the center
blaster emits every frame, so the field is alive even with the mouse idle. Moving the mouse steers it; holding the button down spins a vortex around the cursor.
- Give it a moment to build up — the field ramps over the first ~3–4 seconds from black to a full
churning lava fountain (matches a ~3500 ms preview wait).
- Heavy GPU cost (five full-resolution float passes per frame at 2× CSS resolution). It is
not mobile-safe; treat it as a desktop-only hero/background. No prefers-reduced-motion handling exists in the original.
- Float render targets (
type: FloatType,LinearFilter) are essential; the ping-pongswap()on
all four double buffers each frame is what carries state forward. </content> </invoke>