Project and Author Info
Medusae, by Ash Weeks, is a razor-clean showcase of browser-side soft-body physics. Medusae uses WebGL / Three.js plus a custom Particulate.js solver to drive ~15k particles tied by ~18k constraints. It runs interactively in the browser, even syncing subtle pulses to the soundtrack. The result feels alive and it’s all there in the repo for you to learn from or extend.
Introduction
Plenty of WebGL demos look like screensavers. Medusae isn’t one of them. The question wasn’t “can we make it shiny?” It was: can a jellyfish feel like a jellyfish?
The spark came from Alexander Semenov’s eerie jellyfish photography. Instead of neon blobs in a fake aquarium, Ash built a single creature adrift in dark water.
Released in February 2016, Medusae arrived when most browser graphics barely used the GPU. Ash pushed further: real-time soft-body dynamics in JavaScript, no plugins, no pre-rendered loops.
Under the hood it leans on Thomas Jakobsen’s constraint-based approach (Verlet + iterative relaxation). Every undulation is solved, not keyframed. To make that practical in the browser, Ash wrote a tiny particle/constraint engine (Particulate.js) and wired it to Three.js with a fixed-timestep loop and GPU interpolation.
It lands in a sweet spot: art and engineering in lockstep. The bell breathes, tentacles lag and recover, the whole organism carries weight and timing. Open-sourced, well-architected, and built to study, Medusae is a reference point for anyone chasing convincing soft bodies on the web.
Results
Fire it up and you get a single, ghostly jellyfish in a black void. Tiny dust specks drift. Drag to orbit; it holds up from every angle. Nudge it with the cursor and the body slides away, then settles. The bell breathes, tentacles lag and catch up. It feels alive.
On numbers: the sim updates ~14,828 particles tied by ~18,247 constraints and still renders smoothly. Physics and graphics each hover around ~8 ms, so you hit the 60 fps budget with headroom. Physics can run at a lower fixed rate; GPU interpolation keeps the motion continuous when the CPU spikes. The jellyfish doesn’t explode or jitter—stability is the quiet win.
Visibility mattered too. Chrome Experiments featured it, which put constraint-based soft bodies in front of mainstream web devs. The repo picked up traction, and the patterns traveled: particles-plus-constraints for jellies and cloth, audio-reactive shaders, tiny post stacks instead of overbuilt pipelines. Plenty of later demos borrow those moves.
It also crossed the “tech demo” line. People set it as a live wallpaper, used it as a visual reference, and treated the code as a study piece. If you’re learning graphics, this one project touches shader work, fixed-timestep simulation, buffer plumbing, interpolation, and performance tuning—all in one place.
Bottom line: in 2016, doing this on WebGL 1 without compute was bold. Today, you’d push the solver to WebGPU or WASM, but Medusae already proved the point: the browser can do living, breathing motion, not just spinning cubes and noise.
Challenges and Innovations
Make it fast, make it feel alive.
Simulating ~15k particles and ~18k constraints in JavaScript should’ve been a slideshow. Weeks dodged that with a lean solver (Particulate) and a killer trick: GPU interpolation between fixed-timestep physics frames to hide dropped updates and keep motion continuous.
Soft-body balance is hard.
Too loose and the bell collapses; too stiff and it turns to rubber. The fix was Jakobsen-style Verlet + iterative constraint relaxation with per-region tuning (hood vs tentacles). Constraints correct position errors directly—no spring forces piling up—so the jellyfish keeps its dome, wobbles, then settles.
Stability under load.
Many interconnected constraints want to explode. Medusae reins that in with multiple relax passes per tick, mild damping, and gentle forces (e.g., the PointRepulsor) to avoid injecting energy spikes. Each solve nudges distances toward their rest length rather than overshooting.
WebGL1, pre-compute era.
In 2016, there were no compute shaders or WebGPU. Physics stayed on the CPU, yet the demo still hit ~60 fps on decent desktops. That required tight loops over typed arrays, minimal allocations (no GC churn), and careful scheduling. Whether or not workers were used, the philosophy was the same: keep the hot path simple and cache-friendly.
Procedural > textures.
The hood’s look comes from GLSL patterns—rim weighting plus layered sines—so translucency and “bioluminescence” scale cleanly with no texture bandwidth. Atmosphere comes from GL_POINTS dust animated in the vertex shader; it’s cheap depth that frees budget for constraints.
Interpolation that actually works.
Physics ticks at a fixed rate; the renderer runs at display rate. The vertex shader blends prevPos
→ currPos
by frameMix
, so you get buttery motion without CPU tween passes:
uniform float frameMix;
attribute vec3 prevPos, currPos;
void main() {
vec3 p = mix(prevPos, currPos, frameMix);
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
Interaction without collisions.
PointRepulsorForce
creates a localized push around the cursor/anchor. With clamped distances and cubic falloff, nearby particles slide away convincingly—no full collision system required.
Audio, but tasteful.
A Web Audio analyzer feeds smoothed levels/frequency bins into the sim/shaders. The bell can brighten or contract on beats so the creature feels tethered to the soundtrack.
Technical Overview
Three systems, one loop: physics → buffers → shaders.
- Physics (Particulate.js)
- The jellyfish is a particle lattice: thousands of point masses tied by distance constraints.
- Integration is Verlet + iterative relaxation (Jakobsen-style). It’s unreasonably stable for soft bodies: the bell keeps its dome; tentacles flex without stretching like taffy.
- Constraints are grouped (hood rings, rim/“contour,” mouth, tentacles) so stiffness/damping can be tuned per region.
- Forces are minimal but deliberate: buoyancy/drag, plus a PointRepulsor to keep interiors from collapsing and to support subtle interaction.
- Rendering (Three.js + custom GLSL)
- Three.js owns the scene/camera/VAOs. Geometry for the bell and tentacles maps 1:1 to particle indices.
- The bell uses a custom
bulb-frag.glsl
: rim-weighted lighting, soft translucency, bloom-friendly highlights. No heavy textures; it reads as wet, glassy tissue. - Tentacles are efficient strands (lines/tubes) driven by chained particles—cheap to draw, convincing in motion.
- An ambient dust field renders as GL_POINTS (
Dust.js
+dust-vert.glsl
). The vertex shader adds tiny time-based drifts to sell water volume for almost free.
- Timing & interpolation (Looper.js +
lerp_pos_vertex.glsl
)
- Physics steps at a fixed timestep (for stability). The renderer may run faster.
- Each particle keeps
prevPos
andcurrPos
. A tiny vertex shader blends them byframeMix
so motion looks continuous even if physics is running at 30–45 Hz.
// lerp_pos_vertex.glsl (concept)
uniform float frameMix; // 0..1 between physics ticks
attribute vec3 prevPos;
attribute vec3 currPos;
void main() {
vec3 p = mix(prevPos, currPos, frameMix);
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
That one trick is most of the “butter.”
Physics: Verlet + iterative relaxation (Jakobsen-style)
Medusae solves positions, not velocities, then relaxes constraints several times per fixed step—stable, simple, fast for soft bodies.
// Constraint relaxation (concept)
vec3 d = p2 - p1;
float L = length(d);
float diff = (L - restLength) / max(L, 1e-6);
p1 += d * 0.5 * diff;
p2 -= d * 0.5 * diff;
Constraints are grouped (hood rings, rim/“contour,” mouth, tentacles) so stiffness and damping can be tuned per region. Global forces are minimal but deliberate (buoyancy, drag), layered differently for hood vs tentacles.
You’ll see this pattern in code that assembles “spines” and ribs with distance links:
// Soft-body skeleton (conceptualized from Medusae.js)
const spineA = DistanceConstraint.create([topLen * 0.95, topLen], [pinTop, iTop]);
const spineB = DistanceConstraint.create([midLen * 0.95, midLen], [iTop, iMid]);
const spineC = DistanceConstraint.create([botLen * 0.95, botLen], [pinBottom, iBottom]);
// Hood ribs: outer vs inner rings with gentle slack
const outerRib = DistanceConstraint.create([outerLen*0.9, outerLen], outerIndices);
const innerRib = DistanceConstraint.create([innerLen*0.9, innerLen], innerIndices);
// A procedural radius curve for organic bell shape
function ribRadius(t) {
return Math.sin(Math.PI - Math.PI * 0.55 * t * 1.8) + Math.log(t * 100 + 2) / 3;
}
Adaptive iteration keeps you inside a frame budget:
// Run N relaxation passes; lower N on slower hardware
Medusae.prototype.relax = function (iterations) {
for (let i = 0; i < iterations; i++) system.tick(1);
};
Decoupled timing: fixed physics, smooth rendering
Physics runs at a fixed timestep (stability). Rendering runs at display rate. The GPU blends prev → curr per vertex, so motion stays continuous even when physics ticks at 30–45 Hz.
// Looper-like fixed-step with render interpolation
let accum = 0;
const FIXED_DT = 1/60;
function animateStep(delta) {
accum += delta;
let steps = Math.floor(accum / FIXED_DT);
// advance physics at fixed rate
while (steps-- > 0) updatePhysics(FIXED_DT);
// leftover fraction drives GPU interpolation
const frameMix = (accum % FIXED_DT) / FIXED_DT;
material.uniforms.frameMix.value = frameMix;
}
// lerp_pos_vertex.glsl (core idea)
uniform float frameMix; // 0..1 between physics ticks
attribute vec3 prevPos; // previous particle positions
attribute vec3 currPos; // current particle positions
void main() {
vec3 p = mix(prevPos, currPos, frameMix);
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
This is the smoothness trick: no CPU tween loops over big arrays; the GPU does the tweening per vertex.
Buffer plumbing: two position streams
Particle state sits in Float32Array
s; no object churn. Two streams become BufferAttribute
s:
// Geometry attributes (fed once, updated per physics step)
geo.setAttribute('prevPos', new THREE.BufferAttribute(prevArray, 3));
geo.setAttribute('currPos', new THREE.BufferAttribute(currArray, 3));
// Physics step writes arrays; render only flips the uniform
geo.attributes.prevPos.needsUpdate = true;
geo.attributes.currPos.needsUpdate = true;
This multi-stream setup enables GPU interpolation without extra CPU work.
Rendering: custom GLSL on top of Three.js
Bell shader (bulb-frag.glsl
) — procedural translucency + rim weighting. No heavy textures; plays well with bloom.
// Rim-flavored translucency (representative)
varying vec3 vNormal;
uniform vec3 innerColor, rimColor;
uniform float rimPower;
void main() {
float rim = pow(1.0 - max(dot(normalize(vNormal), vec3(0,0,1)), 0.0), rimPower);
vec3 col = mix(innerColor, rimColor, clamp(rim, 0.0, 1.0));
gl_FragColor = vec4(col, 0.75);
}
Many versions also layer animated sine patterns for organic variation:
// Pattern accumulation (excerpt-flavor)
float accumulate(vec2 uv, float sat, float scale) {
sat -= sin(uv.x * 60.0) * 0.25 + sin(uv.x * 50.0 * scale) * 0.25 + 0.75;
sat -= sin(uv.y * sin(uv.x * 5.0) * 5.0 * scale) * 0.05;
sat -= sin(uv.y * sin((1.0 - uv.x) * 5.0) * 5.0 * scale) * 0.05;
return sat;
}
Tentacles render as lines or thin tubes driven directly by particle chains—cheap to draw, believable in motion.
Dust field (Dust.js
+ dust-vert.glsl
) — GL_POINTS with tiny time-based drift for underwater volume at near-zero CPU cost:
attribute vec3 position; uniform float t;
void main() {
vec3 drift = vec3(sin(t + position.x), cos(t*0.7 + position.y), 0.0) * 0.02;
gl_PointSize = 1.5;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position + drift, 1.0);
}
The system even procedurally generates soft particle sprites:
// Radial dust sprite (canvas-based)
for (let i = 0; i < rings; i++) {
const t = i / (rings - 1);
const r = THREE.MathUtils.mapLinear(t*t, 0, 1, 4, sizeHalf);
const a = THREE.MathUtils.mapLinear(t, 0, 1, 1, 0.05);
ctx.globalAlpha = a;
ctx.beginPath(); ctx.arc(sizeHalf, sizeHalf, r, 0, Math.PI*2); ctx.fill();
}
Forces & interaction: PointRepulsorForce
A compact, distance-based push keeps the interior healthy and supports mouse ripple. Note the clamps for stability:
PointRepulsorForce.prototype.applyForce = function (ix, f0, p0 /* positions */) {
const cx = this.vector[0], cy = this.vector[1], cz = this.vector[2];
const dx = p0[ix] - cx;
const dy = p0[ix+1] - cy;
const dz = p0[ix+2] - cz;
const dist2 = dx*dx + dy*dy + dz*dz;
const diff = PMath.clamp(0.001, 100, dist2 - this._radius2 * this.intensity);
const inv = 1 / diff;
const scale = PMath.clamp(0, 10, inv * inv * inv); // cubic falloff
f0[ix] += dx * scale;
f0[ix+1] += dy * scale;
f0[ix+2] += dz * scale;
};
Takeaway: strong close-range push, fast falloff, numerically safe.
Post: a single pass with taste
LensDirtPass
overlays luminance-gated specks—subtle, but it flips the feel from “mesh on black” to “captured through glass.”
// Post (pseudo)
vec3 col = texture2D(sceneTex, uv).rgb;
float L = max(col.r, max(col.g, col.b));
vec3 dirt = texture2D(dirtTex, uv).rgb;
col += dirt * smoothstep(0.6, 1.0, L) * 0.15;
One pass. Big mood.
Audio: subtle reactivity
AudioController.js
(Web Audio) loads the score and exposes levels/frequency bins so the bell can pulse or glow with the music—no hard sync, just gentle coupling.
Final Thoughts
The lesson isn’t nostalgia for WebGL 1—it's restraint. Weeks chose constraints over springs, position solves over forces, and one good post pass over a pipeline. Result: stable, fast, readable code that still feels fresh in 2025.
These patterns age well. Constraint relaxation maps cleanly to WebGPU compute; the prev/curr + frameMix trick still solves the eternal mismatch between physics ticks and display refresh. Keep them and you can scale to swarms, richer shading, or WASM-backed solvers without losing the vibe.