Real-time MLS-MPM fluid simulation, running entirely on the GPU via Three.js WebGPURenderer and TSL compute shaders.
Built by Aung Pyae Soe (Bill) · billcreates.tech
npm install && npm run dev
Needs a WebGPU-capable browser — Chrome or Edge on desktop. Other browsers get a styled fallback message instead of a crash.
npm run build # tsc --noEmit + vite build
npm run preview # serve the production build
Move the pointer across the fluid to stir it — motion near the cursor pushes particles and flares them bright.
Press h (or append #debug to the URL) to open a hidden tweak panel over the sim and render parameters.
The fluid is simulated with MLS-MPM (Moving Least Squares Material Point Method) on a fixed 64³ grid, advecting ~131k particles. Each substep runs five TSL compute passes: clearGrid zeroes the grid's fixed-point accumulators, p2g1 scatters particle mass and APIC momentum onto the grid, p2g2 derives density and an equation-of-state pressure per particle and scatters the resulting stress, gridUpdate decodes momentum into velocity, applies gravity, and enforces box boundaries, and g2p gathers grid velocity back onto particles, applies the pointer force, and advects positions. Because WebGPU atomics only exist for i32/u32, all grid scatter uses fixed-point integers via atomicAdd/atomicLoad. Rendering is a single instanced SpriteNodeMaterial pass — additive, soft-round monochrome sprites whose brightness is driven by particle speed — reading positions and velocities straight off the sim's storage buffers with no CPU readback.
| Name | Where | Default | Effect |
|---|---|---|---|
gravity |
sim.uniforms.gravity |
-0.3 |
Downward acceleration per substep |
stiffness |
sim.uniforms.stiffness |
3.0 |
EOS pressure response to compression — higher resists compression more |
restDensity |
sim.uniforms.restDensity |
4.0 |
Density at which pressure is zero |
dynamicViscosity |
sim.uniforms.dynamicViscosity |
0.1 |
Damps velocity gradients — higher reads as thicker fluid |
dt |
sim.uniforms.dt |
0.15 |
Substep timestep; larger is faster but less stable |
substeps |
MPMParams.substeps (constructor only) |
2 |
Substeps run per rendered frame |
particleCount |
MPMParams.particleCount (constructor only) |
131072 |
Particle count, fixed at sim creation |
mouseRadius |
sim.uniforms.mouseRadius |
6.0 |
Falloff radius of the pointer's influence, in sim units |
mouseStrength |
sim.uniforms.mouseStrength |
1.0 |
Pointer force multiplier |
tint |
look.uniforms.tint |
ice blue #8ec8e8-ish |
Base particle color |
baseGlow |
look.uniforms.baseGlow |
0.16 |
Brightness at rest |
speedGlow |
look.uniforms.speedGlow |
0.85 |
Extra brightness added by motion |
speedMax |
look.uniforms.speedMax |
1.6 |
Speed at which glow reaches full extra brightness |
particleScale |
look.uniforms.particleScale |
0.32 |
Sprite world size |
index.html Black page, #app canvas mount, footer credit, #nogpu fallback
src/main.ts Boot: WebGPU check, module wiring, frame loop
src/stage.ts WebGPURenderer + scene + camera + resize
src/sim/mpm.ts MLSMPMSim: buffers, 5 compute passes, update(), uniforms
src/pointer.ts PointerForce: pointer → raycast → sim-space pos/vel uniforms
src/material.ts Particle render: additive soft-round sprites, speed-driven glow
src/gui.ts Hidden lil-gui tweak panel (`h` key / #debug)
The sim is self-contained with no runtime network assets, and every module besides main.ts is import-isolated from the others — main.ts is the only file that reaches across modules — so src/ can be lifted whole into an Astro island later.
