Storms and Voxel Clouds¶
This page describes part of the Python framework that comes in the
acron wheel. The simulation and the voxel volumes themselves are
native and available to C++ as well: see Clouds and Weather.
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 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
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.
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 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 ( |
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 inWeatherField.density_at), so a tower leans, bulges and narrows. Up close a hero still looks better; the hero count trades this against cost. Thescreenpresets 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
expdiffers).