Storms and Voxel Clouds

acron.clouds draws its weather from the engine’s native WeatherField, and that field simulates the weather. Moist, warm air rises over hot ground, over hills and along fronts; thunderstorms grow from it, rain, spread anvils and die in the cold air they make, and new cells start along their gust fronts. Near the camera the deepest of those cells are drawn as voxel clouds. These are real volumes with a flat base, cauliflower turrets and an anvil, which the camera can fly into and which cast their shape onto the ground. The 2.5-D cloud layer stays the far, cheap path.

The voxel clouds follow Andrew Schneider’s Nubis3 talk (“Nubis3: Methods (and madness) to model and render immersive real-time voxel clouds”, SIGGRAPH 2023 Advances). The simulation follows Hädrich et al., “Stormscapes: Simulating Cloud Dynamics in the Now” (SIGGRAPH Asia 2020), and Vimont et al., “Interactive meso-scale simulation of skyscapes” (Eurographics 2020), reduced to what a game’s sky needs. Both run on the OpenGL 4.1 development profile on macOS as on the 4.5 targets: every pass is a fragment pass. No numpy is used.

A towering cumulonimbus with an anvil at sunset, fair-weather cumulus in front of it.

A cumulonimbus voxel cloud at sunset behind the simulation’s fair-weather cumulus (storm_demo.py --view authored, ultra).

Getting storms

Nothing to switch on: a CloudRenderer simulates its weather and draws hero volumes by default. The shipped convective preset forces nothing at the camera. It sets a humid, unstable afternoon and lets the simulation grow scattered thunderstorms:

clouds = CloudRenderer(env, quality="high", weather="fair")
clouds.set_weather("convective", seconds=1800)   # half an hour to boil up
clouds.native.add_front(True, 90.0, 3.0, 1.5, 8000.0)   # a cold front
Six frames: fair cumulus at noon, then darker, deeper towers, then thunderstorms with dark bases over the ground.

Fair cumulus at noon turning into thunderstorms as the forcing ramps up: five hours of the simulation’s physics (--view develop).

The weather simulation

The model is a 2.5-D boundary layer on a 64 × 64 grid over the weather tile, 256 m a cell, that repeats with the tile and drifts with the wind. It holds vapour, temperature, stratiform cloud water, rain, anvil ice, the ground’s water and a wind of its own. Over it move convective cells, each with an updraught, a base and a top, the water it holds, its rain and its anvil. Each fixed step (4 s of game time):

  • Warm air is low pressure, so the wind flows towards it; air converging rises. The wind up a slope rises too, and so does the air along a front.

  • Everything is carried by the wind, semi-Lagrangian (stable fluids), and the tile’s edges wrap.

  • Rising saturated air condenses into layer cloud, which rains. Rain that evaporates below the cloud cools the air under it.

  • Where a surface parcel is buoyant past the day’s cap, a cell starts. Its updraught tends to what the climate’s lapse rate allows up to its inversion (the convective available potential energy). It draws the layer’s vapour, and if deep enough it rains. The rain’s cold pool cuts off its warm, moist air and it decays, while the pool’s spreading edge lifts the air round it into new cells. A deep tower starves the cells near it, so storms stand apart.

The presets are the simulation’s targets: humidity, instability, surface heating, temperature and wind. Blending to another preset takes the weather there through the physics; an instant change starts the simulation over in the new weather. The storm, rain, snow, clear and fog presets still force their look right at the camera as before. The same seed and the same calls give the same weather. A step costs about 0.4 ms of CPU. clouds.native.set_sim_speed(1.0) runs the atmosphere at its real pace (a storm towers in about twenty minutes); the default 6 tells a storm’s life in a few minutes.

A game reads the simulation for gameplay:

pos = base.camera.get_pos(render)
clouds.inside_cloud(pos)          # a plane in a cloud, voxel or layer
clouds.uplift_at(pos)             # m/s: thermals, a storm's updraught
clouds.humidity_at(pos)           # the air near the ground, 0..1
clouds.temperature_at(pos)        # degrees C at that height
clouds.native.get_cell(i)         # the storm cells: x, y, base, top

clouds.native.set_terrain(heights, rect, scale) gives the simulation the ground (a grey PNMImage over a world rectangle), so that clouds form on the windward slopes. fill_sim_map writes the humidity, temperature, uplift and cloud water into a texture for a debug view.

Voxel clouds

A voxel cloud (CloudVolume) is a box of voxels holding four values:

  • the distance to the cloud’s envelope, which the march uses to skip empty space;

  • a dimensional profile, how far inside the envelope, which erodes the same noise that carves the layer: wisps at the edges, a dense core;

  • a type, from cumulus to cumulonimbus;

  • a self-shadow, swept towards the sun on the CPU whenever the sun moves.

