Skip to content

Camera

A streamlined, code-driven camera system: one-call rigs for the common camera styles, with camera blending, procedural noise (handheld sway), and impulse shake — and no draw() plumbing. You create a rig once; the engine ticks the camera each frame and pushes the view/projection itself.

import "engine" for Scene, Cam, Ease, Impulse
class Game {
construct new() {}
init() {
_scene = Scene.new()
_player = _scene.addNode("player")
// One line. No camera node, no setViewMatrix, no aspect math.
_cam = Cam.thirdPerson(_scene, _player).distance(6).shoulder(0.6, 1.6).damping(0.25)
}
update(dt) { /* move _player; the camera follows */ }
draw() { _scene.draw() } // that's it
}

Rigs are driven by a per-scene director that solves the active rig at the end of each frame (after your update, so it tracks final transforms) and writes the view/projection. It coexists with the manual Graphics.setViewMatrix path — a scene with no rig behaves exactly as before, and a manual push in draw() overrides the director that frame (last-write-wins). See the Camera Showcase example.

Static factory. Each Cam.<preset> creates a real camera + node in the scene and returns a CameraRig; the first rig created for a scene becomes the live camera automatically.

Returns: CameraRig — An over-the-shoulder follow camera that booms out behind target and faces it.

Parameters:

  • scene (Scene) — The scene to drive.
  • target (Node) — The node the camera follows.

Returns: CameraRig — Hard-locked to head (an eye node) with POV mouse-look.

Returns: CameraRig — Looks down at target from height metres above (strategy/RTS).

Returns: CameraRig — Orbits target on a ring of radius, heading/elevation driven by the mouse.

Returns: CameraRig — A WASD + right-drag mouse-look fly camera (no target). The direct replacement for a hand-rolled free camera.

Returns: CameraRig — A static rig pinned to node’s authored world pose — it sits exactly at the node’s position and reproduces its facing, with no follow, damping, or look. If node carries a Scene.Camera (e.g. a camera instantiated from a .blend), the rig adopts that camera’s lens (blend focal length → vertical FOV, near, far). This is how a camera authored in Blender drives the rig system: instantiate the .blend camera, find its node, and hand it here — scene.draw() then renders from exactly the pose the artist framed, through the same director every code-created rig uses. Make it live with Cam.setActive, or Cam.blendTo between it and a gameplay rig. See the Blender Camera Demo example.

Parameters:

  • scene (Scene) — The scene to drive.
  • node (Node) — The node whose authored world pose (and, if present, Scene.Camera lens) the rig adopts.

Makes rig the live camera immediately (an instant cut).

Eases from the live camera to rig over seconds, using an Ease curve (position lerp + rotation slerp + lens crossfade).

Returns: CameraRig or null — The rig currently driving scene’s camera.

Author a scene’s camera rigs — and an optional split-screen layout — as data in a cameras TOML, loaded in one call (ADR 0104). It is the data-authored counterpart to the code-side Cam.thirdPerson(...).distance(...) chains above: the loader drives the same presets and the same field mapping the fluent setters use, so a TOML rig is identical to its coded equivalent. Re-frame a game’s cameras by editing the file, no recompile. See the Camera TOML example (the data-authored twin of the Camera Showcase).

Returns: Num — the number of rigs created.

Parameters:

  • scene (Scene) — The scene whose camera director the rigs are built on.
  • path (String) — A cameras TOML in the mounted project.

Reads and parses the file, then for each [[rig]] picks the preset by type, applies the overrides, resolves target by SceneNode id (the Scene.addNode name), registers the rig’s name for Cam.rig, and marks the active rig live. An optional [splitscreen] table builds a SplitScreen layout. Parsing never throws — a malformed file yields a partial or empty result (0 rigs).

Returns: CameraRig or null — the rig registered under name in the loaded TOML, or null if none matches. Use it to blend to a named rig, make it active, or tweak it after load.

Each [[rig]] picks a preset by type and applies overrides; omitted keys keep the preset default. The key names mirror the CameraRig setters. The [splitscreen] table is optional. The method names and these key names are a frozen contract (ADR 0104).

