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:
the game’s own look, which
Blendcross-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;an
HourCycle: per-hour values of a few of its fields, interpolated round the clock;layers on top – the weather, a season, any of the game’s own – each a sparse dict of fields that a
Layercross-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_evandfog_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_tintandmoon_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
tapshardware-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. Underacron.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
ceveryintervals[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 thanredraw_shiftof its radius (0.2) or the light has turned more thanredraw_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
distancecaps 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.
Environmentfollowsbase.cam; a second camera (split screen) gets the sky but the cascades, aerial perspective and frame values are the main camera’s.