Camera

Astrarium’s camera layer is a Cinemachine-style virtual-camera system ported to Three.js. It is the canonical answer to “how does the camera get from looking at the Sun to riding alongside Mars?” — and it is built so that adding a new shot is a declarative act, not a new branch in the animation loop.

This is the developer/agent cookbook: the concepts, the component catalog, and the recipes. The conceptual place of the camera among the five animatable axes is in architecture.md; the cross-axis tween conventions (cancellation, Promises) are in storytelling.md. The module catalog (one line per file) is in CLAUDE.md at the repo root.


1. The core idea (borrowed from Cinemachine)

In a naive Three.js app the render loop mutates camera.position / controls.target directly, and every “camera mode” is an if branch fighting over the same object. Cinemachine’s insight — which we adopt — is to decouple the description of a shot from the act of driving the real camera:

The payoff: shots are data. You compose a new one from small pure functions, register it, and raising its priority makes the camera fly there. No loop edits, no mode enum.

       vcam A ─┐
       vcam B ─┤   pickActive()      blend         write
       vcam C ─┼─▶ (max priority) ─▶ (lerp/slerp) ─▶ camera.position
       …      ─┘        │                            controls.target
                        │                            camera.fov / .up / .near
                   brain owns the real camera

2. A vcam is four pure functions plus metadata

createVcam({ id, body, aim, up?, fov?, priority, enabled, extensions?, nearReference? }) (src/camera/vcam.js) returns a plain mutable config object. It holds no camera state. The four slots are pure functions of (now, ctx):

Slot Signature Produces Default
body (now, ctx) → Vector3 camera position required
aim (now, ctx) → Vector3 controls.target (look-at point) required
up (now, ctx) → Vector3 camera up axis upWorldY
fov (now, ctx) → number vertical FOV in degrees null (keep)

Metadata fields:


3. The brain

createBrain({ camera, controls, defaultBlend }) (src/camera/brain.js) is created once per view and brain.update(now) is called once per render frame (see src/views/solar-system.js). Each tick it:

  1. pickActive() — scans vcams, returns the highest-priority enabled one.
  2. If that differs from the current activeId, it fires prev.onDeactivate, snapshots the current real camera state as the blend “from” point, looks up a blend hint, flips activeId, notifies subscribers, and fires next.onActivate.
  3. Evaluates the active vcam’s body/aim/fov/up.
  4. Writes the result to the real camera — directly, or lerped/slerped from the snapshot if a blend is in flight (up is slerped via slerpUnit).
  5. Runs per-vcam extensions, then brain-level extensions, on the final state.
  6. Recomputes the adaptive near plane (§7).

API surface

Method Purpose
addVcam(v) / removeVcam(id) register / unregister
getVcams() / getActiveId() introspection
isBlending() true while a blend is in flight
setBlend(fromId, toId, {ms,curve}) per-pair blend hint (wildcards * allowed)
reactivate(id, now) re-blend after the active vcam’s body/aim was swapped out
cancelBlend() snap to the current target
onActiveChange(cb) subscribe to activation changes ({activeId,prevId,source})
setNextActivationSource(src) tag the next change’s source (e.g. 'user', 'voyage')
addExtension(ext) / removeExtension(ext) brain-level extensions

Blend model (v1 simplification — read this)

When activation changes, the brain snapshots the current camera state as the “from” point and lerps it toward the new vcam’s per-frame output. The previous vcam is not evaluated during the blend — only the incoming one fires. So:

reactivate(id) is a distinct path: the vcam is still active but its body/aim was swapped (e.g. a scale change rebuilt the closure). It re-blends from the current state to the new output without firing lifecycle hooks. Debounce calls to it — each one restarts the blend (see the scale handler in §9).


4. Component libraries

You rarely write a raw body/aim function. You compose a vcam from the catalog of factory functions. Each factory takes config and returns the pure (now, ctx) closure.

body — position (src/camera/body.js)

Factory Shot
transposer anchor world position + offset, in world / local / inertial frame
framingTransposer back off so the target’s bounding sphere fills the FOV
orbitalTransposer orbit a target at (radius, azimuth, elevation); optional auto-spin
trackedDolly ride a Catmull-Rom spline at parameter t ∈ [0,1]
hardLockToTarget sit exactly at a body’s world position (first-person attach)
aboveSurfaceMarker hover altitudeFactor · radius above a surface marker (satellite view)
atCityMarker sit on a surface marker (human-on-the-ground view)
localFrameRide ride a rotating-frame anchor: canonical pose during the blend, then preserve user drag while riding the anchor’s rotation
doNothing preserve ctx.camera.position — lets OrbitControls drive freely

transposer frames:

