Position-based fluids
29,160 particles fall as an 8 m column into a tank with a baffle. Without a density constraint they fold into one flat sheet. With it they splash over the baffle and keep their volume.
The problem
Particles under gravity know nothing about each other. Nothing stops two of them from sitting in the same spot, so a column of water collapses into a layer one particle thick. In the left tank the measured density ends up about 21 times the rest value. A liquid has to resist being squeezed.
How it works
Position Based Fluids turns "do not compress" into one constraint per particle: density over rest density minus 1 should be 0. Density is an SPH sum over neighbours within 0.32 m using the poly6 kernel.
Each iteration computes a scale factor per particle, -C / (sum of squared gradients + 60), then moves every particle by the spiky-kernel gradient weighted by its own and each neighbour's factor. Only compression is corrected, so particles can pull apart freely. After the iterations, velocity is the position change divided by the step, then smoothed by a small viscosity term.
The walls and the baffle are particles too, 23,385 of them, each with a fixed weight.
Neighbour search: every particle is hashed into a 0.32 m cell, the list is sorted by cell, and a table stores each cell's first and last index. A particle then reads only its own cell and the 26 around it.
What I hit
The source's boundary pass has its neighbour sum commented out, so every wall particle gets the same weight, 1,000 / poly6(0) = 21.2. I kept it.
Particles start 0.9 diameters apart, about 27% below rest density, so the column sinks a little before the constraint ever pushes back.
The GPU version sorts with a bitonic sort. On the CPU a counting sort builds the same cell table.
My build
Ported the solver kernels, cell hash, boundary setup and the demo's tank to JavaScript. It runs offline on the CPU and writes positions and density per frame, and three.js draws them. Source defaults: 1/60 s frames, 2 substeps, 2 constraint iterations, radius 0.08 m. The baffle is my addition.
The volume view splats the same kernel sum into a 205×130×55 grid at 0.08 m and marches 64 samples with absorption (0.45, 0.029, 0.018), so red fades first.
At 8 s: 1 iteration leaves the largest compression at +23.0% with 37% of particles above +5%. 2 iterations: +12.5% and 9%. 4 iterations: +6.9% and 0.1%.
Trade-offs
Cost grows with iterations and neighbour count. There is no surface tension or vorticity term, so splashes break into loose spray. The volume only absorbs light: no refraction, no reflection, no foam.
When to use it
Pours, splashes and sloshing in a container, a few tens of thousands of particles. For lakes and oceans a height field is far cheaper.
Source and credits
Technique source: PBD-Fluid-in-Unity by Scrawk (Justin Hawkins), https://github.com/Scrawk/PBD-Fluid-in-Unity, MIT license, commit d7ec63854161cba84c98a890c8aeaa95b7a91a6f. Ported to JavaScript from FluidSolver.compute (predict, density and pressure, density solve, velocity update, viscosity, position update), FluidSolver.cs (step order and iteration counts), GridHash.compute and GridHash.cs (cell hash and start/end table), FluidBoundary.compute (boundary weight), SmoothingKernel.cs (poly6, spiky gradient, viscosity kernels), ParticlesFromBounds.cs and the FluidBodyDemo tank setup. The volume view ports ComputeVolume.compute and the absorption raymarch of Volume.shader.
Render framework: three.js by mrdoob and contributors, https://github.com/mrdoob/three.js, MIT license, r170. Used for drawing only. It is not the source of the fluid technique.
My own code: the counting-sort replacement for the GPU sort, the baffle, the clamp-only comparison run, the density-error colouring, the backdrop scene, camera, labels and video. No external models or assets.