[[rig]]
name = "follow" # optional handle -> Cam.rig(scene, "follow")
type = "thirdPerson" # thirdPerson | firstPerson | topDown | orbit | freeFly | static
target = "player" # SceneNode id (the Scene.addNode name); omit for freeFly
active = true # make this the live camera on load
# all optional overrides (omitted keys keep the preset default):
distance = 6.0
damping = 0.25
offset = [0, 0, 0]
shoulder = [0.6, 1.6] # third-person over-the-shoulder (x, eye height)
fov = 0.9 # radians
near = 0.1
far = 500
sensitivity = 0.003
height = 20 # topDown
radius = 11 # orbit
moveSpeed = 5 # freeFly
noise = [0.12, 0.5] # handheld sway (amplitude, frequency)
collider = 0.3 # enable occlusion collider with this padding
# Damping repair + zoom (ADR 0118). Omitted keys keep the preset default, which for
# every one of these is "behave exactly as before".
pivotDamping = 0.12 # smooths the boom ANCHOR
aimDamping = 0.18 # smooths what the camera POINTS AT
dampingAxes = [0.05, 0.4, 0.1] # per-axis tau [lateral, vertical, boom]; NEGATIVE = inherit damping
lookahead = 0.25 # lead a moving target by this much of its velocity
lensDamping = 0.2 # ramp FOV/near/far instead of popping
zoomRange = [2.0, 14.0] # dolly clamp; omit for unclamped
zoomStep = 1.15 # multiplicative factor per zoom notch
# Look controls (ADR 0116) — these shipped as Wren setters and had no TOML keys until
# ADR 0118; a rig authored in data could not express what the same rig in code could.
invertX = false
invertY = true
pitchClamp = [-0.5, 1.25] # radians
[splitscreen] # optional
players = ["P1", "P2"] # SceneNode ids
layout = "grid" # auto | horizontal | vertical | grid | angled
distance = 6
height = 1.7
dynamic = true
mergeDistance = 8
splitDistance = 12
transitionDuration = 0.4
peel = false
compositeMode = false
perPlayerInput = true

type = "static" is a fixed rig pinned to its target node’s authored world pose (position + facing), with no follow, damping, or look — the data-authored counterpart to Cam.fromNode (ADR 0106). When the target resolves to a .blend camera node and no explicit fov is set, the rig adopts that camera’s lens (blend focal length → FOV, plus near and far unless overridden) — so a .blend camera drives the director purely from data. Set fov (and/or near/far) to override the adopted lens. This replaces the earlier v1 stub that aliased static to thirdPerson.

import "engine" for Scene, Cam, Ease
// The whole camera setup — a third-person "follow" rig (active) + an "overview" orbit rig — in one call.
var n = Cam.loadAssets(scene, "cameras.toml")
// Later: blend to a TOML-named rig.
Cam.blendTo(Cam.rig(scene, "overview"), 1.2, Ease.easeInOut)

A live camera rig. Every setter returns the rig, so calls chain. Which setters are meaningful depends on the rig kind (e.g. radius for orbit, height for top-down, moveSpeed for free-fly).

Stability: the Cam factory, the five named rigs, Ease, Impulse, and blend behaviour are a frozen contract (ADR 0058). The CameraRig setter parameter names/order are experimental for one release while they settle — pin your engine version if you depend on their exact shape.

Shared setters: distance(d) (boom length) · damping(tau) (smoothing time-constant in seconds; 0 = locked) · offset(x, y, z) · fov(radians) · near(n) · far(f) · sensitivity(s) (radians of look per pixel) · noise(amplitude, frequency) (handheld sway; 0 clears) · collider(padding) (pull the camera in front of any wall that would occlude the target, keeping padding metres of clearance — a masked physics raycast; see the collider example) · setActive() · blendTo(seconds, ease).

Per-rig setters: shoulder(x, y) (third-person) · height(h) (top-down) · radius(r) (orbit) · moveSpeed(u) (free-fly).

Config getters: getDistance() · getDamping() · getFov() · getNear() · getFar() · getSensitivity() · getMoveSpeed() · getShoulder() → [x, y] · getZoomRange() → [min, max] · getPivotDamping() · getAimDamping() · getLookahead() · getLensDamping().

Read-modify-write is the point — it is literally what a zoom step is:

rig.distance(rig.getDistance() * 0.9)

Three quantities are damped separately, because they are different things:

SetterSmoothsDefault
damping(tau)the camera eye (where it sits)0.2 on third-person
pivotDamping(tau)the boom anchor (where the boom hangs from)0 — off
aimDamping(tau)the look target (what it points at)0 — off

dampingAxes(lateral, vertical, boom) overrides damping per axis, in camera-yaw-local space. Vertical (stairs, jump arcs) usually wants a much longer tau than lateral (strafe), and one scalar cannot say that.

lookahead(seconds) leads a moving target by a fraction of its velocity, and vanishes at rest.

zoom(notches) applies dolly zoom — positive moves the camera closer. It is multiplicative, so one notch feels the same at 2 m and at 20 m, and it needs no dt: notches are discrete events, and the body solver already damps toward the new boom, so a stepped input produces a smooth move.