localFrameRide({ anchor, offset, getScale }) is the generic “fly in, then ride and let the user orbit” body. offset is a fixed vector in the anchor’s local frame; during the brain’s blend it holds that canonical pose, and after the blend it preserves the user-dragged offset while re-expressing it in the anchor’s current orientation. It replaced the old bespoke sunRelative* family — the Sun-relative framings are now just localFrameRide on a sunRelativeAnchor (see Anchors below), and the seasons/tides shots are the same body with a different local offset.

aim — look-at target (src/camera/aim.js)

Factory Look-at
composer / hardLookAt a body’s world position (hardLookAt is the naming alias)
groupComposer a TargetGroup’s weighted centroid
sameAsFollowTarget mirror the body’s follow target (passed explicitly)
pov first-person yaw/pitch from pointer drag (stateful)
lookHorizon (azimuth, altitude) in the local ENU frame at a marker
lookPanorama auto-rotating azimuth around a marker (cinematic 360° pan)
lookSunset/SunriseDirection the horizon point where the Sun sets/rises at that latitude/date
lookMoonrise/MoonsetDirection same, for the Moon
lookAtSunwardBiased toward the Sun, pulled off the planet center to keep Sun in frame
lookAntisolar opposite the Sun (rainbow / Belt-of-Venus arc)
doNothing preserve ctx.controls.target (free-orbit)

pov is the only stateful aim and the only one returning { aim, onActivate, onDeactivate } — it disables OrbitControls and attaches a pointermove listener while active. The look{Sun,Moon}* family takes getJD() (the live simulated Julian Date) and runs through src/physics/geo.js + seasons/moon models, so the look direction is astronomically correct for the current date and observer latitude — a declRadFn test seam bypasses the JPL pipeline.

up — up axis (src/camera/up.js)

Factory Up vector
upWorldY world (0,1,0) — the default
upLocalGravity local radial away from the planet’s center (standing on a surface)
upPlanetAxis the planet’s spin axis (tilted bodies keep their pole vertical)
upRolled decorator: roll a base-up by rollDeg around the view direction (dutch angle)

slerpUnit(from, to, t) is the spherical-interpolation helper the brain uses to blend up (handles the antipodal-flip singularity deterministically).

Gimbal trap: never use upLocalGravity when the camera looks straight down its own radial (e.g. overhead → surface). The up would be antiparallel to the view direction and Camera.lookAt() rolls chaotically. Use upPlanetAxis instead — see the comment on dest-spin-locked:earth:paris in src/solar-system/vcams/follow-modes.js.

Anchors (src/camera/anchors.js)

Synthetic Object3D-ducks whose world transform feeds transposer / localFrameRide / aim. They are not auto-added to the scene — the caller parents them so the world transform tracks the right body.

Factory Anchor
surfaceAnchor a (lat, lon) point on a planet, parented to its spin mesh
overheadAnchor surfaceAnchor with altitude (geostationary-above-city)
corotatingFrameAnchor two-body frame: +X = primary→secondary, recomputed each frame
sunRelativeAnchor planet-centered frame, +X = Sun→planet outward (derived from live geometry, so an inclined orbit tilts the frame); a thin wrapper over corotatingFrameAnchor
orbitalSidelineOffset a dynamic offset perpendicular to the sun→planet line (inertial sideline)

Compound target (src/camera/target-group.js)

createTargetGroup({ targets: [{target, weight}, …] }) — a duck with getWorldPosition (weighted centroid) and radius (bounding sphere covering all members). Feed it to framingTransposer / groupComposer to frame a system (e.g. Earth + Moon together).

Extensions (src/camera/confiner.js, src/camera/noise.js)

Post-write hooks { apply(ctx, now) }, run after body/aim/fov, before the near-plane pass. Per-vcam (in extensions) or brain-wide (brain.addExtension).

Easings (src/camera/easings.js)

linear, easeInOutCubic, easeOutQuint, easeInOutCosine — named curves for blend hints and flyToVcam.


5. Priorities, activation, and lifecycle

Activation is just priority. The convention in this project (see src/solar-system/vcams/destinations.js):

Priority Role
0 parked / inactive destination
10 free-orbit baseline (OrbitControls drive freely)
20 the active destination
9999 reserved: a flyToVcam in flight (storytelling band)

activate(id) sets the chosen vcam to 20, free-orbit to 10, everything else to 0. The brain notices the priority change next tick and blends.

Lifecycle hooks fire on activation change: onDeactivate on the outgoing vcam, then onActivate on the incoming one. pov uses them to toggle OrbitControls; the satellite vcams flip a scene layer on (sticky). reactivate does not fire them — the vcam is still active, only its output moved.