The renderer keeps them in a 3-D atlas of a few slots (the config’s voxels section: 5 slots of 643), and every pass reads it:

  • the screen-space march, whose steps the distance bounds, so it comes in finely at the cloud’s edge and never steps over one;

  • the panorama, so the environment probe reflects them;

  • the cloud-shadow map, through their swept self-shadow;

  • the cloud fog on geometry.

Four views flying towards a storm cell: the tower in the distance, its flank filling the view, grey fog at its edge, and dimmer grey inside.

Flying into a cumulonimbus at 3.2 km: 7 km out, 3.6 km, at its edge, and inside, where the cloud’s own light fills the view.

Hero volumes are grown from the simulation. The deepest cells within the config’s radius_km become voxel cumulonimbus: their turrets, their anvil blown downwind and their lean come from the cell’s depth, width, anvil and the wind’s shear. As a hero fades in over four seconds, its cell fades out of the weather map, so it is never drawn twice; it is rebuilt as the cell grows. A quality preset says how many heroes there are (heroes in each preset: medium 1, high 2, ultra 4).

A game adds its own clouds too. A volume drifts with the wind like the rest of the sky and, like it, repeats every 16 km:

from acron.atmosphere import CloudVolume

# a storm cell from its depth, tower radius, anvil radius and share
cell = CloudVolume.make_cumulonimbus(7, 9500.0, 2600.0, 3400.0, 0.85,
                                     (6.0, 2.0))
clouds.add_volume(cell, (0.0, 12000.0, 900.0))   # its base, world units

# or any shape from metaballs
blob = CloudVolume((4000, 4000, 3000), 64, 64, 64)
blob.add_ellipsoid((2000, 2000, 900), (1500, 1000, 700))
blob.add_sphere((2600, 2000, 1500), 800)
blob.set_floor(300.0)
blob.voxelize()
clouds.add_volume(blob, (5000.0, 0.0, 1200.0))

A volume must have the atlas’s resolution (643 by default); another is skipped with a warning. The gameplay queries count every volume: density_at, inside_cloud and sun_column see a voxel cloud as the screen does.

A large dark cloud shadow on the ground in the shape of an anvil over a narrower stem.

A storm cell’s shadow: the anvil’s wide fan over the tower’s stem, from the cell’s swept self-shadow.

Lightning in a storm cell lights it from inside. The flash’s place is known, so the cloud glows round it through its depth (the frame slot CLOUD_BOLT_AT).

Cost

Measured on macOS only (an Apple M-series GPU, 1920 × 1080, whole frames including the CPU’s part); the Windows and Linux targets have not been profiled yet:

Case (high)

Frame time

no clouds

6.2 ms

fair weather, voxels off

8.0 ms

fair weather, voxels on, none near

7.7–8.4 ms

one storm cell up close

8.8 ms

inside the storm cell

7.8 ms

thunderstorms, 2 heroes

8.9 ms

the same, voxels off

7.4 ms

With ultra and four heroes in a thunderstorm sky the frame took 18 ms against 9.4 ms without voxels: ultra is for the targets. Building a hero takes about 4 ms of CPU once, and its self-shadow is swept in slices over a few frames.

Known limitations

  • The weather tile repeats every 16 km, and so do the volumes. A hero is the cell nearest the camera, but its copies 16 km away are voxels too.

  • Deep cells that are not heroes are drawn by the 2.5-D layer. Its weather map has 256 m texels, and read straight up a ten-kilometre cell was its footprint extruded (vertical walls, striped from below); the map’s footprint now wanders with height (cl_weather_at(vec3): a slow 3-D warp of up to 600 m from the base noise, the same in WeatherField.density_at), so a tower leans, bulges and narrows. Up close a hero still looks better; the hero count trades this against cost. The screen presets march at a quarter of the resolution and interleave, so a busy storm sky shows some sparkle until it settles.

  • The cloud-shadow map covers a fixed square round the camera (shadows.size_m); a big cell’s shadow can be cut at its edge.

  • The cloud fog on geometry erodes its voxel clouds with the base noise read at the detail’s scale (a lit shader has no sampler to spare for the detail noise), and sees them through the air in front of them as the sky does; the ground seen through a voxel cloud’s flank now matches the same cloud against the sky in brightness, a little smoother in detail, and shows a seam at the horizon where the two meet.

  • The simulation is a boundary layer, not a 3-D fluid: the cells’ vertical structure is parametrised, and fronts are forcings, not the result of a large-scale flow. Terrain shapes uplift only; the ground’s height does not lower the cloud bases.

  • The bits of the simulation match per platform, not across platforms (the C library’s exp differs).