# film
The clock, the shot table and the palettes of GREEN SKY. Every other slice
talks to this module and to nothing else.
## The clock
A fixed **1/24 s per rendered frame**, ignoring the real frame delta. The
film is therefore byte-identical on every run whatever the frame rate, so
it can be recorded a frame at a time and retimed on the host.
Film.FPS 24
Film.FIXED_DT 1/24
Film.DURATION 122 seconds
`scenes/main.scene`'s entrypoint calls `Film.build()` once on load and
`Film.tick()` once per frame. There is no cutscene, no coroutine timeline
and no `task.wait` chain anywhere in the demo.
## The shot table
`Film.SHOTS` — eighteen entries, `{ n, seconds, act, name, brief }`.
`seconds` is the only timing authority in the film; `greensky/camera` owns
where each set-up stands. Shot numbers are stable and are never renumbered,
because every slice keys off them.
Film.shotAt(t) -> the shot on screen at t, and seconds into it
Film.shotStart(n) -> when shot n begins
## Registering a slice
local Film = require("greensky.film")
local M = {}
function M.build() end -- once, on scene load
function M.update(c) end -- every frame
Film.register("pan", M, 10) -- lower order ticks first
The context `c` every update receives:
| field | meaning |
|---|---|
| `t` / `u` | film seconds, and 0..1 across the whole film |
| `dt` | always `FIXED_DT` |
| `frame` | integer frame counter from 0 |
| `shot` / `shotT` / `shotU` | the shot on screen, and progress through it |
| `cut` | true on the first frame of a new shot |
| `act` | 1, 2 or 3 |
| `storm` / `flood` / `clear` | the three progressions, 0..1 |
`Film.arcAt(t)` gives those last three at any time, so a slice can ask what
act III looks like without waiting for it.
## Two palettes, and they are not the same thing
**`Film.PALETTE`** is the **light** — what the sun, the sky and the ambient
are doing in the world, per act, plus the discharge. `greensky/sky` reads
it every frame.
**`Film.PAL`** is the **photograph taken of that light** — the response of
the camera's negative, per act. `greensky/camera` reads it every frame and
writes it into the `gs_grade` post-process pass. Its fields are `ev`
(exposure compensation in stops), `exposure`, `contrast`, `pivot`,
`saturation`, `vignette`, `vigInner`, `grain`, and the ASC-CDL triplets
`lift` (offset, moves the blacks), `gain` (slope, the whites) and `gamma`
(power, the mids).
Both carry `dry`, `storm`, `clear` and `flash`, and both are interpolated
by the same `c.storm` / `c.clear` ramps, so the light and the photograph of
it always agree about which act it is.
`Film.mixGrade(a, b, x)` mixes two `Film.PAL` entries.
Every `gain` in `Film.PAL` is at or below 1 on purpose: the post chain is
display-referred and hard-clipped at 1.0, so a gain above 1 clips the image
rather than opening it up. Level lives in `ev`, in stops.
## The site
`Film.SITE` — the numbers more than one slice needs about the place: pan
radius, runway length and width, hangar position, cloud base and top, the
cloud volume's horizontal extent, where the storm enters and leaves, and
how deep the flood gets. Everything must live inside 8 km of every camera
station, because the far plane is hard-fixed at 8000.
## Curves
Film.ramp(t, a, b) smoothstep, 0 before a and 1 after b
Film.lerp(a, b, x)
Film.mix3(a, b, x) mix two {r, g, b}
Film.clamp01(x)
## Files
film.module/init.luau this module
../design/spec.md read this before changing any of it
../design/shotlist.md the eighteen shots as written
# gs_water.material
The one material `pan_flood` and `pan_flood_far` draw with. It binds
`greensky.pan.gs_water` in the blend queue with `depth_write: true`, which is
the pair of keys the surface needs: the blend queue is what makes
`zero::scene_color` and `zero::scene_depth` read THIS frame's opaque scene
rather than the previous frame's, and the depth write is what keeps the near
chopped sheet in front of the flat disc that carries the water to the horizon
where the two overlap.
Every value here is a starting point. `greensky/pan/water` rewrites the sky,
deck, discharge, arc, chop, rain and clarity properties every frame from the
film's clock, so what is in this file is only what the surface looks like
before the film starts running.
# 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:
```luau
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:
```luau
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.