Skip to content

Camera Rigs ​

Recipes for the three camera setups almost every experience needs: orbit (rotate around a target), third-person follow, and first-person. Each is a complete, working Behaviour script — copy it, attach it, and tune the inspector fields.

The pattern ​

All camera rigs follow the same rules:

  1. Attach the rig script to the camera entity and drive this.transform — CameraSyncSystem keeps the live camera in sync. Never mutate this.threeRef.object.position/rotation on transform-synced entities; TransformSyncSystem overwrites raw Object3D transforms every frame.
  2. Input comes from this.world — getKey / getKeyDown (import KeyboardKeys), getMouse("left") / getMouseDown, consumeMouseDelta(), consumeScrollDelta(), lockMousePointer() / unlockMousePointer() / isMousePointerLocked(). There is no world.input object.
  3. Angles are radians. Own your yaw/pitch as private numbers and build positions/rotations from them — never read angles back out of a quaternion each frame (Euler extraction can gimbal-flip).
  4. Position a camera on a sphere around its target with spherical coordinates:
    ts
    x = target.x + distance * Math.sin(yaw) * Math.cos(pitch);
    y = target.y + distance * Math.sin(pitch);
    z = target.z + distance * Math.cos(yaw) * Math.cos(pitch);
    then this.lookAt(targetPosition).
  5. Smooth with framerate-independent damping: alpha = Math.min(1, smoothSpeed * this.deltaTime), then pos += (desired - pos) * alpha.
  6. Resolve targets lazily. Look the target up in init() AND retry in onUpdate() when it is missing — this survives hot reloads and late model/physics initialization.

Recipe 1 — Orbit camera (rotate around a target) ​

Orbits a target entity: optional auto-rotate, drag with the left mouse button to orbit manually, scroll to zoom. Falls back to orbiting the world origin when no target is set, so it always does something visible.

Setup: attach to the camera entity. Pick the target with the target entity field in the inspector (or type its name into targetName).

ts
import { Behaviour } from "@relu-interactives/spatial-ecs";
import type {
  EntityInput,
  FloatInput,
  TextInput,
  ToggleInput,
  WorldEntityView,
} from "@relu-interactives/spatial-ecs";
import { TransformComponent } from "@relu-interactives/spatial-ecs/components";
import { Vector3 } from "three";

export default class OrbitCamera extends Behaviour {
  data = {
    // Pick the entity to orbit in the inspector…
    target: { type: "entity", value: null } as unknown as EntityInput,
    // …or find it by name (used when no entity is picked).
    targetName: "" as TextInput,

    distance: 6 as FloatInput,
    minDistance: 2 as FloatInput,
    maxDistance: 20 as FloatInput,
    /** Vertical offset of the orbit centre above the target's origin. */
    height: 0.5 as FloatInput,

    autoRotate: true as ToggleInput,
    /** Radians per second when autoRotate is on. */
    autoRotateSpeed: 0.4 as FloatInput,

    mouseSensitivity: 0.005 as FloatInput,
    minPitch: -0.2 as FloatInput,
    maxPitch: 1.3 as FloatInput,
    smoothSpeed: 8 as FloatInput,
  };

  private yaw = 0;
  private pitch = 0.5;
  private currentDistance = 6;
  private targetTransform: TransformComponent | null = null;
  private orbitCentre = new Vector3();

  protected init() {
    this.currentDistance = this.data.distance;
    this.resolveTarget();
  }

  /** Prefer the inspector-picked entity; fall back to lookup by name. */
  private resolveTarget() {
    const picked = this.data.target.value as WorldEntityView | null;
    if (picked) {
      this.targetTransform = this.world.getComponent(
        picked.entityId,
        TransformComponent,
      ) as TransformComponent | null;
      if (this.targetTransform) return;
    }
    if (this.data.targetName) {
      const byName = this.world.getEntityByName(this.data.targetName);
      if (byName) {
        this.targetTransform = this.world.getComponent(
          byName.entityId,
          TransformComponent,
        ) as TransformComponent | null;
      }
    }
  }