var w = Input.mouseWheelY()
if (w != 0 && !Gui.isAnyWindowHovered()) rig.zoom(w)

Clamp it with zoomRange(min, max) and set the per-notch factor with zoomStep(factor) (must be > 1). lensDamping(tau) ramps FOV instead of popping — that is what an aim-down-sights transition wants.

There is deliberately no “aim mode” in the engine. blendTo carries the live rig’s look state into the incoming rig, and a blend already lerps position, slerps rotation and interpolates the whole lens — so an over-the-shoulder aim is just a second rig you blend to:

_hip = Cam.thirdPerson(scene, player).distance(4).shoulder(0.6, 1.6)
_ads = Cam.thirdPerson(scene, player).distance(1.6).shoulder(0.35, 1.55).fov(0.7).lensDamping(0.12)
// on RMB down / up
_ads.blendTo(0.15, Ease.Out)
_hip.blendTo(0.2, Ease.Out)

Easing curves for blends (integer constants): Ease.linear · Ease.easeIn · Ease.easeOut · Ease.easeInOut (smoothstep) · Ease.cut (instant).

Shakes the scene’s live camera with a decaying, distance-attenuated impulse originating at the world point (x, y, z) — for hits and explosions. Concurrent impulses sum.

onPlayerHit(dmg, hx, hy, hz) { Impulse.emit(scene, 0.4 + dmg * 0.05, hx, hy, hz) }

Local co-op split-screen. SplitScreen.new(scene, players) gives each player its own third-person camera in its own screen region; the renderer draws the scene once per viewport with a divider between them. With dynamic merge/split the views fuse into one group-framed camera when the players cluster and split back apart when they spread — see the split-screen example.

import "engine" for Scene, SplitScreen, SplitLayout
var split = SplitScreen.new(scene, [p1, p2, p3, p4]) // players = List of Node
split.layout(SplitLayout.grid) // auto / horizontal / vertical / grid
split.distance(7)
split.height(2)
split.dynamic(true) // merge/split by proximity
split.mergeDistance(5) // spread below this → merge to one view
split.splitDistance(11) // spread above this → split apart (hysteresis)
split.transitionDuration(0.6) // animate the merge/split over 0.6s (0 = instant)

Returns: SplitScreen. players is a List of Node — one third-person camera is created per player. The first SplitScreen for a scene takes over rendering (the single-camera path is suspended while it is active).

  • layout(mode) — a SplitLayout: how the regions are arranged.
  • distance(d) · height(h) — boom length / eye height applied to every player camera.
  • dynamic(on) — Bool; enable proximity-based merge/split.
  • mergeDistance(d) / splitDistance(d) — the hysteresis band: players within mergeDistance merge to one group-framed view; spreading past splitDistance splits them apart.
  • transitionDuration(s) — seconds the merge↔split blend takes. The cells hold in place while each camera eases from its player pose to the shared group pose and the dividers fade, then collapse to one view. 0 is the old instant hard-switch. Default 0.4.
  • compositeMode(on) — Bool; force the RTT composite path (each viewport renders to an offscreen target, then composites) instead of the axis-aligned sub-rect path. Required for overlap / picture-in-picture and enabled automatically by SplitLayout.angled. The composited disjoint split is pixel-equivalent to the fast path, so turning it on is always safe.
  • peel(on) — Bool; the animated rect-peel merge. While a merge is in flight, slide each viewport’s rect from its split cell toward fullscreen (z-stacked, over the composite path) — the Kronnect “views expand together” look — instead of the default fixed-rect camera-converge blend. See /examples/peel.
  • perPlayerInput(on) — Bool (default on); player i reads gamepad i (right stick = look) instead of the shared mouse/keyboard — real split-screen co-op. Falls back to mouse/keyboard for any player whose gamepad is not connected; off routes every player to the shared input.
  • isMerged — Bool getter; has the target state settled on merged?
  • mergeFraction — Num getter, 0..1; the animated blend progress (0 split → 1 merged). Handy for a HUD or to gate logic on the transition.
  • playerCount — Num getter.

Split arrangements (integer constants): SplitLayout.auto (by count: 2 → side-by-side, 3/4 → grid) · SplitLayout.horizontal (columns) · SplitLayout.vertical (rows) · SplitLayout.grid (2×2 for 4) · SplitLayout.angled (2-player diagonal split — the divider is perpendicular to the on-screen direction between the players, so it rotates as they move around each other, Kronnect-style; each player renders full-screen and the renderer composites the two half-planes — it auto-enables compositeMode). See /examples/angled-split.

