sky
Everything in GREEN SKY that is above the ground.
The slice registers itself with greensky/film as sky (order 20), builds
once from M.build(), and is ticked once per rendered frame with the film
context. It owns five things:
| what | how it moves |
|---|---|
| The storm deck | a VolumetricSky (v5) volume, y 700–5200, AABB half-extent 3800. Its whole preset table is rebuilt and reassigned every frame: coverage runs 0.47 sparse → 0.11 solid → 0.42 breaking up, the base drops from 1450 m to 700 m and the top rises to 5200 m, anvilBias flattens to 0.95, and windSpeed carries the mass bodily east. |
| Lightning | 17 discrete events on the film clock, 3–5 frames each. Every one drives BOTH the cloud preset's sun (swung low and sideways, cold, ~44×) AND the scene's directional light (down out of the base, cold, ~23×) AND the ambient — so the cloud volume and the ground agree about where the light came from. Each event calls score.thunder(distance, energy) on its first frame. |
| The sun | one directional Light plus the separate sun inside the cloud preset, both driven from one bearing/elevation pair that crosses from 292°/58° to 295°/9.5°. Colour and intensity blend Film.PALETTE dry → storm → clear. |
| Rain shafts | six dark columns hanging under the cloud base, drifting east at 21 m/s and lengthening from 430 m down to 60 m above the pan as the cell matures. Gone by 1:53. |
| The rainbow | three arcs of boxes about the antisolar point at bearing 115° — a primary at the 42° cone, a secondary at 51°, and a third lying over onto the flood. Absent until 1:41, full by 1:52, held to the end. |
It also owns the background gradient the clouds composite over: the scene
ships no sky, so the slice spawns sky_backdrop with a ProceduralSky and
drives its horizon/zenith/ground colours across the three regimes. Its own sun
disc is off — the deck covers the sky for most of the film and a disc in the
wrong place is worse than no disc.
Entities
All parented under the SKY anchor, all rebuilt from scratch by build():
SKY/
sky_backdrop the ProceduralSky gradient
sky_rain_shaft_1 .. 6 the shafts
sky_rainbow_primary_1 .. 46 the 42-degree arc
sky_rainbow_secondary_1 .. 46 the 51-degree arc
sky_rainbow_reflected_1 .. 46 the arc lying in the flood
The deck itself is the scene's own volumetric_sky entity, and the two lights
are the scene's own sun and ambient. The slice drives them; it does not
create them.
Greybox materials live in /zero/source/greensky/sky/: gs_shaft, gs_bow,
gs_bow_dim. They are flat PBR colours and nothing more — the refine stage
replaces the arcs with a spectral surface shader and the shafts with
precipitation.
The two suns
sun (the scene's directional Light) lights geometry. The cloud preset's
sunDir / sunColor / sunIntensity light the cloud volume. They are
entirely separate systems and nothing couples them, so every lightning event
and every step of the sun's arc writes both. The cloud volume also keeps a
legible sun through act II — a supercell's mass is a bright bruised grey while
the ground under it is nearly black, and crushing both to the ground's exposure
loses the middle of the film to a black frame.
Quality knobs
build() sets stepSize = 42, maxSteps = 288 on the component. The raymarch
advances stepSize per iteration and stops at maxSteps, so the two together
bound how far a ray travels inside the volume. The shipped 4 × 224 reaches
896 m, which crosses the package demo's 700-metre layer; this deck is 4,500 m
thick and 7,600 m across — an 8.8 km diagonal — so 288 × 42 (12 km) is what
covers it from every station in the film.
The discharge is a light event, not a fill
A strike lasts three to five frames. That is not long enough to be a light
going off — a fill that appears and vanishes inside a fifth of a second
reads as a cut. So the raw strobe from flashNow runs through an envelope
with an instantaneous attack and a half-second release, and everything
downstream rides the envelope rather than the strobe: the scene's sun and
ambient, the background gradient, the cloud tint, the bloom and the lens.
M.flashLevel carries it, rewritten every frame.
Read M.flashLevel, not flashNow. Any slice responding to the storm's
light — the grade blending toward Film.PAL.flash, the flood's specular,
the score ducking — wants the envelope. The strobe is the channel; the
envelope is what a camera and an ear see of it.
Three things happen on it:
| The world lights up | The cloud preset's sun goes to PALETTE.flash.sunI (46, inside the raymarch's own tone mapping, where it is what makes the mass read as lit from within). The scene's directional and ambient go to PALETTE.flash.lightI / .ambI — a completely different budget, because the frame the camera takes is display-referred and clipped at 1.0. A scene light far above the act-I sun does not photograph as brighter, it photographs as white with nothing left in it. 6.5 and 0.9 sit a stop and a half over hot act-I noon and about three and a half stops over the storm they interrupt, and the violence comes from the second number. |
| The deck blooms | gs_bloom, this slice's own post-process pass at priority 500 (after the cloud composite at 5). Its bright pass weights every tap by the depth attachment rather than by luminance alone: sky reads depth exactly 0, so sky_gain scales the storm deck's contribution and ground_gain scales the pan's and the flood's. A tap's gain is decided by where the tap stands, not by the pixel it lands on, so the mass glows outward past its own silhouette. Baseline is a faint airglow on the deck's shoulders and nothing on the ground; at full discharge the threshold drops, both gains come up and the halo widens. |
| The frame stops down | gs_bloom's scene_gain, at 2^(-1.2 · flashLevel). It scales the SCENE and not the bloom added to it, so the world darkens around a discharge while the discharge does not — which is what a frame stopping down for a light source looks like. |
Why the stop-down is not exposure. The exposure module has one
compensation slot and the camera slice already owns it — Film.PAL carries
ev per act and hands it over every frame. Two writers on one slot is a
last-writer-wins bug that presents as a flicker nobody can find. Scaling the
scene alone is also the better picture: exposure scales the finished image,
discharge included, which stops the whole frame down uniformly and reads as
a dim flash rather than a bright one in a dark world. M.baseEV and
M.drivesExposure are the seam if a slice ever wants the dip routed through
the exposure module after all; drivesExposure is false and the module is
the camera slice's alone.
Why the bright pass cannot find the discharge by brightness. Nothing in this frame ever exceeds 1.0 — the tonemap is baked into the material shader, so the whole post chain runs on display-referred data (#6215). A threshold low enough to catch a lightning channel also catches sunlit salt crust, and bloom plus a lens flare at that threshold takes a plain act-I frame to uniform white. Hence the depth split, and hence every gain being driven from the film clock rather than from anything in the picture.
lensFx rides the envelope too, at a threshold of 0.94 with a nearly-closed
flare — its halo and streak belong to the discharge alone. Its baseline is
small rather than zero so the render feature stays resident between strikes
instead of being released and rebuilt seventeen times. Grain is left at 0;
that belongs to the grade.
Inspection
Three hooks, all inert in the film:
local Sky = require("greensky.sky")
Sky.hold = 61.0 -- pin the film clock: every slice sees one instant,
-- the camera stops on its station, and two captures
-- of the same moment are comparable
Sky.flashOverride = 0.95 -- force the discharge level without waiting for one
Sky.photoFrozen = true -- stop this slice rewriting the bloom pass and the
-- lens every frame, so one property can be held at a
-- stated value and photographed on its own
Sky.report(61.0) -- what the sky is doing at that film second, plus the
-- bloom pass's live status and the lens's settings
Sky.hold = nil releases the clock. Leave all three inert.
A module field is not a reliable channel here. After a hot reload — of
this file, or of greensky/film, which it requires — the table require
hands an execute is sometimes not the table the film is ticking, and a
field write then takes, reads back as the value written, and reaches
nothing. It is intermittent, it comes back on its own, and every read on
the wrong table is plausible, so a probe reports a healthy sky while the
world ignores it. So each hook is also carried as an attribute on the SKY
anchor, which is scene state and belongs to no VM:
local sky = entity(entity.find("SKY").id)
sky.attribute.set("gs_hold", 63.5) -- same as Sky.hold
sky.attribute.set("gs_flash", 1.0) -- same as Sky.flashOverride
sky.attribute.set("gs_photo_frozen", 1)
sky.attribute.get("gs_flash_level") -- the envelope, published every frame
Resolve the anchor by name at both ends. A scene reload rebuilds it, so an id cached before the reload names a different entity and puts the writer and the reader on two different attribute tables — which looks exactly like the hook doing nothing.
Clear a hook with sky.attribute.remove(name). set(name, nil) leaves the
previous value in place, so a probe that "cleared" its override that way
holds a discharge on the world for every other viewer until the anchor is
rebuilt.
Scoped to this part · feeds back into the world's score.