  protected onUpdate() {
    // Lazy re-resolve so the rig survives hot reloads / late target creation.
    if (!this.targetTransform) this.resolveTarget();

    // Consume the mouse delta EVERY frame (deltas accumulate until consumed —
    // skipping frames would cause a jump when the drag starts) but only apply
    // it while the left button is held.
    const delta = this.world.consumeMouseDelta();
    const dragging = this.world.getMouse("left");
    if (dragging) {
      this.yaw -= delta.x * this.data.mouseSensitivity;
      this.pitch += delta.y * this.data.mouseSensitivity;
      this.pitch = Math.max(
        this.data.minPitch,
        Math.min(this.data.maxPitch, this.pitch),
      );
    } else if (this.data.autoRotate) {
      this.yaw += this.data.autoRotateSpeed * this.deltaTime;
    }

    // Scroll to zoom.
    const scroll = this.world.consumeScrollDelta();
    this.currentDistance += scroll.y * 0.01;
    this.currentDistance = Math.max(
      this.data.minDistance,
      Math.min(this.data.maxDistance, this.currentDistance),
    );

    // Orbit centre: the target's position (world origin when no target).
    if (this.targetTransform) {
      this.orbitCentre.set(
        this.targetTransform.position.x,
        this.targetTransform.position.y + this.data.height,
        this.targetTransform.position.z,
      );
    } else {
      this.orbitCentre.set(0, this.data.height, 0);
    }

    // Desired position on the orbit sphere.
    const desiredX =
      this.orbitCentre.x +
      this.currentDistance * Math.sin(this.yaw) * Math.cos(this.pitch);
    const desiredY =
      this.orbitCentre.y + this.currentDistance * Math.sin(this.pitch);
    const desiredZ =
      this.orbitCentre.z +
      this.currentDistance * Math.cos(this.yaw) * Math.cos(this.pitch);

    // Framerate-independent smoothing, then aim at the centre.
    const alpha = Math.min(1, this.data.smoothSpeed * this.deltaTime);
    this.transform.position.x += (desiredX - this.transform.position.x) * alpha;
    this.transform.position.y += (desiredY - this.transform.position.y) * alpha;
    this.transform.position.z += (desiredZ - this.transform.position.z) * alpha;
    this.lookAt(this.orbitCentre);
  }
}

Recipe 2 — Third-person camera + character controller ​

Two cooperating scripts: the camera orbits behind the character under pointer-lock mouse control, and the controller moves the character relative to the camera. This is the classic third-person setup.

Setup:

  • Attach ThirdPersonCamera to the camera entity; set targetName to the character entity's name.
  • Attach CharacterController to the character entity (must have a PhysicsComponent — dynamic rigid body + capsule collider); pick the camera entity in its cameraEntity field.
  • The character's AnimationComponent state names are configurable through the animStates fields.
ts
// ThirdPersonCamera.ts — attach to the CAMERA entity
import { Behaviour, KeyboardKeys } from "@relu-interactives/spatial-ecs";
import type { FloatInput, TextInput } from "@relu-interactives/spatial-ecs";
import {
  PhysicsComponent,
  TransformComponent,
} from "@relu-interactives/spatial-ecs/components";
import { Vector3 } from "three";

export default class ThirdPersonCamera extends Behaviour {
  data = {
    targetName: "Player" as TextInput,
    distance: 5 as FloatInput,
    /** Look-at height above the character origin (aim at the upper body). */
    height: 1.5 as FloatInput,
    smoothSpeed: 10 as FloatInput,
    mouseSensitivity: 0.003 as FloatInput,
    minPitch: -0.4 as FloatInput,
    maxPitch: 1.2 as FloatInput,
    minDistance: 1 as FloatInput,
    maxDistance: 15 as FloatInput,
  };

  private targetPhysics: PhysicsComponent | null = null;
  private targetTransform: TransformComponent | null = null;
  private yaw = 0;
  private pitch = 0.4;
  private currentDistance = 5;

  protected init() {
    this.currentDistance = this.data.distance;
    this.findTarget();
  }

  private findTarget() {
    const target = this.world.getEntityByName(this.data.targetName);
    if (!target) return;
    this.targetPhysics = this.world.getComponent(
      target.entityId,
      PhysicsComponent,
    ) as PhysicsComponent | null;
    this.targetTransform = this.world.getComponent(
      target.entityId,
      TransformComponent,
    ) as TransformComponent | null;
  }

