API

Module: Camera (ArcRotateCamera + FreeCamera)

Package path: packages/babylon-lite/src/camera/

Purpose

The Camera module provides two camera implementations as plain data objects, plus standalone matrix helpers and companion control functions that wire DOM events to mutate camera properties. Cameras are pure data — they know nothing about the scene or DOM until controls are attached. Both cameras implement the shared Camera interface and integrate with the scene's world-matrix hierarchy via IWorldMatrixProvider / IParentable.

Public API Surface

camera.ts — Shared Camera Contract

/** Minimal camera contract — any camera with world and projection state.
* Both ArcRotateCamera and FreeCamera implement this interface.
* Plain data, no scene knowledge (pillar 4b). */
export interface Camera {
fov: number;
nearPlane: number;
farPlane: number;
viewport?: NormalizedViewport;
children: SceneNode[];
readonly worldMatrix: Mat4;
readonly worldMatrixVersion: number;
ortho?: OrthographicBounds | null;
}
export function getViewMatrix(camera: Camera): Mat4;
export function getProjectionMatrix(camera: Camera, aspectRatio: number): Mat4;
export function getViewProjectionMatrix(camera: Camera, aspectRatio: number): Mat4;
export function getCameraPosition(camera: Camera): Vec3;

arc-rotate.ts

/** ArcRotateCamera — orbits around a target point.
* Uses Babylon.js convention: left-handed, alpha=rotation around Y, beta=elevation.
* Plain data. Does NOT know about the scene.
*
* Push-based dirty tracking: alpha/beta/radius use Object.defineProperty,
* target uses ObservableVec3. Changes call wm.markLocalDirty() immediately.
*
* Inertia follows the Babylon.js model: input handlers accumulate per-frame
* offsets (inertialAlphaOffset, etc.) which are applied and exponentially
* decayed each frame by the controls module. */
export interface ArcRotateCamera extends IWorldMatrixProvider, IParentable {
alpha: number; // Rotation around Y axis (radians)
beta: number; // Elevation angle from Y axis (radians, 0=top, π=bottom)
radius: number; // Distance from target
target: Vec3; // Orbit center point (ObservableVec3 at runtime)
fov: number; // Vertical field of view (radians)
nearPlane: number; // Near clipping plane
farPlane: number; // Far clipping plane
inertia: number; // Inertia for rotation & zoom (0=instant, 0.9=default, 1=no decay)
panningInertia: number; // Inertia for panning (0=instant, 0.9=default)
inertialAlphaOffset: number; // Per-frame accumulated rotation offset
inertialBetaOffset: number;
inertialRadiusOffset: number; // Per-frame accumulated zoom offset
inertialPanningX: number; // Per-frame accumulated pan offset
inertialPanningY: number;
parent: IWorldMatrixProvider | null;
readonly worldMatrix: Mat4;
readonly worldMatrixVersion: number;
}
/** Create a bare ArcRotateCamera with given params. Pure data, no scene knowledge. */
export function createArcRotateCamera(alpha: number, beta: number, radius: number, target: Vec3): ArcRotateCamera;

Default values (set in createArcRotateCamera):

  • fov = 0.8 (~45.8°)
  • nearPlane = 0.1
  • farPlane = 1000
  • inertia = 0.9
  • panningInertia = 0.9

arc-rotate-controls.ts

/** Camera gesture assigned to a pointer button. */
export type ArcRotatePointerAction = "rotate" | "pan";
/** Optional non-touch pointer-button mappings.
* Omitted fields preserve the default independently. */
export interface ArcRotatePointerMappings {
/** Primary button (PointerEvent.button 0). Default: "rotate". */
primaryButton?: ArcRotatePointerAction;
/** Secondary button (PointerEvent.button 2). Default: "pan". */
secondaryButton?: ArcRotatePointerAction;
}
export interface AttachControlOptions {
/** Non-touch pointer-button mappings. Touch remains one-finger rotate + pinch zoom. */
pointerMappings?: ArcRotatePointerMappings;
/** Keyboard controls after enableArcRotateKeyboardControls() installs the opt-in seam. */
keyboard?: boolean | ArcRotateKeyboardOptions;
shouldHandlePointerDown?: (event: PointerEvent) => boolean;
isExternalDragActive?: () => boolean;
isExternalPickPending?: () => boolean;
}
/** Attach orbit/zoom/pan controls to an ArcRotateCamera.
* Matches Babylon.js ArcRotateCameraPointersInput behavior with inertia.
* Input handlers accumulate into the camera's inertial offset properties.
* Inertia is applied each frame via scene._beforeRender (single RAF loop).
* Returns a cleanup function to remove all event listeners and the beforeRender hook. */
export function attachControl(camera: ArcRotateCamera, canvas: HTMLCanvasElement, scene?: SceneContext, options?: AttachControlOptions): () => void;

arc-rotate-keyboard-controls.ts

