Skip to content

Sky orchestration

How the sky, the fog, the clouds and the sun are kept telling one story through dawn and dusk. The code lives in project/game/effects/sky_director.gd (the authorities), _apply_sky in project/game/main.gd (the plumbing), the ridge probe in src/goanna_client_far.cpp (lodUpdateFar), and the shaders (sky.gdshader, atmosphere_volume.gdshader, light_shafts.gdshader).

The problem this replaces

Every lighting band used to be a ramp over the raw astronomical sun elevation, each with hand tuned constants calibrated against a symptom in isolation. Nothing asked whether the sun was actually occluded. At dawn the sequence of events was a constants table playing out: the clouds changed colour at one elevation, the sky at another, the fog gradient at a third, and the fog between the camera and a ridge glowed with a beam the ridge was blocking. The picture disagreed with itself because the layers could not agree: the cloud undersides and the glow around the disc were unrelated luminances.

The two authorities

The beam. One function of solar altitude gives the transmitted sun's hue and strength after its grazing path through the air (SkyDirector.beam_tint, beam_strength). Every warm term consumes it: the disc tint, the dome's directional haze band, the cloud undersides, the fog's sun scatter, the sun light itself. The hue script is keyed to the astronomical elevation, because the colour of twilight is a property of the whole air mass; only the strength is per layer.

One exception, the sun light on the land. Its hue follows the land's own altitude, the one the ridge probe gives, and its fade and golden hour hold follow the astronomical elevation, with the ridge only occluding it through beam_strength at the crest. Over open ground both keys are the same. Behind a ridge the land's last light is then the gold of a setting sun, which is what the eye expects of the moment the sun goes behind a hill. Before 2026-09-29 the fade followed the ridge and the hue did not, and once the far field reached real hills (a 6.5 degree ridge at the test_world plains village) the land dimmed from ten degrees above the crest while the beam was still white, so golden hour never reached it.

Per layer horizons. Each layer hands beam_strength its own altitude, relative to the horizon that layer actually sees:

  • The ground answers to the terrain ridge toward the sun's azimuth, as measured by the ridge probe. Sun energy, the sky fill's twilight carrier, fog_sun_scatter, the screen space shafts and the water's sun glow all gate on it, so the true dawn arrives at the crest, together.
  • The cloud deck sees over that ridge by its own altitude (SkyDirector.cloud_horizon), so it lights first and darkens last by geometry: a long lead over open sea, a short one under a tall range, held back entirely when the range tops the deck.
  • The dome's haze band is air beyond and above the ridge, so it answers only to the false horizon, lifted by AIR_LIFT: high air stays sunlit well after ground level is dark, which is the predawn and afterglow.
  • The dome's own colour blend (day, dawn, night sky colours) stays on the astronomical sun. Its predawn is the false horizon dawn.

This is the two dawn pattern: over flat terrain the ridge probe reports nothing, every ground gate sits on the false horizon, and the whole dawn plays out as the sun crests the skybox's lower hemisphere. With a ridge above the horizon, that skybox dawn becomes the predawn (clouds and high air light, land waits), and the full arrival holds for the crest.

The shadow boundary

Sun visibility is a height, not one scalar. When the sun is below the ridge, the air between the camera and the ridge stands in the ridge's shadow, so it must not glow: fog_sun_scatter and the shafts gate on the ground's beam. The clouds stand above that shadow, so they blaze while the land is dark. The gate also closes the oldest leak in the frame: the sun's shadow map ends at 200 nodes, and beyond it every sun facing slope was floodlit even with the disc behind a mountain. With the sun's energy gated on the composite horizon that cannot happen at twilight. (By day, distant terrain beyond the shadow map is still lit unshadowed; that is an older, separate problem.)

The fog wall

The dome's lower hemisphere renders as terrain wearing full fog, not as unlit ground: the same air colour the depth fog converges to, darkened mildly for depth, with the directional warm term running down it. Where the far field stands proud of the horizon line it dissolves into that same air, and where it dips below, the wall stands in as the horizon, so the disc's edge and the dome blend through one gradient. The radiance cubemap half of the sky shader keeps its separate lighting-only treatment.

