Skip to content

Clouds and weather

Code: crates/sparklight-world (weather.rs, clouds.rs, sky.rs), crates/sparklight-3d (clouds.rs, clouds_gpu.rs, shaders/clouds.wgsl, weather.rs, the hooks hook_surface and the cloud shadow in shaders/hooks.wgsl), crates/sparklight-particles/src/vfx3d/weather.rs (rain, snow, splash effects), crates/sparklight-scene3d/src/weather.rs (the scene steps the weather and runs the precipitation), crates/sparklight-control/src/commands/space3d/weather.rs (the commands). Tests: crates/sparklight-world/src/weather.rs (unit), crates/sparklight-3d/tests/clouds.rs (GPU), crates/sparklight-3d/src/clouds_gpu.rs (unit: the layout of the cloud parameter block and of the view block against the WGSL, the shadow map sizes, the coverage table), crates/sparklight-control/tests/weather.rs (commands), crates/sparklight-control/tests/weather_goldens.rs (goldens goldens/3d/weather_*.png), crates/sparklight-scene3d/tests/weather_wind.rs (wind on cloth).

Everything is data and commands: a definition, a clock and a seed. The same seed and the same steps give the same weather, bit for bit; the weather is part of the world’s snapshot, saved scene and state hash while it is enabled.

Terminal window
spark call space3d.new '{}'
spark call terrain.create '{"path": "samples/world/world.json"}'
spark call sky.clouds '{"enabled": true}' # the volumetric cloud layer
spark call weather.set '{"enabled": true, "kind": "cloudy"}' # the weather drives clouds, fog, wind, rain
spark call weather.transition '{"kind": "storm", "seconds": 120}' # blend over 120 simulated seconds
spark call space3d.step '{"frames": 3600}' # a minute of simulated time (60 steps per second)
spark call weather.get '{}' # state, ground wetness, snow, hash
spark call weather.events '{}' # thunder: when, how far, seconds until it is heard
spark call sky.cloud_shadow '{"x": 120, "z": -40}' # is that ground point in a cloud's shadow?
spark call space3d.capture '{"eye": [0,60,0], "target": [200,40,150], "tier": "high"}'

weather.presets lists the named states (clear, cloudy, overcast, rain, storm, snow, fog) with their parameters. A state is a WeatherParams:

Field Range Meaning
cloud_coverage 0..1 share of the sky covered (calibrated: 0.5 leaves about half of it clear)
cloud_density 0..4 density multiplier of the clouds
cloud_type 0..1 0 flat stratus, 0.5 stratocumulus, 1 towering cumulus and storm cloud
storminess 0..1 darker, thicker rain clouds
precipitation 0..1 intensity of rain or snow (0.3 drizzle, 0.6 steady, 1 downpour)
snow 0..1 share of the precipitation that is snow (a blend is sleet)
wind_speed, gustiness m/s, 0..1 wind on the ground, and how much it gusts
fog 0..0.05 extra fog extinction per metre (0.0002 haze, 0.001 thick fog)
lightning 0..120 strikes per minute

weather.set {kind, params} jumps; weather.transition {kind, seconds, params} blends every parameter (smoothstep) from the weather now to the target over simulated seconds. Time passes when the scene steps (space3d.step) or when weather.step {seconds} is called. The weather advances in fixed ticks of 1/30 s from the total time stepped, so cutting the same time into other steps gives the same state (tested: one step of 120 s, 480 steps of 0.25 s and 120 steps of 1 s give one hash). WeatherDef (weather.configure): enabled (off by default: a disabled weather does nothing and costs nothing, the sky stays as defined), initial, seed of the lightning, and the seconds the ground takes to get wet (wet_seconds), dry (dry_seconds), fill and drain puddles, and for snow to settle and melt.

What the weather leaves behind (weather.get → ground): wetness rises in rain and falls in dry weather, puddles form only on soaked ground in heavy rain (light drizzle never fills them) and drain slower than the ground dries, snow_cover piles up in snowfall and melts (faster in rain). weather.ground sets them (scenes that start wet or snowy) or clears them.