  protected onUpdate() {
    if (!this.targetTransform) {
      this.findTarget();
      if (!this.targetTransform) return;
    }

    // Pointer lock on click; Escape releases it.
    if (this.world.getMouseDown("left")) this.world.lockMousePointer();
    if (this.world.getKeyDown(KeyboardKeys.Escape)) this.world.unlockMousePointer();

    // Orbit with the mouse while locked.
    if (this.world.isMousePointerLocked()) {
      const delta = this.world.consumeMouseDelta();
      this.yaw -= delta.x * this.data.mouseSensitivity;
      this.pitch -= delta.y * this.data.mouseSensitivity;
      this.pitch = Math.max(
        this.data.minPitch,
        Math.min(this.data.maxPitch, this.pitch),
      );
    }

    // Scroll to zoom.
    const scroll = this.world.consumeScrollDelta();
    this.currentDistance -= scroll.y * 0.01;
    this.currentDistance = Math.max(
      this.data.minDistance,
      Math.min(this.data.maxDistance, this.currentDistance),
    );

    // Follow the live rigid body when present (avoids one-frame physics lag).
    let tx: number, ty: number, tz: number;
    if (this.targetPhysics?.rigidBody) {
      const t = this.targetPhysics.rigidBody.translation();
      tx = t.x; ty = t.y; tz = t.z;
    } else {
      tx = this.targetTransform.position.x;
      ty = this.targetTransform.position.y;
      tz = this.targetTransform.position.z;
    }

    const lookAtY = ty + this.data.height;
    const desiredX =
      tx + this.currentDistance * Math.sin(this.yaw) * Math.cos(this.pitch);
    const desiredY = lookAtY + this.currentDistance * Math.sin(this.pitch);
    const desiredZ =
      tz + this.currentDistance * Math.cos(this.yaw) * Math.cos(this.pitch);

    const alpha = Math.min(1, this.data.smoothSpeed * this.deltaTime);
    this.transform.position.x += (desiredX - this.transform.position.x) * alpha;
    this.transform.position.y += (desiredY - this.transform.position.y) * alpha;
    this.transform.position.z += (desiredZ - this.transform.position.z) * alpha;

    this.lookAt(new Vector3(tx, lookAtY, tz));
  }

  dispose() {
    this.world.unlockMousePointer();
  }
}
ts
// CharacterController.ts — attach to the CHARACTER entity (needs PhysicsComponent)
import { Behaviour, KeyboardKeys } from "@relu-interactives/spatial-ecs";
import type {
  EntityInput,
  FloatInput,
  TextInput,
  WorldEntityView,
} from "@relu-interactives/spatial-ecs";
import {
  AnimationComponent,
  PhysicsComponent,
  TransformComponent,
} from "@relu-interactives/spatial-ecs/components";
import { Euler, Quaternion } from "three";

export default class CharacterController extends Behaviour {
  data = {
    walkSpeed: 4 as FloatInput,
    sprintSpeed: 8 as FloatInput,
    rotationSpeed: 8 as FloatInput,
    /** Pick the camera entity in the inspector. */
    cameraEntity: { type: "entity", value: null } as unknown as EntityInput,
    // Animation state names (must match states on the AnimationComponent).
    idleState: "idle" as TextInput,
    runState: "run_forward" as TextInput,
    sprintState: "sprint" as TextInput,
  };

  // Own the Y facing angle — never read it back from the rigid-body
  // quaternion (Euler extraction gimbal-flips at some orientations).
  private facingY = 0;

  protected init() {
    const physics = this.getComponent(PhysicsComponent);
    // Lock physical tipping; we drive yaw manually.
    physics?.rigidBody?.setEnabledRotations(false, true, false, true);
    this.facingY = this.transform.rotation.y;
  }