The ridge probe

lodUpdateFar measures the sine of the terrain horizon's elevation toward the sun's azimuth: the max over drawn block tops within about 20 degrees of the azimuth, at least 128 nodes out (nearer canopy is not a ridge line). A max over tops needs no buried test, since a buried block's top always sits under the surface block above it. The near set is walked whole; the far set with a bounded cursor like the prune sweeps, publishing each completed cycle. sky_state() carries the result (ridge.sin, ridge.height, ridge.distance); _update_horizons in main.gd smooths it over a few seconds and derives the cloud deck's horizon. GOANNA_RIDGE forces the sine for an A/B without terrain.

Fog on the fens

The valley mist exists before and independent of the light: its density follows a diurnal cycle (thicker through night and predawn, burning off through the morning, mist_cycle in main.gd), and at night the mist share of the froxel volume carries a faint emission at the night radiance floor's hue, so the banks are visible weather for the dawn to pierce rather than something the dawn creates. Calibrated on the fixture: 0.24 * night; 0.03 was invisible, 0.9 was moonlit milk.

Continuous local fog

Local fog composites at full strength over both geometry and the sky. A reduced volumetric_fog_sky_affect made a lit mist bank abruptly change at a terrain silhouette. Raising that value alone obscured the sunset clouds, because the Environment's uniform density also filled the zenith. The air density now lives in atmosphere_volume.gdshader, with a smooth 32-node exponential falloff above the local surface and the existing lower fade. Valley mist and local cloud bodies retain their own density fields. Clear-night mist is thinner and scatters the incident light with a neutral albedo; otherwise the old dense, night-coloured medium extinguished the sky. Twilight keeps its established mist density. The distant depth-fog ramp remains separate; this does not replace the far terrain geometry or soften its level-of-detail silhouettes.

At deep night, the Appearance night control adds a faint blue upper sky in linear light. The visible sky and its ambient/SDFGI radiance share that contribution; the night horizon colour, exposure, contrast and saturation are unchanged. The control is gated outside the protected twilight interval.

The fixture

project/studies/dawn_sweep.tscn renders the real shaders over a boxy stand-in ridge, sweeping the sun through dawn twice (flat and ridge variants), with no server. GOANNA_SWEEP_ELEVS, GOANNA_SWEEP_RIDGE and GOANNA_MIST_GLOW narrow a calibration run. Judge composition there; confirm on a server afterwards. Two traps it already caught: an Environment left at the default fog_sky_affect of 1.0 repaints the whole dome with fog colour, and the 3D cloud noise texture builds on a thread, so early frames have no clouds.

The night panorama

shaders/night_sky.gdshaderinc adds a fictional galactic band with mottled stellar clouds, a dark dust lane and subdued emission regions. Two star layers vary their size, brightness and temperature. Their filtered cores retain energy as resolution changes, and gentle scintillation is stronger near the horizon. Three brighter, steady points suggest planets. This is an authored procedural sky, not a real star catalogue or orbital simulation.

Random and constellation stars have flat square cores. Their coverage is integrated over the pixel footprint, retaining sub-pixel light without turning resolved corners into Gaussian dots. The square area matches the previous profile's integrated light. Their orientation follows the sky.

Four fixed asterisms add recognisable shapes: a hooked chain, an uneven kite, a fork and a compact cluster. Each has six stars with varied colour and brightness. The random field thins slightly around them, without connecting lines. They rotate in the same celestial frame as the band. constellation_strength defaults to 1; zero restores the random field.

The band uses a shallow heightfield derived from its own light and dust. Like the material recipe in tools/pbr/pbr_author/extrude.py, neighbouring texels merge into flat plateaus with narrow chamfers at exposed steps. The sky shader intersects their tops and sides and shades those normals. This replaces the rejected angular mosaic, which looked like fish scales. galaxy_relief defaults to 1; zero restores the smooth band.