/** KeyboardEvent.code mappings. Omitted fields retain the Arrow-key defaults. */
export interface ArcRotateKeyboardMappings {
left?: readonly string[];
right?: readonly string[];
up?: readonly string[];
down?: readonly string[];
}
/** Optional keyboard configuration used after installing the keyboard seam. */
export interface ArcRotateKeyboardOptions {
/** Key mappings, matched against KeyboardEvent.code. */
keys?: ArcRotateKeyboardMappings;
/** Prevent browser defaults for mapped keydown/keyup events. Default: true. */
preventDefault?: boolean;
/** Rotation divisor. Higher values rotate more slowly. Default: 100. */
angularSensitivity?: number;
/** Panning divisor. Higher values pan more slowly. Default: 50. */
panningSensitivity?: number;
/** Zooming divisor. Higher values zoom more slowly. Default: 25. */
zoomingSensitivity?: number;
}
/** Install the optional arc-rotate keyboard input seam. Process-global and idempotent. */
export function enableArcRotateKeyboardControls(): void;

pointerMappings is resolved on each non-touch pointerdown. Each button falls back independently, so { primaryButton: "pan" } changes only the primary button while the secondary button continues to pan. The selected action remains fixed for that drag. Both actions use the existing inertial paths and pointer capture; wheel input is always zoom and is not remappable. Touch ignores pointerMappings: one finger rotates and two fingers pinch-zoom exactly as in the default controls.

Keyboard support is a separate opt-in module. Call enableArcRotateKeyboardControls() once before attaching a keyboard-enabled camera. The enabler installs an opaque factory seam in attachControl; when the enabler is absent from a bundle, the factory, listener behavior, held-key state, and optional per-frame/cleanup calls all fold away. This keeps pointer-only scenes free of keyboard implementation bytes.

After installing the seam, keyboard input remains disabled when keyboard is omitted or false. Passing keyboard: true enables the legacy Arrow-key behavior. Passing an object also enables keyboard input and may override any direction's KeyboardEvent.code list independently; an empty list disables that direction. Mapped keydown and keyup events prevent browser defaults unless preventDefault is false; input handling and movement remain enabled either way. Sensitivities are divisors, so the defaults produce angular, panning, and zooming increments of 1 / 100, 1 / 50, and 1 / 25 per rendered frame respectively.

free-camera.ts

/** FreeCamera — positioned in world space, looking at a target point.
* Matches Babylon.js FreeCamera: position + target, left-handed.
* Plain data. Does NOT know about the scene.
*
* Push-based dirty tracking: position and target use ObservableVec3,
* _yaw/_pitch use Object.defineProperty. */
export interface FreeCamera extends Camera, IWorldMatrixProvider, IParentable {
position: ObservableVec3; // World-space position
target: ObservableVec3; // Look-at target (auto-updated by controls from yaw/pitch)
speed: number; // Movement speed (default 2.0, matches BJS)
angularSensitivity: number; // Mouse rotation sensitivity (higher=less sensitive, default 2000)
inertia: number; // Inertia damping factor (0=instant stop, 0.9=smooth, default 0.9)
parent: IWorldMatrixProvider | null;
readonly worldMatrix: Mat4;
readonly worldMatrixVersion: number;
}
/** @internal FreeCamera with internal yaw/pitch state. Not re-exported from index.ts. */
export interface FreeCameraInternal extends FreeCamera {
_yaw: number;
_pitch: number;
}
/** Create a FreeCamera at the given position looking at target. Pure data, no scene knowledge. */
export function createFreeCamera(position: Vec3, target: Vec3): FreeCamera;

Default values (set in createFreeCamera):

  • fov = 0.8 (~45.8°)
  • nearPlane = 1
  • farPlane = 10000
  • speed = 2.0
  • angularSensitivity = 2000
  • inertia = 0.9

glTF-loader consumer: after enableGltfCameras() is called, gltf-feature-camera.ts (see module 04, "glTF camera Node Property") builds every embedded glTF camera as a FreeCamera at (0,0,0) looking toward (0,0,-1) — glTF's own local -Z-forward/+Y-up convention — parented through a fixupNode that cancels the engine's RH→LH root mirror. Each imported camera primes its world transform back to unit scale so the default rigid view inverse remains exact; projection parameters remain in source glTF units. Zero/non-uniform or animated ancestor scale is not supported. The loader then exposes the result via AssetContainer.cameras.

banked-free-camera.ts — Opt-in Rollable Up Vector

/** A FreeCamera with an explicit, mutable world-space up vector. */
export interface BankedFreeCamera extends FreeCamera {
readonly upVector: ObservableVec3;
}
/** Create a FreeCamera whose look-at up vector is explicit and mutable, so the camera can roll. */
export function createBankedFreeCamera(position: Vec3, target: Vec3, up?: Vec3): BankedFreeCamera;

A plain createFreeCamera builds its basis against the shared Vec3Up constant and can never roll. A chase camera bolted to a vehicle on a banked or inverted track needs the third basis vector to follow the vehicle instead; createBankedFreeCamera exposes exactly that and nothing else.

Both constructors share ONE factory — _createFreeCamera(position, target, up: Vec3) in free-camera.ts, which always uses whatever up it is handed and has no notion of "banked": it never branches on the up vector's origin and never defines an upVector property. createFreeCamera passes the shared Vec3Up constant, so scenes that never import the banked constructor are byte-identical to before this feature existed. createBankedFreeCamera does all of the opt-in work itself: it builds its own ObservableVec3 up vector — deferring its dirty callback to _markLocalMatrixDirty(cam) once cam exists, since the factory computes wm internally — and only then defines the public upVector property on the returned camera. Writing upVector invalidates both local and world matrix caches exactly like position / target do. up defaults to world +Y, which makes a banked camera's initial world matrix identical to a plain one's. A degenerate up (parallel to the view direction) falls back to identity rotation, matching writeLookAtWorldMat4LHIntoBuffer.