  protected onUpdate() {
    const rb = this.getComponent(PhysicsComponent)?.rigidBody;
    if (!rb) return;

    // Camera-relative forward/right on the XZ plane.
    const camEntity = this.data.cameraEntity.value as WorldEntityView | null;
    if (!camEntity) return;
    const camTransform = this.world.getComponent(
      camEntity.entityId,
      TransformComponent,
    ) as TransformComponent | null;
    if (!camTransform) return;

    const myPos = rb.translation();
    let fwdX = 0, fwdZ = -1, rightX = 1, rightZ = 0;
    const dx = myPos.x - camTransform.position.x;
    const dz = myPos.z - camTransform.position.z;
    const len = Math.sqrt(dx * dx + dz * dz);
    if (len > 0.001) {
      fwdX = dx / len; fwdZ = dz / len;
      rightX = -fwdZ; rightZ = fwdX;
    }

    const w = this.world.getKey(KeyboardKeys.W);
    const s = this.world.getKey(KeyboardKeys.S);
    const a = this.world.getKey(KeyboardKeys.A);
    const d = this.world.getKey(KeyboardKeys.D);
    const sprint = this.world.getKey(KeyboardKeys.ShiftLeft);

    let moveX = 0, moveZ = 0;
    if (w) { moveX += fwdX; moveZ += fwdZ; }
    if (s) { moveX -= fwdX; moveZ -= fwdZ; }
    if (d) { moveX += rightX; moveZ += rightZ; }
    if (a) { moveX -= rightX; moveZ -= rightZ; }

    const moveLen = Math.sqrt(moveX * moveX + moveZ * moveZ);
    const isMoving = moveLen > 0.001;
    if (isMoving) { moveX /= moveLen; moveZ /= moveLen; }

    // Horizontal velocity; PRESERVE vel.y so gravity keeps working.
    const speed = sprint && w && !s ? this.data.sprintSpeed : this.data.walkSpeed;
    const vel = rb.linvel();
    rb.setLinvel(
      { x: isMoving ? moveX * speed : 0, y: vel.y, z: isMoving ? moveZ * speed : 0 },
      true,
    );

    // Smoothly rotate to face camera-forward (shortest way around).
    const targetAngle = Math.atan2(fwdX, fwdZ);
    let diff = targetAngle - this.facingY;
    while (diff > Math.PI) diff -= 2 * Math.PI;
    while (diff < -Math.PI) diff += 2 * Math.PI;
    this.facingY += diff * Math.min(1, this.data.rotationSpeed * this.deltaTime);
    const q = new Quaternion().setFromEuler(new Euler(0, this.facingY, 0));
    rb.setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }, false);

    // Animation — retry switchState until external clips finish loading.
    const anim = this.getComponent(AnimationComponent);
    let stateName = this.data.idleState;
    if (isMoving) stateName = sprint && w && !s ? this.data.sprintState : this.data.runState;
    if (anim && anim.activeStateName !== stateName) anim.switchState(stateName);
  }
}

Recipe 3 — First-person controller ​

Mouse-look with pointer lock, WASD movement on a physics body, jump, and the camera riding at eye height. The camera entity is the player's head; a separate body entity carries the physics.

Setup: attach to the camera entity. Create a body entity (e.g. an invisible capsule) named per bodyName with a PhysicsComponent (dynamic rigid body + capsule collider). Without a body the script falls back to direct (fly-style) movement.

ts
import { Behaviour, KeyboardKeys } from "@relu-interactives/spatial-ecs";
import type { FloatInput, TextInput } from "@relu-interactives/spatial-ecs";
import { PhysicsComponent } from "@relu-interactives/spatial-ecs/components";
import * as THREE from "three";

export default class FirstPersonController extends Behaviour {
  data = {
    bodyName: "Player" as TextInput,
    moveSpeed: 4 as FloatInput,
    sprintSpeed: 6.5 as FloatInput,
    lookSensitivity: 2 as FloatInput,
    jumpForce: 8 as FloatInput,
    /** Y of the capsule centre when standing on the ground (for the ground check). */
    groundLevel: 0.85 as FloatInput,
    /** Camera height above the capsule centre. */
    eyeHeight: 0.85 as FloatInput,
  };

  private isLocked = false;
  // YXZ order: yaw first, then pitch — the FPS standard (no roll drift).
  private euler = new THREE.Euler(0, 0, 0, "YXZ");
  private forward = new THREE.Vector3();
  private right = new THREE.Vector3();
  private camera: THREE.PerspectiveCamera | null = null;
  private bodyPhysics: PhysicsComponent | null = null;

  protected init() {
    this.camera = this.world.getCamera() as THREE.PerspectiveCamera;
    this.camera.rotation.order = "YXZ";
    this.euler.setFromQuaternion(this.camera.quaternion);
    this.transform.rotation.x = this.euler.x;
    this.transform.rotation.y = this.euler.y;
    this.transform.rotation.z = this.euler.z;
  }

