Camera2D
The scene camera from @motionactor/primitives. It pans, zooms, rolls, frames a box, follows an actor and shakes.
Camera2D is an actor with no picture. Spawn one and its values become the frame’s
camera, which SceneRenderer applies to every layer as one transform: zoom, then rotation,
then the pan, around an origin that defaults to the layer’s centre.
import { function createScene<TSceneExports, const I extends SceneInputs = Readonly<Record<never, SceneInput>>, const L extends SceneLayers = Readonly<Record<never, SceneLayer<BackendRef<string>>>>>(script: SceneScript<I, L, TSceneExports>, options?: SceneDefinitionOptions<I, L>): SceneDefinition<I, L, TSceneExports>Declare a scene. Nothing compiles until a host builds it:
`scene.build({ viewport, fps, inputs })` runs the script as
`(ctx, inputs, viewport, layers)` and returns the compiled scene.
`ctx.spawn` places a root actor in the default layer; `layers.<key>.spawn`
places it in a declared one. `ctx.stage` stages another scene on this
scene's clock, and a staged scene may stage again.createScene } from "@motionactor/core";
import { class Camera2D2D scene camera — controls pan, zoom, and rotation of the entire scene.
centerX/centerY is a **pan offset** in world-space pixels.
(0, 0) = identity (no pan). Zoom/rotation pivot around
the viewport anchor (originX, originY), defaulting to center.
Does not render visually. It declares itself the scene camera, so its
resolved signals become `FrameState.camera`.Camera2D } from "@motionactor/primitives/camera";
const const compiled: CompiledScene<void, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>compiled = createScene<void, Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>>(script: SceneScript<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, void>, options?: SceneDefinitionOptions<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>> | undefined): SceneDefinition<...>Declare a scene. Nothing compiles until a host builds it:
`scene.build({ viewport, fps, inputs })` runs the script as
`(ctx, inputs, viewport, layers)` and returns the compiled scene.
`ctx.spawn` places a root actor in the default layer; `layers.<key>.spawn`
places it in a declared one. `ctx.stage` stages another scene on this
scene's clock, and a staged scene may stage again.createScene(function* (ctx: SceneContextctx) {
const const camera: Camera2Dcamera = ctx: SceneContextctx.CompileContext.spawn<Camera2D>(definition: ActorClass<Camera2D>, overrides?: {
centerX?: SignalInputValue<number> | undefined;
centerY?: SignalInputValue<number> | undefined;
zoom?: SignalInputValue<number> | undefined;
rotation?: SignalInputValue<number> | undefined;
shakeOffset?: {
rotation?: SignalInputValue<number> | undefined;
x?: SignalInputValue<number> | undefined;
y?: SignalInputValue<number> | undefined;
} | undefined;
... 19 more ...;
parent?: undefined;
} | undefined, options?: {
...;
} | undefined): Camera2D (+1 overload)
spawn(class Camera2D2D scene camera — controls pan, zoom, and rotation of the entire scene.
centerX/centerY is a **pan offset** in world-space pixels.
(0, 0) = identity (no pan). Zoom/rotation pivot around
the viewport anchor (originX, originY), defaulting to center.
Does not render visually. It declares itself the scene camera, so its
resolved signals become `FrameState.camera`.Camera2D);
yield* const camera: Camera2Dcamera.Camera2D.moveTo(params: {
x: number;
y: number;
zoom: number;
}, transition: TweenTransition): Generator<number, void, unknown>
Pan + zoom simultaneously (raw offset values).moveTo({ x: numberx: 200, y: numbery: 0, zoom: numberzoom: 1.5 }, 0.8);
}).SceneDefinition<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, void>.build(args: SceneBuildInput<Readonly<Record<never, SceneInput>>>): CompiledScene<void, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>build({ viewport: SceneViewportviewport: { SceneViewport.width: numberwidth: 1920, SceneViewport.height: numberheight: 1080 }, fps: numberfps: 30 });
const compiled: CompiledScene<void, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>compiled.CompiledScene<void, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>.at(frame: Frame, options?: FrameResolutionOptions): FrameState<void>at(24).FrameState<void>.camera: Camera2DState2D scene camera. Identity when no spawned actor's class declares `SCENE_CAMERA`.camera;
// { centerX: 200, centerY: 0, zoom: 1.5, rotation: 0,
// originX: 0.5, originY: 0.5 }
centerX and centerY are a pan in world pixels from where the scene sits untouched, so
(0, 0) with zoom 1 is no camera at all. Import from @motionactor/primitives/camera, which
depends on core alone. The package root exports the same classes alongside helpers that need
Three.js.
The camera frames the left panel with 80 pixels to spare, then follows the right panel while
it slides, so one tween moves both the panel and the view. A shake runs on top of the follow
and dies out, and reset returns the view to identity.
Commands
Each command takes its transition last: seconds, or { duration, easing }. The easing
defaults to Easing.inOutCubic.
| Command | Moves to |
|---|---|
panTo({ x, y }, t) |
That pan |
zoomTo({ zoom }, t) |
That zoom |
moveTo({ x, y, zoom }, t) |
Both at once |
centerOn({ rect, viewport, zoom? }, t) |
The pan that puts the rect’s centre at the origin |
focus({ rect, viewport, padding? }, t) |
The pan and the largest zoom that fit the rect, with padding |
follow({ target, viewport, zoom?, offsetX?, offsetY? }) |
A pan that tracks target from then on |
shake({ profile, viewport, duration, ... }) |
A wobble on top of the pan and roll, for duration |
reset(t) |
Pan 0, zoom 1, rotation 0 |
viewport is the size of the layer the camera looks through. focus also takes paddingX
and paddingY for unequal margins.
Framing an actor
centerOn and focus read the rect once, when they run, and tween to it. Give them
actor.worldLayout(), the box on the stage with percentages and hosts resolved:
import { function createScene<TSceneExports, const I extends SceneInputs = Readonly<Record<never, SceneInput>>, const L extends SceneLayers = Readonly<Record<never, SceneLayer<BackendRef<string>>>>>(script: SceneScript<I, L, TSceneExports>, options?: SceneDefinitionOptions<I, L>): SceneDefinition<I, L, TSceneExports>Declare a scene. Nothing compiles until a host builds it:
`scene.build({ viewport, fps, inputs })` runs the script as
`(ctx, inputs, viewport, layers)` and returns the compiled scene.
`ctx.spawn` places a root actor in the default layer; `layers.<key>.spawn`
places it in a declared one. `ctx.stage` stages another scene on this
scene's clock, and a staged scene may stage again.createScene, class LayoutActor<TEvents extends ActorEventMap = {}>LayoutActor } from "@motionactor/core";
import { class Camera2D2D scene camera — controls pan, zoom, and rotation of the entire scene.
centerX/centerY is a **pan offset** in world-space pixels.
(0, 0) = identity (no pan). Zoom/rotation pivot around
the viewport anchor (originX, originY), defaulting to center.
Does not render visually. It declares itself the scene camera, so its
resolved signals become `FrameState.camera`.Camera2D } from "@motionactor/primitives/camera";
class class CardCard extends class LayoutActor<TEvents extends ActorEventMap = {}>LayoutActor {}
const const viewport: {
width: number;
height: number;
}
viewport = { width: numberwidth: 1280, height: numberheight: 720 };
createScene<void, Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>>(script: SceneScript<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, void>, options?: SceneDefinitionOptions<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>> | undefined): SceneDefinition<...>Declare a scene. Nothing compiles until a host builds it:
`scene.build({ viewport, fps, inputs })` runs the script as
`(ctx, inputs, viewport, layers)` and returns the compiled scene.
`ctx.spawn` places a root actor in the default layer; `layers.<key>.spawn`
places it in a declared one. `ctx.stage` stages another scene on this
scene's clock, and a staged scene may stage again.createScene(function* (ctx: SceneContextctx) {
const const camera: Camera2Dcamera = ctx: SceneContextctx.CompileContext.spawn<Camera2D>(definition: ActorClass<Camera2D>, overrides?: {
centerX?: SignalInputValue<number> | undefined;
centerY?: SignalInputValue<number> | undefined;
zoom?: SignalInputValue<number> | undefined;
rotation?: SignalInputValue<number> | undefined;
shakeOffset?: {
rotation?: SignalInputValue<number> | undefined;
x?: SignalInputValue<number> | undefined;
y?: SignalInputValue<number> | undefined;
} | undefined;
... 19 more ...;
parent?: undefined;
} | undefined, options?: {
...;
} | undefined): Camera2D (+1 overload)
spawn(class Camera2D2D scene camera — controls pan, zoom, and rotation of the entire scene.
centerX/centerY is a **pan offset** in world-space pixels.
(0, 0) = identity (no pan). Zoom/rotation pivot around
the viewport anchor (originX, originY), defaulting to center.
Does not render visually. It declares itself the scene camera, so its
resolved signals become `FrameState.camera`.Camera2D);
const const card: Cardcard = ctx: SceneContextctx.CompileContext.spawn<Card>(definition: ActorClass<Card>, overrides?: {
id?: undefined;
cursor?: undefined;
cursorSeconds?: undefined;
sceneFps?: undefined;
onInit?: undefined;
onEnter?: undefined;
onExit?: undefined;
enter?: undefined;
exit?: undefined;
parent?: undefined;
layout?: {
x?: SignalInputValue<LayoutValue> | undefined;
y?: SignalInputValue<LayoutValue> | undefined;
width?: Readonly<{
kind: "layout.percent";
value: number;
}> | SignalInputValue<number | `${number}%` | Readonly<{
kind: "layout.percent";
value: number;
}>> | undefined;
height?: Readonly<...> | ... 1 more ... | undefined;
} | undefined;
... 11 more ...;
bounds?: undefined;
} | undefined, options?: {
...;
} | undefined): Card (+1 overload)
spawn(class CardCard, {
layout?: {
x?: SignalInputValue<LayoutValue> | undefined;
y?: SignalInputValue<LayoutValue> | undefined;
width?: Readonly<{
kind: "layout.percent";
value: number;
}> | SignalInputValue<number | `${number}%` | Readonly<{
kind: "layout.percent";
value: number;
}>> | undefined;
height?: Readonly<{
kind: "layout.percent";
value: number;
}> | SignalInputValue<number | `${number}%` | Readonly<{
kind: "layout.percent";
value: number;
}>> | undefined;
} | undefined
layout: { x?: SignalInputValue<LayoutValue> | undefinedx: 600, y?: SignalInputValue<LayoutValue> | undefinedy: 300, width?: Readonly<{
kind: "layout.percent";
value: number;
}> | SignalInputValue<number | `${number}%` | Readonly<{
kind: "layout.percent";
value: number;
}>> | undefined
width: 320, height?: Readonly<{
kind: "layout.percent";
value: number;
}> | SignalInputValue<number | `${number}%` | Readonly<{
kind: "layout.percent";
value: number;
}>> | undefined
height: 200 },
});
yield* const camera: Camera2Dcamera.Camera2D.focus(params: {
rect: Rect;
viewport: CameraViewport;
} & FocusOptions, transition: TweenTransition): Generator<number, void, unknown>
Focus on a rect — auto-compute zoom to fit it within the viewport
with optional padding.focus(
{ rect: Rectrect: const card: Cardcard.LayoutActor<{}>.worldLayout(): ResolvedLayoutBoxworldLayout(), viewport: CameraViewportviewport, FocusOptions.padding?: number | undefinedpadding: 80 },
0.6,
);
}).SceneDefinition<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, void>.build(args: SceneBuildInput<Readonly<Record<never, SceneInput>>>): CompiledScene<void, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>build({ viewport: SceneViewportviewport: { SceneViewport.width: numberwidth: 1920, SceneViewport.height: numberheight: 1080 }, fps: numberfps: 30 });
A rect read this way is a snapshot. When the card moves later, the camera stays where it was.
Following
follow replaces the pan with a derived value of the target’s layout,
so the camera keeps the target centred on every later frame without another command. It
takes no time. A later panTo, focus or reset writes the pan again and ends the follow.
Shake
shake adds a seeded wobble on top of the pan and rotation the camera already has, including
a live follow. The wobble is a function of the shake’s own progress, so a scrubbed frame
matches a played one, and it starts and ends at exactly zero.
The wobble lives in the camera’s shakeOffset group, which the camera adds to centerX,
centerY and rotation when it becomes the frame’s camera. The actor’s own centerX stays
where the pan or follow put it.
import { function createScene<TSceneExports, const I extends SceneInputs = Readonly<Record<never, SceneInput>>, const L extends SceneLayers = Readonly<Record<never, SceneLayer<BackendRef<string>>>>>(script: SceneScript<I, L, TSceneExports>, options?: SceneDefinitionOptions<I, L>): SceneDefinition<I, L, TSceneExports>Declare a scene. Nothing compiles until a host builds it:
`scene.build({ viewport, fps, inputs })` runs the script as
`(ctx, inputs, viewport, layers)` and returns the compiled scene.
`ctx.spawn` places a root actor in the default layer; `layers.<key>.spawn`
places it in a declared one. `ctx.stage` stages another scene on this
scene's clock, and a staged scene may stage again.createScene } from "@motionactor/core";
import { class Camera2D2D scene camera — controls pan, zoom, and rotation of the entire scene.
centerX/centerY is a **pan offset** in world-space pixels.
(0, 0) = identity (no pan). Zoom/rotation pivot around
the viewport anchor (originX, originY), defaulting to center.
Does not render visually. It declares itself the scene camera, so its
resolved signals become `FrameState.camera`.Camera2D } from "@motionactor/primitives/camera";
createScene<void, Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>>(script: SceneScript<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, void>, options?: SceneDefinitionOptions<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>> | undefined): SceneDefinition<...>Declare a scene. Nothing compiles until a host builds it:
`scene.build({ viewport, fps, inputs })` runs the script as
`(ctx, inputs, viewport, layers)` and returns the compiled scene.
`ctx.spawn` places a root actor in the default layer; `layers.<key>.spawn`
places it in a declared one. `ctx.stage` stages another scene on this
scene's clock, and a staged scene may stage again.createScene(function* (ctx: SceneContextctx) {
const const camera: Camera2Dcamera = ctx: SceneContextctx.CompileContext.spawn<Camera2D>(definition: ActorClass<Camera2D>, overrides?: {
centerX?: SignalInputValue<number> | undefined;
centerY?: SignalInputValue<number> | undefined;
zoom?: SignalInputValue<number> | undefined;
rotation?: SignalInputValue<number> | undefined;
shakeOffset?: {
rotation?: SignalInputValue<number> | undefined;
x?: SignalInputValue<number> | undefined;
y?: SignalInputValue<number> | undefined;
} | undefined;
... 19 more ...;
parent?: undefined;
} | undefined, options?: {
...;
} | undefined): Camera2D (+1 overload)
spawn(class Camera2D2D scene camera — controls pan, zoom, and rotation of the entire scene.
centerX/centerY is a **pan offset** in world-space pixels.
(0, 0) = identity (no pan). Zoom/rotation pivot around
the viewport anchor (originX, originY), defaulting to center.
Does not render visually. It declares itself the scene camera, so its
resolved signals become `FrameState.camera`.Camera2D);
yield* const camera: Camera2Dcamera.Camera2D.shake(params: {
profile: CameraShakeProfileName;
viewport: CameraViewport;
duration: Duration;
intensity?: number;
frequency?: number;
rotation?: boolean;
seed?: number;
} & CameraShakeEnvelopeOptions): Generator<...>
Procedural camera shake, added on top of whatever the camera
is already doing. Runs for `duration` seconds and settles to exactly zero,
leaving no residual offset on the base pan.
Deterministic: the offset is a pure function of progress, so scrubbing to
a frame gives the same result as playing to it.shake({
profile: CameraShakeProfileNameprofile: "handheld-tele-strong",
viewport: CameraViewportviewport: { width: numberwidth: 1280, height: numberheight: 720 },
duration: numberduration: 1.5,
intensity?: number | undefinedAmplitude multiplier. Default 1.intensity: 1.4,
});
}).SceneDefinition<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, void>.build(args: SceneBuildInput<Readonly<Record<never, SceneInput>>>): CompiledScene<void, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>build({ viewport: SceneViewportviewport: { SceneViewport.width: numberwidth: 1920, SceneViewport.height: numberheight: 1080 }, fps: numberfps: 30 });
| Option | Default | |
|---|---|---|
profile |
The kind of camera, below | |
duration |
Seconds | |
intensity |
1 |
Multiplies the amplitude |
frequency |
1 |
Multiplies the speed |
rotation |
true |
Includes roll. false reads as a gimbal |
attack |
0.08 |
Fraction of the run spent ramping in |
settle |
0.25 |
Fraction spent ramping out |
seed |
Shifts the noise, so two cameras shake differently |
The profiles are measured from real cameras. The handheld ones are tuned to move the frame by about the same distance on screen, but a wide lens drifts in slow, large swings and a telephoto jitters in small, fast ones, so they read as different lenses.
| Profile | Lens | Roll |
|---|---|---|
handheld-normal-mild, -strong, -extreme |
50 mm | None |
handheld-wideangle-mild |
28 mm | None |
handheld-wideangle-strong |
28 mm | Slight |
handheld-tele-mild |
85 mm | None |
handheld-tele-strong |
85 mm | Slight |
rig-6d-shake |
50 mm | Fine vibration, tens of hertz, on every axis |
rig-6d-wobble |
50 mm | A sway of one or two degrees, a few times a second |
A profile without roll never changes rotation, whatever rotation is set to.
Not for
Moving one actor: tween its layout. Parallax: put the background in its own
layer and move that layer’s actors at a different
rate, since the camera moves every layer the same way.