Lightning is drawn from a hash of the seed and the tick number: when a strike happens, its pulses, its direction in the clouds and its distance (0.8 to 8 km) are fixed by the seed. While a strike lasts the scene gets a cool white directional light from the bolt’s direction and the clouds light up from inside, brightest around the bolt. weather.events hands over thunder events (time, delay = distance / 343 m/s, distance, strength) once, for an audio system to schedule.

sky.clouds {enabled, definition} edits atmosphere.clouds (CloudDef): base_altitude (1800 m) and thickness (2200 m) of the layer, coverage, density, cloud_type (used while the weather is off; the weather sets them otherwise), extinction, noise scales (shape_scale, detail_scale, weather_scale), detail_strength, silver_lining, multi_scatter, powder, anisotropy, max_distance, shadows, shadow_strength, shadow_extent, temporal, light_shafts, seed. Off by default.

How a frame is made (clouds_gpu.rs, shaders/clouds.wgsl; the same density function is on the CPU in sparklight_3d::clouds):

  1. Shape: a shell between two spheres around the planet (its centre follows the camera, the noise is looked up by world position so the clouds stay put). Density comes from a tiled weather map (coverage and type noise, scrolled by the wind), a 64^3 Perlin-Worley shape volume and a 32^3 Worley detail volume (generated on the CPU from seed with integer hashes: identical on every machine), shaped by a height gradient of the cloud type.
  2. Light: every ray step marches towards the sun, with a multiple scattering approximation (octaves with halved extinction, scattering and anisotropy), a dual-lobe phase function with the silver lining lobe, the powder darkening of thin edges, a diffusion term for thick storm cloud, ambient light from the sky above and the ground below, and the flash of lightning. The sun’s colour is what reaches the clouds’ height, so high cloud turns orange before the ground does at dusk.
  3. Cost control: the trace is done at a quarter of the pixels (high and ultra; low and medium trace fewer) with one jittered sub-pixel per frame, and blended with the reprojected previous frame (temporal reprojection: rotation reprojection with a rejection of large changes; a camera cut reset_cloud_history drops it). A still camera converges, a moving one does not ghost (tests: the followed history differs from a fresh render of the same view by 0.17/255 mean).
  4. Aerial perspective: the clouds are hazed towards the sky colour by the atmosphere’s own extinction over their distance, so far clouds melt into the horizon.
  5. Composite: over the sky pixels of the HDR image only (geometry in front is never overdrawn), with the light shafts added over the whole image: sky pixels see the sun through gaps in the clouds, objects block it.
  6. Shadows: a top-down cloud shadow map (transmittance of the sun’s light, 128 x 128 one-byte samples around the camera, snapped to texels) is drawn into a small texture and copied into a 16 KB uniform buffer (view binding 22, so the surface shaders spend no sampled texture on it: the built-in PBR shader binds 15 of 16 and 7 of 8 storage buffers, tested in hooks_shaders.rs); the renderer’s hook reads it with a bilinear filter by hand and multiplies the sun’s shadow visibility with it for every surface (r3d_cloud_shadow_at), with a fade at its edge. sky.cloud_shadow {x, z} answers the same question on the CPU. With the clouds off the sun is dimmed as a whole by the weather’s overcast instead.

Quality tiers (the tier of the world, QualityTier low..ultra, sets CloudQuality):

Tier Traced image (1080p frame) Primary steps Light steps Octaves Shadow march steps
low 480x270 20 3 2 6
medium 672x378 32 4 3 8
high 960x540 48 6 4 10
ultra 960x540 80 8 4 14

Measured cost at 1920x1080 on an Apple M5 Max (Metal), the cloud passes’ own GPU time (shadow, trace, composite), best frame of 180 in three rounds through the tiers on a machine that other jobs were using (the median of the best round is in brackets); coverage 0.55, high renderer tier otherwise. Reproduce with cargo test -p sparklight-3d --release --test clouds every_tier -- --nocapture (.gwdev/out/3d/clouds_cost.txt):

