Fluids & soft bodies
import Ollin gives you two GPU particle-dynamics systems. The first is ParticleFluid, tens of thousands of particles that pour, splash, and settle like water. The second is SoftBodies, soft blobs that squash on impact and spring back. Both systems live inside a walled box, both use the SpatialHash neighbor search, and both respond to a mouse pull or push. They follow the same pattern as the artificial-life sims, so you build the system in setup(), then update and draw it in draw().
What holds for those sims holds here too. Both systems are chaotic, so the order the GPU sums neighbors in decides where a run goes. An export, or a take playing back, fixes that order, so the same seed draws the same frames on the same machine. A live window keeps the faster order, and another GPU's arithmetic can take a run somewhere else.
Contents
Particle fluid
The fluid is smoothed-particle hydrodynamics (Müller, Charypar & Gross 2003), which runs in three steps. First, each particle measures how crowded it is. Next, that crowding becomes pressure. Finally, the pressure pushes neighbors apart with equal and opposite forces. A second near-pressure term (Clavet, Beaudoin & Poulin 2005) is sharper and always repulsive, so it keeps particles from clumping. It also gives the free surface its tension, so the fluid forms beads and filaments, and drops look like drops. The fluid starts as a block hanging in the box, so the first seconds are a dam break.
var fluid: ParticleFluid!
override func setup() {
fluid = makeParticleFluid(count: 26_000, radius: 12)
}
override func draw() {
background(.black)
if mouseIsPressed { fluid.pull(at: Vector2(mouseX, mouseY)) }
blendMode(.add)
stepParticleFluid(fluid)
drawParticles(fluid)
}
Three values are fixed when you build the fluid. count is the number of particles. radius is the interaction range, which sets the fluid's resolution. The same radius sets the packing too, through the derived spacing. bounds is the box, and it defaults to the canvas. Every other property of the liquid is live, so you can change it while the sketch runs:
| Parameter | Meaning | Default |
|---|---|---|
gravity | pull in points/s²; tilt it or set it to zero | (0, 1500) |
stiffness | pressure strength (resistance to squeezing); a higher value wants more substeps | 240 000 |
nearStiffness | the pressure that prevents clumping and gives surface tension | 150 000 |
viscosity | how much each substep smooths velocity across a neighborhood (0…1); a higher value flows like syrup | 0.12 |
bounce | fraction of normal velocity kept at a wall (0…1) | 0.25 |
substeps | fixed substeps per frame (2…8) | 4 |
colorSlow / colorFast / speedForFastColor | the tint ramp from slow to fast particles | blue → ice, 1 100 pt/s |
restDensity | target density relative to the seeded packing | 1 |
drawParticles(fluid) draws each particle as a disc. With additive blending, the result looks like light through water. For a continuous liquid surface, draw the particles into a layer, blur it, and threshold the blur (makeRenderTarget(...) + .gaussianBlur(radius:) + .threshold(_:softness:)). This is the classic metaball technique.
Example: Examples/Simulation/ParticleFluid.
Soft bodies
Soft bodies use meshless shape matching (Müller, Heidelberger, Teschner & Gross 2005). Each body is a cloud of particles that remembers its rest shape. Every substep, the body finds the rotation that best maps the rest layout onto its current layout. Then it pulls each particle back toward its spot. That one pull is the whole elasticity model, so there are no springs to tune, and the simulation cannot blow up. A fully crushed blob springs back. Bodies collide with each other through inelastic contacts found in the neighbor hash, so they tumble into piles.
var blobs: SoftBodies!
override func setup() {
blobs = makeSoftBodies(bodies: 12, radius: 80)
}
override func draw() {
background(.black)
if mouseIsPressed { blobs.pull(at: Vector2(mouseX, mouseY)) }
stepSoftBodies(blobs)
drawParticles(blobs)
}
The blobs start scattered in the upper part of the box, spaced apart from each other. Each blob's size varies around radius, and each body gets its own hue. The parameters:
| Parameter | Meaning | Default |
|---|---|---|
squish | how firmly a body holds its shape, 0…1 (low is jelly, high is rubber) | 0.3 |
gravity | pull in points/s² | (0, 1600) |
bounce | how much a body bounces off a wall (0…1) | 0.35 |
damping | fraction of velocity kept per second; lower settles piles faster | 0.4 |
collisionStrength | how hard touching bodies push apart, points/s² | 35 000 |
substeps | fixed substeps per frame (2…8) | 4 |
Example: Examples/Simulation/SoftBodies.
Grabbing and splashing
Both systems accept an interaction that lasts one frame. Call the method again on every frame while the mouse is held down:
if mouseIsPressed { fluid.pull(at: Vector2(mouseX, mouseY)) } // grab
override func keyPressed() { fluid.push(at: Vector2(mouseX, mouseY)) } // splash
pull(at:strength:radius:) fades gravity out inside its radius and damps any swirl. The held fluid then hangs at the cursor instead of orbiting it or streaming down. push applies the same force outward. In both calls, strength is an acceleration in points/s².
Notes
- Credits. Ollin reimplements three models from published papers, all credited in
ATTRIBUTION.md. They are the SPH model (Müller et al. 2003), the near-pressure term (Clavet et al. 2005), and shape matching (Müller et al. 2005). - Substeps are the stability budget. Both systems run fixed substeps against a clamped frame clock. Because of that clamp, a particle never moves more than about half its interaction radius per substep. A hitch in the frame rate therefore cannot blow up the simulation. If you raise
stiffnessfar beyond the default, add a couple more substeps. - Cost. Each substep rebuilds the neighbor hash and runs the interaction passes. Cost therefore scales with
count × substeps, multiplied by the neighbor density. The fluid example's 26 000 particles at 4 substeps run in real time on Apple silicon.