The relief follows the cloud artwork, rather than applying an unrelated surface pattern over it. Its projection and illustrative light are fixed in the celestial frame, keeping the sky at infinity when the player moves. This is a stylised heightfield, not a physical model of galactic matter or the terrain's lamp-lit PBR material. The scattering, stars and terrain are not quantised by this control. The outer glow remains diffuse.

The panorama turns with the server's day clock. Twinkling and meteors use a shared monotonic application clock, keeping local player views in step without the shader TIME rollover. A meteor can occur in each 83-second slot, with a variable delay and skipped slots. It lasts at most 1.15 seconds. Many events happen outside the player's view.

The server's star visibility, alpha, tint, scale, count-derived density and day opacity still apply. Zero count or scale now removes the stars rather than retaining a minimum population. Star daylight follows the astronomical sun, so a ridge cannot reveal stars while shading the ground at noon. Galactic detail fades earlier than the stars through twilight, dims near the moon and extinguishes near the horizon. Clouds and terrain cover the entire panorama, including meteors. Custom sky types omit the added band, planets, meteors and twilight scattering.

The visible dome also gains a blue/violet twilight shoulder and a faint pink anti-solar arch. The existing beam, fog and cloud lighting remain the authorities for the warm horizon. Celestial detail and the new scattering are omitted from the radiance cubemap; they do not add ambient light or appear in that reflection fallback.

project/studies/night_sky_study.gd is an offline fixture with its own viewport. Add it to a developer client and await capture(directory) to save night, twilight, daylight and cloud views. It also compares hidden stars against zero count/scale and checks that disabling the additions leaves full daylight identical. It does not replace a server or performance test. The night-sky study records the first render checks and their limits.

The baked horizon

src/goanna_horizon.cpp bakes a cylindrical panorama of every terrain column the client knows about: the snapshot (top surface and resolved tile colour per block column, from the finest retained chain data) is extracted on the main thread, a worker marches it into albedo and distance images, and main.gd hands them to the sky shader. The dome composites it after the discs (a far mountain hides a rising sun) and before the clouds, and relights it every frame from the same beam and air authorities as everything else, so the panorama rides through dawn, dusk and biome fades without being re-rendered; its own aerial perspective dissolves toward the same directional air as the haze band. Rebaked when the camera moves about a hundred nodes or on a slow clock. GOANNA_HORIZON=0 disables it; GOANNA_HORIZON_R0 forces the inner radius, which is how it is A/B'd against terrain that is actually drawn.

The shoreline correction uses exact boundary records when available and the finest summary level otherwise. Liquid keeps its independent surface height and material. Using the coarsest cell ceiling inflated low shores into 16-node walls. Extraction has a two millisecond slice budget as well as its chain-count limit. Horizontal sampling is still one height per mapblock column; this is not a volumetric representation of distant land.

By default it fills directions the far field knows about but has not built or has holes in, since the chain radius equals the far draw distance. set_far_mesh_distance (env GOANNA_FAR_MESH=<nodes>) splits those knobs: chains, summaries and the bake keep filling to the far distance while region meshes stop at the mesh radius, the fog closes at the drawn edge, and the band beyond is panorama alone at zero draw calls. Measured at the test_world beach with the mesh horizon at 512 against knowledge to 960: regions 604 to 428, camera draws 1895 to 1350, and the horizon stays continuous, silhouette ranges over the water where meshes end. Off by default until benchmarked; the graphics profiles are the natural owner of the default once it is.

Not done yet

  • The moon gets no ridge treatment; moonrise still uses the astronomical horizon.
  • Daytime far terrain beyond the shadow map's 200 nodes is lit with no occlusion (see above).
  • The probe fan is fixed at about 20 degrees; a sunrise through a narrow notch inside the fan reads as crested a little early.
  • The night mist glow is calibrated on the fixture only; its hue on a live server night is unjudged. The rest of the sequence was verified on the test_world Mineclonia server on 2026-08-30; docs/history/development-log.md has the numbers.