Chase Camera

a third-person camera that lags behind its target.

▶ Run in browser

Tags: 3d

ChaseCamera is a Camera3D that follows a target node. Give it the node to follow and where to sit relative to it, and it moves itself every frame:

ChaseCamera(target=player, offset=Vec3(0, 2.5, 6), look_offset=Vec3(0, 0.5, 0), half_life=0.18)

offset is in the target’s own frame (X right, Y up, +Z behind), so the camera swings round to stay behind the target as it turns; set rotate_with_target=False for a fixed world-space offset instead. look_offset shifts the point it looks at, here just above the cube’s base.

half_life is the lag: the seconds it takes the camera to close half the gap to where it wants to be. Zero snaps every frame, and larger values trail further behind, which is what makes motion read as motion. snap() jumps straight to the rest pose, for a teleport or a scene change.

The red cube drives like a tank: left and right turn it in place and forward and back walk it along its heading, so the lag you see is the camera’s follow and not a sideways slide.

Controls: WASD / arrows / left stick - Up and down walk, left and right turn Q / E, shoulder buttons - Less / more lag (half_life) R, A button - Snap the camera to its rest pose The on-screen pad drives all of these with a finger.

Source

  1"""Chase Camera: a third-person camera that lags behind its target.
  2
  3``ChaseCamera`` is a ``Camera3D`` that follows a target node. Give it the node
  4to follow and where to sit relative to it, and it moves itself every frame:
  5
  6    ChaseCamera(target=player, offset=Vec3(0, 2.5, 6), look_offset=Vec3(0, 0.5, 0), half_life=0.18)
  7
  8``offset`` is in the target's own frame (X right, Y up, +Z behind), so the camera
  9swings round to stay behind the target as it turns; set
 10``rotate_with_target=False`` for a fixed world-space offset instead.
 11``look_offset`` shifts the point it looks at, here just above the cube's base.
 12
 13``half_life`` is the lag: the seconds it takes the camera to close half the gap
 14to where it wants to be. Zero snaps every frame, and larger values trail further
 15behind, which is what makes motion read as motion. ``snap()`` jumps straight to
 16the rest pose, for a teleport or a scene change.
 17
 18The red cube drives like a tank: left and right turn it in place and forward
 19and back walk it along its heading, so the lag you see is the camera's follow
 20and not a sideways slide.
 21
 22Controls:
 23    WASD / arrows / left stick   - Up and down walk, left and right turn
 24    Q / E, shoulder buttons      - Less / more lag (half_life)
 25    R, A button                  - Snap the camera to its rest pose
 26    The on-screen pad drives all of these with a finger.
 27"""
 28
 29import math
 30
 31from simvx.core import (
 32    Anchor,
 33    Axis1D,
 34    Axis2D,
 35    CanvasLayer,
 36    ChaseCamera,
 37    Controller,
 38    DirectionalLight3D,
 39    Input,
 40    Key,
 41    Label,
 42    Material,
 43    Mesh,
 44    MeshInstance3D,
 45    Node,
 46    TouchController,
 47    Vec3,
 48    WorldEnvironment,
 49    on_input,
 50)
 51from simvx.graphics import App
 52
 53WALK_SPEED = 6.0
 54TURN_RATE = math.radians(140.0)
 55LAG_RATE = 0.5  # half_life seconds gained per second of holding E
 56
 57
 58class ChaseCameraScene(Node):
 59    input_context = {
 60        "move": [Axis2D.WASD, Axis2D.ARROWS, Controller.LEFT_STICK],
 61        "lag": [Axis1D(low=Key.Q, high=Key.E), Axis1D(low=Controller.LEFT_BUMPER, high=Controller.RIGHT_BUMPER)],
 62        "snap": [Key.R, Controller.A],
 63    }
 64
 65    def on_ready(self):
 66        self.add_child(WorldEnvironment(bloom_enabled=False, ambient_light_energy=0.6))
 67        self.add_child(DirectionalLight3D(direction=(-1, -2, -1)))
 68        self.add_child(MeshInstance3D(mesh=Mesh.plane(40), material=Material(colour=(0.18, 0.2, 0.22))))
 69
 70        # Obstacles on a checkerboard, so the lagging camera has something to slide past.
 71        obstacle = Material(colour=(0.4, 0.5, 0.6))
 72        for i in range(-2, 3):
 73            for j in range(-2, 3):
 74                if (i + j) % 2:
 75                    position = (i * 4, 0, j * 4)
 76                    self.add_child(
 77                        MeshInstance3D(mesh=Mesh.box(0.8), material=obstacle, pivot="bottom", position=position)
 78                    )
 79
 80        self.player = self.add_child(
 81            MeshInstance3D(mesh=Mesh.box(1.0), material=Material(colour=(0.9, 0.3, 0.2)), pivot="bottom")
 82        )
 83        self.camera = self.add_child(
 84            ChaseCamera(
 85                target=self.player, offset=Vec3(0, 2.5, 6.0), look_offset=Vec3(0, 0.5, 0), half_life=0.18, fov=65.0
 86            )
 87        )
 88
 89        self.hud = self.add_child(CanvasLayer()).add_child(Label(anchor=Anchor.TOP_LEFT, inset=12, font_size=18))
 90        self.add_child(TouchController(buttons=(Controller.A, Controller.LEFT_BUMPER, Controller.RIGHT_BUMPER)))
 91
 92    @on_input("snap")
 93    def _snap(self, _event):
 94        self.camera.snap()
 95
 96    def on_update(self, dt):
 97        move = Input.value("move")  # +y is back, as down the screen in 2D
 98        self.player.rotate_y(-move.x * TURN_RATE * dt)
 99        self.player.position += self.player.forward * (-move.y * WALK_SPEED * dt)
100
101        # half_life's range=(0, 5) clamps it, so holding Q stops at a snapping camera.
102        self.camera.half_life += Input.value("lag") * LAG_RATE * dt
103        self.hud.text = f"half_life = {self.camera.half_life:.2f} s\nWASD move, Q/E lag, R snap"
104
105
106if __name__ == "__main__":
107    App(title="Chase Camera", width=1280, height=720).run(ChaseCameraScene())