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.
from acron.atmosphere import CloudNoise
base = CloudNoise.make_base(128, seed)
detail = CloudNoise.make_detail(32, seed)
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.
from acron.atmosphere import WeatherField
from acron.core import Texture
weather = WeatherField(seed, 64)
weather.set_preset("storm", 120) # two minutes to build
weather_map, rain_map = Texture(), Texture()
def update(dt):
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 inap);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 alongget_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’smix(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()andget_layer_top(), the extent of the layer in the map, andget_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) # the volume the shader samples
if weather.density_at(camera_pos) > 0.05:
pass # inside a cloud
sun.color = sun_color * weather.sun_column(camera_pos, to_sun)
The rest of the field¶
get_preset(),get_blend()(how far the last blend has got, 0..1) andhas_preset().set_preset()returns False for an unknown name.get_moisture(),get_convection(),get_stratus(),get_fog()andget_snow(): the preset’s knobs as blended now.get_cloud_base()gives the cumulus base in metres.get_storm(), the storm level near the focus, 0..1.get_num_bolts()andget_bolt()give the lightning since the lastclear_bolts(), as (x, y, z, strength) in world metres; at most 64 are kept.get_seed(),get_map_size(),get_time()(seconds stepped) and the constantsget_tile()andget_max_height()(12000 m, the scale of the map’s heights).get_focus(), the point thatset_focus()set, andget_base_noise_size(), the size of the base volume the twin samples (0 until it has one).From Python, the getters are also properties (
weather.wind,weather.offset,weather.layer_offset,weather.storm, …).
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:
get_humidity()(the layer’s relative humidity),get_temperature()(°C, less the lapse rate with height) andget_uplift()(m/s: the layer’s uplift, and inside a cell’s updraught its speed, for gliders and turbulence), at any world point;fill_sim_map()writes them for debug views (RGBA32F: r humidity, g temperature anomaly, b uplift, a cloud water).The cells:
get_num_cells(),get_cell()(x, y, base, top),get_cell_state()(updraught, how much of it exists, anvil, rain) andget_cell_id(), stable over a cell’s life.The water budget:
get_water_total()andget_water_source()(what the climate added, less what it took). Withset_sim_sources()off, the processes only move water about and the total stays put.
weather.set_preset("convective", 1800) # a hot afternoon
weather.add_front(True, 90, 3.0, 1.5, 8000) # a cold front from the west
lift = weather.get_uplift(glider_pos) # m/s
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:
add_volume()adds a game’s own;set_hero_count()turns the deepest simulated cells withinset_hero_radius()of the focus into hero volumes. A hero is built atset_hero_resolution(), fades in over 4 s as its cell fades out of the weather map, follows the cell, and is rebuilt as the cell grows (at most one build every 1.5 s). Its self-shadow followsset_light_dir().
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.
from acron.atmosphere import CloudVolume
cell = CloudVolume.make_cumulonimbus(seed, 9000, 2500, 3300, 0.8, (6, 2))
cell.set_center(weather.world_to_air((x, y, cloud_base)))
weather.add_volume(cell)
in_cloud = weather.density_at(plane_pos) > 0.02
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
DirectionalLightwith 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.
from acron.atmosphere import SkyModel
air = SkyModel().with_weather(haze=2.0, humidity=0.5)
sun.color = air.transmittance(0.0, sun_dir.z) * sun_illuminance
ambient.color = air.sky_irradiance(sun_dir, sun_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.