Scope (v1): up to 4 composited viewports. Overlays draw once full-screen unless Graphics.perViewportOverlays(true) is set, which replays the retained-UI HUD (ADR 0064) and SDF text (ADR 0105) into each cell — screen-space text scaled per cell, world-space text re-projected by that cell’s camera (see /examples/split-text-demo); world-space sprites are drawn per viewport by the scene pass already. The merge/split is an animated camera-converge blend by default (transitionDuration/mergeFraction, ADR 0060), with the RTT rect-peel (peel) + angled split available (ADR 0061). See ADR 0059–0061.

A first-class composite viewport (ADR 0062) for picture-in-picture / minimap / security-cam — independent of SplitScreen. Each Viewport owns its own camera; the renderer composites it at its rect/z/opacity/border over the RTT composite path. Keep using Cam for the main view and add Viewports on top (the main view becomes the fullscreen base), or build a fullscreen base Viewport yourself.

import "engine" for Scene, Cam, Viewport
var cam = Cam.thirdPerson(scene, player) // the main view (fullscreen base)
var mini = Viewport.new(scene) // a top-down minimap in the top-right corner
mini.rect(0.72, 0.04, 0.24, 0.24) // screen-fraction sub-rect (0..1)
mini.topDown(player, 34) // straight-down camera 34 units up
mini.border(3)
mini.z(1) // above the main view

Returns: Viewport. Renders scene from its own camera into its sub-rect. Goes out of scope → its camera is removed and it stops rendering.

  • rect(x, y, w, h) — screen-fraction sub-rect (0..1); default fullscreen.
  • z(order) — composite order (higher = on top; the main Cam view sits behind).
  • opacity(a) — 0..1 composite alpha (cross-fade / ghost overlays).
  • border(width) / borderColor(r, g, b) — a frame around the viewport (px; 0 = none).
  • Camera (pick one): follow(target) (third-person) · topDown(target, height) (minimap) · orbit(target, radius) · firstPerson(head) · freeFly().

See the picture-in-picture example.


View and projection. Attach to a Node via setNode(node) so the camera’s position and orientation follow the node. Most games use the rigs above instead — the Cam factory creates and drives a Camera for you — but you can drive a Camera yourself for full manual control.

Returns: Camera — A new camera. Attach it to a node with setNode and set a tag so Scene.findCameraByTag can find it.

Method / propertyReturns / ParametersDescription
getTag()StringTag string (e.g. for findCameraByTag)
setTag(tag)—tag (String) — Set the tag
nodeNode or nullNode this camera is attached to
setNode(node)—node (Node) — Attach camera to this node

The camera’s view matrix is derived from the node’s world transform. Set the node’s position/rotation to move the camera.

MethodReturnsParametersDescription
getViewMatrix()(matrix)—View matrix (world → view space)
getProjectionMatrix(aspect)(matrix)aspect (Num) — width/heightProjection matrix for the given aspect ratio
getFovYRadians()Num—Vertical FOV in radians
setFovYRadians(r)—r (Num)Set vertical FOV (radians)
getNearPlane()Num—Near clip plane distance
setNearPlane(n)—n (Num)Set near plane
getFarPlane()Num—Far clip plane distance
setFarPlane(f)—f (Num)Set far plane

Use with Graphics.setViewMatrix and Graphics.setProjectionMatrix when drawing the scene.

var aspect = Window.getWidth() / Window.getHeight()
var view = _camera.getViewMatrix()
var proj = _camera.getProjectionMatrix(aspect)
Graphics.setViewMatrix(view)
Graphics.setProjectionMatrix(proj)
_scene.draw()

worldToScreen(worldX, worldY, worldZ, viewportWidth, viewportHeight)

Section titled “worldToScreen(worldX, worldY, worldZ, viewportWidth, viewportHeight)”

Returns: List of three numbers [screenX, screenY, depth] or null if the point is behind the camera.

Parameters:

  • worldX, worldY, worldZ (Num) — World-space position.
  • viewportWidth, viewportHeight (Num) — Viewport size in pixels.

Convert a world position to screen coordinates. screenX, screenY are in pixel space; depth is the depth buffer value or distance.

screenToWorld(screenX, screenY, depth, viewportWidth, viewportHeight)

Section titled “screenToWorld(screenX, screenY, depth, viewportWidth, viewportHeight)”

Returns: List of three numbers [worldX, worldY, worldZ] — World position at the given screen point and depth.

Parameters:

  • screenX, screenY (Num) — Screen coordinates (e.g. mouse position).
  • depth (Num) — Depth value or distance (e.g. 0 = near plane, 1 = far plane, or linear depth depending on implementation).
  • viewportWidth, viewportHeight (Num) — Viewport size.

Convert a screen point and depth back to world space (e.g. for placing objects at the cursor).