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.1farPlane = 1000inertia = 0.9panningInertia = 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 = 1farPlane = 10000speed = 2.0angularSensitivity = 2000inertia = 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] = 1getCameraPosition(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.0001cosB = cos(beta)cosA = cos(alpha)sinA = sin(alpha)
eye.x = target.x + radius * cosA * sinBeye.y = target.y + radius * cosBeye.z = target.z + radius * sinA * sinBThis is the Babylon.js left-handed spherical coordinate convention:
alpharotates around the Y axisbetais 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.xdy = target.y - position.ydz = 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 targetxAxis = 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] = 1writePerspectiveMat4LHIntoBuffer 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:
| Feature | Assumption |
|---|---|
| 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
| Constant | Value | Description |
|---|---|---|
angularSensibility | 1000 | Babylon default |
panningSensibility | 50 | Pixels per unit |
wheelPrecision | 3 | Wheel delta divisor |
Inertia Epsilon Thresholds
| Constant | Value | Used 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 / angularSensibilitycamera.inertialBetaOffset -= dy / angularSensibilityPan action (secondary-button default)
camera.inertialPanningX += -dx / panningSensibilitycamera.inertialPanningY += dy / panningSensibilityWheel (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.radiuson 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 mapping | No modifier | Ctrl | Alt 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)
// Rotationalpha += inertialAlphaOffsetbeta += inertialBetaOffsetbeta = clamp(beta, 0.01, π - 0.01) // prevent gimbal flipinertialAlphaOffset *= camera.inertiainertialBetaOffset *= camera.inertiaif |offset| < ROTATION_EPSILON: offset = 0
// Zoomradius -= inertialRadiusOffsetradius = max(0.01, radius)inertialRadiusOffset *= camera.inertiaif |offset| < RADIUS_EPSILON: offset = 0
// Panning (uses camera.panningInertia, not camera.inertia)rightX = -sin(alpha)rightZ = cos(alpha)panScale = radius * 0.001target.x += rightX * inertialPanningX * panScaletarget.y += inertialPanningY * panScaletarget.z += rightZ * inertialPanningX * panScaleinertialPanningX *= camera.panningInertiainertialPanningY *= camera.panningInertiaif |offset| < PANNING_EPSILON: offset = 0Scene Integration
When scene is provided to attachControl:
applyInertiais registered onscene._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
| Event | Handler | Options |
|---|---|---|
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 deltacrX += dy / camera.angularSensitivity // pitch deltaMovement 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 spacecosY = cos(_yaw), sinY = sin(_yaw)position.x += sinY × cdZ + cosY × cdXposition.y += cdYposition.z += cosY × cdZ - sinY × cdX
// 4. Recompute target from yaw/pitchcosP = cos(_pitch)target = (position.x + sinY×cosP, position.y + sin(_pitch), position.z + cosY×cosP)
// 5. Decay accumulators (inertia)cd* *= camera.inertiacr* *= camera.inertiaif |accumulator| < camera.speed × 0.001: accumulator = 0Canvas Focus
If the canvas has no tabindex attribute, attachFreeControl sets canvas.tabIndex = 0 to make it keyboard-focusable.
Event Registration
| Event | Handler | Options |
|---|---|---|
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 Lite | Babylon.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.tsimports:Vec3,Mat4from../math/types.js.arc-rotate.tsimports:Vec3,Mat4from../math/types.js;Vec3Upfrom../math/vec3.js;writeLookAtWorldMat4LHIntoBufferfrom../math/write-look-at-world-mat4-lh-into-buffer.js;IWorldMatrixProvider,IParentablefrom../scene/parentable.js;createWorldMatrixStatefrom../scene/world-matrix-state.js;ObservableVec3from../math/observable-vec3.js.arc-rotate-controls.tsimports:ArcRotateCamerafrom./arc-rotate.js;SceneContext,SceneContextInternalfrom../scene/scene.js.free-camera.tsimports:Camerafrom./camera.js;Vec3,Mat4from../math/types.js;Vec3Upfrom../math/vec3.js;writeLookAtWorldMat4LHIntoBufferfrom../math/write-look-at-world-mat4-lh-into-buffer.js;IWorldMatrixProvider,IParentablefrom../scene/parentable.js;createWorldMatrixStatefrom../scene/world-matrix-state.js;ObservableVec3from../math/observable-vec3.js.free-camera-controls.tsimports:FreeCamera,FreeCameraInternalfrom./free-camera.js;SceneContextfrom../scene/scene.js.- Depended on by:
scene.ts(creates camera), render pipeline (reads camera matrices).
Test Specification
| Test | Description |
|---|---|
| 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
| File | Size | Purpose |
|---|---|---|
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) |