Contents

M1 engine design

How the engine turns a RenderJob into frames. Scope: everything the Lighthouse cold open needs. Read with ARCHITECTURE §5, §6 and §9, and ADRs 0002, 0004, 0008 and 0010.

Module map (engine/addons/reelwright/runtime/)

Module Owns Must not
spec/generated/ Typed data classes generated from the schemas (RwTimeline…, RwCharacter…, RwSet…, RwStyle…) Be edited by hand
util/ RwHash, RwSeed, RwEase (easing curves, matching common#/$defs/Easing), RwPalette (hex and @slot resolution), RwUnits (cm↔m, Px1080 scaling) Depend on scenes
style/ RwStyle: turns a StyleSpec into shader parameters. Shaders: 3D cel (light() banding, shadow tint, rim), ink hull for set silhouettes, backdrop paint, post (grain, vignette, weave, grade, chromatic aberration) Know about characters
stage/ RwSetBuilder: SetInstance → Node3D. Primitive meshes (prim.box, prim.cylinder, prim.ring, prim.extrude, prim.panel with openings), materials from palette slots, element parenting, backdrops (starfield.v1), lighting presets (key, fill, ambient, rim, practicals) Sample randomness except RwHash of explicit seeds
fx/ Effects as pure functions of (t, seed): sparks.v1 and a show's FxInstances (RwFxInstance), drawn by RwFxDrawing Keep simulation state between frames
character/ RwCharacterBuilder, RwCharacterRig, RwPoseSolver, RwCharacterCard, model sheet Read Timelines
player/ RwTimelinePlayer: builds the stage for a Timeline and implements evaluate(frame). RwActorState sampling, camera, transitions, stepping Draw anything itself
render/ RwFrameServer, the Movie Maker driver, the job runner (frames, stills, set_views, model_sheet), writing RenderResult Contain stage logic

The contract between player/ and character/

RwTimelinePlayer samples the Timeline into one RwActorState per actor per frame. A RwCharacterCard turns that state into pixels. These two classes are the whole interface.

class_name RwActorState extends RefCounted
var frame: int                    # episode frame being rendered
var anim_frame: int               # frame after character stepping ("on twos")
var seed: int                     # actor seed (boil, fidgets)
var visible: bool
var position_m: Vector3           # floor point under the character
var facing_deg: float             # world yaw, 0 faces +Z, 90 faces +X
var posture_a: StringName         # blend from posture_a to posture_b by posture_t
var posture_b: StringName
var posture_t: float              # eased 0..1
var emotion_a: StringName         # expression blend, same pattern
var intensity_a: float
var emotion_b: StringName
var intensity_b: float
var emotion_t: float
var viseme: StringName            # "A".."H" or "X"
var look_target_m: Vector3        # effective world gaze point (already blended)
var look_with: StringName         # "eyes" | "head" | "body"
var look_weight: float            # 0 = straight ahead along facing, 1 = fully on target
var gestures: Array[RwGestureState] # active gestures: id, phase 0..1, intensity, hand, target_m, has_target
var gait: StringName              # "" when not moving
var gait_phase: float             # cycles since the gait began (unwrapped; see below)
var move_speed_mps: float
var blink: float                  # 0 open .. 1 closed
var breath_phase: float           # 0..1
var breath_amplitude: float
var speaking: bool
var speaking_intensity: float
var lift_m: Vector3               # (M6) off the floor: the body's offset from position_m
var float_weight: float           # (M6) 0 on the floor .. 1 at the float's hang height
var squash: float                 # (M6) a drop's landing: the figure's height shrinks by this share

(implementation) gait_phase counts cycles at the compiler's cadence and is not wrapped to 0..1: the pose solver takes a step its legs cannot reach shorter and as many times faster, so a planted foot keeps pace with the floor, and it scales the count before wrapping it.

class_name RwCharacterCard extends Node3D
static func create(character: RwCharacterInstance, style: RwStyle, palette: RwPalette) -> RwCharacterCard
func apply(state: RwActorState, camera: Camera3D, lighting: RwLightingState) -> void
func get_screen_bounds(camera: Camera3D) -> Rect2   # for tests, framing checks

What apply does:

  1. Viewing angle. It computes the relative view yaw: facing_deg minus the yaw of the vector from the character to the camera. The rig uses it to pick the body view (front, three-quarter, profile or back, mirrored for the other side) and to turn the 2.5D head. (implementation) Lying down, the view turns toward the side the camera sees (as far as the body lies, 0..1, RwCharacterCard.lying_view): on a flat card a body lying along the line of sight is a blob with its head in front, whichever way it lies (the twenty-fifth watch-it's Chief under the table); from the side it lies across the frame, as a cartoon draws it.
  2. Pose. It poses the rig through RwPoseSolver: posture base pose, then gait cycle ((implementation) crouched, a shuffle: the crouch's bent legs, each foot stepping 0.12 of the leg forward and back and lifting 0.05 as it comes forward, the hips kept low), then gestures (additive, from the GestureLibrary; a targeted one aims the arm at its target, and (implementation) one of contact puts the hand on it, as far as the arm reaches, at any intensity; cover_eyes puts a palm over each eye; a dance_hold's other hand takes the partner's, halfway between them and out to its own side, so both partners' free hands meet; a shake_hands hand takes the partner's halfway between them, below the shoulders, closing round it, and a won grip goes down onto the loser's side, pin for the winner and pinned for the loser, both to one point a little past the middle; a joined hand beyond reach leans the body in, up to 45 degrees, then moves it in up to 15 cm, as across a table), then look (eyes, then head, then body), then breathing and blink, then expression, then mouth shape.
  3. Card size. It sizes its SubViewport so the character renders 1:1 with its projected screen height (clamped to 64..4096 px), and sizes and places its quad so the feet sit on position_m.
  4. Billboarding. The quad is a Y-axis (cylindrical) billboard, so characters stay upright under camera roll and tilt.
  5. Depth. The quad is pushed toward the camera by card_depth_bias_m (default 0.25 m) and scaled to keep its projected size. That way a chair behind the character's torso does not slice through the card, while a console in front still occludes it. Alpha scissor is used for crisp depth, and the ink outline is drawn inside the card's alpha.
  6. Lighting. The 2D cel shader gets a light direction projected from the 3D key light into the card plane, plus the key, fill, ambient and rim colors of the active lighting preset.

The player creates and poses cards only through RwCardFactory, which names the card class in one line. It first drew a stand-in capsule card with the same API; since the character module landed it draws RwCharacterCard.

Timeline sampling rules (player/)

  • Keys interpolate from each key to the next using the key's ease (RwEase); the last key holds. Facing interpolates along the shortest arc.
  • Blend keys (posture, expression, look) run from frame to frame + blendFrames, from the previous state to the key's value.
  • Stepping. Actor channels are sampled at anim_frame = frame - ((frame - scene.startFrame) % style.timing.characterStep). The camera uses cameraStep and FX use fxStep. Visemes arrive pre-stepped.
  • Camera. Position, look-at, lens and roll are keyed. The lens converts to a vertical field of view for a 36×24 mm film back at the output aspect (horizontal-fit). Shake is RwHash noise of (seed, frame), scaled by amplitude, at frequencyHz.
  • Transitions.
    • cut: nothing.
    • dissolve: render the previous shot's camera and scene for the overlap and blend. A previous shot that faded out (Shot.out, FADE OUT.) is black past its end, so what it blends from is that black: the post pass's black_b keeps its part of the frame black after every effect, and the dissolve comes up out of it, the frames of a fade in (implementation). A wipe or an iris from it reveals the shot over black.
    • dip_to_black: fade the frame from black over frames frames.
    • wipe/iris: a mask in the post shader; whip: both shots as one strip panning left, blurred along it.
  • On the face (implementation): Actor.mess keys blend their cover from the key before's over blendFrames; a wipe keeps the kind and color while it fades (RwActorSampler._sample_mess). The 2.5D head draws it with the face (RwHeadBuilder._add_grime, _add_lumps). Soot, dust, sand, mud and paint are blotches on the skin under the eyes and mouth, spreading from the cheeks to the nose, forehead and chin as the cover grows, with a wash over the whole face from 0.7, so a sooty face keeps its white eyes. Sand, dust and mud are specked with dark and light grains, so sand reads as sand and not as a tan. Snow and cream are inked lumps on top, and from 0.7 a splat over the eyes and nose with drips. A 3D figure's head takes the color, by the cover, in eighths.
  • On the set (implementation): Scene.damage marks are drawn by RwSetMarks, each once, at the most it is ever across, from its seed, under its piece's node: a wild wall hides its marks with it, and a lighting change relights them (RwStyleMaterials.is_cel, which now relights see-through surfaces too, as the hiccups glass of water was not). A mark lies 2 mm off its piece's front, the side facing the middle of the stage, or its top, lit as the set's surfaces are (rw_cel_mark.gdshader: the cel body with MARK, whose vertex colors shade and feather it and which draws nothing further out than reach, so it spreads from its spot as its keys grow, quick then slowing). A crack is jagged, tapering lines that fork; across the glass of an opening its piece frames (a window ring's, a panel's hole) it is pale and finer, joined by broken rings like a web, and on anything solid it is in the style's ink. A scorch is a burnt-black middle in a sooty halo that rises up an upright face; a dent a shadowed hollow over a lit lip, creased round (the lip in the piece's own color lightened, since vertex colors go no brighter than 1); a stain a splash with drops thrown round it and, upright, a drip or two. With no spot of its own a crack in a piece that frames an opening goes in the middle of the glass and anything else beside the opening, on its wider side. It never spreads past its face, nor a crack past its glass. Its triangles wind clockwise seen from the front: wound the other way the mark, drawn both sides, had its normal turned away and was lit from behind.
  • Scenes switch sets at their startFrame. The player keeps only the active scene's stage alive, plus the previous one during a dissolve.
  • Effects. An FX event plays sparks.v1, built in (RwSparks), or one of the show's effects, fx.<id>, which the job carries in documents.fx (RenderJob 1.3) and RwFxInstance draws from its FxInstance. Both are pure functions of the time since the event's start, drawn by RwFxDrawing (stars and glows added, puffs mixed with the atmosphere, a flash light). A particle emitter's particle i is born at startSec + i/rate (a burst all at startSec); its life, speed, direction (a cone of spreadDeg round directionM), start in its shape (point, sphere, box, disc, line), spin and the rest come from RwHash of the event's seed, the instance's seed, the emitter, the cycle and i; it flies in closed form with drag and gravityMps2; a star's stretch draws it that many times its length along its flight at 4 m/s or more, on top of the style's squash, keeping its width, as a trail behind it: a comet, its round white-hot head at the particle, tapering and fading to the tail ((implementation) folded into its speed, it was lost under the style's cap, and the twenty-fourth watch-it's meteors drew as sparks; stretched about its middle, half the trail ran ahead); sizeM, opacity (curves, each key's ease to the next) and color (a ramp) run over its life. Intensity thins the particles (half at 0). Sparks, streaks, debris, confetti and rain are stars stretched along their flight; smoke, steam, dust, bubbles and snow are puffs (or as sprite says). A chunk or dot sprite is a solid inked lump, its edge a little uneven and different for each (popcorn, a pebble, a crumb, a grain of sand) (implementation): the forty-first watch-it's buttery popcorn drew as four-point stars. A glow emitter is a glow over its span; the flash lights the room by its curve, as far as radiusM. A looping instance starts its emitters again every durationSec while the event lasts; the event's end stops births, and what is in the air finishes its life. (implementation) So too sparks.v1, whose smoke also shrinks with intensity (to 0.4 of its size at 0): the twenty-fourth watch-it's 0.35 s, 0.35-strong meteor strike brought a full cloud, born after it ended, that hid the crack it made. Beam and shockwave emitters are not drawn yet (fx.unsupported_emitter, once); an effect the job does not carry is timeline.unsupported_fx.

Set builder major 1 (stage/)

The core computes look targets from element geometry (element:<id> is the world-space top-center), so both sides read primitives the same way:

  • prim.box, prim.cylinder, prim.ring and prim.panel are centered on positionM.
  • Cylinder: sizeM[1] is the length along param axis (x, y or z, default y); sizeM[0] and sizeM[2] are the diameters.
  • Ring: faces +Z; outer diameter max(sizeM[0], sizeM[1]), depth sizeM[2]; params innerDiameter and rivets.
  • Extrude: param profile is SVG path data (M, L, H, V, Z) with x = depth toward the front and y = height up from positionM; centered in depth on sizeM[2], extruded along X over sizeM[0]. A trim slot draws a thin band around both end caps.
  • Panel: params opening (circle or rect), openingDiameter or openingWidth/openingHeight, and openingOffsetX/openingOffsetY from the face center (x toward the panel's local +X). Openings are real holes.
  • Any element: param tiltDeg tips its top toward its front, about local X, after yawDeg (screens on sloped consoles).
  • Interior sets (exterior: false) get a one-sided ceiling at ceilingM and a fourth wall on the downstage edge (maxZ). They are visible only from inside, cast no shadows, and take the first structure panel's color.
  • An element with wild: true is a wild wall: a real wall standing in the open side for shots from inside, so a reverse sees the room's own wall instead of the flat shell. It is built like any element but never casts a shadow, and it starts hidden. Each frame, after the camera is aimed, RwSet.view_from shows it only when the camera stands in front of its inner face (the +Z side of its geometry); a camera outside, or inside the wall, sees through it. Visibility is a function of the camera's position alone, so a frame renders the same alone or in sequence, and the room's light is identical either way.
  • kit.* pieces become magenta placeholders with a set.unsupported_piece warning.

Render path (render/)

  • Main viewport. A SubViewport at output.width × output.height with Forward+, msaa_3d 4×, own world, and post-processing on the full frame.
  • Per frame: player.evaluate(frame), update the card SubViewports, force_draw, read back, write the PNG, record its sha256. Character viewports must be drawn before the main one in the same force_draw.
  • Px1080. Every pixel-sized style value is multiplied by output.height / 1080.
  • Movie Maker path (capture: movie_maker): the same player driven by _process with --write-movie <dir>/frame.png --fixed-fps <fps> and stretch=viewport at the output size. The job runner pre-creates and empties the folder, then renames the 8-digit outputs to the 6-digit convention. Movie Maker takes its size from the project's viewport setting at startup, so the child engine runs from <dir>/.movie/project: links to engine/ plus an override.cfg with the output size. Nothing shared changes. CI compares both paths on selected frames; on Metal they match exactly.
  • Transitions smash_cut is drawn as a cut. (implementation) whip swings both pictures left as one strip in the post shader, smeared along the swing (32 taps), fastest halfway; it was drawn as a dissolve before.

Tests

  • evaluate() purity: frame N rendered alone ≡ frame N after 0..N-1.
  • Card occlusion: a console pixel stays in front of the crouched Chief.
  • Stepping: frames 2k and 2k+1 have identical actor state with characterStep = 2.
  • Golden frames of the demo at 640×360, from a fixed frame list, compared per renderer (exact on lavapipe, ±1 elsewhere) with diff images written on failure.
  • Set builder: element count, bounds and the parenting transform chain for the bridge.
  • Character: rig validation (all template bones and parts present, skin weights normalized, no degenerate polygons); the model sheet renders; head yaw sweep has no popping between adjacent 5° steps (silhouette IoU above a threshold).