Equivalent to Babylon.js camera.upVector, which TargetCamera._getViewMatrix feeds to Matrix.LookAtLHToRef.

free-camera-controls.ts

/** Attach keyboard + mouse controls to a FreeCamera.
* Matches Babylon.js FreeCamera input behavior.
* Camera stays plain data — this function reads/writes its properties.
* Returns a cleanup function to remove all listeners and the beforeRender hook. */
export interface FreeCameraControlOptions {
upKeys?: readonly string[];
downKeys?: readonly string[];
fastKeys?: readonly string[];
fastMultiplier?: number;
}
export function attachFreeControl(camera: FreeCamera, canvas: HTMLCanvasElement, scene?: SceneContext): () => void;
export function attachConfigurableFreeControl(camera: FreeCamera, canvas: HTMLCanvasElement, scene?: SceneContext, options?: FreeCameraControlOptions): () => void;

attachFreeControl retains the default mappings and byte-identical implementation used by existing scenes. attachConfigurableFreeControl is a separate opt-in so custom key arrays and speed-boost logic add zero bytes to ordinary free-camera users. Its upKeys/downKeys replace the default Space+PageUp / Shift+PageDown vertical mappings; fastKeys and fastMultiplier apply a held-key speed boost. Mouse look and inertia are unchanged.

orthographic.ts — Opt-in Orthographic Projection

/** Live orthographic view-volume extents, in world units. Mutable and animatable:
* every setter invalidates the camera's projection cache. A plane left `null` is
* derived from `halfHeight` (horizontally scaled by the render aspect ratio). */
export interface OrthographicBounds {
halfHeight: number;
left: number | null;
right: number | null;
bottom: number | null;
top: number | null;
}
/** Initial extents — every field optional, omitted planes derived from `halfHeight` (default 1). */
export interface OrthographicBoundsOptions {
halfHeight?: number;
left?: number | null;
right?: number | null;
bottom?: number | null;
top?: number | null;
}
/** Switch a camera to an orthographic projection; returns the live `camera.ortho` bounds. */
export function enableOrthographicCamera(camera: Camera, bounds?: OrthographicBoundsOptions): OrthographicBounds;
/** Switch a camera back to its perspective projection. */
export function disableOrthographicCamera(camera: Camera): void;

Works with any camera that satisfies the Camera contract (ArcRotate, Free, Geospatial) — the projection is orthogonal to how the camera is positioned. Depth still comes from camera.nearPlane / camera.farPlane; camera.fov has no effect in this mode, so an orthographic camera zooms by changing halfHeight.

Changing extents at runtime

enableOrthographicCamera is called once. The bounds it returns (also reachable as camera.ortho) stay live, so extents can be driven every frame:

const ortho = enableOrthographicCamera(camera, { halfHeight: 6 });
onBeforeRender(scene, () => {
ortho.halfHeight = 6 + Math.sin(t) * 2; // zoom
});

Each field is an accessor that invalidates the camera's projection state on change. That matters because the projection cache is keyed on _cameraChangeKey + aspect ratio — neither moves when only the extents do, so without the accessor the new bounds would not be picked up until the camera moved.

Invalidation goes through a dedicated projection revision, not merely clearing _projVer / _vpVer and not by marking the camera transform dirty. Clearing the matrix caches alone fixes the getters but not the frame: per-frame consumers gate their GPU uploads on a camera change key, and the forward pass's _writePassSceneUBO returns early while [camera, fog, changeKey, aspect, exposure, contrast, envTextures] are unchanged (ShaderMaterial, text, clustered lighting, TAA and CSM have equivalent gates). Changing a view volume moves none of those, so a steady-state scene would keep rendering the previously uploaded view-projection even though getProjectionMatrix returned a fresh matrix.

Every bounds setter therefore bumps camera._projRev, and projection-dependent consumers key on _cameraChangeKey(camera), which sums it with the transform version (and also polls fov / nearPlane / farPlane — see Projection Change Detection):

camera.worldMatrixVersion + (camera._projRev ?? 0);

Both terms are monotonically non-decreasing, so the sum is too and any change in either strictly increases it — it cannot alias. (Same version-summing idiom as shadow-base.ts and gltf-feature-lights-punctual.ts.)

The revision is deliberately separate from worldMatrixVersion rather than folded into it. Marking the camera transform dirty would signal camera motion, which additionally invalidates the camera's children (the world-matrix state pushes invalidation through _children) and, under floating origin, makes wrapRenderableForFO rebase every renderable in the scene — a per-frame cost if ortho.halfHeight is animated, for a change that moved nothing in world space. Transform-only consumers (floating origin, child nodes, mesh UBOs) keep reading worldMatrixVersion and are correctly unaffected.

Because the fields are real own enumerable properties (defined via Object.defineProperty, not left optional), they also resolve as animation property paths. resolvePropertyBinding walks the path with in and writes through a plain target[prop] = value assignment, which lands on the setter:

