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 surface is rebuilt every frame from the particles on a grid finer than the simulation (Zhu and Bridson's smooth surface where particles are dense, bounded by the union of particle spheres where they are sparse), smoothed, and flattened by curvature flow along the surface in the bulk of the liquid.
- Ray tracing of that surface with exact Fresnel, refraction through the body with Beer–Lambert absorption and scattering, total internal reflection, re-entry into other drops, GGX sun glints, and footage-based reflections; the footage is looked up where the refracted ray meets the ground or the backdrop, in a copy of it with whatever stands nearer there (colliders drawn in it, the depth pass's surfaces, the matte; found by tracing each pixel's ray) filled in from the footage beside it, mirrored across its edges, so an object in the water shows no ghost of itself (for reflections and rays that leave for the backdrop, only colliders and the matte are filled in: a real wall in the depth pass may be what they meet).
- Spray, foam and bubbles rendered as scattering media; spray droplets as motion-blurred sprites depth-tested against the surface; foam as a Worley lace stretched by the flow; a ground wetness map darkens the footage.
- Anisotropic kernels (after Yu and Turk 2013): the covariance of the particles around each 2×2×2 block of cells stretches their kernels along sheets and streams.
- Caustics and shadows by photon tracing: key-light photons from a grid over the ground are refracted through the surface (and out through drops and sheets), absorbed on the way, and gathered into a light map the renderer multiplies onto the ground; with open water the photons start on the paths still water would bend onto their texel, so the map meets the open water without a seam.
- Open water drawn past the box as a flat sheet at the level, blended into the simulated surface over the calming layer; HDRI environments with the sun found from the brightest spot.
- Molten liquids: the crust coordinate is a field on the simulation grid (liq_crust_adv.wgsl), advected by the grid's velocity (semi-Lagrangian, midpoint rule) with the skin's age alongside it; particles swap places at the surface too much to carry it. Where no crust has formed it is the place itself; at irregular shared moments (0.3 to 0.8 s apart) all the melt that has been out in the air since the last one freezes it, so each batch of crust starts undistorted and then rides the flow, and batches meet along seams. A skin's thickness grows as the root of its age, varied by stretch marks (noise smeared along the flow's direction in the crust coordinate by a line integral convolution, so they ride it), wrinkles, ropes and pores, and thinned where the surface is spreading now (not by the stretch since its batch formed, which jumps from batch to batch); its surface temperature falls with that thickness toward the air's, and tears open where the strain rate across them times Crust forms in passes a threshold. Plates are Voronoi cells in that coordinate with two finer fracture levels that come in with the local stretch (so plates stay about their size on the surface); crack widths are measured on the surface (border distance times the stretch across it, from the coordinate's Jacobian) and open by the surface strain rate across the crack times Crust forms in. Temperatures: the melt's from the particles' heat just under the surface (the particles' heat is evened out with their cell's, liq_heat_mix.wgsl), the skin's from its age; light from a blackbody table (camera response) relative to 1300 K. The march only records molten hits in a per-pixel buffer; a separate pass (liq_lava_shade.wgsl) shades them, and the glow's light on the surroundings is gathered from the surface into up to 60 point lights and summed onto a ground map the march reads with one lookup. A skin's lumps (a few centimetres high, tens across, in the crust coordinate) are moved into the signed distance after the surface is built and smoothed (liq_lava_disp.wgsl), so the march finds them. Its rind's reflection is a weak broad sheen times a heavy-tailed random factor per pixel-sized cell (a pixel holds a few dozen millimetre facets; now and then one sends the sun's glint at the camera). Heat haze: the air the surface heats is put on the simulation grid (liq_lava_air.wgsl: hottest at the surface, falling off over 25 cm, heated as the surface's excess temperature to the 4/3 power) and traced by the fire's haze pass (haze_opl.wgsl); the composite bends the liquid element by it as well as the footage.
- Fire and liquid in one box: per sample, the fire over the footage becomes the backdrop the liquid refracts and reflects, then the fire is marched again only up to the liquid surface and laid over it.
The compositor#
The compositor runs these steps in order:
- footage decoded to linear
- heat-haze displacement
- fire light cast onto the footage
- bloom
- grain
- view transforms: Standard (footage untouched, highlight roll-off), AgX, ACES fit, and OCIO displays and views baked into a 65³ LUT over a log2 shaper (footage in OCIO colour spaces through a second LUT)
How it is tested#
pip install pytest
pytest -q
The suite covers:
-
scene and keyframe round trips, and camera maths
-
EXR, PNG and VDB round trips, and
.chanimport -
frame-accurate footage decoding and the point tracker
-
solver stability, incompressibility and determinism on the GPU; a small scene of each kind (fire, liquid, fabric, weather, fire with water, lava, falling objects) simulated in two processes at once, each loading the GPU while the other runs, and compared exactly (
tests/test_determinism.py); the GPU lists kept in a fixed order (glowing matter's lights, pieces of ice, the radiant sources:tests/test_gpu_lists.py) -
the window without a GPU, offscreen with a stand-in for the engine: every preset loading from the effects list, a stale frame kept off the screen, Properties' pages, search and Advanced, number fields, objects added, selected (Ctrl+click, box drag), moved, repeated, grouped, duplicated, deleted and undone, your blocks, the keys and Timing editors, layers, Ctrl+K, roto, the shot camera from the Build view and packing a project (
tests/ui/) -
liquids: stability, volume, incompressibility, determinism, source flow rates, whitewater, rendering, the cache, switching between fire and liquid, and VDB output; viscosity, open water holding still at its level, the narrow band keeping the volume with far fewer particles and while the water sloshes, wind on spray, contact angle, floating and sinking objects, fire and liquid in one box, lava glow and honey, the EXR mattes, thin sheets and the HDRI reader (
tests/test_liquid_features.py) -
liquids, round 3: the sea carrying floating crates, the surface mesh closed and facing out and its OBJ and USD output, lava cooling, dye carried and drawn, a lighter liquid rising through water, rain numbers and rain splashes, a current carrying the open water, a camera under water, a lamp lighting the water, drops on the lens, the footage's depth pass stopping the liquid, the water showing the ground behind a rock in the footage rather than a ghost of it, a real wall in the depth pass staying in the water's reflection, and a box following a boat (
tests/test_liquid_round3.py) -
heat and phase changes: water's enthalpy, density (densest at 4 °C), vapour pressure and boiling curve; a pot boiling off at the rate its heat brings in, a pan of hot water evaporating into dry air at the Lewis-analogy rate, supercooled water staying liquid until it can hold no more and then flashing to slush, an ice cube keeping its shape and floating with the right share above the water, and the ice and steam presets (
tests/test_liquid_thermal.py) -
weather: saturation vapour pressure, lapse rates and fall speeds against the textbook values; the air column turning snow into wet snow, sleet, freezing rain or rain as its temperatures do; small hail melting into rain on the way down and big hail arriving; snow sublimating away in dry air; snow building up at the rate it falls and the density fresh snow has; hail bouncing then resting on the ground, touching it; freezing rain glazing at its rate; the same fall every run, new pieces taking the free slots in order and packed in slot order; the weather presets (
tests/test_weather.py) -
clouds: a storm sounding's condensation level and CAPE; the background column stable and its mixed layer well mixed; a capped sky staying clear; a warm bubble growing a storm to the tropopause that rains on the ground; the microphysics conserving water; following the storm keeping the shear and taking off the drift; the sky presets (
tests/test_clouds.py) -
the sea: the FFT ocean against the sum of its waves, heights and slopes as asked (Cox and Munk), the simulation boundary, whitecaps against the wind (Monahan), foam the same however a frame is reached, surges and bores, waves spreading over the open water at their speed, shoaling, the sea settings, presets and tides (
tests/test_ocean.py) -
rendering
-
command-line renders of every output type, and argument checking
-
swirl, moving emitters and colliders, dousing, sparks bouncing off colliders, colourants, spreading fire, tracked air in a sealed box, mesh distance fields and steam (
tests/test_features.py) -
16 emitters, hollow colliders with openings, the banded mesh bake against the exact one and its disk cache, holdouts, fire light and scorch on surfaces (and their EXR layers), multiple scattering, detail upres, heat expansion in a closed box, latent heat, water from a liquid putting fire out, and a burning object carrying its fire as it moves
-
flame fronts running back through spilled vapour, heavy vapour hugging the ground, soaked ground not relighting, spot fires from embers, soot on walls, fire climbing terrain, a deforming mesh sequence pushing the air, the growing box, the disk cache resuming exactly from a checkpoint, USD import placing a camera and moving and deforming objects, and reading shapes, instances, skinned meshes, curves, points, volumes, nested instancers and lights, VDBs as other programs write them (ZIP, Blosc, half floats, tiles), lights in the set lighting the smoke and shadowed by it, a narrow beam keeping its width, CG lights lighting the ground and smoke shadowing real ones, volumes filling the box where they are, keeping it topped up and releasing, a volume's temperature grid, Z-up VDBs and USD volumes as smoke, soot growing with a collider's size, a burn growing with a collider's size and showing all over the grown collider, what a moving box and a swinging door cover pushed out of their way, smoke carried round a box driven through it with no tunnel left behind, a puff of smoke keeping its smoke as a box is driven through it, a broken piece lying still being a still wall to the smoke, a burnable that keeps its size keeping the burn layout it had, deep samples for a wisp only a later pass sees, spot fires on burnable colliders, soot travelling with a moving collider, deep samples across anti-aliasing and with embers, surface shadows, footage matte and depth holdouts, deep samples flattening to the beauty, OCIO views, motion blur from the cache and the upres velocity (
tests/test_round3.py) -
importing: the mesh atlas packed in columns within the 2048-cell limit (the same solid field as one stack), what it has no room for left out with a notice, bit-shuffled and Zstd Blosc data, a VDB's velocity and temperature layers and header boxes, a field as fine as the simulation, a tilted USD volume keeping to the field's limits, a sequence on one grid blending between its frames, a VDB's velocity moving its smoke off at its own strength whatever the emitter's Velocity strength, every other emitter setting the velocity exactly as before (its cache standing), a volume pouring liquid where it is dense, USD particles as a source (one-frame caches too) and still points as balls holding their volume, IES, portal, geometry and shadowless lights, and the USD frame offset and range in the app and on the command line (
tests/test_import_gaps.py) -
fabric: real materials, a hanging curtain barely stretching, bending against Peirce's cantilever, silk draping over a ball, a sheet folding onto itself without passing through, a flag streaming and fluttering in the wind (and hanging down in still air), fabric slowing the air, a curtain catching, burning through and feeding the fire, wool putting itself out, polyester shrinking and melting, fabric under the fire and behind objects in the shot, cached frames, saving and the mesh export, shadows on the footage and from objects, cloth floating, soaking and sinking in water, wet cloth not catching (
tests/test_fabric.py); wet fabric: materials holding water as real fabrics do, a soaked cloth draining to its hem and dripping drops that fall and land, wicking into dry cloth as the square root of time with no water lost, wet cloth hanging heavier, a soaked towel in the flames holding at the boil and steaming into the gas, and the fire's radiation drying a towel beside it (tests/test_wet_fabric.py)
Something wrong or missing on this page? Edit it on GitHub.