  protected onUpdate() {
    // Pointer lock on click; detect external unlock (Escape).
    if (!this.isLocked && this.world.getMouseDown("left")) {
      this.world.lockMousePointer();
      this.isLocked = true;
    }
    if (this.isLocked && !this.world.isMousePointerLocked()) this.isLocked = false;
    if (!this.isLocked) return;

    // Lazy-resolve the physics body.
    if (!this.bodyPhysics) {
      const body = this.world.getEntityByName(this.data.bodyName);
      if (body) {
        this.bodyPhysics = this.world.getComponent(
          body.entityId,
          PhysicsComponent,
        ) as PhysicsComponent | null;
      }
    }
    const rb = this.bodyPhysics?.rigidBody ?? null;

    // Mouse look — yaw + pitch, pitch clamped just short of straight up/down.
    const delta = this.world.consumeMouseDelta();
    if (delta.x !== 0 || delta.y !== 0) {
      this.euler.y -= delta.x * 0.002 * this.data.lookSensitivity;
      this.euler.x -= delta.y * 0.002 * this.data.lookSensitivity;
      this.euler.x = Math.max(
        -Math.PI / 2 + 0.01,
        Math.min(Math.PI / 2 - 0.01, this.euler.x),
      );
      this.transform.rotation.x = this.euler.x;
      this.transform.rotation.y = this.euler.y;
      this.transform.rotation.z = this.euler.z;
    }

    // Movement basis from camera yaw only — strafing stays horizontal.
    this.forward.set(0, 0, -1).applyQuaternion(this.camera!.quaternion);
    this.forward.y = 0;
    this.forward.normalize();
    this.right.set(1, 0, 0).applyQuaternion(this.camera!.quaternion);
    this.right.y = 0;
    this.right.normalize();

    let mx = 0, mz = 0;
    if (this.world.getKey(KeyboardKeys.W)) { mx += this.forward.x; mz += this.forward.z; }
    if (this.world.getKey(KeyboardKeys.S)) { mx -= this.forward.x; mz -= this.forward.z; }
    if (this.world.getKey(KeyboardKeys.A)) { mx -= this.right.x; mz -= this.right.z; }
    if (this.world.getKey(KeyboardKeys.D)) { mx += this.right.x; mz += this.right.z; }
    const len = Math.sqrt(mx * mx + mz * mz);
    if (len > 0) { mx /= len; mz /= len; }

    const sprinting =
      this.world.getKey(KeyboardKeys.ShiftLeft) ||
      this.world.getKey(KeyboardKeys.ShiftRight);
    const speed = sprinting ? this.data.sprintSpeed : this.data.moveSpeed;

    if (rb) {
      const vel = rb.linvel();
      const pos = rb.translation();

      // Horizontal velocity; preserve vel.y so gravity keeps working.
      rb.setLinvel({ x: mx * speed, y: vel.y, z: mz * speed }, true);

      // Grounded: capsule centre near standing height and not rising.
      const isGrounded = pos.y <= this.data.groundLevel + 0.08 && vel.y < 0.5;
      if (isGrounded && this.world.getKeyDown(KeyboardKeys.Space)) {
        rb.setLinvel({ x: vel.x, y: this.data.jumpForce, z: vel.z }, true);
      }

      // Camera rides the body at eye height.
      this.transform.position.x = pos.x;
      this.transform.position.y = pos.y + this.data.eyeHeight;
      this.transform.position.z = pos.z;
    } else {
      // No body: direct fly-style movement.
      this.transform.position.x += mx * speed * this.deltaTime;
      this.transform.position.z += mz * speed * this.deltaTime;
    }
  }

  dispose() {
    if (this.isLocked) this.world.unlockMousePointer();
  }
}

Optional additions: a crosshair can be parented to the camera object (this.world.getCamera().add(mesh) with depthTest: false, positioned at z: -0.5) — remove it in dispose(). One-shot sounds can be spawned with getObjectManagementSystem().requestCreate("audio", { path, options: { autoplay: true } }, …).


Common mistakes ​

MistakeWhy it failsDo instead
Mutating this.threeRef.object.position/rotationTransformSyncSystem overwrites it every frameMutate this.transform
world.input.getKey(...)There is no world.inputthis.world.getKey(KeyboardKeys.W)
Reading yaw back from a quaternion each frameEuler extraction gimbal-flipsOwn yaw/pitch as private numbers
rb.setLinvel({ x, y: 0, z })Kills gravity — the body floatsPreserve vel.y
Degrees for rotationEverything is radiansMath.PI / 2, not 90
Consuming mouse delta only while draggingDeltas accumulate → jump on clickconsumeMouseDelta() every frame, apply conditionally
onInit() lifecycle hookNever called — the hook is init()protected init() { … }