const clip = createPropertyAnimationClip("orthoZoom", [
{
path: "ortho.halfHeight",
keys: [
{ frame: 0, value: 6 },
{ frame: 60, value: 2 },
],
},
]);
createPropertyAnimationGroup(manager, camera, clip, { fromFrame: 0, toFrame: 60, loop: true });

Setting a plane to a number produces an off-center volume (Babylon's orthoLeft / orthoRight / orthoBottom / orthoTop); setting it back to null returns it to the derived extent.

Internal Architecture

Shared World-Matrix Integration

Both camera types use createWorldMatrixState() for push-based dirty tracking with the scene's parent–child hierarchy. The camera's local world matrix is computed from its own state (orbital params for ArcRotate, position+target for Free), then optionally multiplied by a parent's world matrix.

The view matrix is derived from the world matrix by transposing the upper 3×3 rotation block and negating the translation:

viewMatrix[0..2] = column 0 of worldMatrix (transposed row 0)
viewMatrix[4..6] = column 1 of worldMatrix (transposed row 1)
viewMatrix[8..10] = column 2 of worldMatrix (transposed row 2)
viewMatrix[12..14] = -(rotation^T × eye)
viewMatrix[15] = 1

getCameraPosition(camera) reads translation from the final world matrix: { x: w[12], y: w[13], z: w[14] }.

ArcRotateCamera Position Calculation

The camera's local eye position is computed from spherical coordinates:

sinB = sin(beta) // if sinB == 0, clamp to 0.0001
cosB = cos(beta)
cosA = cos(alpha)
sinA = sin(alpha)
eye.x = target.x + radius * cosA * sinB
eye.y = target.y + radius * cosB
eye.z = target.z + radius * sinA * sinB

This is the Babylon.js left-handed spherical coordinate convention:

  • alpha rotates around the Y axis
  • beta is the polar angle from the +Y axis (0 = looking straight down, π = looking straight up)
  • At alpha = -π/2, beta = π/2, the camera is on the +Z axis looking at the target

The local world matrix is: transpose(upper 3×3 of view) + eye position.

ArcRotateCamera Dirty Tracking

alpha, beta, radius use Object.defineProperty with setters that call wm.markLocalDirty() on change. target is an ObservableVec3 that calls the same dirty callback when any component (x, y, z) is mutated.

FreeCamera Position & Orientation

The FreeCamera's local world matrix is computed via writeLookAtWorldMat4LHIntoBuffer(_localMat, position, target, up) — see World Matrix (all cameras) below. up is the shared Vec3Up constant for createFreeCamera and the camera's own upVector for createBankedFreeCamera; _createFreeCamera reads whichever it is handed without branching.

Initial yaw/pitch are derived from the position→target direction:

dx = target.x - position.x
dy = target.y - position.y
dz = target.z - position.z
_yaw = atan2(dx, dz)
_pitch = atan2(dy, sqrt(dx² + dz²))

FreeCamera Dirty Tracking

position and target are ObservableVec3 instances. _yaw and _pitch use Object.defineProperty. All mutations call wm.markLocalDirty(). A BankedFreeCamera adds upVector, its own ObservableVec3 (constructed and defined entirely in banked-free-camera.ts) whose dirty callback invalidates both the camera's local and world matrix caches via the shared _markLocalMatrixDirty() seam.

View Matrix

Both cameras use the same world-matrix-to-view inversion (described above). This is equivalent to createLookAtMat4LH(eye, target, Vec3Up) for their respective eye/target values.

World Matrix (all cameras)

A camera's local matrix is its camera-to-world matrix — cameras parent like any other node, and getViewMatrix inverts it per frame. writeLookAtWorldMat4LHIntoBuffer(out, eye, target, up) writes it directly as the columns [xAxis, yAxis, zAxis, eye], where the basis is the same one createLookAtMat4LH derives:

zAxis = normalize(target - eye) // left-handed: +Z looks at the target
xAxis = normalize(cross(up, zAxis))
yAxis = cross(zAxis, xAxis)

All three factories (ArcRotate, Free, Geospatial) call it. They previously built a view matrix with createLookAtMat4LH and inverted it back by hand — allocating a Float32Array, computing a translation column of three dot products that was immediately overwritten with the eye, then transposing the rotation — with the 17-line transpose block copy-pasted into each factory. Degenerate input (eye on target, or the view direction parallel to up) leaves an identity rotation with the eye translation, matching createLookAtMat4LH's identity fallback exactly.

Projection Matrix

Both cameras: createPerspectiveMat4LH(fov, aspectRatio, nearPlane, farPlane) — left-handed perspective with reverse-Z zero-to-one depth (nearPlane maps to 1, farPlane maps to 0).

Projection Change Detection

fov, nearPlane and farPlane are plain writable fields on a plain-data camera (pillar 4b′), so a write notifies nobody. Both the matrix caches in camera.ts and every projection-dependent per-frame consumer key on _cameraChangeKey, which polls those three by value and folds any drift into camera._projRev:

export function _cameraChangeKey(camera: Camera): number {
if (camera._projFov !== camera.fov || camera._projNear !== camera.nearPlane || camera._projFar !== camera.farPlane) {
camera._projFov = camera.fov;
camera._projNear = camera.nearPlane;
camera._projFar = camera.farPlane;
camera._projRev = (camera._projRev ?? 0) + 1;
}
return camera.worldMatrixVersion + (camera._projRev ?? 0);
}

Polling here rather than installing accessors in every camera factory keeps the projection contract in one place, costs nothing per camera type, and works for a hand-rolled object satisfying Camera — the same reasoning behind world-matrix-state.ts polling a foreign parent's version instead of pushing to it. Orthographic bounds are pushed instead (see below): that module already owns setters, so pushing is exact and costs the poll nothing.

Orthographic Projection Seam (zero-cost opt-in)

camera.ts holds a module-local let _orthoProjector = null plus a single @internal setter _installOrthographicProjector(), called only from orthographic.ts. getProjectionMatrix branches on _orthoProjector !== null && camera.ortho. When enableOrthographicCamera is absent from a bundle the setter tree-shakes, the bundler proves the projector is always null, and the entire orthographic branch folds away — perspective-only scenes stay byte-identical. This is the same seam pattern as _stencilResolver / _stdVertexColorFragment in standard-pipeline.ts.

Cache invalidation for live bound changes is deliberately kept out of the shared path: the bounds setters bump camera._projRev. Projection-dependent consumers read _cameraChangeKey(camera) in place of camera.worldMatrixVersion, which is a substitution rather than an extra comparison, so no per-frame gate grows a slot.

writeOrthoOffCenterMat4LHIntoBuffer writes a reverse-Z OrthoOffCenterLH matrix so orthographic cameras share the engine's reverse-Z depth state (clear 0, compare greater):

m[0] = 2 / (right - left) m[12] = (left + right) / (left - right)
m[5] = 2 / (top - bottom) m[13] = (top + bottom) / (bottom - top)
m[10] = -1 / (far - near) m[14] = far / (far - near)
m[11] = 0 m[15] = 1

writePerspectiveMat4LHIntoBuffer only writes the terms a perspective matrix needs and relies on the rest of a freshly allocated (zeroed) cache. The orthographic writer overwrites all 16 elements, so switching perspective → orthographic on the shared cache is safe unconditionally; the reverse is not symmetric, because m[12], m[13] and m[15] are written only by the orthographic path, so disableOrthographicCamera clears exactly those three before handing _projCache back. Optional projectors fully overwriting their output is the contract, so a future third projection type cannot be contaminated by whichever ran before it. That cleanup lives in the lazy module so the shared perspective path pays nothing for it.

Consumers that still assume a perspective projection

Orthographic support is projection-level; a few features derive screen-space quantities from the projection and need their own handling. Clustered lighting's projectedSphereBounds branches on proj[11] === 0 (1 for perspective, 0 for orthographic — a projection-agnostic discriminator) and uses depth-independent bounds that honour the off-center offsets in proj[12] / proj[13]; the perspective path divides the silhouette by view depth and ignores those offsets entirely.

Still perspective-only, and therefore not supported with an orthographic camera:

FeatureAssumption
Gaussian splatting
1/z splat sizing and the linear-depth decode in gs-depth-fragments.ts
Camera gizmo
Always draws a perspective frustum wireframe (camera-gizmo.ts)

Both enable/disable reset the projection state, as does every bounds setter — that is what lets extents change without the camera moving (the projection cache is otherwise keyed on _cameraChangeKey + aspect ratio).

View-Projection Matrix

Both cameras: multiplyMat4(projectionMatrix, viewMatrix).


ArcRotateCamera Controls — Inertia Model

Sensibility Constants

ConstantValueDescription
angularSensibility
1000
Babylon default
panningSensibility
50
Pixels per unit
wheelPrecision
3
Wheel delta divisor

Inertia Epsilon Thresholds

ConstantValueUsed for
ROTATION_EPSILON
0.001
Alpha/beta offsets
RADIUS_EPSILON
0.001
Radius offset
PANNING_EPSILON
0.0001
Panning X/Y offsets

Input Handlers

Input handlers do not directly modify camera properties. They accumulate into the camera's inertial* offset fields, which are applied and decayed each frame by applyInertia().

Rotate action (primary-button default)

camera.inertialAlphaOffset -= dx / angularSensibility
camera.inertialBetaOffset -= dy / angularSensibility

Pan action (secondary-button default)

camera.inertialPanningX += -dx / panningSensibility
camera.inertialPanningY += dy / panningSensibility

Wheel (Zoom)

camera.inertialRadiusOffset -= (deltaY * camera.radius) / (wheelPrecision * 1000)

Zoom is proportional to current radius (logarithmic feel).

Touch Pinch (Zoom — direct, no inertia)

Two-finger pinch directly modifies radius:

on touchstart (2 fingers): pinchStartDist = distance between fingers
pinchStartRadius = camera.radius
on touchmove (2 fingers): dist = distance between fingers
camera.radius = pinchStartRadius * (pinchStartDist / dist)
camera.radius = max(0.01, camera.radius)

Keyboard (opt-in)

keydown records the event's code and current modifier state; keyup removes it. blur and cleanup clear all held-key and modifier state. Recognized mappings prevent browser Arrow-key scrolling. Events with metaKey are not recorded, and holding Meta suppresses any already-held mapping. Shift does not alter the mapping. Ctrl takes precedence over Alt.

At the start of every applyInertia() call, each held mapped key adds one legacy-compatible increment:

Held mappingNo modifierCtrlAlt without Ctrl
left
inertialAlphaOffset -= 1 / 100
inertialPanningX -= 1 / 50
same as no modifier
right
inertialAlphaOffset += 1 / 100
inertialPanningX += 1 / 50
same as no modifier
up
inertialBetaOffset -= 1 / 100
inertialPanningY += 1 / 50
inertialRadiusOffset += 1 / 25
down
inertialBetaOffset += 1 / 100
inertialPanningY -= 1 / 50
inertialRadiusOffset -= 1 / 25

The configured angularSensitivity, panningSensitivity, and zoomingSensitivity replace 100, 50, and 25. Opposing directions cancel. Combined two-axis rotation and Ctrl-panning directions are normalized before their sensitivity is applied, so diagonal movement has the same magnitude as a single-axis movement. Alt zoom remains one-dimensional. Keyboard input feeds the same inertial offsets as pointer and wheel input before those offsets are integrated and decayed; it does not create another animation loop.

Per-Frame Inertia Application (applyInertia)

Called each frame via scene._beforeRender:

invoke the opaque opt-in input hook (when installed for this attachment)
// Rotation
alpha += inertialAlphaOffset
beta += inertialBetaOffset
beta = clamp(beta, 0.01, π - 0.01) // prevent gimbal flip
inertialAlphaOffset *= camera.inertia
inertialBetaOffset *= camera.inertia
if |offset| < ROTATION_EPSILON: offset = 0
// Zoom
radius -= inertialRadiusOffset
radius = max(0.01, radius)
inertialRadiusOffset *= camera.inertia
if |offset| < RADIUS_EPSILON: offset = 0
// Panning (uses camera.panningInertia, not camera.inertia)
rightX = -sin(alpha)
rightZ = cos(alpha)
panScale = radius * 0.001
target.x += rightX * inertialPanningX * panScale
target.y += inertialPanningY * panScale
target.z += rightZ * inertialPanningX * panScale
inertialPanningX *= camera.panningInertia
inertialPanningY *= camera.panningInertia
if |offset| < PANNING_EPSILON: offset = 0

Scene Integration

When scene is provided to attachControl:

  • applyInertia is registered on scene._beforeRender — single RAF chain.
  • Cleanup removes the callback from _beforeRender.

When scene is omitted, inputs can accumulate inertial offsets but no standalone animation loop is created.

Event Registration

EventHandlerOptions
pointerdown
onPointerDown
—
pointermove
onPointerMove
—
pointerup
onPointerUp
—
wheel
onWheel
{ passive: false }
contextmenu
onContextMenu
— (prevents right-click menu)
touchstart
onTouchStart
{ passive: false }
touchmove
onTouchMove
{ passive: false }
touchend
onTouchEnd
—
gesture*
onGesture
{ passive: false }
keydown
onKeyDown
— (opt-in keyboard module)
keyup
onKeyUp
— (opt-in keyboard module)
blur
onBlur
— (opt-in keyboard module)

Pointer capture (setPointerCapture/releasePointerCapture) keeps drags active outside canvas.

The primary and secondary buttons can independently select the rotate or pan action through AttachControlOptions.pointerMappings. Defaults are equivalent to the historic fixed mapping:

attachControl(camera, canvas, scene, {
pointerMappings: {
primaryButton: "rotate",
secondaryButton: "pan",
},
});

To make a primary-button drag pan without application-owned camera math:

attachControl(camera, canvas, scene, {
pointerMappings: { primaryButton: "pan" },
});

Non-touch pointers include mouse and pen input. Touch pointer events deliberately bypass this mapping so one-finger orbit and two-finger pinch zoom are stable. Wheel zoom, external-interaction guards, pointer capture, and cleanup do not depend on the mapping.

Keyboard controls are opt-in:

enableArcRotateKeyboardControls();
attachControl(camera, canvas, scene, { keyboard: true });

Custom mappings and sensitivities remain local to the attachment:

enableArcRotateKeyboardControls();
attachControl(camera, canvas, scene, {
keyboard: {
keys: {
left: ["KeyA"],
right: ["KeyD"],
up: ["KeyW"],
down: ["KeyS"],
},
angularSensitivity: 200,
panningSensitivity: 100,
zoomingSensitivity: 50,
},
});

FreeCamera Controls

Input Bindings

Key(s)Action
W / ArrowUp
Move forward (+Z local)
S / ArrowDown
Move backward (−Z local)
A / ArrowLeft
Strafe left (−X local)
D / ArrowRight
Strafe right (+X local)
Space / PageUp
Move up (+Y world)
Shift / PageDown
Move down (−Y world)
Mouse drag (any button)
Look around (yaw/pitch)

Mouse Rotation

Mouse drag accumulates into rotation accumulators:

crY += dx / camera.angularSensitivity // yaw delta
crX += dy / camera.angularSensitivity // pitch delta

Movement Speed Formula

Matches Babylon.js frame-rate-independent speed calculation:

dt = max(deltaMs, 1)
moveSpeed = camera.speed × sqrt(dt² / 100000)

Per-Frame Update

Called each frame via scene._beforeRender with deltaMs:

// 1. Accumulate keyboard input (local space)
cdZ += moveSpeed (forward/back)
cdX += moveSpeed (strafe)
cdY += moveSpeed (up/down)
// 2. Apply rotation
_yaw += crY
_pitch -= crX
_pitch = clamp(_pitch, -(π/2 - 0.01), π/2 - 0.01)
// 3. Transform local direction → world space
cosY = cos(_yaw), sinY = sin(_yaw)
position.x += sinY × cdZ + cosY × cdX
position.y += cdY
position.z += cosY × cdZ - sinY × cdX
// 4. Recompute target from yaw/pitch
cosP = cos(_pitch)
target = (position.x + sinY×cosP, position.y + sin(_pitch), position.z + cosY×cosP)
// 5. Decay accumulators (inertia)
cd* *= camera.inertia
cr* *= camera.inertia
if |accumulator| < camera.speed × 0.001: accumulator = 0

Canvas Focus

If the canvas has no tabindex attribute, attachFreeControl sets canvas.tabIndex = 0 to make it keyboard-focusable.

Event Registration

EventHandlerOptions
pointerdown
onPointerDown
—
pointermove
onPointerMove
—
pointerup
onPointerUp
—
contextmenu
onContextMenu
—
keydown
onKeyDown
—
keyup
onKeyUp
—

Cleanup removes all 6 event listeners and the _beforeRender callback.


Babylon.js Equivalence Map

Babylon LiteBabylon.js
Camera interface
BABYLON.Camera base class
createArcRotateCamera(alpha, beta, radius, target)
new BABYLON.ArcRotateCamera("cam", alpha, beta, radius, target, scene)
camera.alpha / beta / radius / target
Same property names
camera.fov (default 0.8)
camera.fov (default 0.8)
camera.nearPlane / camera.farPlane
camera.minZ / camera.maxZ
camera.inertia (default 0.9)
camera.inertia (default 0.9)
camera.panningInertia (default 0.9)
camera.panningInertia (default 0.9)
camera.inertialAlphaOffset
camera.inertialAlphaOffset
camera.getViewMatrix()
camera.getViewMatrix()
camera.getProjectionMatrix(aspect)
camera.getProjectionMatrix()
enableOrthographicCamera(camera, bounds)
camera.mode = Camera.ORTHOGRAPHIC_CAMERA
camera.ortho.left / right / bottom / top
camera.orthoLeft / orthoRight / orthoBottom / orthoTop
camera.ortho.halfHeight (aspect-derived width)
no equivalent — BJS defaults to half the render size in pixels
Animate path "ortho.halfHeight"
Animation on orthoTop / orthoBottom / …
disableOrthographicCamera(camera)
camera.mode = Camera.PERSPECTIVE_CAMERA
attachControl(camera, canvas, scene)
camera.attachControl(canvas, true)
enableArcRotateKeyboardControls(); attachControl(..., { keyboard: true })
ArcRotateCameraKeyboardMoveInput
angularSensibility = 1000
camera.inputs.attached.pointers.angularSensibilityX/Y
panningSensibility = 50
camera.inputs.attached.pointers.panningSensibility
wheelPrecision = 3
camera.inputs.attached.mousewheel.wheelPrecision
Primary drag → rotate (default, configurable)
ArcRotateCameraPointersInput button 0
Secondary drag → pan (default, configurable)
ArcRotateCameraPointersInput button 2
Wheel → zoom radius
ArcRotateCameraMouseWheelInput
Pinch → zoom radius (direct, no inertia)
ArcRotateCameraPointersInput multitouch pinch
Beta clamped to [0.01, π-0.01]
camera.lowerBetaLimit / upperBetaLimit
createFreeCamera(position, target)
new BABYLON.FreeCamera("cam", position, scene); camera.setTarget(target)
createBankedFreeCamera(position, target, up)
ditto, plus camera.upVector = up (used by Matrix.LookAtLHToRef)
camera.speed (default 2.0)
camera.speed (default 2.0)
camera.angularSensitivity (default 2000)
camera.inputs.attached.mouse.angularSensibility
attachFreeControl(camera, canvas, scene)
camera.attachControl(canvas)
WASD / Arrow keys
FreeCameraKeyboardMoveInput
Mouse drag → yaw/pitch
FreeCameraMouseInput
Pitch clamped to ±(π/2 − 0.01)
BJS FreeCameraMouseInput pitch limits
_yaw / _pitch (internal)
BJS internal _cameraRotationMatrix

Dependencies

  • camera.ts imports: Vec3, Mat4 from ../math/types.js.
  • arc-rotate.ts imports: Vec3, Mat4 from ../math/types.js; Vec3Up from ../math/vec3.js; writeLookAtWorldMat4LHIntoBuffer from ../math/write-look-at-world-mat4-lh-into-buffer.js; IWorldMatrixProvider, IParentable from ../scene/parentable.js; createWorldMatrixState from ../scene/world-matrix-state.js; ObservableVec3 from ../math/observable-vec3.js.
  • arc-rotate-controls.ts imports: ArcRotateCamera from ./arc-rotate.js; SceneContext, SceneContextInternal from ../scene/scene.js.
  • free-camera.ts imports: Camera from ./camera.js; Vec3, Mat4 from ../math/types.js; Vec3Up from ../math/vec3.js; writeLookAtWorldMat4LHIntoBuffer from ../math/write-look-at-world-mat4-lh-into-buffer.js; IWorldMatrixProvider, IParentable from ../scene/parentable.js; createWorldMatrixState from ../scene/world-matrix-state.js; ObservableVec3 from ../math/observable-vec3.js.
  • free-camera-controls.ts imports: FreeCamera, FreeCameraInternal from ./free-camera.js; SceneContext from ../scene/scene.js.
  • Depended on by: scene.ts (creates camera), render pipeline (reads camera matrices).

Test Specification

TestDescription
ArcRotate
 
getCameraPosition at alpha=-π/2, beta=π/2
Camera should be at (target.x, target.y, target.z + radius)
getCameraPosition at alpha=0, beta=π/2
Camera at (target.x + radius, target.y, target.z)
getViewMatrix is valid LH lookAt
Multiply view × position should give NDC-like coords
getProjectionMatrix aspect ratio
Verify m[0] = tan/aspect, m[5] = tan
getViewProjectionMatrix = proj × view
Compare with manual multiply
beta clamping
Inertia application clamps beta to [0.01, π-0.01]
wheel zoom proportional
Large radius → larger absolute change
pan shifts target via inertia
Accumulated panning offsets move target, radius unchanged
pinch zoom
Two-touch events correctly scale radius directly
inertia decay
After input stops, offsets decay by camera.inertia per frame
cleanup removes all listeners + beforeRender
After cleanup, events and RAF hook removed
primary/secondary mappings are independent
Either button can select rotate or pan without changing the other button
custom mappings preserve touch
One-finger touch still rotates and two-finger pinch still zooms
keyboard defaults and modifiers
Arrow rotation, Ctrl panning, Alt vertical zoom, Ctrl-over-Alt precedence
keyboard direction normalization
Default/custom diagonals preserve single-axis speed; opposing directions cancel
keyboard lifecycle
Held input repeats per frame; keyup, blur, and cleanup stop further accumulation
keyboard filtering
Meta-modified mappings are ignored; Shift does not alter mappings
custom keyboard options
Direction code lists and all three sensitivity divisors override independently
keyboard disabled by default
No keyboard listeners or held-key input unless keyboard is requested
FreeCamera
 
initial yaw/pitch from position→target
Verify atan2 computation
WASD movement in local space
W moves along +Z local, A along −X local
mouse drag rotates yaw/pitch
Verify angular sensitivity scaling
pitch clamped to ±(π/2 − 0.01)
Extreme pitch values clamped
inertia decay on accumulators
Movement/rotation decay by camera.inertia
target updated from yaw/pitch
Target re-derived each frame from orientation
world-to-view matrix consistency
View = inverse of world matrix
cleanup removes 6 listeners + beforeRender
All handlers detached
Orthographic
 
view volume corners → NDC
Reverse-Z depth (near→1, far→0); x/y independent of depth
off-center volume
Volume midpoint projects to NDC origin
aspect-derived horizontal extent
halfWidth = halfHeight * aspectRatio
revert to perspective
No stale m[12] / m[13] / m[15] left in the shared cache
re-enable re-arms the projection cache
Changing halfHeight takes effect without a camera move
live bound mutation
ortho.halfHeight = x invalidates proj + viewProj caches
steady-state scene UBO re-upload
Bound change re-opens the real _writePassSceneUBO gate
runtime enable/disable re-upload
Toggling after the first frame also re-opens the gate
projection change is not camera motion
_cameraChangeKey moves; worldMatrixVersion/worldMatrix do not
no-op assignment does not re-upload
Writing a bound its current value skips the GPU write
halfHeight is number-only
Planes accept null; halfHeight cannot go degenerate
null plane toggles derived/off-center
Assigning a number then null restores the derived extent
bounds are own enumerable properties
Animation paths like "ortho.halfHeight" resolve and write
Projection parameters
 
fov write rebuilds the projection
m[5] = 1/tan(fov/2) follows, camera at rest
near/far write rebuilds the projection
Reverse-Z depth terms m[10] / m[14] follow
propagates through the view-projection cache
getViewProjectionMatrix is not stale either
steady-state scene UBO re-upload
Each of fov / near / far re-opens the real _writePassSceneUBO gate, under perspective and ortho
no-op rewrite does not re-upload
Rewriting a parameter with its current value skips the GPU write

File Manifest

FileSizePurpose
src/camera/camera.ts
~15 lines
Shared Camera interface contract
src/camera/arc-rotate.ts
~198 lines
ArcRotateCamera data + world matrix + dirty tracking
src/camera/arc-rotate-controls.ts
~400 lines
ArcRotate pointer/wheel/touch input with inertia model
src/camera/arc-rotate-keyboard-controls.ts
~160 lines
Opt-in ArcRotate keyboard mappings, listeners, and input seam
src/camera/free-camera.ts
~161 lines
FreeCamera data + world matrix + dirty tracking
src/camera/banked-free-camera.ts
~44 lines
Opt-in FreeCamera with an explicit mutable up vector
src/camera/free-camera-controls.ts
~184 lines
FreeCamera keyboard/mouse input with inertia
src/camera/orthographic.ts
~75 lines
Opt-in orthographic projection (installs the seam)