Clouds and Weather

This page describes part of the Python framework that comes in the acron wheel. Its CPU half – the cloud noise and the weather field with its gameplay queries – is native and available to C++ as well: see 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

low

panorama

1024 x 256

–

40

3

no

no

128

–

6

medium

screen

1/4

1/8

28

3

yes

no

256

4

8

high

screen

1/4

1/8

40

4

yes

yes

256

6

12

ultra

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)

low

1.3 for a panorama band, every other frame

medium

6.4

high

8.2

ultra

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

low

about 0.8

–

–

medium

about 1.3

about 1.0

about 1.0

high

about 3.1

about 2.1

about 2.4

ultra

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

clear

no clouds

fair

scattered cumulus

overcast

a closed stratiform deck, hazy air

rain

a low, thick deck with rain curtains and ground fog

storm

towering cumulonimbus with anvils, rain and lightning

fog

a low stratus bank and dense ground fog

snow

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.wind are a separate setting. A game that wants both to agree sets Look.wind from clouds.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).