Clouds and Weather

The acron.atmosphere module is the native half of volumetric clouds: the tiling noise volumes the clouds are carved from, and a weather model that says where the clouds are, how high they reach, where it rains and which way the wind blows. The shaders that march through the clouds live in the Python tier, acron.clouds (see Clouds and Weather), and read what this module makes. The same field answers gameplay questions on the CPU, without a GPU: is the camera inside a cloud, how much of the sun gets through, is it raining here.

The module also holds SkyModel, the sky’s light on the CPU: the colour of the sunlight that reaches a point, and the sky’s ambient light (see The sky’s light below).

The design is ported from the in-house game Castaway (its core/weather and cloud renderer), after the ideas of Andrew Schneider’s Nubis talks (Guerrilla Games, SIGGRAPH 2015–2023). The field simulates its weather (see The simulation), and CloudVolume holds voxel clouds (see Voxel clouds).

Noise volumes

CloudNoise makes three textures on the CPU, on a few worker threads, with the GIL released. Each one tiles seamlessly, and the same size and seed always give the same bytes.

CloudNoise.make_base()

An N3 RGBA16 volume (128 by default). Red is the body’s shape: a Perlin fbm dilated by Worley cells and eroded by Worley fbm. Green, blue and alpha are Worley fbm at 4, 8 and 16 cells per tile, for the turrets and cloudlets. Each channel is stretched so that its 1st to 99th percentile fills the whole range: the shader’s remaps stretch the shape tenfold at the clouds’ edges, and 8 bits showed as terraces there. The volume is meant to cover get_base_tile() metres (4096).

CloudNoise.make_detail()

An N3 RGBA16 volume (32 by default): Worley fbm at 2, 4 and 8 cells in red, green and blue, and their 0.625 / 0.25 / 0.125 sum in alpha. It covers 512 metres.

CloudNoise.make_curl()

An N2 RGBA16 image (128 by default). Red and green hold the curl of a tiling gradient noise (0.5 is no swirl). Blue is 0.5 and alpha is 1; both are spare. It covers 2048 metres.

The noise is a function of the position in the tile, not of the texel grid, so a small volume of a seed is a down-sampled copy of a large one. All the tiles divide the weather tile, so a drifting sky wraps without a seam. The tile sizes are get_base_tile(), get_detail_tile() and get_curl_tile(). On an Apple M-series CPU the 1283 base takes about 0.12 s.

PT(Texture) base = CloudNoise::make_base(128, seed);
PT(Texture) detail = CloudNoise::make_detail(32, seed);
PT(Texture) curl = CloudNoise::make_curl(128, seed);

The weather field

WeatherField is a continuous, deterministic model over one tile of sky, 16384 metres (get_tile_m()) on a side, which repeats. The field drifts with the wind, so it lives in the air’s frame: air = world - get_offset(), where the offset is wrapped into the tile. At each point it holds a cloud cover, a type (0 stratus, 1 cumulus, 2 cumulonimbus), a base and a top height, and a precipitation. It is built from three parts:

  • Convective cells sit on two jittered 2048 m grids, with fair-weather puffs on a third. Each cell lives 140–260 seconds. It grows, matures, and rains if it is deep and the air is moist. If it towers into a cumulonimbus it spreads an anvil. Then it decays. The convection decides how many cells there are and how deep they grow, and their bases sit at the day’s condensation level: 1800 m in dry air down to 650 m in humid air.

  • Stratiform layers come from tiling value noise: low stratus and mist, or a mid-level sheet.

  • Forced looks for clear sky, rain, snow and storm are centred on the focus. At the player the weather is exactly the preset’s, and further off the sky keeps some structure: a storm becomes separate towers over a broken deck.

A preset sets targets for all of these: clear, fair, overcast, rain, storm, fog and snow. set_preset() blends towards it over the given seconds. The blend starts from wherever the weather is at that moment, so a new preset in the middle of a blend never jumps. step() advances time. It moves the cells through their lives and the blend towards the preset. It also turns the wind, which veers slowly about a direction taken from the seed, and moves the field with the wind. It measures the storm level near the focus (get_storm()) and collects lightning bolts from the towering cells (get_bolt()).

The maps

