Interactive Fluid Particle Logo
Goal
Build a full-viewport WebGL hero where a PNG logo is rasterized pixel-by-pixel into tens of thousands of soft round GL_POINTS particles floating on a near-black background. The star effect: moving the mouse repels nearby particles with an inverse-square force, scattering them fluidly, and each particle then springs back to its home position through velocity damping plus a constant return force — with an exponentially damped clamp that stops any particle drifting more than ~100px from home. The whole simulation is raw WebGL + a hand-rolled physics loop on requestAnimationFrame. No GSAP, no Three.js, no libraries at all.
Tech
Vanilla HTML/CSS/JS with ES module scripts. Zero npm dependencies — use the raw canvas.getContext("webgl") API directly. Put the two GLSL source strings in a sibling module and import them:
import { vertexShader, fragmentShader } from "./shaders.js";
Layout / HTML
Minimal — the entire page is one canvas:
<body>
<canvas id="canvas"></canvas>
<script type="module" src="./script.js"></script>
</body>
Page title: "Interactive Fluid Particle Logo".
Styling
Only a global reset — nothing else:
* { margin: 0; padding: 0; box-sizing: border-box; }
The canvas is sized by JS (below) and the background color is painted by WebGL's clearColor, not CSS.
Config (top of script.js)
Centralize every tunable in one object; all the exact values matter:
const config = {
logoPath: "/path/logo.png", // square PNG with transparency
logoSize: 1250, // sampling grid resolution (px)
logoColor: "#404040", // tint multiplied onto the logo pixels
canvasBg: "#141414", // WebGL clear color
distortionRadius: 3000, // mouse influence radius (device px, used squared)
forceStrength: 0.0035, // scales the repulsion acceleration
maxDisplacement: 100, // max px a particle may drift from home
returnForce: 0.025, // spring factor pulling back to origin
};
Canvas + WebGL setup
- Canvas sizing (DPR-aware):
canvas.width = innerWidth * dpr; canvas.height = innerHeight * dpr;withcanvas.style.width/heightset to the CSS pixel size (dpr = window.devicePixelRatio || 1). All simulation coordinates live in device pixels. - Context:
canvas.getContext("webgl", { alpha: true, depth: false, stencil: false, antialias: true, powerPreference: "high-performance", premultipliedAlpha: false }). - Blending:
gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); - Compile the two shaders, link one program. No depth test, no textures.
Shaders (shaders.js)
Vertex shader — positions arrive in device-pixel space; convert to clip space with a Y-flip, forward the per-particle color, fixed point size 3.5:
precision highp float;
uniform vec2 u_resolution;
attribute vec2 a_position;
attribute vec4 a_color;
varying vec4 v_color;
void main() {
vec2 zeroToOne = a_position / u_resolution;
vec2 clipSpace = (zeroToOne * 2.0 - 1.0);
v_color = a_color;
gl_Position = vec4(clipSpace * vec2(1.0, -1.0), 0.0, 1.0);
gl_PointSize = 3.5;
}
Fragment shader — each point is a soft anti-aliased disc: radial alpha falloff via smoothstep, discarding fully transparent particles:
precision highp float;
varying vec4 v_color;
void main() {
if (v_color.a < 0.01) discard;
vec2 coord = gl_PointCoord - vec2(0.5);
float dist = length(coord);
float alpha = 1.0 - smoothstep(0.0, 0.5, dist);
gl_FragColor = vec4(v_color.rgb, v_color.a * alpha);
}
Building the particles from the logo
- Load the PNG with
new Image(). Inonload, draw it onto an offscreen 2D canvas oflogoSize × logoSize(1250×1250) at scale 0.9, centered (size = 1250 * 0.9,offset = (1250 - size) / 2). getImageDataover the full 1250×1250 grid. Loop rowsi(y) and columnsj(x);pixelIndex = (i * logoSize + j) * 4.- For every pixel with alpha > 10, create one particle at:
particleX = canvas.width/2 + (j - logoSize/2) * 1.0particleY = canvas.height/2 + (i - logoSize/2) * 1.0
i.e. 1 particle per source pixel, 1 device-pixel spacing, logo centered in the canvas (so the point cloud spans up to 1250 device px).
- Per-particle color = original pixel RGB (normalized 0–1) multiplied channel-wise by the
#404040tint, alpha = original pixel alpha / 255 (this keeps anti-aliased logo edges soft). Result: dark-grey logo on the#141414background — subtle, low-contrast. - Each particle object stores
{ originalX, originalY, velocityX: 0, velocityY: 0 }. Current positions live in a flatFloat32Array(positionArray, 2 floats per particle) uploaded to agl.DYNAMIC_DRAWbuffer; colors in aFloat32Array(4 floats per particle) uploaded once asgl.STATIC_DRAW. - Only after particles exist, start the rAF loop.
Expect a large count (a bold logo yields ~100k–400k points) — the physics loop must be plain indexed for loops over typed arrays to stay 60fps.
The interaction physics (the important part — be exact)
Activity gate — simulation sleeps when idle
Keep a counter animationCount = 0. Every mousemove sets animationCount = 300; each physics tick decrements it and the whole physics update early-returns when it reaches 0 (rendering continues every frame regardless). Net effect: the fluid simulation runs for ~300 frames (~5s) after the last mouse movement, letting every particle settle home, then freezes for free performance.
Mouse tracking
mousemove on document, converted to device pixels relative to the canvas:
mouse.x = (event.clientX - rect.left) * dpr;
mouse.y = (event.clientY - rect.top) * dpr;
animationCount = 300;
Per-frame physics (for each particle i, current pos read from positionArray)
Let radiusSquared = distortionRadius² = 9,000,000.
- Inverse-square repulsion —
deltaX/deltaY = mouse − current,distanceSquared = deltaX² + deltaY². If0 < distanceSquared < radiusSquared: force = -radiusSquared / distanceSquared(negative → pushes away from the cursor; explodes near the cursor, fades with distance).angle = atan2(deltaY, deltaX).- Displacement-based falloff:
forceMultiplier = max(0.1, 1 − distFromOrigin / (maxDisplacement * 2))wheredistFromOriginis the particle's current distance from its home — particles already far from home resist further pushing (floor at 0.1). velocityX += force * cos(angle) * forceStrength * forceMultiplier(same for Y withsin).forceStrength = 0.0035.- Friction:
velocityX *= 0.82; velocityY *= 0.82;every tick, unconditionally. - Spring integration:
targetX = currentX + velocityX + (originalX − currentX) * returnForce(returnForce = 0.025 — a lazy constant pull home; same for Y). - Soft max-displacement clamp: compute the target's offset from home; if its length
distFromOrigin > maxDisplacement (100): excess = distFromOrigin − maxDisplacementscale = maxDisplacement / distFromOrigindampedScale = scale + (1 − scale) * exp(−excess * 0.02)— an exponentially damped clamp: slight overshoot allowed near the limit, hard stop far past it (no visible snapping).- position =
original + offset * dampedScale, and bleed energy:velocity *= 0.7(both axes).
Otherwise position = target.
- After the loop, re-upload positions with
gl.bufferSubDataon the dynamic buffer.
Render (every frame)
gl.viewport(0, 0, canvas.width, canvas.height), clear to #141414 (alpha 1), bind program, set u_resolution = (canvas.width, canvas.height), bind position attrib (2 floats) and color attrib (4 floats), then gl.drawArrays(gl.POINTS, 0, particleCount).
Resulting feel
Sweeping the cursor through the logo blasts a hole through the particles; they curl outward fluidly (the inverse-square profile makes close particles fly and distant ones barely stir), then drift back over ~1–2 seconds like iron filings re-forming, with no elastic wobble thanks to the 0.82 friction + damped clamp.
Resize behavior
On window.resize, re-run the canvas sizing, then reposition the existing particles as a centered √N × √N square grid at 1px spacing (row = floor(i / dim), col = i % dim, positioned around the new canvas center), reset positionArray to those spots and re-upload with bufferSubData. (This is a deliberate cheap re-center — after a resize the cloud reflows into a solid square block rather than re-sampling the logo; keep this behavior.)
Assets / images
- 1 image — a square (1:1) PNG logo/mark on a fully transparent background: a simple, bold, geometric shape (e.g. an abstract mountain/triangle mark) in a light grey, ideally ≥1000px so the 1250-grid sampling stays crisp. Solid silhouette with clean anti-aliased edges works best; the code multiplies its color by
#404040, so a light source logo lands as dark grey on the near-black canvas. No real brand.
Behavior notes
- Desktop / mouse-driven — there is no touch fallback; on touch devices the logo simply renders statically.
- The logo renders at device-pixel scale (1250 device px wide at most), so on a 2× display it appears ~625 CSS px wide — that's expected.
- No GSAP, no ScrollTrigger, no smooth-scroll — one rAF loop drives everything.
- The
animationCountgate is the only perf guard; keep it, along withpowerPreference: "high-performance"and the typed-array buffers.