Cloud tier Own passes Frame GPU span without -> with clouds
low 1.55 ms (2.38) 0.34 -> 1.37 ms
medium 1.08 ms (1.37) 0.46 -> 1.02 ms
high 2.20 ms (2.80) 0.40 -> 1.59 ms
ultra 3.10 ms (3.93) 0.39 -> 2.00 ms

The passes overlap on a tile GPU, so their sum can exceed the frame span’s difference; the frame spans are of an empty scene (a ground plane). Numbers from a machine under load vary by about a millisecond from run to run (the low tier measured 1.2 to 1.6 ms in two runs, and slower than medium in one: the traced image is smaller but the first tier measured pays for the GPU clocking up).

One shader hook, hook_surface(si, s) of sparklight::hooks (library default: the surface unchanged), runs after every material’s surface. The renderer’s implementation (shaders/hooks.wgsl) reads RenderSettings::weather (WeatherSettings: wetness, puddles, snow_cover, rain; the world fills it from the weather) and changes the surface; a dry frame returns it unchanged at the cost of one branch.

  • Wet: the albedo darkens (a power of the colour and a factor, less on metal) and the roughness drops, patchy with a noise in world space.
  • Puddles: where a low-frequency noise falls below a threshold that grows with puddles, on faces that are nearly flat: a very smooth dark mirror (roughness 0.025, index of refraction 1.33), the normal flattened and rippled while it rains (rings that spread from random points). The reflections in a puddle are the renderer’s own: screen-space reflections and reflection probes over the sky.
  • Snow: on faces that point up (a noisy edge), the albedo goes to snow white, the roughness to 0.78, metal and clear coat go; more snow_cover reaches steeper faces.

Space3D::weather_step (every fixed step, with the open world) keeps up to three effects that follow the stream focus: weather_rain (a GPU-simulated down-draught of streaks that collide with the depth buffer, so they stop on roofs and bonnets), weather_snow (GPU flakes with a curl turbulence) and weather_splash (rings on the ground at the focus’ ground height); effect presets are in vfx.preset as weather_*. Emission scales with the intensity of rain or snowfall (the essential floor of 0.75 of the authored emission holds against the scene’s vfx.quality only, never against the intensity: a drizzle of 0.3 emits 30 %), rain leans with the wind (atan(wind / 15 m/s)), effects stop when the intensity falls below 2 %. Wind: the weather’s wind speed becomes the foliage’s wind strength and speed (World::effective_wind, written into the grass, tree and bush materials by FoliageGpu::set_wind when it changes) the cloth’s wind (Space3D::weather_wind, added to each cloth’s own wind) and the clouds’ drift (1.6 times the ground wind). Thunder: weather.events hands over up to 64 thunder events since the last call; a storm that is not drained for several minutes drops the rest, counts them (WeatherState::dropped_events) and logs one warning. Lightning adds a directional light (Space3D::weather_lights, peak 5.0 illuminance) and lights the clouds from inside.

weather.get, weather.presets, weather.set, weather.transition, weather.step, weather.configure, weather.ground, weather.events, sky.get, sky.set, sky.clouds, sky.cloud_shadow (schemas: spark commands, schemas/commands/; MCP tools weather_* and sky_*). Errors: E_WORLD_NONE (no open world), E_WEATHER_KIND (not a named state), E_WEATHER_PARAM (a parameter out of range or unknown: the path names it), E_WEATHER_DEF (an invalid weather definition), E_WORLD_ATMOSPHERE (an invalid cloud or atmosphere field).

goldens/3d/weather_clear_noon.png, weather_overcast.png, weather_sunset_clouds.png, weather_storm_wet_road.png, weather_snow.png: rewritten with SPARKLIGHT_UPDATE_GOLDENS=1 cargo test -p sparklight-control --release --test weather_goldens on a Mac with Metal, and looked at before committing. Each test also asserts numbers about the picture (blue sky above the horizon in clear noon, a darker grey sky and ground in overcast, orange clouds at sunset, wet road darker than dry, snow brighter than grass).