Atmosphere and weather
This file covers the weather and atmosphere domain: the superseded synoptic/regional simulation tier and the provider seam it introduced, the world coupling and data-ingestion phases built on it, and the spatial weather field, cloudscape window, and GPU regional nest that replace its core.
1. Weather simulation (T1 synoptic layer and T2 regional grid, phases W4-W6)
Superseded. This section documents what is currently shipped. The design it was built from (
docs/design/weather_and_clouds.md) has been retired — removed, in git history — and replaced bydocs/design/ATMOSPHERE_SYSTEM/README.md, whose §1 audits why the W4-W6 simulation tier does not deliver what it claims (chiefly: the meteorology never reaches the sky spatially, becauseextract()samples one column and compiles it into a globally uniformCloudscapeover tiled noise). This section and its world coupling and data ingestion subsections below are replaced tier by tier as that plan’s phases land; the render tier survives it largely intact. Phase A has landed — see the spatial weather field. The single-column bridge described below still runs (it is what compiles the deck stack), but it is no longer the only thing the renderer hears from the simulation, and the column it samples is now taken at the camera rather than at the scene’s geodetic anchor.
The retired plan split weather into four tiers — T1 synoptic, T2 regional, T3 cloudscape compile,
T4 render — with one rule: the sim tiers (T1/T2) live in the sim domain under SushiLoop’s
determinism discipline (fixed tick, seeded RNG, serialized, replayable, no wall-clock or
render-side input), and T3/T4 are render-side and free to be nondeterministic. T3
(CloudscapeCompilePass, see
the cloud pass) and T4 (the
cloud render pipeline) predate this section and are untouched by it; this section documents the new
sim-domain half and the seam that connects it.
T1 — the global dynamical core
(engine/domain/atmosphere/include/SushiEngine/atmosphere/quasigeostrophic_core.hpp,
Atmosphere::QuasiGeostrophicCore) is a two-layer moist quasi-geostrophic model on a 512x256
latitude/longitude grid. Potential vorticity and column water are the only prognostic variables;
wind, pressure, vertical motion, fronts and the jet are all diagnosed from them. Cyclogenesis is
emergent — the initial state is a zonal jet plus a perturbation of a hundredth of its speed, and
what grows is whatever the mean state is unstable to, measured at 0.254/day. One step costs
~36 ms of one core and is due once per six minutes of game time, so advance() carries the
remainder itself and is capped at eight steps per call, which means one call covers at most 48
minutes of game time.
This replaced SynopticLayer, an analytic layer of up to eight moving elliptical Gaussian pressure
systems with life-cycle timers and stylized fixed-angle front rays. It was never a simulation and
did not claim to be: it translated authored shapes, so nothing could form, deepen or decay that had
not been placed. docs/design/ATMOSPHERE_SYSTEM/README.md §1 records the audit and §11’s Phase C
the swap. The state that used to be a trivially-copyable SynopticState is now megabytes of field,
which is why the editor persists it to a binary sidecar beside the scene rather than into the scene
JSON.
T2 — the regional grid (regional_weather_grid.hpp, since retired — see
the regional nest;
RegionalWeatherGrid) is a camera(observer)-centered grid of columns — wind, temperature offset,
humidity, cloud water, convective fraction, precipitation — over three vertical levels
(Simulation::CloudLevel::Low/Mid/High, matching Render::CloudGenus’s own WMO étage grouping
rather than the finer profile nothing downstream can consume yet).
It ticks on its own nested Loop::FixedTimestepClock (15 s default), driven only by the
caller-supplied dt, never wall-clock: semi-Lagrangian advection of humidity/temperature/
cloud-water by T1’s wind (backtraces each cell to its departure point and bilinearly samples the
prior tick’s field — unconditionally stable at any dt, unlike naive forward-difference
advection), orographic lift (wired through a pluggable terrain-height sampler that defaults to
flat, since no terrain height field exists anywhere in the engine yet — see the CHANGELOG for the
audit), diurnal convection (a standalone solar-position estimate feeding a CAPE proxy), and a
condensation/precipitation/evaporation moisture closure.
Grid cells address an absolute equirectangular tangent-plane lattice (anchored at lat 0/lon 0)
rather than one relative to the grid’s own moving origin — the same floating-origin discipline
engine/presentation/render/source/scene/shadow_uniforms.cpp’s cascade snap already uses (see
shadows): floor to a lattice coordinate that does not
itself depend on where the camera currently is, so a rebase (triggered once the observer drifts a
whole cell width from center) never introduces drift or a visible seam. Cells scrolled in during a
rebase are filled from a deterministic background-climatology function of position, never left
uninitialized and never drawn from the RNG. Samples are linearly blended between the last two
ticks by the nested clock’s own interpolation fraction, so a consumer sampling every render frame
sees continuous motion between the grid’s much coarser ticks.
The Simulation::IWeatherProvider seam
(engine/world/simulation/include/SushiEngine/simulation/weather_types.hpp,
engine/world/simulation/include/SushiEngine/simulation/weather_provider.hpp,
engine/world/simulation/include/SushiEngine/simulation/weather_cloudscape_compiler.hpp) is where
DIP happens: a provider’s entire contract is WeatherColumn sample_column(GeodeticPosition) —
coverage/density/convective-mix per level plus surface wind/precipitation — and nothing about
pressure systems, grid cells, or RNGs crosses it. ProceduralWeather wraps T1+T2 behind it;
StaticWeather formalizes the pre-existing manual Cloudscape deck authoring as an equally
legitimate provider (bucketing each enabled deck’s genus into its WMO étage), so the renderer
genuinely cannot tell a hand-authored sky from a procedural one — the LSP property the design doc’s
§9 calls for. IngestedWeather
(engine/world/simulation/include/SushiEngine/simulation/ingested_weather.hpp, W6, see data
ingestion) implements the same interface
from GRIB/METAR-sourced data without this seam changing.
WeatherCloudscapeCompiler is the separate, stateless, pure-function class that turns any
provider’s WeatherColumn into a Render::Cloudscape — picking a genus per level from its
coverage/convective mix and reserving a fourth deck slot for Cumulonimbus when the low level is
both filled and strongly convective (the “cumulus line” at a cold front). RuntimeSimulation
wires all of this at exactly two points, both through its WeatherDirector
(engine/world/simulation/source/runtime/weather_director.hpp): step_once() ticks the installed
provider, and WeatherDirector::prepare_frame, run before each snapshot build, writes the compiled
Cloudscape into Environment::clouds — the identical write
path manual authoring already used, so CloudscapeCompilePass (T3) and the entire render tier
needed zero changes to consume procedurally-driven weather.
Which provider is installed is a mode, and it used to be a boolean that meant something else.
ISimulation::weather_mode names Manual or Procedural; the predicate it replaced was
static_cast<bool>(weather_provider_), which made the absence of a provider the definition of
Manual. That is not a naming quibble: with no provider there is no published WeatherField, so
one hand-authored deck stack was applied to every square metre of the body and a planet seen from
orbit was uniformly overcast. Both modes install a provider now, and the distinction is where the
sky comes from — placed or grown:
-
SeededWeather(engine/world/simulation/include/SushiEngine/simulation/seeded_weather.hpp) places it.Atmosphere::SynopticField(engine/domain/atmosphere/include/SushiEngine/atmosphere/synoptic_field.hpp) puts a dozen signed Gaussian pressure centres on a zonal cloud climatology from a 64-bit seed; a low raises coverage and convects, a high drives it to zero. Deterministic (SplitMix64, not a standard-library distribution, whose bit-to-value mapping is implementation-defined), defined at every point on the body, and evolving in nothing but the ITCZ’s seasonal migration — which is the correct behaviour for a sky an author chose rather than a limitation of one. -
ProceduralWeathergrows it, in the quasi-geostrophic core and the GPU nest.
StaticWeather — the uniform decomposition of an authored deck stack — remains in
engine/world/simulation/include/SushiEngine/simulation/weather_provider.hpp as the honest field
form of a provider with no horizontal structure, and is what an embedder wanting exactly that
installs.
The planetary answer travels separately from the local one, and has to.
IWeatherProvider::publish_field fills a lattice a few hundred kilometres across, which is
everything a baked cloudscape window can see and nothing a camera in orbit can: at planetary
distance one texel of a 64-cell global grid is six hundred kilometres.
IWeatherProvider::synoptic_field is the second channel — the placement itself, in closed form —
which RuntimeSimulation::publish_synoptic_field rotates from geographic into scene space through
Environment::planet_body_axes and publishes as Render::SynopticFieldView
(engine/domain/environment/include/SushiEngine/environment/synoptic_field.hpp), twelve
directions by value.
engine/presentation/render/shaders/cloud_globe_field.glsl’s planet-scale globe field evaluates the
same function from the same centres (engine/presentation/render/shaders/synoptic_field.glsl),
so the baked windows and the field they fade into cannot disagree about where the weather is —
disagreement would show as a seam at the far window’s rim. Null is the honest answer for a provider
whose solution is regional, and leaves the renderer on the zonal climatology alone, which remains
true of every body with an atmosphere.
Determinism proof. tests/integration/test_weather_determinism.cpp was the weather-domain
analogue of tests/integration/test_deterministic_replay.cpp (see SushiLoop
core) and tests/integration/test_particle_determinism.cpp (see the
particle system): two independently constructed ProceduralWeather instances,
seeded identically and ticked through the same fixed-step/julian-date stream, must reach
byte-identical SynopticState and sampled WeatherColumn/Cloudscape state. A second suite proves
the visible half of the acceptance bar — a hand-placed low’s cold front measurably sweeps past a
fixed point over simulated hours and the compiled Cloudscape reacts at the moment it does.
Editor integration. The Environment panel’s Clouds section
(applications/editor/source/environment/weather_panel.cpp) carries a Manual/Procedural mode
radio, a synoptic map overlay drawn from live T1 state with a Pressure/Fronts field switch, a
time-of-day scrub tied to the master epoch, and a mean-state readout. Manual mode offers a
weather seed and a Reroll button; Procedural mode replaces those with the preset row, whose
buttons inject an anomaly into the global flow rather than setting deck parameters, and adds an
Inject Anomaly control. There are no per-system depth/radius/speed/heading sliders: those are
quantities the core derives, so the panel offers a click on the map instead.
Scene serialization (engine/world/serialization/source/scene_serializer.cpp’s
weather_to_json/weather_from_json) writes the mode by name and the weather seed into the
scene JSON, and the core’s own state through capture_state/restore_state into a binary
.atmos sidecar beside it — there is no system list to serialize, because the core holds fields
rather than placed systems. T2’s regional grid is not captured at all: it reseeds from the
restored parent solution and the current observer on the next tick.
Deliberately out of W4’s scope, and unaffected by it: fog/turbidity coupling, precipitation
particles, wet surfaces, lightning, a weather_wind() gameplay API, and real-data ingestion (the
retired plan’s §5.3/W5/W6) — the IWeatherProvider seam this phase introduces is what lets those
land later without revisiting T1, T2, or the bridge. W5, below, is that “later”.
1.1. World coupling (phase W5)
The retired plan’s §5.3 (“coupling weather -> world (beyond clouds)”), with its §7 acceptance bar:
“rain falls from the cell that is raining, under a dark base, with wet ground and reduced
visibility — one cause, every symptom.” The whole phase turns on that last clause: every symptom
below is derived from the same WeatherColumn sample WeatherDirector::prepare_frame already
takes to compile the Cloudscape
(above), not four
independently tuned systems that happen to agree.
Simulation::WeatherWorldCoupling
(engine/world/simulation/include/SushiEngine/simulation/weather_world_coupling.hpp) is
WeatherCloudscapeCompiler’s sibling: a separate, stateless, pure-function class that compiles
the identical WeatherColumn into Render::WeatherCoupling
(engine/domain/environment/include/SushiEngine/environment/environment.hpp) — extra froxel-fog
density, extra atmosphere Mie turbidity, ground wetness, a precipitation-intensity echo, and a
near-surface wind passthrough. prepare_frame samples one column, hands it to both compilers, and
writes the results into Environment::clouds and Environment::weather respectively.
WeatherCoupling is deliberately additive/multiplicative rather than a replacement:
VolumetricFogPass and AtmosphereLUTPass add its bias fields onto the author’s own
FogParameters::density/AtmosphereScatteringParameters::mie_coefficient at push-constant build
time, so a scene with weather off renders exactly as it did before this phase (every field
defaults to zero), and the author’s fog/atmosphere sliders are never overwritten in place the way
Environment::clouds is — which is what keeps this field safe to recompute from scratch every
frame without the read-modify-write hazard a full overwrite would create through the
editor’s environment() -> edit -> set_environment() round trip. AtmosphereLUTPass’s existing
medium_changed() memcmp gate already rebuilds the static transmittance/multi-scatter LUTs
exactly when the weather-adjusted Mie coefficient changes tick to tick — “runtime-dynamic in the
Hillaire model” (the design doc’s §5.3) needed no new plumbing.
Cloud-base darkening is the design doc’s literal recipe (density += density * weather.a)
applied inside WeatherCloudscapeCompiler::compile() itself, on the low deck only (the band T2’s
moisture closure actually rains from) — before CloudscapeCompilePass’s bake ever sees the deck,
so the bake’s own contract is untouched.
Wet surfaces follow the exact MaterialFlags pattern MATERIAL_PARALLAX_SHADOWS already
established: Render::Material::weather_wettable (off by default, like parallax_shadows) packs
to a new Assets::MATERIAL_WEATHER_WET (MaterialFlags) bit in
engine/presentation/render/source/material/material_system.cpp;
engine/presentation/render/shaders/pbr.frag darkens albedo and drops roughness toward a thin
water film only where that bit is set, scaled by a global wetness read from
scene.light_shadow_b.y — otherwise-spare UBO space (see
engine/presentation/render/source/scene/scene_uniforms.hpp’s field doc) rather than growing
SceneBlock, since every shader that already declares light_shadow_b (it carries a secondary
caster’s shadow index) needed no binding changes. create_terrain() opts its own material in by
default so the acceptance scenario shows wet ground without extra authoring; every other material
opts in explicitly.
Precipitation VFX: WeatherDirector::prepare_frame builds one synthetic, sim-owned
VFX::CompiledEmitter (its rain_ CosmeticEmitter) each frame precipitation is active, and
appends a Render::ParticleEmitterView for it straight into RenderScene::particle_emitters — the
same seam an authored particle-emitter entity’s Cosmetic sub-emitters already write through (see
scene emitters), just sourced from weather instead
of a record. Not an authored ECS entity: that would clutter the Hierarchy/Outliner and the scene
file with a system-generated object. Uses the GPU cosmetic path per
QualityParameters::gpu_particles’s own doc (“the deterministic CPU particle path is unaffected; it
is gameplay, not a quality knob”) — ambient weather rain is squarely cosmetic. Rain only:
WeatherColumn carries no temperature signal, so there is no honest basis to pick snow over rain (a
named scope-down, not a fabricated phase test).
Simulation::weather_wind(weather, position, altitude, time)
(engine/world/simulation/include/SushiEngine/simulation/weather_wind.hpp) is the design doc’s
“one sampling API… GoT pattern: analytic + perturbation, no dense volume”. The base half is
IWeatherProvider::wind_at (altitude-parameterized via level_fraction); this file adds only the
position/altitude -> level_fraction mapping and a deterministic, stateless pseudo-noise
perturbation. That perturbation used to be scaled by the distance to a drawn front; since Phase
C it is scaled by IWeatherProvider::frontal_strength_at, the thermal gradient the flow has
actually concentrated (5 K/100 km being a strong front), because a dynamical core draws nothing.
A free function over const IWeatherProvider&, not a new virtual — the provider already answers
everything it needs, and adding a virtual would widen the seam every provider must satisfy for
arithmetic none of them differ on. The concrete W5 consumer is the rain emitter’s lateral drift
(fed through the emitter’s gravity module, scaled well down since rain falls in ~2 s); the
CPU-deterministic particle path is named, unbuilt follow-up scope below.
Scoped down, named rather than silent:
-
Dew-point spread, the design doc’s literal fog driver, is approximated by the low band’s cloud coverage/density (itself derived from condensed water past T2’s relative-humidity threshold) rather than adding a humidity field to
WeatherColumn’s contract for the one consumer that would use it —engine/world/simulation/include/SushiEngine/simulation/weather_world_coupling.hpp’s file docs name this explicitly. -
Valley/orographic fog is not implemented: W4 already left orographic lift wired but inert (no terrain-height field exists anywhere in the engine), so there is no real per-position lift signal to drive a valley-fog term with; fabricating one would be exactly the “invented signal” this phase’s own instructions warn against.
-
CAPE and terrain roughness are not part of
weather_wind()’s turbulence term. CAPE is an internal, per-tick intermediate insideRegionalWeatherGrid::tick_grid, not part ofWeatherColumn’s stable sampled contract; terrain roughness has no source anywhere in the engine (the same gap the orographic-lift note documents). -
The CPU-deterministic particle path does not consume
weather_wind(). The physics solver’s only per-body external-acceleration channel today isGravitySampler(make_gravity_sampler()), which applies uniformly to every rigid body for orbital gravity summation — repurposing it for wind would apply wind-strength acceleration to every rigid body regardless of shape, and a distinct wind-force channel for particles is real physics-solver surgery, not an additive extension. Named as a deferral, not silently dropped. -
No dedicated flight model exists in the engine yet, so
weather_wind()’s “the flight-sim payoff” half of the design doc’s ask has no consumer to wire into; the function itself is the extension point a future flight model would call. -
Lightning is not implemented this phase. It is the one §5.3 sub-bullet not part of the design doc’s §7 acceptance bar’s four required symptoms (rain, dark base, wet ground, reduced visibility), and safely wiring a transient flash into
CloudLightVolumePass’s amortized, slowly-refreshing bake (or its named simpler alternative, a direct additive term inengine/presentation/render/shaders/cloud.frag) is real, standalone render-tier work better done as its own follow-up than rushed alongside everything above. -
Ground wetness has no soak-in/dry-out lag.
WeatherWorldCoupling::compileis a pure function of the current column, so wetness tracks precipitation instantaneously; a puddle-persistence model is real, unbuilt follow-up scope.
Tests. tests/unit/test_weather_world_coupling.cpp (Unit_WeatherWorldCoupling,
Unit_WeatherCloudscapeCompiler, Unit_WeatherWind) exercises the pure functions in isolation
from T1/T2’s own tick: a clear column compiles to an all-zero WeatherCoupling; a rained-out
column raises fog/turbidity/wetness together and monotonically with more rain; ground_wetness
stays clamped; precipitation darkens only the low cloud deck; and weather_wind() reduces to the
bare analytic field far from any front and gusts more strongly near one (a front-relative point
solved analytically from front_proximity’s own formula, not guessed).
1.2. Flight-sim polish and data ingestion (phase W6, final)
The retired plan’s §7 W6 phase is marked “tier/optional” and names five items: in-cloud whiteout
tuning, canopy wisp particles, icing/turbulence exposure to gameplay, hero envelope assets near
airfields, and IngestedWeather (GRIB/METAR). This is the last phase of the W0-W6 roadmap; it
lands four of the five with real value and scopes the fifth out entirely, named below rather than
left unstated.
In-cloud whiteout tuning (engine/presentation/render/shaders/cloud.frag) is a
self-contained shader change: past a transmittance threshold
(smoothstep(0.02, 0.25, transmittance)), the accumulated scattered colour blends toward a glow
keyed on the view/sun angle mu — the residual forward bias real multiple scattering keeps even
as it isotropizes with depth — instead of the flat, direction-blind tone the march’s existing
Beer-decayed sun term and constant ambient term produce on their own once deep inside a thick
deck. No push-constant, MRT, or pass signature changed.
Canopy wisp particles follow the exact precedent WeatherWorldCoupling’s rain emitter set at
W5: a second synthetic, sim-owned VFX::CompiledEmitter (WeatherDirector’s wisp_
CosmeticEmitter) built fresh each frame by prepare_frame and appended to
RenderScene::particle_emitters, not an authored ECS entity. Unlike the rain emitter it is not
gated on precipitation or even on ProceduralWeather being active — it reacts to whichever
Cloudscape is currently live in scene_.environment.clouds, Manual or Procedural, gated purely by
the camera sitting within 60 m of an enabled deck’s own base_altitude/top_altitude
(Render::cloud_genus_profile) with at least 25% effective coverage — the design doc’s “canopy/base
boundary… close flythroughs” read literally as a camera-proximity trigger, not a weather-state
one.
Simulation::icing_risk(column, altitude) /
turbulence_intensity(synoptic, position, altitude, time) (new:
engine/world/simulation/include/SushiEngine/simulation/weather_flight_hazards.hpp) are two
small, stateless, pure query functions — explicitly not a flight model, following W4/W5’s own
established call on this exact ask: no flight/aircraft/vehicle system exists anywhere in the
engine, so these are “the extension point a future flight model would call”, the identical framing
weather_wind()’s own doc already used.
icing_risk needed a temperature signal WeatherColumn never carried; rather than invent one, a
new WeatherLevelState::temperature_offset_c field
(engine/world/simulation/include/SushiEngine/simulation/weather_types.hpp) surfaces state T2 has
tracked internally since W4 (WeatherCell::temperature_offset_c) but never had a consumer for,
combined with a standard-atmosphere lapse rate at the query altitude into a trapezoidal risk curve
peaking in the classic supercooled-liquid band and weighted by the same liquid-water proxy
engine/world/simulation/include/SushiEngine/simulation/weather_world_coupling.hpp already uses.
turbulence_intensity wraps a new wind_gust()
(engine/world/simulation/include/SushiEngine/simulation/weather_wind.hpp, factored out of
weather_wind(), which is now exactly wind_at() + wind_gust()), normalizing the same
front-proximity-scaled perturbation gust magnitude W5 already computes into a bounded [0, 1]
scalar.
IngestedWeather (new:
engine/world/simulation/include/SushiEngine/simulation/ingested_weather.hpp) is design doc
§5.4’s real-data IWeatherProvider: a caller-supplied background column blended toward the
nearest of zero or more added METAR stations — pure background beyond 60 km of every station, pure
station inside 15 km, linearly blended between. GRIB2 binary decoding is named future work, not
fabricated: set_background(WeatherColumn) is the stub seam a real decoder would eventually fill.
METAR, by contrast, is fully decoded this phase by a real, working parser (new:
engine/world/simulation/include/SushiEngine/simulation/metar_parser.hpp,
parse_metar()/metar_to_weather_column()) — a tolerant whitespace-token scanner covering wind
(calm/variable/gust), cloud layers bucketed into a CloudLevel by a new shared
cloud_level_for_altitude() helper
(engine/world/simulation/include/SushiEngine/simulation/weather_types.hpp), temperature/dewpoint,
and present-weather-derived precipitation intensity, narrowed to exactly what WeatherColumn can
use rather than a general METAR decode.
The phase’s key deliverable regardless of how much real decoding it contains: IngestedWeather is
a genuine, LSP-substitutable IWeatherProvider — WeatherCloudscapeCompiler and
WeatherWorldCoupling accept it with zero changes, proven by
tests/unit/test_ingested_weather.cpp’s
SubstitutesForAnyOtherProviderThroughTheSharedCompilers. It is not wired into
RuntimeSimulation’s live procedural-weather slot or the editor this phase — there is no real
GRIB/METAR data source feeding it yet, so wiring an unused seam into the live sim would be surface
area with nothing behind it.
Scoped down, named rather than silent:
-
Hero envelope assets near airfields (Ultra tier) are not implemented, and no infrastructure for them was added either. The design doc frames this as hand-placed NVDF-style hero cloud volumes at a small number of showcase airfields — real art-directed content. This engine has no airfield/POI placement system and no art pipeline to author such a volume from; a genuine implementation needs a new T3 bake target and an LOD/blend seam into the existing field, real architecture work with no data behind it yet. Landing a placement-only data structure with no renderer consumer would not honestly earn the “hero” name the design doc uses, so this is scoped out entirely rather than partially stubbed.
-
METAR station elevation is not modelled; a report’s temperature only ever informs the
LowCloudLevelbucket rather than being placed at a real station altitude, since this engine has no airport-elevation database to place it more precisely with. -
IngestedWeatherowns no clock and ticks nothing, unlikeProceduralWeather— a real-data provider is a snapshot the caller refreshes by re-callingset_background/add_station, not a fixed-step simulation.
Tests. tests/unit/test_weather_flight_hazards.cpp (Unit_WeatherFlightHazards),
tests/unit/test_metar_parser.cpp (Unit_MetarParser), and
tests/unit/test_ingested_weather.cpp (Unit_IngestedWeather) exercise the hazard functions,
the parser against realistic report strings, and the provider’s blend plus its LSP
substitutability through the shared compilers, respectively.
1.3. The spatial weather field (atmosphere phase A)
docs/design/ATMOSPHERE_SYSTEM/README.md §7. Everything above this subsection describes a bridge
that hands the renderer one column, which is why nothing the simulation computed could be seen
as spatial structure: the sky’s visible pattern came from a static tiled fBm map, modulated only by
that column’s global bias. This subsection is the second channel — the simulation’s own horizontal
grid, addressed in world space and read by the cloud march per sample.
The data. Render::WeatherField
(engine/domain/environment/include/SushiEngine/environment/weather_field.hpp) is a borrowed
view — cells_x × cells_z × level_count samples of
coverage/density/convective-fraction/precipitation, plus a scale-and-offset from scene-absolute
world XZ to field UV, the three band centre altitudes, whether the renderer may resolve genus from
it (see
derived genus),
and the altitude span the genera it resolves to occupy. Borrowed rather than copied because
Environment is copied per frame while the payload changes on a multi-second cadence; the sim owns
the storage (Simulation::WeatherFieldBuffer) and revision is how the renderer notices a change.
Who fills it. IWeatherProvider::publish_field, on the interface rather than behind a
capability query because every implementation can answer it truthfully: ProceduralWeather
publishes its grid, IngestedWeather samples its own station blend onto a lattice (its structure
is real but implicit in its query), and StaticWeather publishes one column everywhere, which is
the whole truth about a manually authored sky.
The scene mapping, stated once in
engine/world/simulation/include/SushiEngine/simulation/weather_field_buffer.hpp and shared by
RuntimeSimulation::geodetic_at_scene: the scene is a flat tangent patch anchored at the sky
observer’s geodetic position, with +X east and +Z north. Not a new convention — the rain
emitter’s wind drift already drove WindSample::eastward_mps/northward_mps into exactly those
axes. Eastward metres carry a 1/cos(latitude) factor because the simulation grid’s lattice is
plate carrée (x = R · longitude).
Transport. WeatherFieldPass
(engine/presentation/render/source/passes/weather_field_pass.{hpp,cpp}) uploads the field into a
fixed 64×64×3 R8G8B8A8_UNORM 3-D image — 3-D rather than a 2-D array so hardware trilinear
blends the three vertical bands as a march sample climbs through them, in the same single fetch the
horizontal interpolation already costs. The image is always the full square and coarser producers
are resampled up on the host, so its view and descriptor are fixed for the renderer’s lifetime;
staging is a three-buffer ring indexed by the frame’s own slot, and the image is cleared once so a
scene that never publishes weather still binds a defined resource. It registers before the cloud
bakes that read it.
Consumption. Phase A read the field per march sample, turning its coverage into a scale about
the column the deck stack was compiled from and applying it as a re-threshold. That mechanism is
gone (see
the window):
the field is now an input to the cloudscape bake, which resolves coverage and genus per baked
column, so the march reads the answer rather than the meteorology and the light volume and the shadow
map inherit the same answer instead of each needing their own lookup. The scene
uniforms carrying the field’s addressing ride the block’s tail
(engine/presentation/render/shaders/scene_weather_tail.glsl, included by every shader that needs
to reach them), with the camera position folded into the map’s offset in double.
The column is now sampled at the camera, not at the scene’s geodetic anchor — the same point while the weather was uniform, not the same point now. The deck stack, the fog, the wetness, and the rain therefore all describe where the player stands.
Phase A deferred three items together on the record — the field stayed a wrapping tile, so the light volume and the cloud shadow map could not consult it and genus could not become a derived label. The cloudscape window is that one change.
Substitutability, repaired. ISimulation used to return a concrete ProceduralWeather* and
the host stored that type, which is why IngestedWeather was written, tested, and uninstallable.
The host now holds std::unique_ptr<IWeatherProvider> through a single install_weather_provider,
IWeatherProvider::tick lets it advance time without knowing what it installed, and the editor
reaches authoring through the optional IWeatherAuthoring capability (weather_authoring()),
which a provider fed by real observations simply does not offer.
tests/unit/test_weather_field.cpp (Unit_WeatherField) defends the claim that matters — a
published field is non-uniform when the simulation is — plus the addressing, every provider’s
ability to publish, and (since the window phase) the producer’s own genus authority. The acceptance
bar has not yet been confirmed by eye in a live editor session.
1.4. The cloudscape window, and genus as a derived label (atmosphere phase B1)
docs/design/ATMOSPHERE_SYSTEM/README.md §7.2/§7.4.
The spatial weather field gave the renderer a
spatial field but read it per march sample, on top of a bake that was still one globally compiled
deck stack painted over a periodic tile. This subsection replaces both halves of that: the tile
becomes a camera-centred window, and the deck stack stops deciding what a march sample finds.
Why the tile had to go. The baked field was addressed by fract(p.xz / tile). fract is
many-to-one, so a bake addressed that way holds a uv with no recoverable world position —
which is why CloudLightVolumePass and CloudShadowMapPass could not consult a spatial weather
field at all, and why per-column genus was impossible (every texel evaluated the same decks). Being
camera-relative rather than world-anchored, the tile also travelled with the camera. One property —
invertibility of the address — was blocking all three.
Two windows. engine/presentation/render/shaders/cloud_field_window.glsl is the single place
their addressing lives, shared by the view march, the light volume, the shadow map
and the ground/mesh shadow lookup, so those four cannot disagree about which piece of the
world a texel is. A near window keeps the old 32 768 m span and 128 m texel; a far window
covers 262 144 m at ~1 km per texel. Two rather than one because the march reaches fourteen shell
thicknesses (~150 km): a single window with that reach would put ~600 m between texels next to the
camera. They cross-fade across the near window’s outer rim, and because both are baked from the
same weather field they agree about where the weather is and differ only in how finely they carve
its shape — which is what makes the hand-off read as detail falling away with distance rather than
as a ring.
The derived bakes needed no lookup of their own. Once the light volume and the shadow map address window space, the field they march already carries the simulation’s coverage per column, so both became spatially correct by addressing the right texels. A clearing stops casting shadow because there is no cloud in the field there to cast it.
Genus, derived. Render::classify_cloud_genus / cloud_band_towers /
cloud_genus_thresholds
(engine/domain/environment/include/SushiEngine/environment/environment.hpp) are the one
authority. The GPU bake resolves a genus per baked column from the three simulated bands and
evaluates that genus’s own cloud_genus_profile; a cumulonimbus is added on top of the low band
where deep convection supports one, rather than replacing it, so a cumulus field with one storm
growing out of it stays representable.
WeatherCloudscapeCompiler resolves the same label through the same function for the editor
readout and METAR-style reporting — its three hard-coded if chains (the OCP finding in the design
doc’s §1.6) are gone, and its thresholds are uploaded to the GPU as data so a retune is a data
change rather than two edits that can disagree. The classifier’s branch structure is the one
thing that exists twice, in C++ and GLSL, and the two copies name each other.
Render::WeatherField::derives_genus is how the producer says whether its column state is
meteorology at all: StaticWeather’s column is an authored deck stack decomposed into bands, and
re-classifying it would overrule the author, so it answers no and the bake stays on the authored
stack. union_base_m/union_top_m are taken by the producer as it fills the cells — the only
party that sees every column — because with genus per column the march shell is a property of the
field, not of the deck stack: a cumulonimbus growing 300 km away still has to be inside the span
the march crosses.
The far window’s own light. engine/presentation/render/shaders/cloudscape_far_light.comp
writes optical depth toward the sun into the far field’s b channel, because
CloudLightVolumePass covers only the near window and lighting everything past it from that
window’s edge would show a front’s shape while lighting it flat. Source and destination are two
images rather than one read-modify-write, since the march reads texels its neighbouring
invocations write.
Placement and cadence. CloudscapeCompilePass::update_window runs between the scene uniform
fill and its upload — the bake and every consumer of the bake read the same mapping out of that
block, and the pass that owns the window state cannot reach it once uploaded, so the pass keeps the
state and the scene view owns only the ordering. Origins snap to each window’s own texel lattice,
which is what makes re-centring invisible: the bake is a pure function of the pattern position, so
a window that moved a whole number of texels reproduces the identical value at the identical world
point.
A rebake is triggered by an authored change, a moved march shell, the camera drifting 8 % of the span out of the window, a weather cadence (1 s near, 4 s far — the published field is interpolated every frame between the simulation’s own 15 s ticks, so “the weather changed” is continuously true and cannot gate anything alone), and, for the far window’s sun channel, the sun moving a degree. The wind has no trigger of its own: it is expressed purely as a shift of the window’s origin, absorbed by the bake and republished as a residual each frame, so the sky advects continuously between rebakes with no staleness — the lookup slides, the content does not go out of date.
What this let go. The per-sample coverage-scale/reference-column mechanism
(cloud_weather_field.glsl, WeatherField::reference_coverage) is deleted; so is the march’s
mirrored anti-repetition tap, which existed to hide a tile period there is no longer any of. The
field’s two density channels collapse to one — the cumuliform/cirriform split existed for
per-layer anti-repetition offsets that were never actually distinct, and every consumer summed the
pair — and freeing the march’s weather-field binding is what made room for the far window in the
last per-pass image slot. Samplers move from REPEAT to CLAMP_TO_EDGE.
Named limit. Ground more than ~16 km from the camera loses its cloud shadow: the map covers exactly the near window and fades out across its rim. It had one before, but it was a repeated copy of the camera’s own patch, and at that distance a wrong dappling reads as texture where a missing one reads as haze. A second, coarser shadow cascade over the far window is the natural follow-up.
1.5. The regional nest: anelastic convection on the GPU (atmosphere phase B2)
docs/design/ATMOSPHERE_SYSTEM/README.md §6. World coupling and
data ingestion describe a simulation
whose own audit (§1.3–§1.5 of the design doc) found three independent 2-D layers with no vertical
advection at all, saturation expressed as if (humidity > 0.85), no latent heating,
semi-Lagrangian advection at Courant ≈ 0.02 — its maximally diffusive regime — and all of it in a
single-threaded scalar CPU loop. This subsection replaces it.
The model. Prognostic potential temperature, three moisture species (q_v, q_c, q_r) and a
staggered Arakawa C-grid velocity field, over a camera-following domain of 192×192×48 cells at 2 km
horizontally and ~54–560 m vertically. Eleven compute stages per step: shift, advect velocity,
advect scalars, surface, forces, divergence, pressure, project, microphysics, extinction,
readback.
-
Transport is MacCormack-corrected semi-Lagrangian with a monotone limiter, at Courant ≈ 1 chosen from the flow’s own speed. The limiter clamps the correction to the upstream stencil’s range, which is what stops an unlimited scheme manufacturing negative mixing ratios and spurious cold pools.
-
The anelastic constraint is
∇·(ρ̄u) = 0— the reference density, not the true one, which is what filters sound waves out and therefore why the step is set by advection rather than by the speed of sound. That single approximation is what the whole cost argument rests on. -
Buoyancy is
B = g(θ_v′/θ̄_v − q_c − q_r): thermal buoyancy through the virtual potential temperature, minus the weight of the water the cloud is carrying. Latent heat released by condensation raises θ′, which raises B, which lifts more vapour to its condensation level. That closed loop is why a cumulus grows rather than merely existing, and it cannot be written down without a vertical velocity to feed it. -
Microphysics is Kessler warm rain: Magnus saturation, a one-Newton-step saturation adjustment (the fixed point moves with the temperature the latent heating changes), autoconversion, accretion, sub-cloud evaporation, and terminal-fall-speed sedimentation. Cloud base is nowhere placed — it falls out where a rising parcel’s
q_s(T)drops to its ownq_v, which is the lifting condensation level by definition. -
Cloud-top radiative cooling is the sink a cloud has and the clear air beside it does not: a cloud top is optically thick in the longwave with space above it, so it loses ~70 W/m² across a layer tens of metres deep. Carried as the flux difference across a level rather than a local
dF/dz, which telescopes down a column toF0·(1 − e^{−κW})and so cannot radiate away more than the top is given at any vertical resolution — the differential form over-cools an optically thick level by its own opacity.It also cannot radiate forever: the sky above returns a growing share of what the top emits as it cools, so the loss is scaled by how far the top still is above the temperature at which the two balance and is gone at
cloud_top_equilibrium_depressionbelow ambient. Without that bound the constant is a sink rather than a flux, and nothing in this model warms a cloud — a deck that persisted cooled its column 42.7 K over 72 h and did not stop. The entrainment the cooling drives is resolved rather than parameterized, and is under-resolved at the spacing the upper levels carry. -
The other direction of the same longwave reaches the ground: the covered share of the sky the surface balance radiates to is the cloud base’s own emission rather than Brutsaert’s clear-sky air, blended by cover and by how black the deck is. A cloud base holding 30 g/m² is 0.98 of a black body and it is warm, so an overcast column that is shading the sunlight away is also returning most of the loss — applying the clear-sky value under it leaves the ground losing tens of watts to a sky that is not there. The cloud-base temperature and liquid water path come out of the column walk the extinction stage already runs for the shading.
-
The Rayleigh sponge hangs from the rigid lid over the upper half of the domain — an edge at 9 km, not the quarter-domain edge at 13 km a cloud model usually takes — and the difference is a measurement. Its
sin²ramp is exactly zero below its lower edge, so a mode that sits underneath it is not damped at all however large the rate is: one parked at 12.4 km, immediately below the 13 km edge, reached ±13 K over 72 h and was still growing, and doubling the rate moved it to ±11 K. Covering those levels leaves +0.02 K. Depth is the parameter that decides this and rate is not. Deeper is not free either — an edge at 6 km collapses the wind between 4.6 and 9 km to 0.02 m/s and reverses θ′ at 6 km, which is the sponge standing in for the weather; at 9 km the boundary layer, the cloud deck and everything below 4 km are unchanged to within 0.08 K. -
The boundary layer is parameterized, because the eddies that carry surface heat and moisture out of the lowest level are two orders below a 2 km grid. Vertical diffusion of total θ and the moisture species on Troen & Mahrt’s
K = κ·w_s·z·(1 − z/h)²— linear in height at the ground, where surface-layer similarity requires it, and peaking ath/3— over a depth diagnosed per column by the parcel method and capped byboundary_layer_depth_m. The depth carries a floor of the two lowest levels: mechanical turbulence at the ground does not switch off with the stratification, and without the floor a level that is momentarily colder than the one above it decouples entirely and then accumulates without limit — measured at −8.76 K, which fogged the level. -
Subgrid cloud fraction sits on top of the adjustment and is what lets a grid-mean model draw a cumulus at all. A cell’s humidity is a mean over 4 km²; a fair-weather cumulus is a 200 m–1 km thermal, saturated inside while the cell around it is not. So the cell’s humidity is a top-hat distribution of half-width
(1 − critical)·q_sabout that mean (Sommeria–Deardorff; Smith 1990’s uniform member), condensation takes its saturated tail, and the fraction and the condensate come out together. Atcloud_critical_humidity = 1it collapses exactly onto the all-or-nothing adjustment it generalises, so there is one condensation path and not two. The fraction rides in the moisture volume’s fourth channel and the extinction volume’s alpha, and it is what the cloudscape bake now thresholds against — cell-mean σ divided by it is the in-cloud water the bake draws at.
The pressure solve is the piece worth reading. The grid is deliberately anisotropic, so the
Laplacian’s vertical coupling outweighs its horizontal by (Δx/Δz)² — two orders of magnitude near
the ground — and a point smoother would converge appallingly. It solves the vertical exactly with
a Thomas sweep per column and iterates the horizontal by red-black colouring. Naive Thomas wants
four private arrays, a kilobyte per column, which spills at a 64-column workgroup; everything but
the c′ factors is recomputed from analytic functions of the level index and d′ is written
straight into the pressure image, which is 256 bytes instead. Named limit: with no coarse-grid
correction the smooth horizontal error decays slowly, so a fixed sweep count leaves a small
residual divergence — a slight mass imbalance, not a wrong answer. Semi-coarsened multigrid with
this shader as its smoother is the refinement and drops in unchanged.
Where it lives, and why that was forced. The editor runs three ISceneViews (Scene, Game, VFX
preview). A per-view nest would simulate three divergent atmospheres at three times the cost and
several hundred megabytes, and the simulation would have to pick one to answer “what is the
weather”. So AtmosphereNest
(engine/presentation/render/source/atmosphere/atmosphere_nest.*, public state in
engine/domain/environment/include/SushiEngine/environment/atmosphere_nest.hpp) is a device-level
service in AssetLibrary, beside the cloud noise, built on first use so a scene that never enables
weather never pays its ~135 MB. It is centred on the simulation’s observer, not on any camera,
which decouples it from views entirely.
Ordering against three readers. The step records into its own command buffer, submits on the
graphics queue and signals a timeline semaphore each view’s first submission waits on. Without it,
three views sampling a resource an earlier submission is writing is a race the validation layers
cannot see. AtmosphereForcing carries an absolute game clock rather than a per-frame delta, so
the step is idempotent and whichever view reaches it first does the work.
Seeding is re-centring. A shift larger than the domain leaves every cell without a source, which is exactly “fill from the base state and the parent solution” — one code path, not two that would eventually disagree about what a fresh cell contains. Surviving cells are copied, not resampled, because the lattice snaps to whole cells against an absolute origin.
The data plane. Nothing reads the GPU state synchronously (the design doc’s §3.2).
engine/presentation/render/shaders/atmosphere_readback.comp compiles the nest into a 32×32
lattice of WeatherColumn-shaped records, copied into a triple-buffered host ring and taken once
per completed step. Render::IAtmosphereMirror is the one seam that flows renderer → simulation;
IAssetLibrary is that source, so a host binds the two in one line
(simulation->set_atmosphere_mirror(&renderer->assets())). This is the design doc’s §9.2 query
mirror pulled forward from phase E, deliberately and only as far as it had to be: the moment the
grid moved onto the GPU there was nothing left on the CPU for sample_column to read. The design
doc’s §9.1 full AtmosphereProfile stays in phase E, where it has consumers written for it.
The profile beside the columns. The same dispatch also writes AtmosphereProfileLevel — the
observer column’s unreduced vertical state, published on AtmosphereMirror alongside the coarse
records. Every field of a WeatherColumn is already a vertical reduction, which is what gameplay
wants and is exactly what a column that refuses to condense has destroyed the evidence in: a log of
exact zeros is the case where the only remaining question (“why”) is answerable solely by height.
Sixteen floats a level over at most 64 levels is 4 KB against the columns’ 64, written from samples the dispatch was already fetching. It carries state and not diagnosis — θ′, the three mixing ratios and the base state’s own vapour beside them, the diagnosed cloud fraction, the wind, the extinction, the buoyancy the level would feel and the divergence the projection was handed — because a derived summary is what the columns already are. The Meteorology panel renders it level by level, cloudy rows tinted, which is where a clear sky is diagnosed without leaving the editor.
sushiengine_atmosphere_probe (tools/probes/atmosphere/main.cpp) drives all of it
headlessly: a Vulkan device with no window, the nest stepped through hours of game time in seconds
of wall clock, and the profile written to CSV. A measuring instrument rather than a test — it
asserts nothing, and tests/unit/test_atmosphere_nest.cpp is where the pinning lives. It exists
because the questions this tier raises are of the form “what is it doing, and at what height”,
which no pass/fail answers and no editor session answers quickly; parameter overrides
(--sensible, --latent, --seed, --eddy, --pbl-w, --critical, --sweeps) let one term be
separated from the rest by running without it — which is how all three of the boundary-layer
defects fixed on 2026-07-28 were found. Beside the column it reports the whole mirror’s sky:
cloudy-column fraction, mean coverage and mean cloud base, because one 2 km column is a noisy
sample of an intermittent field.
Before the first readback, and in a host that never binds a mirror, every weather query answers from the base state: a clear sky with the synoptic wind. Honest rather than convenient — nothing has been simulated, so there is no condensate to report, and inventing a coverage there is precisely the fabricated signal the design doc’s §1 audit was written about.
What T1 is. The parent solution the Davies relaxation zone nudges toward is
Atmosphere::QuasiGeostrophicCore (phase C): real geostrophic flow around lows nothing placed,
with the boundary’s temperature and moisture anomalies taken as departures from the core’s own
zonal mean — the nest’s base state already carries the mean, so what the parent must supply is the
eddy. The interim that stood here, SynopticLayer’s stylized frontal mask, is gone; cyclogenesis
is emergent and fronts are diagnosed from the thermal gradient rather than drawn from a ray pair.
Retired here. regional_weather_grid.hpp (the design doc’s own disposition table) and
test_weather_determinism.cpp — bit-exact replay of the weather is given up deliberately (the
design doc’s §0 and §14), and the test’s subject no longer exists.
tests/unit/test_atmosphere_nest.cpp replaces it by pinning what is still checkable: the base
state against the International Standard Atmosphere, Magnus saturation against its textbook values,
the stretched grid closing on its domain top, and the mirror’s cold start and transcription.
Cloud shape now comes from the condensate (the design doc’s §7.1/§7.3, phase B2b).
engine/presentation/render/shaders/cloudscape_field.comp has a third path, taken whenever the
nest is running: it reads σ_ext from
engine/presentation/render/shaders/atmosphere_extinction.comp’s field directly and uses no genus,
no deck and no height gradient at all. A cumulus has a flat base because the condensation level is
flat, not because cloud_height_gradient draws one; an anvil spreads because the updraft spread
it. The nest’s stretched levels invert analytically (w = (altitude/top)^(1/1.5)), so altitude →
texture W is one pow, and the horizontal map is stamped in update_window because the nest is
centred on the simulation’s observer while the scene block is camera-relative — that is the one
place both frames are in hand.
Baked density is stated against the extinction of 1 g/m³ of liquid water rather than in absolute 1/m. That keeps the medium’s authored absorption meaning exactly what it always meant: where the cloud is and how its density falls off across its own edge is now physics, while how opaque a given amount of water reads stays an authored property of the medium. Tiled noise survives only as a sub-2 km modulation at 35 % amplitude — the design doc’s §7.3 demotion in its final form, since the nest resolves 2 km and that is all the noise is still needed for.
The march shell follows suit: WeatherFieldBuffer::fill_from_mirror takes the lowest cloud base
and highest cloud top the readback reports and publishes those as the union span, so the baked
field’s thirty-two vertical texels sit on the cloud instead of on the empty stratosphere above it.
A classifier could only ever have said “a cumulus reaches 3.2 km” because the catalogue says so;
the nest knows where this cloud’s top is.
The three paths are ordered: nest first, then per-column genus from the published field, then the authored deck stack. Each is the truthful answer when the one above it has nothing to say.
Two invariants the cloud field holds itself to. Both went unenforced for as long as the bake
existed, and neither is visible without measuring the noise rather than looking at it. The window
bake no longer carries either: engine/presentation/render/shaders/cloudscape_field.comp bakes
only the envelope — coverage, water, vertical structure — and the carve moved to the march, which
the cloud chapter documents as built.
coverage is a percentile, so the field it cuts must be uniform.
remap(base_shape, 1 - coverage, 1, 0, 1) * coverage only means “keep the densest coverage of the
sky” if base_shape is uniform on [0, 1]. Ported exactly and sampled, it is a narrow bell with
support [0.574, 0.906], so the cut never reached the field above ~0.43 coverage and every deck at
half coverage or more came out a gapless slab shaped only by cloud_height_gradient.
march_uniform_shape() (engine/presentation/render/shaders/cloud_noise_volume.comp) pushes it
through its own CDF (a logistic approximation to the normal one, one exp) as the precombined
march volume is generated, before anything thresholds it. The transform is monotone, so it cannot
change the field’s level sets — it changes which one coverage selects, which was the entire
defect.
Nothing may carve a feature its own grid cannot sample. The shape volume lays four Worley cells
per shape_scale, so a cloud is shape_scale / 4 — 1050 m for cumulus, against the far window’s
1024 m texel. One sample per cloud, and a bake that thresholds before it stores makes the alias
arrive with its gaps filled rather than blurred. The floor now sits in the march instead:
engine/presentation/render/shaders/cloud_noise_lod.glsl’s carve_lod measures the carve’s own
texel against the march footprint and selects the mip that keeps the fetch inside its Nyquist limit,
which is why the far field draws cloud clusters rather than a slab.
The Meteorology panel (applications/editor/source/atmosphere/meteorology_panel.cpp) is the
tuning and logging surface for all of this: the sky’s animation rate against the rate the nest can
actually step, the solar forcing, the observer’s column, and a CSV log sampled on the nest’s clock
rather than the wall clock so runs at different time scales stay comparable. When the column is
empty it names which link is broken — procedural weather off, nest disabled, forcing unpublished,
or stepping without a completed readback — because the nest is built lazily behind several
conditions and “no readback yet” on its own sends you to the wrong subsystem. The readback’s three
spare extent lanes carry surface relative humidity, the column’s peak |w|, and the lifting
condensation level: diagnostics the renderer does not consume, but the only ones that answer why
a column is clear rather than merely that it is.

