← All work

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.

Unreal Engine 5.8, Lumen. The slime drops onto a canal street at golden hour, splats, and pulls itself back into a dome. The street is the Fab Asian Canal Environment pack. Everything on the slime is simulated and rendered by the plugin.
34,600MPM particles
64³grid, 2 particles per cell per axis
39substeps per 1/60 s (CFL)
3.6–4.0 mssimulation per frame, both engines
0.5 msslime rendering at 1440p (URP, fenced)

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.

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:

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 testBeforeAfter
Shape at rest72 × 72 × 40 cm, egg82 × 82 × 35 cm, dome
Speed at impact1.4 m/s (terminal)3.9 m/s (free fall)
Splat+3% wider+28% wider, 20 cm tall
Recovery0.1 s snap~1 s rebound and ooze
Substeps per frame5439
In the canal: dome → cube → donut → pill → star → dome. Every shape holds the same volume of goo.
Crawling and jumping. Squash and stretch come from the physics plus a small spring on the mold.

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 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.

The dome-shaped slime at rest on the canal street in Unreal
Unreal 5.8 Canal street, Lumen.
The dome-shaped slime on the Unity URP test stage
Unity 6.5 URP Test stage, backlit. The green pool on the floor is the goo's own coloured shadow.
After 10 s at rest, same spawnUnreal 5.8Unity 6.5 URP
Size (cm)82.5 × 82.0 × 35.481.8 × 81.8 × 35.5
Substeps per 1/60 s3939
Volume ratio J (min–max)0.71–1.160.70–1.19
Particles (different random jitter)≈34,60034,623

Where each piece lives

UnrealURP
Engine hookFWorldSceneViewExtension. The simulation runs after the base pass (once the distance field is up to date), the slime draws before depth of fieldScriptableRendererFeature with two Render Graph passes before post-processing. The simulation is a command buffer submitted each frame
Scene collisionEngine Global Distance Field, sampled in a shader permutationA 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 dataStructured buffer, trilinear by hand (8 loads per sample)Copied into a 3D texture once per frame: 1 hardware-trilinear fetch per sample
BubblesSplatted with texture atomics, normals from depth derivativesCompacted into a list and intersected analytically along the ray (exact normals, no atomics)
Sky lightingSkylight SH from the view uniform bufferUnity's ambient probe, packed the same way
ShadowLumen lights it; a wet decal grounds itA compute pass marches sun rays through the sim grid for a coloured, Beer-Lambert shadow
GPU timing on MetalProfileGPU per scopeNo per-pass GPU timestamps in Metal players, so fenced timing
Unity 6.5 URP Scripted playthrough, backlit: it drops and splats, crawls into the cube and squishes against it (baked-SDF collision), jumps, becomes a star, crawls home and turns to face the camera. The box re-centres on the body as it goes.

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.

Unity 6.5 URP Feeding time: he crawls over, chomps a carrot, and you can watch it tumble and dissolve inside him.

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.

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

1

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.

2

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.

3

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.

4

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.

5

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.

6

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.