fill_map() packs the field into the weather map the cloud shader reads. The map is map_size2 texels of RGBA8, repeating and linearly filtered:

Channel

Meaning

r

cover, 0..1

g

type / 2: 0 stratus, 0.5 cumulus, 1 cumulonimbus

b

cloud base / 12 km

a

cloud top / 12 km

Texel (i, j) holds the air point ((i + 0.5), (j + 0.5)) × tile / map_size, so the shader samples the map at air.xy / tile. fill_precipitation() writes the precipitation in red (0.1 drizzle, 0.4 a shower, 1 a downpour) and the share of it that falls as snow in green. A 642 map takes about 0.3 ms per step and fill, and a 1282 map about 0.7 ms, so a game can refresh it every frame.

PT(WeatherField) weather = new WeatherField(seed, 64);
weather->set_preset("storm", 120);
PT(Texture) weather_map = new Texture, rain_map = new Texture;

weather->step(dt);
weather->fill_map(weather_map);
weather->fill_precipitation(rain_map);

The CPU twin

density_at() evaluates the shader’s base density at a world point (metres, z the height above sea level): the cloud profile for the type, eroded by the base noise, as Castaway’s cl_density_base computes it, without the detail erosion. It reads the map as last packed, filtered bilinearly and repeating as the GPU filters it. It samples the same base volume when the renderer shares it with set_base_noise(). Without a shared volume, the field bakes a 643 base of its own seed, which is the same noise down-sampled. The shader inputs the twin assumes are these:

  • the air position ap = world.xy - get_offset(), as is: not wrapped into the tile again (the layers’ stretch below is not periodic in ap);

  • the weather map at ap / get_tile_m();

  • the base noise at (sample + get_noise_offset()) / 4096, where the offset scrolls the noise upwards at 1.6 m/s so the puffs boil;

  • for layers (lay = 1 - smoothstep(0.12, 0.3, g)), the noise drawn out along the wind: sxy = ap + lay * ((M - I) * ap + get_layer_offset()), where M stretches 0.6 along get_wind_dir() and 1.3 across it. The layer offset is (0, 0) until the drift offset first wraps, so the formula starts as Castaway’s mix(ap, M * ap, lay). Each wrap of the offset moves every air point by a whole tile, and the layer offset takes back what that does to the stretch, so the layers don’t jump;

  • get_layer_base() and get_layer_top(), the extent of the layer in the map, and get_extinction(), the extinction per metre at density 1.

sun_column() marches the twin twelve steps from a point, or from the layer’s base if that is higher, to the layer’s top, towards the light. It returns the transmittance, 0..1, and never quite goes black, because thick cloud lets some light through by scattering: max(exp(-od), 0.35 exp(-0.2 od)), where od is optical_depth(), the raw optical depth of the same march. The light’s elevation counts as at least asin(0.12), about 7 degrees, as in Castaway’s shadow map. A game uses it to dim the directional light under a cloud. It takes about 0.5 µs. precipitation_at(), cover_at() and sample() (cover, type, base, top) read the field directly, not the map.

density_at() and sun_column() use the map as last packed, as the GPU does. A game that never fills a texture calls update_map() after stepping. set_override() forces cover, type, heights or rain everywhere, for a scripted sky or a test; a value below 0 leaves that part alone, and clear_override() ends it.

weather->set_base_noise(base);
bool in_cloud = weather->density_at(camera_pos) > 0.05f;
float lit = weather->sun_column(camera_pos, to_sun);

The rest of the field

The noise bakes, step(), fill_map(), fill_precipitation() and update_map() release the GIL. The noise textures keep their RAM images in Acron’s BGRA order, and the maps are packed the same way.

The simulation

