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:
- A virtual camera (vcam) is a stateless description of a shot: where the camera
should be (
body), what it looks at (aim), which way is up (up), and its lens (fov). It owns no camera; it only knows how to compute those four things for a given instant. - The brain is the single component that owns the real
THREE.PerspectiveCameraand theOrbitControls. Each frame it picks the highest-priority enabled vcam, evaluates it, and writes the result to the real camera — blending smoothly when the active vcam changes.
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) |
nowisperformance.now()in milliseconds.ctxis{ camera, controls, isBlending }— the brain’s handle on the real objects. Components readctx.camera.position(e.g.framingTransposerapproaches along the current view direction) and may readctx.isBlendingto switch between a canonical pose and a steady-state behavior.- Stateless by contract. Same
now+ same scene state ⇒ same output. The brain never ticks an inactive vcam, so a vcam must not rely on being called every frame. The few stateful components (pov,localFrameRide’s “ride” steady-state) document this explicitly and re-seed themselves fromctx.isBlending.
Metadata fields:
priority(number) — higher wins. Mutated to activate/deactivate (see §5).enabled(bool) — disabled vcams are never evaluated.extensions— array of post-write{ apply(ctx, now) }hooks (see §6).nearReference—(ctx) → numberfor the adaptive near plane (see §7).onActivate(ctx)/onDeactivate(ctx)— optional lifecycle hooks (see §5).
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:
pickActive()— scans vcams, returns the highest-priority enabled one.- If that differs from the current
activeId, it firesprev.onDeactivate, snapshots the current real camera state as the blend “from” point, looks up a blend hint, flipsactiveId, notifies subscribers, and firesnext.onActivate. - Evaluates the active vcam’s
body/aim/fov/up. - Writes the result to the real camera — directly, or lerped/slerped from the
snapshot if a blend is in flight (
upis slerped viaslerpUnit). - Runs per-vcam extensions, then brain-level extensions, on the final state.
- 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:
- Static → anything blends are exact.
- Moving → moving blends are approximated by the frozen snapshot. Acceptable for the durations we use; a future v1.1 may evaluate both endpoints.
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:
world—anchor.world + offset(offset is in world axes).local—anchor.world + anchor.quaternion · offset(offset rotates with the anchor → corotating / spin-locked).inertial—anchor.translation + offset(anchor’s rotation ignored → fixed in the inertial frame; the body drifts laterally as it orbits).
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
upLocalGravitywhen the camera looks straight down its own radial (e.g. overhead → surface). The up would be antiparallel to the view direction andCamera.lookAt()rolls chaotically. UseupPlanetAxisinstead — see the comment ondest-spin-locked:earth:parisinsrc/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).
sphereConfiner({center, radius})/boxConfiner({min, max})— clamp the position.noise({amplitude, frequency, seed})— deterministic per-frame shake (handheld feel).
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:
- Read live via a number-or-getter (preferred).
transposer’s andlocalFrameRide’sgetScale,aboveSurfaceMarker/atCityMarker’splanetRadius, andtrackedDolly’spositionall accept a() => numberre-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 debouncedbrain.reactivate(activeId)to re-snap to the canonical pose (no body rebuild). SeeonScaleChangedinsrc/solar-system/vcams/destinations.js. - 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 andbrain.reactivate(activeId). Scale itself never needs this becausegetScaleis live.
A vcam reads the scale axis; it must never write it. No
setBodyScalefrom anonActivatehook — 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:
- [ ] Offsets/radii read live scale (a
getScalegetter), not captured. - [ ]
upwon’t be antiparallel to the view direction (gimbal trap). - [ ] Anchors are parented to the body whose motion they should track (and added to the scene graph so their world transform resolves).
- [ ] If a mode toggle swaps the offset config, rebuild the body + debounced
reactivate.
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.