camera
The camera of GREEN SKY. Eighteen set-ups, one camera entity, a cut every six to eight seconds.
greensky/camera
The camera of GREEN SKY. Eighteen set-ups, one camera entity, a cut every six to eight seconds.
What it is
scenes/main.scene builds exactly one camera, film_camera. This module
moves it. On the frame the film crosses a shot boundary the camera is
teleported to the next station; inside a shot it either sits still or runs
that station's authored move. There is no second camera, no priority
cutting, and nothing is ever enabled, disabled or despawned to make a cut.
The one call that matters
Camera.poseAt(t) -> position, aim, fov, roll
Pure. No engine state is read, nothing is cached between calls, and the
same t always gives the same four answers — which is what makes the film
repeatable, and what lets anything ask where a shot stands without running
the demo. Camera.apply(t) is the same answer written onto the camera.
local Film = require("greensky.film")
local Camera = require("greensky.camera")
Film.seek(107) -- the arc
Camera.apply(107) -- park the view on it
How a pose becomes a rotation
Position, aim point, vertical fov and roll go to Transform.quatFromBasis,
which takes the three axes explicitly — right and up are the entity's local
+X and +Y, forward its local -Z.
That is not the obvious choice and it is deliberate, and the reason is
roll. Transform.lookAtQuat derives a rotation from a view direction
alone, which leaves it nothing to say about the turn about that direction.
Shot 6 rolls from -14 to +14 degrees while aimed near-straight-up, and roll
here is simply the right and up axes turned about forward before the
quaternion is built.
Stating the basis buys one more thing, on the two near-vertical shots: it fixes which way is up in frame. Where the view comes within about eight degrees of vertical the up reference swaps from world-up to north, so shot 6 (looking up) and shot 13 (looking down) get an image-up this module chose rather than one a steep view direction implied — and rather than a near-zero cross product.
Measured on this engine, lookAtQuat is otherwise accurate at both poles:
aiming it at exactly +Y comes back 0.04 degrees off, and it gives a
well-defined image-up. It is roll that decides this, not pitch.
The stations
Camera.STATIONS[n] for shot n, matching Film.SHOTS. Every entry is
data:
| field | meaning |
|---|---|
from / to | camera position at shotU 0 and 1. No to means locked off. |
aim / aimTo | the point it looks at, same convention |
fov / fovTo | vertical degrees — the lens |
roll / rollTo | degrees about the view axis |
ease | "smooth" (default) or "linear" |
track | function(t) -> position, aim, overriding the above |
Ten of the eighteen are locked off, which is the shot list's intent: the world is what moves. The moves that do exist are written with three helpers so they can be checked by eye — a dolly is "this station, pushed 260 m toward what it is looking at", not a second set of coordinates.
Shot 18 restates shot 1's numbers rather than referencing them, so the film's whole argument — same station, same lens, same 28 cm off the ground, different world — can be checked by reading two rows.
camera/stations.md is that table laid out as a shot sheet.
The tracking shots
Shots 3, 9 and 12 ride KILO. Where the ship is belongs to
greensky/gliders, which exposes poseAt(ship, t) for exactly this. This
module never reaches into that slice's entities and never parents the
camera to one — the particle simulation feeds its shader the camera's local
position, so a parented camera aims every billboard wrong.
If poseAt ever answers with the world origin — which is what an unbuilt
stub returns, and which nothing in this film ever flies through — the pose
is read as "no answer" and a stand-in flies the path the shot list
describes, purely so those three set-ups still have a subject to be
composed around. A real flight path always wins.
The grade
The lens does not stop at the front element. This slice also owns the film
stock: one post-process pass, gs_grade, registered at priority 800 —
after the volumetric-cloud composite (5), after the rainbow (6), after
bloom (500), and before the blit. It carries an ASC-CDL (slope / offset /
power), contrast about a pivot, saturation, a vignette and film grain, and
it is written every frame from Film.PAL.
Camera.gradeAt(t) -> the grade table in force at film time t
Camera.grade(c) writes it into gs_grade and sets the exposure
Film.PAL.dry, .storm and .clear are the three acts; the grade in
force is dry -> storm by c.storm, then -> clear by c.clear, then
pulled toward .flash by the sky slice's discharge level. So the same two
ramps that move the light move the photograph of it, and the three acts are
authored as numbers in one table rather than as code.
Three engine facts shaped it, all measured on this world:
- The post chain is display-referred and hard-clipped at 1.0 — the
tonemap is baked into the material shader. A gain above 1 clips rather
than opening the image up, so every
gaininFilm.PALis at or below 1 and the LEVEL is carried in stops by the exposure module. - No
AutoExposurecomponent exists in this scene, and none may be added. That component ticks the exposure module with the real frame delta; this film runs on a fixed 1/24 s clock, so it would adapt at wall-clock rate and would double-tick against this slice.exposure.set{ auto = false, compensation = … }plusexposure.tick(Film.FIXED_DT), here, once a frame. - Grain runs on the film clock (
c.frame), not on wall time, so a frame-by-frame recording reproduces exactly.
Measured on a flat 18% field graded to act II: grain off gives a perfectly uniform frame (standard deviation 0.000 codes); grain on gives 2.19 codes of standard deviation, 45..53 over a 49 mean.
Files
camera.module/init.luau this module and the grade
camera/stations.md the eighteen set-ups as a shot sheet
Scoped to this part · feeds back into the world's score.