By default the field simulates its weather (set_simulated() switches back to the scripted lattice of cells above, which is cheaper and fully procedural). The model 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. It is a 2.5-D model of the boundary layer on a periodic grid over the tile, 642 cells of 256 m by default (set_sim_size()), in the frame that drifts with the wind, plus convective cells that move over it:

  • The grid holds water vapour, the temperature’s anomaly, stratiform cloud water, rain, anvil ice, the ground’s water and a perturbation wind. The wind and the temperature form a shallow-water pair: warm air is low pressure, so the wind flows towards it, and divergence lets a cold pool spread and thin. Convergence lifts the air, and so do slopes under the wind (set_terrain()) and fronts (add_front()).

  • Every field is advected semi-Lagrangian (stable fluids) with periodic boundaries; the water fields keep their totals. Rising saturated air condenses into stratiform cloud, which rains out; rain that evaporates below the cloud cools the air.

  • A cell starts where a surface parcel is buoyant past the cap: the surface’s heating, the layer’s anomaly, the lift and a thermal’s chance warmth. Its updraught tends to the CAPE of the climate’s lapse rate up to its inversion, and its top climbs with it. The updraught draws the layer’s vapour, deep cells rain, and the rain’s cold pool cuts the cell’s own supply. Its gust front lifts the air round it, where new cells start. A cumulonimbus spreads an anvil downwind. A deep tower starves the cells near it (compensating subsidence), so towers stand apart.

The presets are its forcings. Humidity, instability (lapse rate and inversion height), surface heating, temperature and wind relax the layer towards their targets, so set_preset() blends as before. An instant preset starts the simulation over, seeded with the lattice’s cells at their stages of life and thinned the way the simulation would thin them. The convective preset forces nothing at the focus: scattered thunderstorms grow purely from the simulation. The preset’s other looks (clear, rain, snow, storm, fog round the focus) and the stratiform layers are drawn on top as before.

The simulation steps at a fixed get_sim_step() of 4 s, each step get_sim_speed() times as long in physics time (6 by default, so a cell lives a few minutes, as in the lattice; 1 is the real atmosphere’s pace). The maps interpolate the last two states, so a step() of any length stays continuous. Same seed, same calls, same weather: the steps run in a fixed order, the chance comes from hashes, and the module is built without fused multiply-adds or fast math, like proctree. The standard library’s exp and pow still differ between platforms, so the bits match per platform. A step takes about 0.4 ms on an Apple M-series CPU; an instant preset, with its spin-up, about 3 ms.

What it exposes:

weather->set_preset("convective", 1800);
weather->add_front(true, 90, 3.0, 1.5, 8000);
float lift = weather->get_uplift(glider_pos);

Voxel clouds

CloudVolume is a voxel cloud, after Schneider’s Nubis3 (Guerrilla Games, SIGGRAPH 2023): a hero cloud near the camera that the 2.5-D layer cannot draw well, such as a towering cumulonimbus up close or a cloud an aircraft flies through. A box of nx × ny × nz voxels sits in the drifting frame (set_origin(), or set_center() with its anchor). Each voxel holds:

  • the signed distance to the cloud’s envelope (negative inside), from an exact Euclidean distance transform: a march skips empty space by it;

  • a dimensional profile, 0 at the envelope’s edge rising to 1 at get_edge() metres inside. It erodes the base noise the way the layer’s height profile does, so the edges break into wisps and the core fills;

  • a type, 0 cumulus to 1 cumulonimbus;

  • after update_light(), the depth of cloud towards the key light (expected density × metres): its self-shadow.

The envelope comes from metaballs (add_sphere(), add_ellipsoid(), a flat set_floor()) voxelised by voxelize(), or from a storm cell: make_cumulonimbus() builds a tower of jittered balls with cauliflower turrets, leaning with the shear, under an anvil blown downwind with a lumpy rim and an overshooting top. A large-scale volume of the layer itself comes from build_from_field(). A 643 storm cell builds in about 4 ms.

The light sweep goes slice by slice across the light’s main axis, each slice from the one before, so the whole volume costs about as much as reading it once (2.6 ms at 643). It runs again when the light moves more than half a degree, a few slices per call, while the last finished sweep stays in use. fill_shape() and fill_light() write a volume into its slot of a 3-D atlas: RGBA8 holding the distance, profile and type, and R16 holding the light depth, log-encoded.

The field counts volumes in density_at(), optical_depth() and sun_column(), so gameplay sees them: a plane inside a cumulonimbus, the sun hidden behind one. A volume repeats with the tile like the rest of the field. The density is the shader’s: the profile erodes the base noise, remap(n, 1 - p, 1, 0, 1), softened at the edges and filled in the core. Volumes come from two places:

march_density() integrates the density along a segment, sampling everywhere or, with skip, only where the volumes’ distances allow cloud. The two agree (the tests hold them to 1e-5) with a fifth of the samples.

PT(CloudVolume) cell = CloudVolume::make_cumulonimbus(
  seed, 9000, 2500, 3300, 0.8, LVecBase2f(6, 2));
cell->set_center(weather->world_to_air(LPoint3f(x, y, cloud_base)));
weather->add_volume(cell);
bool in_cloud = weather->density_at(plane_pos) > 0.02f;

The sky’s light

SkyModel is a planet’s air as three media, after Hillaire 2020 (“A Scalable and Production Ready Sky and Atmosphere Rendering Technique”):

  • molecules that Rayleigh-scatter, thinning exponentially with height;

  • aerosols that Mie-scatter and absorb, thinning faster;

  • an ozone layer that only absorbs, in a tent profile around its centre.

Units are kilometres and 1/km, as in the shaders. The defaults are the Earth of Hillaire’s paper:

Parameter

Default

Unit

ground_radius

6360

km

top_radius

6460

km

rayleigh_scattering

(5.802, 13.558, 33.1) e-3

1/km, rgb

rayleigh_scale_height

8

km

mie_scattering

3.996e-3

1/km

mie_absorption

4.40e-3

1/km

mie_g

0.8

none

mie_scale_height

1.2

km

ozone_absorption

(0.650, 1.881, 0.085) e-3

1/km, rgb

ozone_centre

25

km

ozone_width

30

km

ground_albedo

0.3

none

The constructor takes all twelve parameters in this order, and each has a property. with_weather() returns a copy with the aerosols scaled by a haze factor and a humidity: wet aerosols scatter more and absorb less. The CPU model computes in double precision.

transmittance()

The share of light (rgb, 0..1) that crosses the air from its top to a point height_km up, arriving from cos_zenith (1 is straight down from overhead), and zero when the planet is in the way. It integrates in 48 steps by default, crowded quadratically round the ray’s lowest point (its start, or half either side of the perigee of a ray that dips first), where the aerosols’ 1.2 km layer is; the ozone layer integrates exactly. That is within 0.3 % of the exact optical depth, and it is the integration the shaders’ transmittance table uses. This lights a DirectionalLight with the reddened sun or moon. It takes about 0.6 µs.

sky_irradiance()

A rough irradiance (rgb) the sky gives a horizontal surface under a sun in the direction sun_up (z up), for an AmbientLight. It sums single scattering over 32 upward directions of 16 samples each (spaced quadratically, finer near the surface), lights each sample with a 12-step transmittance, and adds 40 % for the higher orders of scattering. The sun’s illuminance is a number or an rgb triple. It takes about 100 µs.

extinction() and ray_to_top() (-1 when the ray meets the ground) are the building blocks. The shaders read the real sky from the look-up tables; this model is for lights and gameplay.

PT(SkyModel) earth = new SkyModel;
PT(SkyModel) air = earth->with_weather(2.0, 0.5);
LVecBase3f sun = air->transmittance(0.0, sun_dir[2]) * illuminance;
LVecBase3f amb = air->sky_irradiance(sun_dir, illuminance);

Determinism

The textures and the field are pure functions of the seed, the sizes and the sequence of calls to step, set_preset, set_focus and set_override, down to the byte, on one platform. The worker threads each compute separate texels, so the result doesn’t depend on how many there are. When a worker can’t be started, or with support-threads #f in Config.prc, the work runs on the calling thread and gives the same bytes. The module is compiled without fused multiply-adds or fast math (/fp:precise on MSVC, -ffp-contract=off -fno-fast-math elsewhere), but the weather calls the C library’s exp, sin, cos and atan2, which may round differently in the last place on another operating system or C library: a replay recorded on one platform can drift on another, so a networked game should send the weather’s state (the preset, the focus, the time) rather than expect every peer to compute the same bytes.

step advances at most an hour per call (a longer dt is clamped, with a warning; a negative or non-finite one does nothing), and the published methods refuse non-finite points and parameters. A field is not thread-safe: its BLOCKING methods release the GIL, so don’t use one field from two threads at once. Fields can’t be copied.