How it works#

The physics and the rendering behind Blackbody, for the curious and for anyone extending it. Everything runs in GPU compute shaders (WGSL, through wgpu), driven from Python.

The fire solver#

Fire is an incompressible, buoyant, reacting gas on a staggered (MAC) grid, entirely in GPU compute shaders (WGSL, through wgpu). - MacCormack advection with a revert-to-semi-Lagrangian limiter. Next to a moving collider the correction is left out, and behind it and along its sides the fields are filtered without the cells inside it (which hold nothing), so the cells it leaves fill from beside them rather than with empty air. A cell it covers in a step hands what it holds (smoke, fuel, heat, the other carried fields) to the free cell just outside the surface nearest it before the reaction empties it, so the gas it runs into is shoved ahead and pressed together rather than deleted; on the upres grid too. A collider, a broken piece or matter moving less than a hundredth of a cell in a step is taken as still (what lies still is baked again every step and jitters a little), and the gas is carried round it exactly as round a still wall. - Combustion with an oxygen limit, so fuel-rich cores burn from the outside in. With tracked air, an advected oxygen field is used up by burning, mixed by a sub-grid diffusion and replaced through open boundaries; flames die when about 80% of it is gone. - Heat release, soot, gas expansion, and exponential plus radiative cooling. - Vorticity confinement, curl-noise turbulence and disturbance, and emitter swirl (a Rankine vortex driven at the base). - Wind with gusts, and an absorbing layer at open boundaries so the box edge never shows. - Colliders from analytic shapes or meshes (a GPU-baked signed distance field, inside/outside by generalised winding number). Animated colliders rebuild their distance field every substep, and their surfaces carry their velocity into the pressure solve, so they push the gas. - Emitters are evaluated every substep; a moving one imposes its own velocity. A Volume emitter's density (and temperature, and velocity) goes into the mesh atlas next to the meshes, scaled so its densest 0.5% is about 1, kept no finer than half a simulation cell; a sequence puts every frame on one grid (the union of the frames' boxes from the VDB headers) and the atlas holds the two frames around each step, blended like a deforming mesh's. Filling takes the maximum of the gas and the volume in the one substep its start time falls in; where the volume is dense the air's velocity is relaxed toward the volume's own at its own strength, on top of the emitter's Velocity at its Velocity strength (each taken up at its own strength, whatever the other's; any other emitter sets the velocity bit for bit as before, and Velocity from the volume is left out of its cache signature). A volume turned other than by quarter turns is resampled trilinearly on the upright box around it, a slab at a time, after averaging down by the whole part of what the limits ask. USD particles are splatted onto a grid as soft balls first. The atlas is one r32float 3-D texture: grids stack along z in columns at most 2048 deep, standing side by side in x and y on a 32-cell grid, so no side passes the 2048 cells every GPU allows; a grid's corner travels to the shaders as one float (z + 2048 × (x tile + 64 × y tile), exact below 2^24). Dousing cools toward 100 °C and turns the heat removed into water vapour; vapour is lighter than air and adds lift. - Burnable surfaces: a cell layer on the floor, and for each burnable collider a grid in the collider's own frame and in proportion to its size (so the burn moves, turns, grows and shrinks with it), that catches from hot gas or burning neighbours, releases fuel, smoulders and burns out. - Heat expansion from the ideal gas law at constant pressure (the divergence is the heating rate over the absolute temperature). In a box closed on every side the mean of the pressure equation's source is removed, since a sealed box can only hold a net-zero expansion. - Latent heat: condensed water is tracked and its heat added to the gas; the stiff balance between condensing and warming is solved with Newton's method each step. - Detail upres: the fire's fields on a grid 2 or 3 times finer, advected (MacCormack) by the simulation's velocity plus curl noise scaled by the local vorticity, with the reaction run on the fine grid too. - Premixed flame fronts: fuel whose mixing limit no longer holds it back catches from a neighbour above the ignition temperature and burns through the cell in about (cell size / flame speed). Heavier-than-air fuel adds negative buoyancy; with it (or flame fronts) on, the fuel's total is measured before and after advection on the GPU and the gain semi-Lagrangian advection makes where heavy vapour settles is taken back out. - Spot fires: embers landing above a temperature are counted with atomics, per floor column or, on a burnable collider, per cell of its object-burn atlas (found through the collider's own frame), and the burn steps light each with the chance per ember. Soaked surfaces hold a negative catching progress that dries back toward zero. Soot sticks to the floor and closed sides in the grid, and to colliders in a soot atlas laid out in each collider's own frame (like the burn atlas), where every cell just outside the surface gathers soot from the smoke at its current world position. That atlas is laid out in coordinates divided by the collider's size, so its soot scales with it. - Deforming meshes: every frame of a sequence (or an animated USD prim) is baked on one grid that holds them all; the atlas holds the frames either side of each step, the distance is blended between them, and the surface's normal velocity (the change of distance times the frame rate) goes into the collider boundary condition. - A growing box: when the smoke's bounding box comes within the absorbing layer of an open side, the whole state is read back, the grid is reallocated with whole blocks of 8 cells added on that side (the origin moving out, so nothing moves in the world), and the state is written back in place. Cached frames keep their own layout. - Checkpoints: the whole solver state (velocity, fields, pressure, burn state, soot, embers) is written into the disk cache every few frames (a JSON header and zlib-compressed arrays per frame, written in the background and renamed into place). - Fabric: XPBD in small steps (about 600 a second) with four constraint passes each, keeping each constraint's multiplier across the passes so the cloth converges to its true stiffness. Long-range attachments (Kim et al. 2012: each vertex kept within its distance along the cloth from its nearest pin) stop hanging cloth stretching however long or finely meshed it is. A panel's bending is solved each step by a multigrid V-cycle on its own grid (Galerkin coarse levels, Chebyshev smoothing), so stiff fabrics reach their measured stiffness at any detail; cloth that has charred, or a mesh, bends by the constraint passes. Stretch along the threads is stiff when pulled and soft when pushed together (so it wrinkles); shear is soft; bending is the isometric bending energy of Bergou et al. (2006), its compliance from the fabric's bending rigidity and checked against Peirce's cantilever test. Constraints are graph-coloured and solved colour by colour (Gauss–Seidel on the GPU). The air acts on each vertex as pressure drag across the sheet and skin friction along it, from the simulated gas (thinner where hot) or the wind, applied exactly over the step; the momentum the cloth takes, the gas loses, through a coupling grid twice the cell size. Self-collision is vertex against triangle (the triangles around the vertices near each vertex, found through a hashed grid), at the larger of the two fabrics' thicknesses, with friction between the layers; a vertex that crossed a triangle during the step is put back on the side it started on, so a fast fold cannot pass through. The colliders' exact shapes and the ground hold it with friction, and it drapes over sand, snow and mud by their surface, while they meet it as a sheet on their grid (see the matter guide). Cloth that tears breaks a thread stretched past its breaking point (a stretch: the cloth's own stretch is kept soft and its tethers hold it), and of the thread's two ends the one pulled harder all round tears away, as burnt-away cloth goes, so a rip runs on from its tip. The cloth is spread onto the light volume as extinction (its area times -ln(1 - opacity)), so every shadow marched through the smoke is cast by the cloth too; its own channel lets the march shade the footage under it from the key light and the sky, and the fire's light on cloth is marched toward the centre of the fire's light through the smoke, the cloth and the objects. In a liquid, the water's velocity (its MAC grid) and its cells act on each vertex: drag as in air at the water's density, and buoyancy on the fibres and the pores of the weave, which hold air until the cloth soaks. Its water (per kg of fibre) adds to its heat capacity and its mass in the constraints, and at the boil the heat goes into boiling it off. Water moves along the cloth each step: capillary wicking as nonlinear diffusion (diffusivity rising with the square of how wet it is, so the front is sharp), with gravity on it over the fabric's capillary rise; and the free water beyond what the fabric keeps drained flows downhill along the cloth (upwind, at a speed that falls as the square of how full the gaps are), dripping where there is nowhere lower to go, as drops of 2–2.8 mm radius falling with quadratic drag. The water boiled off becomes steam in the gas on the coupling grid, mixed in at 100 °C after taking its latent heat from the gas. The fire's radiation: the gas's emission (optically thin, 4κσT⁴ at the flames' real temperature, not the look's) is gathered on a coarse grid of about 4,000 cells and summed at every vertex as point sources; the cloth absorbs it and radiates its own heat away when hotter than its surroundings. The liquid's march takes the drawn cloth as a layer: in front of the water it is what is seen; rays through the water find it in the layer's depth (as screen-space reflections are found). Each vertex's temperature follows the gas with the cloth's own heat capacity and spreads along it; burning vertices feed fuel, heat and smoke to the gas, char, and burn through. Each vertex carries a burn coordinate (below 0 its heat short of ignition, 0 to 1 its burn progress): interpolated across the triangles, its contours put the flame front, the char, the glowing rims and the holes between the vertices, sharp to the pixel. Charred hinges take a rest curvature (char curls); burnt-off vertices are carried by the air as ash, cooling, and drawn as tumbling flakes. Gathered panels are built pleated (a wave whose length along it is the cloth's width), so their pins hold the pleats. Synthetics shrink as they soften and melt. Drawn by rasterising before the march, which stops at the cloth's depth. - A geometric multigrid pressure solve. Its open boundaries are Dirichlet at the face, which keeps it consistent across levels. - Adaptive substeps from a GPU CFL reduction.

The renderer#

A volume ray-marcher. - Emission follows Kirchhoff's law: flame and hot soot emit in proportion to their absorption, times Planck blackbody radiance integrated against the CIE 1931 colour-matching functions. Thick flame saturates; thin flame stays translucent. - Smoke scattering uses a Henyey–Greenstein phase function. It is lit by blurred fire light, a shadowed key light and sky occlusion. - Steam is the vapour above what the air can hold at the local temperature (Magnus formula), scattered as white droplets and shadowed like smoke. - Flame colourants add their line colour wherever the gas is hot. - Multiple scattering after Wrenninge et al.: extra octaves with the albedo's share of the light, thinner shadowing and flatter scattering. - Colliders in the shot are found by sphere tracing their exact shapes and stop the volume march, as do the footage's surfaces from its depth pass; the ground plane, those colliders and the footage's surfaces receive fire light from a short list of point lights built from the fire's emission, with a cosine and inverse-square falloff, shaded by one soft shadow ray toward the lights' weighted centre (sphere traced through the colliders, with a penumbra as wide as the fire, and marched through the smoke). - Lights in the set: each light's transmittance through the smoke is marched toward it from every cell of the light volume (four lights to a texel). At every step of the main march the light itself (inverse square softened by its radius, the spot cone or area facing) is worked out exactly and scattered with the smoke's Henyey–Greenstein phase function, and each multiple-scattering octave sees it with thinner shadowing and flatter scattering. On surfaces, a CG light adds its light and a real one loses what the smoke blocks, both relative to the key and sky light there plus the real lights' own; shadows are sphere traced through the colliders (a penumbra as wide as the light) and marched through the smoke. - Deep samples: the first anti-aliasing pass that sees anything in a pixel places depth bins where the opacity (or light) builds up; every pass then adds the colour and opacity it gathers into the bin its depth range falls in, and a later pass that finds something no bin covers (a thin wisp between its samples) opens a bin of its own while there is room. Those sums are linear in the passes, so after dividing by the transmittance in front of each bin the samples flatten back to the averaged beauty exactly. Embers add their light and nearest depth per pixel with atomics, as a sample with no alpha, divided by the light the samples in front let through (the flat image adds them at full strength); an ember behind samples letting less than 2% through goes at the front of the one that hides it. - Render-time detail noise, empty-space skipping, multi-sample anti-aliasing and velocity motion blur. - GPU embers and sparks are carried by the simulated air, launched into a cone, bounce off the ground and colliders, and are drawn as motion-blurred streaks.

The liquid solver#

Liquids are FLIP/APIC particles on the same MAC grid, all on the GPU. - Particle-to-grid transfers into fixed-point integer atomics, so the result does not depend on thread order and the simulation is deterministic. - A free-surface pressure solve with ghost-fluid boundary conditions (the surface found from the particle density) and a surface-tension pressure jump from the curvature, solved by conjugate gradients preconditioned with a multigrid V-cycle, entirely on the GPU (the CG scalars never come back to the CPU). - A volume correction that pushes apart particles that have bunched up, so the liquid keeps its volume. - Particles move through the grid velocity (midpoint rule) inside the liquid and ballistically where they are sparse; moving colliders carry their velocity into the solve; contact friction at solids. - Time steps limited by the flow speed, by surface tension's capillary time step, and by the gravity-wave time step of a cell. - Whitewater (after Ihmsen et al. 2012) from trapped air (converging flow), vorticity and stretched crests, classed as spray, foam and bubbles by the liquid density each step; spray is dragged toward the wind. - Viscosity by implicit diffusion of the grid velocity (Jacobi sweeps, no-slip at solids and the ground, stress-free at the surface). Contact angle by turning the surface normal at solids, which the surface tension then acts on. Wind as form drag on the exposed surface. - Open water: the sides under the water level hold the liquid in, a damping layer (acting before gravity each step, so still water stays exactly still) absorbs waves, liquid heaped at the edge runs off, and top-up sources refill cells that drain. - Narrow band (after Ferstl et al. 2016): particles only near the surface; deep cells are grid-only liquid that keeps its velocity from step to step, found each step by a distance-to-air relaxation; particles sinking into it are freed, particles are born where the grid carries liquid out of it (at the outflow's flux), and cells the surface returns to are topped up to a full cell with the grid velocity. Every band pass books the particles it creates and frees (a cell joining or leaving the deep liquid counts as a full cell) and the outflow pays the balance back, so the exchange is exact. Particles are 48 bytes (the APIC matrix in half floats). - Floating objects: the pressure on every face of a floating collider (with the hydrostatic correction to the face and seepage under a resting body) is summed on the GPU each substep and read back once a frame; the body then moves as a rigid body with added mass, drag toward the liquid's velocity and a waterline spring for the frame of lag. - Fire and liquid share one grid: the liquid's share of each cell drives the fire solver's dousing, and particles in gas above boiling are freed at a rate that grows with the heat.

The liquid renderer#

The compositor#

The compositor runs these steps in order:

How it is tested#

pip install pytest
pytest -q

The suite covers:

Something wrong or missing on this page? Edit it on GitHub.