Camera
The 3D scene's camera, scene.camera: a Node you place and aim, plus the lens (zoom,
view distance), the exposure, screen ↔ world conversions for tap-picking and follow() to ride
another node. A 2D scene's view is Camera2D; the device's camera feed is the
CameraView plugin.
- position the camera and point it at an object
- make the camera follow the player or a moving object
- zoom in or out by changing the field of view, and set how far the camera sees
- find the 3D object the user tapped (a ray from a screen point)
- convert a 3D position to screen coordinates, to put a label over it
- make the scene darker or brighter, or simulate eye adaptation
- animate a zoom or a dolly shot
Platforms: every host; exposure (exposureCompensation, setExposure) and camera.animateTo do nothing on the web (Web Lite) or in headless tests.
At a glance
const scene = new Scene()
const floor = Mesh.box({ name: 'floor', size: [10, 0.2, 10], material: Material.lit() })
.aspect(Shape, {})
.aspect(Physics, { motion: 'static' })
scene.add(Light.sun(), floor)
scene.camera.position = [0, 2, 4]
scene.camera.lookAt([0, 0.5, 0])
scene.open()
scene.addEventListener('touchstart', ev => {
const ray = scene.camera.getRay(ev.clientX, ev.clientY)
const hit = Physics.raycast(ray.origin, ray.dir)
if (hit) console.log('tapped', hit.node?.name)
})Place and aim the camera: scene.camera
scene.camera.position = [0, 2, 4] // the whole Node transform surface: position, eulerAngles, quaternion
scene.camera.lookAt([0, 0.5, 0]) // −Z (forward) at a world point
scene.camera.forward // the world view directionYou never construct a camera: every Scene creates its own (overlays share the
parent's). In an ARScene the camera is driven by device tracking, and in a
VRScene by the headset — read it, don't place it.
Zoom and view distance: fov, near, far
camera.fov // vertical field of view, DEGREES — settable (default 60)
camera.near // near clip distance, m — settable (default 0.01)
camera.far // far clip distance = view range, m — settable (default 1000)
camera.setProjection({ fov: 45, far: 5000 }) // any subset, one call
camera.horizontalFov // horizontal field of view, degrees (read-only, from fov + the viewport's aspect)
camera.displaySize // [width, height] of the scene view, logical units (read-only)A smaller fov is a longer lens — a zoom in. The aspect ratio stays host-owned: you set the lens,
the host keeps it across every resize. Nothing nearer than near or further than far draws, so
far is the scene's view range — raise it for open worlds. Shadows keep their own (shorter) range,
so a long view distance costs nothing in shadow sharpness.
fov, horizontalFov and displaySize are read from the host's view — meaningful once
the scene is open and has a viewport. Before that (and on the windowless host, which has no
viewport) displaySize reads [0, 0] and fov a meaningless number. displaySize is in logical
units on every host — the units of the scene's touches and of getRay, whatever renderScale is;
× device.pixelRatio for device pixels.
camera.projectionMatrix (a Float32Array(16)) is not filled by the engine hosts — it reads
zeros. Build the matrix from the lens instead: Mat4.perspective(camera.fov * DEG2RAD, device.width / device.height, camera.near, camera.far) (below).
Hosts that predate the projection call keep the fixed defaults — setting it there is inert, not an error.
The lens in a scene file: CameraPlace or a camera node
In a scene file the lens comes from the node the camera rides — a
CameraPlace wins over a camera node, which wins over the
top-level camera: block:
eye: { position: [0, 1.6, 4], aspects: [use(CameraPlace, { fov: 50, far: 5000, active: true })] },
cam: { camera: { fov: 45, far: 5000 }, position: [0, 5, 10] }, // …or a camera nodeMake the 3D camera follow the player or a moving node: camera.follow
camera.follow(target: Node | CameraPlace | null): this
camera.following // the followed node, or null (read-only)Every late frame — after every other aspect has moved things — the camera takes the node's world
position and rotation, looking down the node's local −Z. Moving the node moves the view: a dolly on
a path, a cutscene shot, a mount on a vehicle. A node carrying a CameraPlace (or the place itself,
follow(eye.get(CameraPlace))) also hands over its fov / near / far. One followed node per
camera: following another replaces it, and follow(null) releases the camera where it is.
The camera copies the pose rigidly. A third-person view is a child node of the player, placed behind it and aimed at it:
const scene = new Scene()
const player = Mesh.box({ name: 'player', size: [0.6, 1.8, 0.6], material: Material.lit() })
const eye = new Node()
eye.position = [0, 3, 6] // behind and above the player, which faces −Z
player.add(eye)
scene.add(Light.sun(), player)
eye.lookAt([0, 1, 0]) // tilted down at the player (a world point)
scene.camera.follow(eye) // the camera now moves and turns with the player
scene.open()Animate a zoom or a dolly: camera.animateTo
camera.animateTo({ fov: 30, duration: 300 }) // fov / near / far on top of the node's transform props
camera.animateFrom({ fov: 90, duration: 600 })A zoom or a dolly, natively on the game clock (duration in ms) — the props per target are in
Timeline, the returned handle in
animate.
Scene brightness and eye adaptation: exposure {#exposure}
camera.exposureCompensation = -2 // stops: +1 doubles the scene's brightness, −2 quarters it
camera.setExposure({ iso: 400 }) // the physical camera: aperture (f-stops), shutterSpeed (s), iso, compensation
camera.ev100 // read-only: the resulting EV100, compensation includedThe default is f/16 · 1/125 s · ISO 100 — EV100 15, bright sunlight, the exposure every light
intensity in these docs is tuned against — and exposureCompensation (default 0; also the scene
option of the same name) is the one knob most games need on top of it: a dark interior, a
white-out, or eye adaptation — when the sun swings into view, drop it a couple of stops over
~0.15 s and let it recover over a second or two. The sun stays at the display's peak (its scene
value is far above the tone curve's shoulder) while everything else goes dark, which is what makes
it read as blinding. Cheap to set every frame.
Scene-referred light — sun, sky, IBL, lit surfaces — follows the exposure. Particle emissive is
post-exposure (1 = white on screen) and does not, so a muzzle flash or a fireball keeps its
on-screen brightness while the world around it darkens.
Hosts that predate the call keep the fixed default.
Tap to pick a 3D object: getRay, getViewDirection
camera.getViewDirection(screenX, screenY): Vec3 // world-space unit direction through a screen point
camera.getRay(screenX, screenY): Ray // origin = camera.worldPosition, dir = that directionScreen coordinates are logical px — the same space as ev.clientX / ev.clientY. getRay
pairs directly with Physics.raycast for tap-picking (the example at the top); its
dir is unit length, so a ray parameter is a distance in world units. Ray is documented in
ray-plane-noise.
3D position to screen coordinates (world → screen)
There is no worldToScreen helper; it is four lines over the lens:
const toScreen = (camera: Camera, p: Vec3 | [number, number, number]): Vec2 => {
const ndc = Mat4.perspective(camera.fov * DEG2RAD, device.width / device.height, camera.near, camera.far)
.mul(camera.worldMatrix.invert())
.transformPoint(p)
return new Vec2((ndc.x + 1) / 2 * device.width, (1 - ndc.y) / 2 * device.height) // logical px
}A point behind the camera projects too — check
camera.forward.dot(new Vec3(p).sub(camera.worldPosition)) > 0 before using the result.
Pitfalls
// ✗ moving the camera in an ARScene — tracking overwrites it every frame
arScene.camera.position = [0, 2, 4]
// ✓ move the WORLD instead: parent content under scene.root and place that
// ✗ the device's size as the scene's — the scene view may be smaller than the display
scene.camera.getRay(device.width / 2, device.height / 2)
// ✓ the scene view's own size, in the units of its touches
const [w, h] = scene.camera.displaySize; scene.camera.getRay(w / 2, h / 2)See also
- Scene — where the camera comes from; overlays share it
- Post-processing —
exposureafter bloom, as opposed toexposureCompensationhere; depth of field - Node — the inherited transform surface (
position,lookAt,forward) - 3D physics & shapes — raycasting the rays you build here
- Ray, Plane, Noise — the
Rayreturned bygetRay - Scene files —
CameraPlace, camera nodes