The Sky, Day and Night

This page describes part of the Python framework that comes in the acron wheel. The sky’s CPU light model is native and available to C++ as SkyModel (see Clouds and Weather).

acron.sky puts a physically based sky over a game: an atmosphere that scatters light the way the Earth’s does, a sun and a moon that stand where they would for a date, an hour and a place, stars that wheel round the pole, cascaded shadows for the key light and an environment probe that turns the sky into reflections and ambient light. It is the base the clouds and the forests build on; Sky, Weather and Forests shows how they fit together and which OpenGL versions they need.

The package is in two halves. The pure-Python half – the clock, the ephemerides, the atmosphere’s parameters and the artistic looks – imports no engine module and works anywhere, including on a server. The GPU half – Environment, SkyPasses, SunShadows, EnvironmentProbe – needs a core-profile context.

Time and place

DayClock is game time: a local civil hour of a day of a year, at a latitude and longitude (north and east positive), with the place’s UTC offset. time_scale sets how many game seconds pass per real second (60: an hour a minute; 0 or paused stops it); advance() rolls the hours, days and years over. sync_to_system() takes the date, hour and offset from the computer’s clock, for a game whose sky follows the player’s. as_dict() / from_dict() put it in a save game.

from acron.sky import DayClock, sky_at

clock = DayClock(latitude=45.81, longitude=15.98, utc_offset=2.0)
clock.set_date(2026, 10, 26, hour=17.5)
now = sky_at(clock)
print(now.sun_elevation, now.moon_phase)     # degrees; 0 new .. 1 full

sky_at() turns a clock into a Sky: unit vectors towards the sun and the moon in the world frame (+x east, +y north, +z up, turned by north_heading), their elevations, the lit fraction of the moon and the rotation of the star sphere. The sun follows the Astronomical Almanac’s low-precision formulae (good to a few hundredths of a degree), the moon the Almanac’s short series (about a third of a degree, without parallax), and the stars are one rotation driven by local sidereal time. All of it is cheap enough to run every frame, and gameplay can call it without a renderer: how long until sunset, is the moon up.

The atmosphere

Atmosphere holds the air as three media, after Hillaire (2020), who follows Bruneton and Neyret (2008): Rayleigh-scattering molecules and Mie-scattering aerosols, each thinning exponentially with height, and an ozone layer that only absorbs. The units are the shaders’: kilometres and 1/km. The defaults are Hillaire’s Earth; another planet is another set of numbers. with_weather() returns the same air with haze times the aerosols and a humidity that makes them whiter.

The sky passes (SkyPasses) turn the air into look-up tables, as in Hillaire’s paper, but as fragment passes rather than compute shaders, so the same code runs on every target:

Look-up table

Size

When

transmittance

256 x 64

when the air changes (weather, planet)

multiple scattering

32 x 32

when the air changes

sky view (sun)

256 x 128

every frame

sky view (moon)

256 x 128

every frame

sky irradiance

4 x 1

every frame

aerial perspective

32 x 32 x 32

every frame (slices side by side)

The transmittance table uses Bruneton and Neyret’s parameterisation; the multiple-scattering table is Hillaire’s isotropic approximation of all the higher orders, which is what keeps a sunset sky from going black above the glow. Each transmittance ray is integrated in 40 steps that crowd quadratically round the ray’s lowest point (its start, or the perigee of a ray that dips first), where the aerosols’ 1.2 km layer is, and the ozone layer is integrated exactly: within 0.3 % of the exact optical depth, in clear air and in haze alike (40 even steps missed 2.7 % in clear air, 6.4 % under haze). The multiple-scattering and view marches space their steps quadratically from the point too. The sky view is drawn for the sun by day and for the moon by night, and the aerial-perspective volume holds the light scattered in, and the transmittance, between the camera and every point up to 48 km away.

