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.mdhas the numbers.