A shared virtual-camera system for Decentraland SDK7 scenes: a multi-camera manager, cinematic mode (with optional UI hooks), dolly/crane-style camera animations, and player lock/unlock while a virtual camera is active.
This module owns camera and input state only. It has no opinion on how a scene renders its own UI — see Cinematic-mode UI hooks for how a host plugs its own UI in.
A tour of what this module can actually do, before the API-level reference below.
addCamera(cameraData) creates as many virtual camera entities as a scene
needs — one per interaction point, chess seat, theatre screen, dispenser,
event board, whatever. Each is tracked in the shared MultiCameraManager
component. Only one is ever "live" (assigned to MainCamera) at a time;
switching which one is live is what enterCinematicMode() /
useVirtualCamera() do. There's no built-in cycling/carousel between
cameras — a host decides when to switch and to which one.
enterCinematicMode(camera) / exitCinematicMode() is the one call most
features need: it assigns the given camera to MainCamera, and
exitCinematicMode() also unlocks player movement automatically. Optional
UI (letterbox bars, prompts, whatever a scene wants) hooks in on top — see
Cinematic-mode UI hooks for the full default /
per-call-override / opt-out story.
Set lookAtTarget: true on a CameraData and the camera continuously aims
at a shared look-at point (cameraTarget) instead of holding a fixed
rotation. While a camera is active and not marked avatarAttached, a
background system (CameraFollowSystem) gives that look-at point a subtle,
randomized "wobble" — picking a small offset every ~0.2–1.2s and
smooth-interpolating toward it — so a static shot doesn't feel perfectly
locked-off. Cameras with avatarAttached: true skip the wobble, since
they're expected to already be tracking a moving target.
setCameraFollowTarget(entity) redirects the follow/wobble system to track
any entity — not just the player. guideNPC.ts uses this to have the
tutorial guide camera follow the NPC; nekkoBarBartender.ts uses it to
follow the bartender. centerCameraTarget() snaps the follow target back to
the scene's own default center point (sceneCenterTarget, set once via
setSceneParent/initCamera).
lockPlayer() / unlockPlayer() disable/enable player movement (jog, jump,
run, walk) directly via InputModifier, without needing to go through
enterCinematicMode/exitCinematicMode at all — useful for cases that want
to freeze the player without switching the active camera.
startCameraAnimation(animationType) plays a pre-configured Tween on
whichever camera is currently active, computed from wherever the followed
entity currently is. The available moves (each with its own duration and
easing curve, defined in cameraConfig.ts's cameraAnimations table):
DOLLY_IN, DOLLY_OUT, DOLLY_IN_FAST, DOLLY_OUT_FAST, CRANE_DOWN,
MOVE_ALONG_RUNWAY_OPPOSITE, MOVE_WITH_AVATAR, and NONE (a very short
settle-in move). These are hand-tuned for the scene this module was
originally built for — treat the specific positions/durations in
cameraConfig.ts as a starting point to retune, not universal constants.
Every CameraData.position passed to addCamera() is automatically offset
relative to whatever entity was passed to setSceneParent(). That means
camera coordinates can be authored as if the scene sat at the world origin,
regardless of where the scene is actually parented — important for a
multi-parcel layout like Genesis Plaza, where the same relative camera
placements need to work no matter which parcel offset the scene ends up at.
CameraData.importedRotation: true applies a corrective rotation (a
-90°/180° axis fix-up) before placing the camera — for cameras whose
transform data originates from an imported GLTF/3D tool with different axis
conventions than the SDK expects, rather than needing every caller to
hand-correct their rotations.
Every camera carries a types: CAMERA_TYPE[] array in its CamData
component. Only CAMERA_TYPE.CENTER_TARGET exists today, but the enum is
meant to be extended by consuming scenes for their own querying/filtering
needs (e.g. "give me all cameras tagged for this minigame").
disableCameraSwitch() / enableCameraSwitch() toggle a
cameraSwitchEnabled flag on MultiCameraManager — but nothing inside this
module currently reads that flag to gate behavior. It's exposed for a host
to set and query its own logic against, not a built-in switch-blocking
mechanism (an earlier "cycle to next camera of type X" feature that would
have read it was removed as unused during extraction). Similarly,
TutorialCameraData is a convenience type for hosts authoring their own
scripted waypoint sequences — nothing in cameras.ts consumes it directly.
This module is distributed as a git subrepo
(not an npm package) — it's meant to be vendored as real files directly inside
a consuming scene's own source tree, sharing that scene's own @dcl/sdk
install rather than bringing its own.
From the root of the scene's repo:
git subrepo clone <this-repo-url> src/modules/cameraThis copies the module's files into src/modules/camera/ and adds a
.gitrepo file there tracking where it came from. Import it the same way as
any other local module, e.g. with this project's @modules/* path alias:
import { initCamera, addCamera } from "@modules/camera/cameras"
import { CAMERA_TYPE } from "@modules/camera/cameraConfig"To pull in updates later, from the scene repo's root (with a clean working
tree — git subrepo requires no unstaged changes anywhere in the repo):
git subrepo pull src/modules/cameraTo contribute changes back upstream:
git subrepo push src/modules/cameraThis repo has its own package.json declaring @dcl/sdk as a dependency —
but that version has no effect once vendored into a scene via subrepo.
The vendored files have no node_modules of their own (it's gitignored, so
subrepo never copies it), so import { engine } from "@dcl/sdk/ecs" in
cameras.ts resolves via Node's normal upward directory search straight to
the consuming scene's own node_modules/@dcl/sdk — whatever version that
happens to be. Bumping the version in this repo's package.json doesn't
change what any scene actually runs.
That package.json exists purely so this repo can be opened and developed
standalone — running npm install here gives you @dcl/sdk types for
IntelliSense and lets you run tsc --noEmit in isolation, without needing a
host scene present at all.
One caveat: don't run npm install inside a scene's vendored copy (e.g.
central-plaza/src/modules/camera/). That would create a node_modules/@dcl/sdk
right there, which Node's resolver would find before walking up to the
scene's own copy — silently reintroducing two separate engine instances in
the same bundle (the exact bug this module's design otherwise avoids).
Call these once, during scene startup, before anything calls initCamera()
or addCamera():
import { initCamera } from "@modules/camera/cameras"
import { setSceneParent } from "@modules/camera/sceneParent"
setSceneParent(mySceneRootEntity)
initCamera()setSceneParent(entity)— the one piece of scene-specific data this module can't know on its own: which entity camera positions declared in world-space should be positioned relative to (mirrors however the rest of the scene parents its own content). If never called, cameras are positioned relative to world origin instead.initCamera()— creates the camera manager and the default camera rig. Safe to call more than once; only does anything the first time.addCamera()also calls this internally if it hasn't run yet, so calling it explicitly is optional but recommended for predictable ordering.
enterCinematicMode() / exitCinematicMode() switch the active camera and
optionally trigger UI (e.g. letterbox bars) — but this module doesn't
implement any UI itself. Wire up a scene-wide default once:
import { setCinematicUIHooks } from "@modules/camera/cameras"
setCinematicUIHooks({
onEnterCinematic: () => showMyLetterboxBars(),
onExitCinematic: () => hideMyLetterboxBars(),
})Every enterCinematicMode(camera) call with no second argument uses this
default. Individual call sites can override it:
// Skip UI entirely for this one:
enterCinematicMode(chessCam, {})
// Use different UI for this one:
enterCinematicMode(theatreCam, {
onEnterCinematic: () => showTheatreOverlay(),
onExitCinematic: () => hideTheatreOverlay(),
})Whichever hook set was passed to enterCinematicMode() (the override, or the
default if none given) is what the next exitCinematicMode() call fires.
Only one cinematic session is assumed active at a time — this isn't
per-camera state, so don't rely on overlapping concurrent sessions.
| Export | Signature | Notes |
|---|---|---|
initCamera |
() => void |
Creates the camera manager + default rig. Idempotent. |
addCamera |
(cameraData: CameraData, transitionTime?: number) => Entity |
Creates a new virtual camera entity from a CameraData config. |
enterCinematicMode |
(camera: Entity, uiHooks?: CinematicUIHooks) => void |
Activates camera as the main camera; fires enter UI hook. |
exitCinematicMode |
() => void |
Returns to the player's own camera; fires exit UI hook; unlocks player input. |
setCinematicUIHooks |
(hooks: CinematicUIHooks) => void |
Sets the scene-wide default UI hooks (see above). |
useVirtualCamera |
(camera: Entity) => void |
Lower-level: just assigns MainCamera, no UI hook, no player lock. |
freeCamera |
() => void |
Lower-level counterpart to useVirtualCamera. |
lockPlayer / unlockPlayer |
() => void |
Disables/enables player movement input directly. |
setCameraFollowTarget |
(entity: Entity) => void |
Re-targets the camera-follow system (wobble/tracking) to a different entity. |
centerCameraTarget |
() => void |
Resets the follow target back to the scene's default center. |
getDefaultCamera |
() => Entity |
Returns the currently active camera entity. |
disableCameraSwitch / enableCameraSwitch |
() => void |
Toggles the cameraSwitchEnabled flag on the manager component. |
startCameraAnimation |
(animationType: ANIMATION_TYPE) => void |
Plays a dolly/crane-style tween on the current camera (see cameraConfig.ts for the available types and their durations/easings). |
MultiCameraManager, CamData, CameraTarget, ModelTracker |
components | Exposed for direct reads (e.g. CamData.getMutable(camera)) where a host needs lower-level access. |
sceneCenterTarget, modelTracker, cameraTarget, cameraWobbleTarget, cameraManager |
Entity |
Internal entities, exposed for advanced use. |
| Export | Notes |
|---|---|
CAMERA_TYPE |
Enum tagging what a camera is used for (currently just CENTER_TARGET). |
ANIMATION_TYPE |
Enum of available startCameraAnimation() animations (DOLLY_IN, DOLLY_OUT, CRANE_DOWN, etc). |
CameraData |
Config shape passed to addCamera() — position, fov, rotation, whether it should look at the camera target, etc. |
TutorialCameraData |
Simpler camera-waypoint shape used for scripted tutorial/guide sequences. |
cameras |
Default array of CameraData presets used by initCamera(). |
cameraAnimations |
Duration/easing table for each ANIMATION_TYPE. |
| Export | Signature | Notes |
|---|---|---|
setSceneParent |
(entity: Entity) => void |
Call once at scene startup (see Setup). |
sceneParentEntity |
() => Entity | undefined |
Read the currently-set scene parent. |
import { addCamera, enterCinematicMode, exitCinematicMode } from "@modules/camera/cameras"
import { CAMERA_TYPE } from "@modules/camera/cameraConfig"
import { Quaternion, Vector3 } from "@dcl/sdk/math"
const myCamera = addCamera({
types: [CAMERA_TYPE.CENTER_TARGET],
position: Vector3.create(4, 3, 4),
fov: 50,
targetVerticalOffset: 1.0,
avatarAttached: false,
rotation: Quaternion.fromEulerDegrees(0, 0, 0),
importedRotation: false,
lookAtTarget: true,
})
// somewhere in a pointer-event handler:
enterCinematicMode(myCamera)
// ...and to leave:
exitCinematicMode()Presentation/UI (letterbox bars, on-screen hints, buttons) lives in each consuming scene, not here — see Cinematic-mode UI hooks. Keeping this module to camera/input state only is intentional: it's the one thing every scene needs identically, while UI treatment is something different scenes reasonably want to differ on.