The CPU twin, acron.atmosphere.SkyModel (native, in double precision), answers what the GPU cannot hand back without a stall: the colour of the sunlight after it crossed the air (Atmosphere.transmittance()), which lights the DirectionalLight, and a rough sky irradiance (Atmosphere.sky_irradiance()) for the AmbientLight. It integrates the transmittance the way the table does (in 48 steps), and the two agree to 0.1 % of the optical depth.

Looks

The physics decides where the sun is and what colour its light has; a Look decides how the game wants it to read. It is a frozen bag of numbers: haze and humidity, height fog (density per metre at its base, falloff, base height, a tint), the sun’s and moon’s illuminance, the exposure (exposure_ev, and the eye’s adaptation: adaptation and its ceiling night_exposure), the shadow strength, the sun disc and moon sizes, earthshine, stars, twinkle, the Milky Way, and the wind (world metres per second, with a gust factor).

Looks stack in three layers:

  1. the game’s own look, which Blend cross-fades to over seconds – Environment.set_look() starts a fade, and a new one mid-fade starts from wherever the last had got to, so the sky never pops;

  2. an HourCycle: per-hour values of a few of its fields, interpolated round the clock;

  3. layers on top – the weather, a season, any of the game’s own – each a sparse dict of fields that a Layer cross-fades. Environment.set_layer() starts one by name, a new name goes on top, and {} fades a layer out. The clouds drive the "weather" layer from their presets (Clouds and Weather).

The game’s look and its hour cycle hold absolute values: an hour’s value replaces the base look’s. A layer is relative to the look beneath it, so a storm makes the game’s air hazier and its exposure brighter rather than setting both to fixed numbers that would lose the game’s own. Each field composes by its kind (acron.sky.COMPOSE, compose()):

  • offsets add (0 leaves the field alone): exposure_ev and fog_density;

  • factors and tints multiply (1 leaves the field alone; a tint per channel): haze, shadow_strength, sun_disc, moon_brightness, stars, milky_way, zodiacal_light, airglow, sky_brightness, fog_tint and moon_tint;

  • absolute physical parameters replace: every other field – the humidity, the haze height, the fog’s falloff, height, cap and tint amount, the illuminances, the adaptation, the disc sizes, twinkle, earthshine, the light pollution and its colour, the wind.

The exposure is the case that matters most: a game sets its base exposure (and darkens its nights with the hour cycle), and a weather’s exposure_ev is an offset in EV on top of it at every hour – the overcast preset’s +0.3 EV is 0.3 EV over whatever the game had. Fog densities add because extinctions do. A field a layer does not name stays as it is, and a layer cross-fading over seconds fades its offset, its factor or its share of the new value, so a weather changed mid-fade does not pop either.

from acron.sky import HourCycle, Look

base_look = Look(haze=1.2, exposure_ev=0.5, wind=(4.0, 1.0, 0.0))
hours = HourCycle({5.5: {"haze": 1.8, "fog_density": 0.002},
                   9.0: {"haze": 1.2, "fog_density": 0.0},
                   21.0: {"exposure_ev": -0.5}})
env = Environment(base, look=base_look, hours=hours)

# at 9:00 haze 1.2 x 2.5 = 3.0 and exposure 0.5 + 0.3 = 0.8 EV
env.set_layer("weather", {"haze": 2.5, "humidity": 0.6,
                          "exposure_ev": 0.3}, seconds=30)
# a season on top of the weather: a little more haze, a duller sun disc
env.set_layer("season", {"haze": 1.1, "sun_disc": 0.8})

# the game changes its own look; the weather and the season stay on top
env.set_look(env.base_look.replace(wind=(9.0, 2.0, 0.0)), seconds=5)

Look.overridden() (absolute values, the hour cycle’s way) and the layers refuse unknown field names, so a typo in a JSON preset fails at once.

The environment

Environment wires it all to a ShowBase. Once a frame – in its own task at sort 45, after the game has moved the camera and before the frame renders – it advances the clock, places the sun and the moon, applies the look stack, writes the frame values, lets the sky passes run and sets the colour and direction of two engine lights it adds to the scene root: key_light (a DirectionalLight: the sun by day, the moon once the sun is 6 degrees below the horizon) and ambient_light. Materials that light from engine lights only are lit by the right sun that way; shaders that include acron.shaderlib read the real sky instead.

