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.
Five-minute workflow
Section titled “Five-minute workflow”spark call space3d.new '{}'spark call terrain.create '{"path": "samples/world/world.json"}'spark call sky.clouds '{"enabled": true}' # the volumetric cloud layerspark call weather.set '{"enabled": true, "kind": "cloudy"}' # the weather drives clouds, fog, wind, rainspark call weather.transition '{"kind": "storm", "seconds": 120}' # blend over 120 simulated secondsspark call space3d.step '{"frames": 3600}' # a minute of simulated time (60 steps per second)spark call weather.get '{}' # state, ground wetness, snow, hashspark call weather.events '{}' # thunder: when, how far, seconds until it is heardspark 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 states and parameters
Section titled “Weather states and parameters”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.
Volumetric clouds
Section titled “Volumetric clouds”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):
- 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
seedwith integer hashes: identical on every machine), shaped by a height gradient of the cloud type. - 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.
- 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_historydrops 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). - 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.
- 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.
- 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).
Wet surfaces, puddles and snow
Section titled “Wet surfaces, puddles and snow”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_coverreaches steeper faces.
Precipitation, wind, lightning light
Section titled “Precipitation, wind, lightning light”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.
Commands
Section titled “Commands”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
Section titled “Goldens”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).