Real-time rendering · GPU simulation
SlimeLab
A physically simulated slime character that you can squash flat and watch pull itself back together. I built it twice, as an Unreal Engine 5.8 plugin and as a Unity 6.5 URP renderer feature, to see what really costs time and what the engine changes.
The simulation
A continuum, not a mesh with springs
The slime is simulated with MLS-MPM (Hu et al. 2018): particles carry mass, velocity and deformation, and a background grid solves the momentum update each substep. There is no mesh and no connectivity. That is why it can be squashed into a puddle, torn and poked, and still flow back into one body.
- Material. Each particle stores the elastic left Cauchy-Green tensor
b_E(after Yue et al. 2015), not the deformation gradient. Volume is compressible neo-Hookean. Shear is capped by a Herschel-Bulkley flow rule, so below the yield stress it wobbles like jelly and above it it flows with shear-thinning viscosity. Working onb_Eavoids a per-particle SVD. - Portable atomics. Particle-to-grid scatters with fixed-point integer atomics (216), so the same code runs on Metal, D3D12 and Vulkan without float atomics.
- Surface tension. A continuum surface force (Brackbill 1992) computed from the grid's fill fraction pulls bumps in and closes dents, which is what makes it re-goo.
- Stable timestep. The substep count comes from the CFL condition on the elastic wave speed, and simulated time is capped at 1/60 s per frame, so a slow frame becomes slow motion instead of a death spiral.
Making a fluid behave like a character
Giving each particle a rest position failed as soon as the goo mixed. So the character's shape is a mold: a signed distance field in the body's frame. Particles outside it are pulled back in, and grid-level density control (after Thuerey 2006) moves goo from overfull to empty parts of the mold. Shape-shifting blends between two molds of equal volume.
Where the mold sits turned out to matter most. Anchored at the centre of mass, a squashed body's mold was squashed too. Anchored at the lowest particle, a tall mold pulled goo upward, which lifted the lowest particle, which lifted the mold: the slime launched itself into the sky. The fix is to anchor on the lowest particle that is actually supported by the floor or the scene, and fall back to the centre of mass in the air.
Getting the sploosh right
It wasn't stiff. It was falling through treacle.
The first version landed stiff and looked like an egg rather than the dome in the reference. Tuning didn't help, so I wrote a drop test: an 80 cm fall that logs width, height, centre of mass and mean velocity every three frames. The first run showed the real problem. The slime reached a terminal velocity of 1.4 m/s after about 40 cm, as if it were falling through syrup.
The mold was placed once per frame, but a frame is 39 to 54 physics substeps. During those substeps the body fell away from its mold, and the mold's pull dragged it back, which acted exactly like air drag. It landed slowly, so it couldn't splat. Moving the mold with the body's mean velocity within the frame gave exact free fall.
Changing the blob to a dome (half an ellipsoid on a flat base, like a drop of goo on a table) then exposed two more bugs:
- A 3.5 g fall. In the air the mold was centred on the body's centre of mass by its bounding box. A dome's centroid sits an eighth of its height below its box centre, so the mold kept pulling the goo downward, and the goo followed.
- A 0.55 g hover. Density control, which re-inflates a squashed body, isn't momentum-free. On a shape whose flat base and curved top don't mirror each other, its pushes don't cancel, and they held the dome up. It now runs only while the body rests on something.
With the physics honest, the rest was feel. A landing weakens the shape memory by up to 88%, which recovers over about half a second (tracked on the GPU, no readback). The material became more liquid, so impacts spread and flow while the mold does the shaping, and the lower stiffness cut the substeps from 54 to 39.
| 80 cm drop test | Before | After |
|---|---|---|
| Shape at rest | 72 × 72 × 40 cm, egg | 82 × 82 × 35 cm, dome |
| Speed at impact | 1.4 m/s (terminal) | 3.9 m/s (free fall) |
| Splat | +3% wider | +28% wider, 20 cm tall |
| Recovery | 0.1 s snap | ~1 s rebound and ooze |
| Substeps per frame | 54 | 39 |
Rendering
Ray-march the grid, then treat it as a lens
The first version splatted particles into screen-space depth and thickness and smoothed them with a narrow-range filter. From a distance that works. Up close it looked lumpy and gridded, and no amount of filtering removed the lattice. The shipping version ray-marches the simulation grid's fill fraction directly: find the c = 0.5 isosurface, refine it by bisection, take the normal from the gradient, and keep marching to measure thickness. It is smooth by construction.
The composite then shades that surface as clear goo:
- Two-surface lens refraction with a little dispersion per colour channel, carried a few local thicknesses into the scene, so thick regions magnify what is behind them.
- Beer-Lambert absorption, set so that 45 cm of goo transmits exactly the slime colour, plus a cheap single-scatter term for the body colour.
- GGX specular with a broad sheen lobe, which is what makes the form read as wet instead of glowing.
- Screen-space reflections with a sky escape: rays that leave the screen over the sky reflect the actual sky pixels rather than the blurry skylight.
- Bubbles that rise, pop at the surface and re-form deep inside.
- A coloured shadow (URP). The slime is drawn after the engine's lighting, so it can't cast a normal shadow. Instead each ground pixel near it marches toward the sun through the simulation grid and applies Beer-Lambert to the goo the light crosses, so thick goo casts a green pool and thin edges barely darken. It follows every splat for free.
- Glow eyes inside the goo. Mesh eyes were invisible, because the composite refracts the background and then replaces most of it with the scatter term. The eyes are now emissive ovals evaluated along the refracted view ray, dimmed by the goo between them and the surface.
Two engines
The same slime in Unreal and Unity
The Unity simulation shader is generated from the Unreal one, so the maths is identical. The simulation runs in a normalized, Z-up box in both. Only the matrix that places that box in the world differs (Unity is Y-up and uses metres). Before rendering anything I checked that both produce the same body from the same spawn.
| After 10 s at rest, same spawn | Unreal 5.8 | Unity 6.5 URP |
|---|---|---|
| Size (cm) | 82.5 × 82.0 × 35.4 | 81.8 × 81.8 × 35.5 |
| Substeps per 1/60 s | 39 | 39 |
| Volume ratio J (min–max) | 0.71–1.16 | 0.70–1.19 |
| Particles (different random jitter) | ≈34,600 | 34,623 |
Where each piece lives
| Unreal | URP | |
|---|---|---|
| Engine hook | FWorldSceneViewExtension. The simulation runs after the base pass (once the distance field is up to date), the slime draws before depth of field | ScriptableRendererFeature with two Render Graph passes before post-processing. The simulation is a command buffer submitted each frame |
| Scene collision | Engine Global Distance Field, sampled in a shader permutation | A static-scene SDF baked once on the GPU with VFX Graph's MeshToSDFBaker (3.1 cm voxels), sampled the same way behind a keyword |
| Ray-march data | Structured buffer, trilinear by hand (8 loads per sample) | Copied into a 3D texture once per frame: 1 hardware-trilinear fetch per sample |
| Bubbles | Splatted with texture atomics, normals from depth derivatives | Compacted into a list and intersected analytically along the ray (exact normals, no atomics) |
| Sky lighting | Skylight SH from the view uniform buffer | Unity's ambient probe, packed the same way |
| Shadow | Lumen lights it; a wet decal grounds it | A compute pass marches sun rays through the sim grid for a coloured, Beer-Lambert shadow |
| GPU timing on Metal | ProfileGPU per scope | No per-pass GPU timestamps in Metal players, so fenced timing |
Feeding time
A bunny that eats carrots
In the Unity port the slime grew some personality: floppy bunny ears, a round tail and a mouth. Now it's free-range: it crawls to the carrots lying around, opens up and chomps.
- The ears and tail are goo too. They're shapes on springs that lag the body's acceleration, fly up while it falls and flop when it lands. Each keeps its own shape memory and is refilled even in mid-air, so a landing squashes them without draining them. They cost about 1 ms of simulation.
- A mouth that's part of the mold. The mold splits at a mouth line under the eyes and the top half hinges back, with the head tilting and the cheeks puffing out so the opening becomes a pocket. The renderer flags the inside of the mouth and shades it as a darker pocket, hiding the glow eyes behind its roof.
- Eating. Carrots on the floor are colliders the goo bumps and pushes. When one reaches his lips he chomps; it sinks into his middle, where you can see it through the clear goo, tumbles and shrinks away over four seconds while the goo flushes peach and his eyes turn the goo's complementary colour.
- Two bugs worth telling. The ears and mouth moved the mold's own centre of mass, so re-centring the mold on the body chased itself: a 43 cm slide in 2.5 seconds with the mouth open. Measuring that offset and compensating for it left one 4 cm shift, then stillness. And steering proportional to the speed error couldn't overcome the drag of an open mouth (1 cm in 2 seconds); an integral term on the measured speed got him crawling again.
Performance
Measure first, then change the algorithm
All numbers are from packaged or player builds on an Apple M5 Pro at 2560×1440, with the camera on the slime.
Unity gives no per-pass GPU timings in a Metal player, so the URP benchmark uses fenced timing. It submits the work, blocks on a one-element GPU readback, and subtracts the cost of an empty fence. It reports medians of 60 to 100 runs.
The first profile of one simulation frame (54 substeps, before the material change) showed where the time went:
The two grid passes were almost half of it, and they swept all 262,144 cells every substep while the slime fills about 3% of the box. Two changes followed. Both are exact: the rest state matches to the last printed digit with and without them.
- Active region. Once per frame, the pass that already reduces the body's shape also collects the particles' bounds and top speed. It turns them into a cell region, padded for the transfer stencil, the surface-tension ring and the furthest any particle can move this frame, and writes indirect dispatch arguments. The grid passes then dispatch over that region only, with no CPU readback. The ray-march treats cells outside it as empty, so stale data can't show up as ghost goo.
- Lazy mold normals. The mold's normal costs six SDF evaluations but is only used for particles outside the mold, which is almost none of them. Evaluate the distance first and the normal only when it is used.
I found both in the Unity port and then ported them back to Unreal. Each is behind a switch, so it can be measured against the old algorithm in the same build.
With the same algorithm, both engines cost about the same. Unreal measures 4.0 ms per frame and Unity 3.6 ms, and the optimizations cut 27–33% in both. With the old, stiffer material (54 substeps) the saving was 30–45%. The algorithm is the lever here, not the engine.
Rendering
In URP the slime costs about 0.5 ms at 1440p: a fenced camera render with the slime minus the same render without it, interleaved. In Unreal the per-scope ProfileGPU figure for the slime pass read anywhere from 0.4 to 4.0 ms for the same configuration, so I don't report it as a number.
Measurement lessons
Most of the surprises were in the measuring
Interleave A/B inside one process
Background GPU load on a desktop Mac moved absolute timings by ±30% between runs. An "optimized" build once measured slower than the baseline, while a render with no slime at all also got slower. Alternating A and B run by run inside one process is the only comparison I trust.
Check the frame you are timing
The first Unreal benchmark placed its camera with a console command that ran before the pawn existed. For several runs it timed a view of the floor with the slime off-screen. A screenshot of the benchmark frame is now part of every run.
Per-scope GPU timings can mislead on Metal
Stalls get booked onto whichever scope is open: in one capture, trivial clears measured 10 ms. Treat small scopes with suspicion and cross-check with a second method.
Log the physics, not just the look
"Too stiff" sounded like a tuning problem. A drop test that logged the centre of mass every three frames showed a 1.4 m/s terminal velocity in a vacuum, a bug that had been there from the start. Two more (a 3.5 g fall, a 0.55 g hover) only showed up as numbers.
Symmetric test shapes hide flips
From the first day of the port, the Unity slime rendered upside down: its ray-march read screen rows in the opposite order from URP's colour and depth targets on Metal. An egg looks the same both ways up. The dome gave it away, with its flat base on top and its eyes at the bottom. Asymmetric test content catches orientation bugs.
Background apps throttle themselves
The Unreal editor drops to 3 fps when it isn't the foreground app, and a Unity player pauses. With simulated time capped per frame, that looked exactly like a physics bug where the slime floats in slow motion. It wasn't one.
Next
What I'd do next
- A proper hero sequence in the canal with Movie Render Queue, with the slime facing the camera so the eyes read.
- A look pass on the URP version against the Unreal reference, and the squash-and-stretch springs, which aren't ported yet.
- Water as a second material in the same grid, so goo and water push on each other with no extra coupling code.
- G2P is now the largest kernel. Next I'll try shared-memory gathers, and test whether P2G's atomics are bandwidth-bound or contention-bound.
- Time Unreal with the same fenced method, so both engines' render numbers come from the same measurement.