The sky itself is a cube round the camera, drawn first in the background bin at the far plane. It shows the scattered light, the sun’s disc with limb darkening, the moon with its phase, its maria and earthshine, the real stars of the Yale Bright Star Catalogue, the Milky Way, zodiacal light, airglow and light pollution (The Night Sky), and the height fog out to the horizon.

The exposure follows the light the way an eye does, part of the way (Environment._adaptation). Each frame the environment works out the clear sky’s light on the ground – the sun and the moon over the horizon, the sky’s own light, and the night sky’s glow (starlight, airglow, zodiacal light and light pollution, night_glow_irradiance) – and raises the exposure by (the sun’s light 30 degrees up / that light) to the power Look.adaptation (0.85), up to Look.night_exposure (8000). By day it is 1; a sunset reads, a full moon’s field is dark but readable (about a fifth of the day’s brightness on the screen), a crescent’s darker, and a moonless night darker still, with the treeline against the sky and the nearest shapes still there – never black. The weather does not move it (a weather layer’s exposure_ev adds to the exposure), and Look(adaptation=0) switches it off, leaving the exposure to the game.

The night sky’s glow lights the world too: sky_ambient_irradiance adds it (the NIGHT_AMBIENT frame slot), and the environment probe sees the night sky at its physical brightness (its picture of the sky keeps the display gains of The Night Sky out), so a moonless night is lit by starlight and airglow at about 1/250 of a full moon, and a city’s light pollution at up to a third of it.

The blue-grey of night vision is applied once, as the picture is shown: the frame pipeline’s composite (The Frame Pipeline) shifts the colour towards rod vision with purkinje(color, frame_night()) before it tonemaps (its purkinje setting scales the shift; 0 turns it off), and without the pipeline every module’s shader does it in frame_display(), its last step. The shift is mesopic: it fades out where the picture is bright, so a sodium lamp keeps its orange in a blue-grey street.

Environment(lights=False) leaves the engine lights out; root puts the sky’s inputs on another node than render; metres_per_unit, altitude and north_heading fit it to the game’s world (Sky, Weather and Forests describes the units). destroy() takes it all down again.

Frame values

Everything the shaders need to know about this frame – the sun’s direction and colour, the camera, the exposure, the fog, the wind, the atmosphere’s parameters, the shadow cascades’ matrices, the clouds’ state – is in one small float texture, FrameUniforms: one RGBA32F texel per named slot, written by name on the CPU, uploaded once a frame and bound once at the scene root. Shaders read slot FRAME_SUN_DIR with frame_value().

Why a texture rather than shader inputs: setting a shader input on the scene root every frame makes the engine recompose the render state of every node under it, which can cost more than the frame. A texture bound once changes nothing in the scene graph; the shaders just read new texels.

A game adds slots of its own with FrameUniforms.register() – before it compiles the shaders that read them, because the slot numbers are compiled in – and writes them from a frame writer, which the environment calls after its own slots and before the upload:

env.frame.register("HEADLIGHTS", "xyz position, w on")

def write_headlights():
    on = 1.0 if lights_on else 0.0
    env.frame.set_xyz("HEADLIGHTS", car.get_pos(render), on)

env.add_frame_writer(write_headlights)

The texture has room for 64 slots; the sky and its shadows use 48, the clouds 8 more and the forest 2.

Sun shadows

SunShadows renders four shadow cascades for the key light into one depth atlas (2 x 2 tiles of resolution texels each) and writes their matrices to the frame values, where sun_visibility() reads them. It does not use the engine’s own shadow-casting lights, so it works with any shader that includes acron.shaderlib. What keeps its edges steady:

  • each cascade covers the bounding sphere of its slice of the view frustum, so its size does not change as the camera turns;

  • the cascade’s centre moves in whole texels of its own map, so a moving camera does not make the edges crawl;

  • the casters’ reach towards the light is capped (caster_reach), so a low sun over far hills does not shade the whole map;

  • the split distances blend uniform and logarithmic spacing (split_blend), and over a band at each cascade’s far end a growing, dithered share of the pixels reads the next cascade (the frame pipeline’s TAA resolves the dither), so no pixel pays for two cascades.

