Clouds and Weather¶
acron.clouds puts volumetric clouds into the sky of acron.sky: fair-weather cumulus, closed decks, towering
storms with anvils and rain curtains, fog banks and snow, cross-faded from one
weather to another over seconds or minutes. They cast shadows on the ground,
let light shafts through their gaps, swallow mountain tops and aircraft that
fly into them, light up with lightning, and show in the environment probe’s
reflections. The same weather answers gameplay questions on the CPU: is the
camera in a cloud, how much sun gets through here, is it raining.
The design follows Andrew Schneider’s Nubis talks (Guerrilla Games, SIGGRAPH
2015–2023), by way of two in-house games’ cloud systems that the module
generalises. It needs the engine’s native acron.atmosphere (the noise
and the weather field, see Clouds and Weather); a build without it raises
NativeMissingError when a CloudRenderer is made. It uses no
numpy. Platform requirements are the sky’s: an OpenGL 4.5 core profile on
Windows and Linux, 4.1 on macOS for development (see Sky, Weather and Forests).
Getting clouds¶
Make the renderer right after the environment, before any lit shader is
compiled – it adds the frame slots and defines that make sun_visibility
include the clouds’ shadow and that switch on apply_clouds and
apply_light_shafts in lit shaders:
from acron.sky import Environment
from acron.clouds import CloudRenderer
env = Environment(base)
clouds = CloudRenderer(env, quality="high", weather="fair")
scene.set_shader(env.make_shader("lit_basic.vert", "lit_basic.frag"))
clouds.set_weather("storm", seconds=120) # builds up over two minutes
clouds.set_weather("clear") # at once
clouds.set_quality("medium") # rebuilds the passes
The constructor’s switches leave parts out: shadows=False (no cloud
shadow map), fog=False (no CLOUDS_FOG in lit shaders), shafts=False
(no light shafts anywhere), panorama=False (no panorama in the screen
modes: the probe then reflects no clouds), apply_look=False (the weather
leaves the environment’s "weather" layer alone). config takes a
CloudConfig, presets the game’s own weather presets and seed
another sky.
How the clouds are drawn¶
The density. A weather map, 64 x 64 texels over a 16 km tile that repeats, says at every point how much of the sky is cloud (cover), what kind (0 stratus, 1 cumulus, 2 cumulonimbus), where the base and the top are. The density shader builds a 2.5-D shape from it – flat bases, rounded turrets, anvils that spread under the tropopause – and erodes the edges with tiling 3D noise: a 1283 Perlin-Worley volume for the body and a 323 Worley volume for the detail (wispy low down, billowy above), with a curl-noise swirl near the bases. The noise tiles divide the weather tile, so a sky that drifts with the wind wraps without a seam.
The light. Each step of the march takes the key light through cone taps towards it plus one long tap (the shadow of the cloud’s own body), a two-lobe Henyey-Greenstein phase function for the silver lining and the forward glow, octaves of multiple scattering in the manner of Wrenninge’s approximation, and the “powder” darkening of thin edges seen against the light. The march stores light factors rather than colours; the sky composite colours them with this frame’s sun and sky light, so a sunset tints the clouds at once, without waiting for them to be marched again.
The screen-space march (the medium, high and ultra presets) resolves the clouds in a buffer at a quarter, a half or the whole of the screen’s resolution (medium, high, ultra) and marches them in a buffer half that size a side. Each march ray goes through one texel of its 2 x 2 block of the resolved buffer, a different one each frame in Bayer order (Schneider’s Nubis, Hillaire’s Frostbite clouds), so every resolved texel is marched once in four frames. The pass is dense, with no idle GPU lanes, and is jittered by blue noise indexed by the texel’s own update count.
A temporal resolve reprojects last frame’s result through last frame’s
camera (the cloud carried by the wind) and reads it Catmull-Rom, so the
wind’s sub-texel moves do not blur it frame after frame. A texel marched
this frame clamps that history to the new march’s variance and blends the
new march in, taking more of it while the view moves. The other texels
keep their reprojected history until their turn. Off-screen or invalid
history falls back to the march, upsampled. The history is dropped after a
camera jump, a big swing of the light, a weather change made at once or a
window resize. The sky upsamples the result at the pixel. With
acron.pipeline, the TAA leaves sky pixels’ own samples as they are:
the clouds are already resolved, and they are marched without the camera’s
jitter.
Because the clouds are composited inside the sky’s own draw, which is behind everything, terrain and objects occlude them exactly, at full resolution, with no depth buffer read.
The panorama (every preset) is the amortised path: the whole sky marched into an azimuth-by-elevation texture a band at a time, a new panorama cross-faded in as each finishes. It is the low preset’s clouds, and what any view other than the main camera sees – in particular the environment probe, so reflections and ambient light carry the clouds.
The shadow map marches the layer straight down through each texel of a
map fixed in the air, so the shadows drift with the clouds and do not crawl as
the camera moves. Every lit shader gets it through sun_visibility().
Under the clouds. A deck turns the sun it hides into diffuse light: the
renderer scales and greys the sky’s ambient term by the cover and optical
depth overhead, so an ordinary overcast is about as bright as a clear sky’s
shade and only thick storm cloud is dark. The key DirectionalLight (for
materials lit by engine lights) is dimmed by the CPU’s estimate of the sun
column at the camera.
Light shafts. The key light’s in-scatter along a view ray – from the
atmosphere’s own Rayleigh and Mie media, so hazier air gives stronger shafts
– is taken back where the clouds’ shadow hides the light, and on geometry
where the sun’s cascades do too, which gives crepuscular rays through cloud
gaps and through trees and past buildings. The sky gets them from the
renderer; lit shaders from apply_light_shafts().
Cloud fog. Geometry inside, above or behind the layer fades into it –
a mountain top in a cloud, a valley seen from above the deck, the ground from
an aircraft inside a cloud – through apply_clouds() in the lit shader,
which marches the part of the view segment inside the layer. A surface below
the clouds seen from below them costs one height test. The cloud’s light
reaches the eye through the air in front of it (the aerial perspective at
the cloud’s depth), as the sky’s clouds do, so a cloud over the ground and
the same cloud against the sky match.
Lightning. The weather field’s storm cells throw bolts, which flash the
clouds from inside; CloudRenderer.flash() makes one on demand (a
scripted strike, a sound cue). Rain curtains hang under thick, closed
cells when the preset’s precipitation is up, whiter and softer for snow.
Quality presets¶
The presets live in the configuration (acron/clouds/data/clouds.json):
Preset |
Mode |
Resolution |
March |
Steps |
Taps |
Detail |
Curl |
Shadow |
Shafts |
Fog |
|---|---|---|---|---|---|---|---|---|---|---|
|
panorama |
1024 x 256 |
– |
40 |
3 |
no |
no |
128 |
– |
6 |
|
screen |
1/4 |
1/8 |
28 |
3 |
yes |
no |
256 |
4 |
8 |
|
screen |
1/4 |
1/8 |
40 |
4 |
yes |
yes |
256 |
6 |
12 |
|
screen |
1/2 |
1/4 |
64 |
5 |
yes |
yes |
512 |
12 |
16 |
Resolution is the panorama’s size for low. For the screen modes it is
the resolved buffer’s fraction of the screen, and March is the marched
buffer’s fraction (interleave 4: one texel of each 2 x 2 block a frame;
16 marches one of each 4 x 4 block, a sixteenth of the rays, but converges
more slowly; 1 marches every texel every frame). Steps is the view ray’s
budget (three times finer inside cloud), Taps the cone taps towards the
light, Shadow the cloud shadow map’s texels a side, Shafts the light
shafts’ samples along a ray (shaft_samples) and Fog the cloud fog’s
steps in lit shaders (fog_steps). The screen modes draw a panorama band
(for the probe) every fourth frame and the shadow map every second; low
draws one of its 16 bands every other frame and has no light shafts and no
voxel heroes.
The presets were retuned for the GTX 1660-class budget of the desktop
targets, measured on a GTX 660 (Kepler, about 2.6 times slower) at
1920 x 1080 in the nature_stack sample, fair weather. The march took
24 ms on high and 103 ms on ultra when they resolved at 1/2 and full
resolution; now:
Preset |
March (GPU ms, GTX 660, 1080p, fair) |
|---|---|
|
1.3 for a panorama band, every other frame |
|
6.4 |
|
8.2 |
|
28.5 |
high thus resolves at a quarter of the resolution, as Guerrilla’s Nubis
does, and its edges are softer than the half-resolution figures below.
Before that retune, on macOS, OpenGL 4.1, an Apple M4 Pro at
1920 x 1080 (high at 1/2, ultra at full resolution): The figures are GPU time from timer queries: every cloud pass
plus the main pass, minus the main pass without clouds. Each is the mean of
120 frames, with the camera on the ground looking up into the clouds.
Preset |
Fair |
Overcast |
Storm |
|---|---|---|---|
|
about 0.8 |
– |
– |
|
about 1.3 |
about 1.0 |
about 1.0 |
|
about 3.1 |
about 2.1 |
about 2.4 |
|
about 8.3 |
about 7.1 |
about 6.6 |
(milliseconds). In the earlier version, which marched one texel of each 2 x 2 block of a 1/4-resolution buffer (1/2 at ultra), these were about 1.8 / 1.2 / 1.3 (medium), 2.2 / 1.4 / 1.5 (high) and 6.6 / 4.7 / 4.3 (ultra). That version idled three GPU lanes of every 2 x 2 quad, so it saved little, and its clouds were never finer than their 1/4 buffer. Measured on frame sequences (fair weather, high, with the pipeline) against a full-resolution, 256-step, jitter-averaged reference:
the clouds’ edges went from 0.32 to 0.55 of the reference’s gradient energy, and from 0.19 to 0.42 while panning;
the frame-to-frame noise of a still view fell from 1.7 to 1.0 grey levels, which is about the clouds’ own drift (0.9);
the mean error fell from 15 to 12 grey levels, and from 18 to 13 while panning.
At full resolution (the earlier ultra) the edges reached 0.9 of the
reference’s. The CPU side (stepping the weather, packing its map every
fourth frame, writing the frame values) costs about 0.15 ms a frame.
high is the default; ultra is for screenshots and strong desktop
GPUs.
Weather¶
A WeatherPreset is the cloud numbers of one weather – cover,
type, base_m and top_m (above sea level), extinction (per metre
at full density), precipitation, snow, erosion, lightning
(flashes a minute) – and the acron.sky.Look values that go with it
(haze, humidity, fog, an exposure lift under a deck). They are the
environment’s "weather" layer (The Sky, Day and Night), composed
over the game’s own look: the haze multiplies the game’s, the fog density and
exposure_ev add to the game’s, and humidity and the fog’s falloff replace.
The shipped presets are examples for a game to copy and tune:
Preset |
What it is |
|---|---|
|
no clouds |
|
scattered cumulus |
|
a closed stratiform deck, hazy air |
|
a low, thick deck with rain curtains and ground fog |
|
towering cumulonimbus with anvils, rain and lightning |
|
a low stratus bank and dense ground fog |
|
a closed deck with soft snow curtains |
The shipped names drive the native WeatherField directly: convective cells
that grow, rain and die over minutes, stratus layers, a wind that veers
slowly, all centred on the camera so the weather at the player is exactly the
preset’s and the sky further off keeps some structure. A game’s own preset
(a WeatherPreset the field does not know) is forced everywhere.
CloudRenderer.set_weather() cross-fades either over seconds, and a
new weather in the middle of a fade starts from wherever the last one had
got to.
from acron.clouds import CloudRenderer, WeatherPreset, load_presets
presets = load_presets("data/my_weather.json") # the game's own copy
presets["haar"] = WeatherPreset(cover=0.9, type=0.0, base_m=80.0,
top_m=400.0, look={"fog_density": 0.006})
clouds = CloudRenderer(env, presets=presets, weather="haar")
CloudConfig holds what does not change with the weather: the noise
sizes and tiles, the march’s reach, the light gains, the panorama, the
temporal blend, the shadow map, the fog, the shafts and the quality presets.
It is checked when it loads, so a tile that does not divide its neighbour or
a size that is not a power of two fails there and not as a seam in the sky.
with_overrides() changes a few values:
from acron.clouds import CloudRenderer, default_config
cfg = default_config().with_overrides({"shafts": {"gain": 1.0},
"layer": {"max_distance_km": 30.0}})
clouds = CloudRenderer(env, config=cfg)
At run time, set_fog() and
set_shafts() change the fog’s strength and the shafts’
gain without recompiling anything.
Gameplay queries¶
The renderer’s CPU twin evaluates the same density the shader marches (without the fine detail), from the weather map as last uploaded, so it agrees with what is on screen. Positions are world points; the renderer converts them with the environment’s units:
pos = base.camera.get_pos(render)
if clouds.density_at(pos) > 0.05:
pass # inside a cloud: muffle sounds
sun = clouds.sun_column(pos) # 0..1 of the key light
rain = clouds.precipitation_at(pos) # 0..1 at the ground below
wind = clouds.wind # m/s, world frame
clouds.native is the WeatherField itself, for its storm level,
lightning bolts and the rest (Clouds and Weather). Rain and snow particles are
the game’s: the module gives the amount and the wind to drive them.
Storms and voxel clouds¶
The weather field simulates its weather: thunderstorms grow, rain and decay from the preset’s humidity, instability and wind, and the deepest cells near the camera are drawn as voxel clouds the camera can fly into. See Storms and Voxel Clouds.
Known limitations¶
One cloud layer between the ground and 12 km, on a planet of its own (350 km radius) so that it meets the horizon tens of kilometres away; no cirrus layer above it.
The march reaches 45 km; beyond it the panorama’s horizon takes over.
The clouds drift with the weather field’s wind; the trees and the sky’s
Look.windare a separate setting. A game that wants both to agree setsLook.windfromclouds.wind.The screen-space history needs a few dozen frames to converge after a jump; a screenshot tool should step that many frames first.
Only the main camera gets the screen-space march; every other view (a second camera, the probe) sees the panorama.
Cloud fog and light shafts on geometry need the game’s shader to include them (see Shaders in the Environment); the forest’s shaders do not apply cloud fog yet.
The GPU timings are from macOS; the targets have not been profiled yet.
References¶
Andrew Schneider and Nathan Vos, “The Real-time Volumetric Cloudscapes of Horizon: Zero Dawn”, SIGGRAPH 2015 Advances in Real-Time Rendering, and Schneider’s later Nubis talks (2017, 2022, 2023).
Sébastien Hillaire, “Physically Based Sky, Atmosphere and Cloud Rendering in Frostbite”, SIGGRAPH 2016 Physically Based Shading course.
Magnus Wrenninge et al., “Oz: The Great and Volumetric”, SIGGRAPH 2013 Talks (the multiple-scattering octaves).
Jorge Jimenez, “Next Generation Post Processing in Call of Duty: Advanced Warfare”, SIGGRAPH 2014 (interleaved gradient noise, the five-tap Catmull-Rom history read).