6. Adaptive near plane (#183)

The near plane is a scale-dependent quantity, so the brain manages it adaptively rather than hardcoding a value per vcam. Each tick:

refDist = vcam.nearReference?.(ctx) ?? distance(camera, controls.target)
near    = min(baseNear, max(1e-6, refDist · 0.02))

nearReference is the distance to the nearest framed geometry (not the near value itself). Absent, the brain falls back to the look-target distance — a good default for object-framing cams. The renderer’s logarithmic depth buffer absorbs the resulting wide near:far ratio, so a body stays visible whether it’s rendered at true scale (tiny) or exaggerated. Never hardcode a fixed near — the old per-vcam 0.0001 is gone.


7. Scale-axis discipline (#183) — the rule that bites

Every geometric value a vcam consumes — an offset magnitude, an anchor radius, a framing distance — must read the live effective scale, not a value captured once at construction. The scale axis (src/solar-system/state/scale.js) is user/voyage-driven and changes at runtime; a captured p.radius becomes stale and the body either vanishes into the near plane or floats far off-frame. Two patterns cover the camera:

  1. Read live via a number-or-getter (preferred). transposer’s and localFrameRide’s getScale, aboveSurfaceMarker/atCityMarker’s planetRadius, and trackedDolly’s position all accept a () => number re-read every frame. Pass a live getter (e.g. earthEffectiveRadius), not a captured number. The destination cams use this path — their offsets reframe automatically as the scale changes; a scale change needs only a debounced brain.reactivate(activeId) to re-snap to the canonical pose (no body rebuild). See onScaleChanged in src/solar-system/vcams/destinations.js.
  2. Rebuild + reactivate on a config (not scale) change. When a mode toggle swaps a body’s offset config — e.g. the sun-visible toggle swaps localFrameRide’s local offset vector — rebuild the affected bodies and brain.reactivate(activeId). Scale itself never needs this because getScale is live.

A vcam reads the scale axis; it must never write it. No setBodyScale from an onActivate hook — exaggeration stays an explicit user/voyage choice.


8. Storytelling adapter (src/camera/storytelling.js)

For cinematic voyages (#96), flyToVcam(brain, id, { duration, curve }) → Promise<id> wraps activation in a Promise: it registers a per-pair blend hint, bumps the target to priority 9999, and resolves when the blend settles (redistributing priorities to keep the target winning). A new flyToVcam mid-flight cancels the previous one silently (resolves at current state) per the storytelling cancellation policy.

lockUserInput(controls) / unlockUserInput(controls) gate OrbitControls during a voyage; isLocked() / onLockChange(cb) expose the edge-triggered lock state for ambient UI (the timeline dims itself while a voyage runs).


9. Recipe: add a new shot

import { createVcam } from '../../camera/vcam.js';
import { transposer } from '../../camera/body.js';
import { composer } from '../../camera/aim.js';
import { upPlanetAxis } from '../../camera/up.js';

brain.addVcam(
  createVcam({
    id: 'dest-my-shot',
    priority: 0, // parked; activate(id) raises to 20
    body: transposer({
      anchor: marsParentGroup,
      offset: orbitalSidelineOffset({ planet: marsParentGroup, sun, distance: 8 }),
      frame: 'inertial',
    }),
    aim: composer({ lookAt: marsParentGroup }),
    up: upPlanetAxis({ planet: marsParentGroup }),
    fov: () => 30,
    // nearReference: (ctx) => ctx.camera.position.distanceTo(marsWorldPos), // if needed
  })
);

For a “fly in and let the user orbit while it tracks the body” shot, use localFrameRide on a sunRelativeAnchor instead — that’s how the planet/moon/seasons/tides destinations are built. Then wire a UI/voyage trigger to activate('dest-my-shot') (or flyToVcam(brain, 'dest-my-shot') for a scripted flight). That’s the whole flow — no render-loop edit.

Checklist:


10. Cinemachine ↔ Astrarium map

Cinemachine Astrarium
CinemachineBrain brain.js
CinemachineVirtualCamera + Priority vcam.js + priority field
Body: Transposer (binding modes) transposer({ frame: world / local / inertial })
Body: Framing Transposer framingTransposer
Body: Orbital Transposer orbitalTransposer
Body: Tracked Dolly trackedDolly
Aim: Composer / Group Composer composer / groupComposer
Aim: POV pov
Aim: Same As Follow Target sameAsFollowTarget
Target Group createTargetGroup
Extension: Confiner sphereConfiner / boxConfiner
Extension: BasicMultiChannelPerlin noise
Blends / Custom Blend asset defaultBlend + setBlend(from, to, …) hints
Timeline / Cinemachine Shot flyToVcam + the voyage runner

What we don’t mirror: dead-zone/soft-zone composer damping, the OrbitalTransposer’s free-look heuristic-assist, and (today) two-sided moving blends. Net-new for astronomy: the localFrameRide body + sunRelativeAnchor (Sun-relative “ride” framing), the look{Sunset,Sunrise,Moonrise,Moonset,Antisolar} aim family, the ENU/lat-lon anchors, the spin-axis up, and the adaptive near plane.

← Astrarium