Quality presets. SunShadows(env, quality="high") picks the cascades’ resolution, the filter and how often the far cascades redraw (acron.sky.shadows.QUALITIES; resolution, taps and intervals override one each):

preset

resolution

taps

intervals (frames)

on a GTX 660 at 1080p (the cascades’ draw, nature_stack)

low

1024

1

1, 2, 4, 8

about 1.6 ms

medium

2048

4

1, 2, 4, 4

about 3 ms

high

2048

8

1, 1, 2, 4

about 6 ms

ultra

2048

12

1, 1, 2, 2

about 6.5 ms

  • The filter is taps hardware-compared bilinear taps on a Vogel disc (a golden-angle spiral) rotated per pixel by blue noise – the same filter as the native PBR shader’s local shadows; one tap reads the centre, a single 2 x 2 comparison. Under acron.pbr’s environment the preset’s count also goes to the PBR shader (shading-shadow-taps, set in the configuration, wins over it).

  • Staggered cascades. The near cascade redraws every frame; cascade c every intervals[c] frames, out of phase with the others, as Decima and Frostbite update their far cascades. The shader reads each cascade with the matrix it was drawn with, and a point outside a cascade that has not caught up with the camera reads the next one. A cascade redraws at once when the view has moved more than redraw_shift of its radius (0.2) or the light has turned more than redraw_degrees (1); redraw() redraws them all on the next frame (after a teleport).

from acron.sky import SunShadows

shadows = SunShadows(env, quality="high", distance=400, caster_reach=120)
water.hide(SunShadows.MASK)          # never casts
shadows.set_enabled(False)           # everything lit, nothing rendered

The cascades draw every node under the scene root with a depth-only shader that keeps alpha-tested cut-outs (texture 0’s alpha below 0.5). A node whose vertex shader moves its vertices – wind, skinning on the GPU – casts its undeformed shape unless it installs a caster of its own, as the forest does.

The environment probe

Reflections and ambient light should show the sky that is actually there: its sunset, its clouds. EnvironmentProbe renders a copy of the sky (the same shader, so the clouds come with it) into a small float cubemap and filters it into octahedral maps:

  • five GGX-prefiltered levels, for roughness 0, 0.25, 0.5, 0.75 and 1, filtered by importance sampling with the filtered-importance-sampling trick of Křivánek and Colbert: each sample reads the cube’s mip whose texels match its solid angle, so a few dozen samples stay smooth;

  • one irradiance map, the cosine-convolved sky for diffuse light.

The six maps are tiles of one texture (probe_atlas), so a material spends one texture unit on the probe. They hold the sky’s light unexposed, like every other light (a material multiplies by frame_exposure() once, at its end), and without the sun’s and the moon’s discs: a material lights itself from the key light, and the discs would count it twice. Without a probe (or after destroy()) the same functions read the sky’s tables instead.

Shaders read them through env_specular(dir, roughness), env_irradiance(n) and Karis’s split-sum env_brdf_approx (see Shaders in the Environment). The sky changes slowly, so the probe redraws every interval seconds (0.5 by default) or on refresh(); one frame draws the cube and filters it. Octahedral 2D maps are used rather than the cube’s own mipmaps because the engine cannot render into a cubemap’s mip levels.

Known limitations

  • The probe sees only the sky: no terrain, no buildings. Interiors and valleys need their own probes or occlusion.

  • The ephemerides are low-precision and the moon is geocentric: right for a sky, not for astronomy.

  • The four cascades share one atlas resolution, and distance caps how far shadows reach (300 world units by default).

  • A far cascade that skips frames shows the wind a frame or three late in its shadows.

  • Environment follows base.cam; a second camera (split screen) gets the sky but the cascades, aerial perspective and frame values are the main camera’s.