Chase Camera

3rd-person lag-and-spring follow camera.

▶ Run in browser

Tags: 3d

Demonstrates:

  • ChaseCamera node tracking a moving target with smoothed offset

  • half_life Property as the visible knob (lag vs snap)

  • The trail effect: camera lags behind so player motion reads naturally

Controls (third-person: left/right turn the player, up/down walk): W / Up : Walk forward S / Down : Walk backward (no rotation) A / Left : Turn left D / Right : Turn right Q / E : Decrease / increase half_life (snap <-> heavy lag) R : Snap camera to rest pose (no smoothing) Escape : Quit

Run: uv run python examples/features/3d/chase_camera.py

Source

  1"""Chase Camera: 3rd-person lag-and-spring follow camera.
  2
  3Demonstrates:
  4  - ChaseCamera node tracking a moving target with smoothed offset
  5  - half_life Property as the visible knob (lag vs snap)
  6  - The trail effect: camera lags behind so player motion reads naturally
  7
  8Controls (third-person: left/right turn the player, up/down walk):
  9    W / Up           : Walk forward
 10    S / Down         : Walk backward (no rotation)
 11    A / Left         : Turn left
 12    D / Right        : Turn right
 13    Q / E            : Decrease / increase half_life (snap <-> heavy lag)
 14    R                : Snap camera to rest pose (no smoothing)
 15    Escape           : Quit
 16
 17Run: uv run python examples/features/3d/chase_camera.py
 18"""
 19
 20from __future__ import annotations
 21
 22import math
 23
 24from simvx.core import (
 25    ChaseCamera,
 26    DirectionalLight3D,
 27    Input,
 28    InputMap,
 29    Key,
 30    Material,
 31    Mesh,
 32    MeshInstance3D,
 33    Node,
 34    Text2D,
 35    Vec3,
 36    WorldEnvironment,
 37)
 38from simvx.graphics import App
 39
 40PLAYER_SPEED = 6.0
 41TURN_RATE = math.radians(140.0)  # deg/s: left/right turn the player in place
 42
 43
 44class ChaseCameraScene(Node):
 45    def on_ready(self):
 46        InputMap.add_action("quit", [Key.ESCAPE])
 47        InputMap.add_action("up", [Key.W, Key.UP])
 48        InputMap.add_action("down", [Key.S, Key.DOWN])
 49        InputMap.add_action("left", [Key.A, Key.LEFT])
 50        InputMap.add_action("right", [Key.D, Key.RIGHT])
 51        InputMap.add_action("lag_more", [Key.E])
 52        InputMap.add_action("lag_less", [Key.Q])
 53        InputMap.add_action("snap", [Key.R])
 54
 55        # Default gradient sky: it lights the scene well enough to read the
 56        # camera lag, which is what this page is about.
 57        env = self.add_child(WorldEnvironment())
 58        env.bloom_enabled = False
 59        env.ambient_light_energy = 0.6
 60
 61        self.add_child(DirectionalLight3D(position=(5, 10, 5)))
 62
 63        # Target the camera will chase. Turning it in place swings the camera's
 64        # rest pose around with it, which is where the lag reads most clearly.
 65        self._player = self.add_child(
 66            MeshInstance3D(
 67                mesh=Mesh.cube(size=1.0),
 68                material=Material(colour=(0.9, 0.3, 0.2, 1.0)),
 69                pivot="bottom",
 70                position=(0, 0, 0),
 71                name="Player",
 72            )
 73        )
 74
 75        # Ground reference: a checkerboard would be nicer but the cube
 76        # primitive scaled flat is enough for the lag readout.
 77        self.add_child(
 78            MeshInstance3D(
 79                mesh=Mesh.cube(size=1.0),
 80                material=Material(colour=(0.18, 0.2, 0.22, 1.0)),
 81                position=(0, -0.05, 0),
 82                scale=Vec3(40, 0.1, 40),
 83                name="Ground",
 84            )
 85        )
 86
 87        # A few static obstacles so the camera lag has geometry to slide past.
 88        for i in range(-2, 3):
 89            for j in range(-2, 3):
 90                if (i + j) % 2 == 0:
 91                    continue
 92                self.add_child(
 93                    MeshInstance3D(
 94                        mesh=Mesh.cube(size=0.8),
 95                        material=Material(colour=(0.4, 0.5, 0.6, 1.0)),
 96                        pivot="bottom",
 97                        position=(i * 4, 0, j * 4),
 98                    )
 99                )
100
101        self._cam = self.add_child(
102            ChaseCamera(
103                target=self._player,
104                offset=Vec3(0, 2.5, 6.0),
105                look_offset=Vec3(0, 0.5, 0),
106                half_life=0.18,
107                fov=65.0,
108            )
109        )
110
111        self._hud = self.add_child(
112            Text2D(
113                text="",
114                position=(12, 12),
115                font_scale=1.1,
116                colour=(1, 1, 1, 1),
117            )
118        )
119        self._update_hud()
120
121    def _update_hud(self):
122        self._hud.text = (
123            f"ChaseCamera demo: half_life={float(self._cam.half_life):.2f} s\n"
124            "WASD / arrows move; Q/E lag less/more; R snap; Esc quit."
125        )
126
127    def on_update(self, dt: float):
128        if Input.is_action_just_pressed("quit"):
129            self.app.quit()
130            return
131
132        # Third-person tank controls: left/right rotate the player in place,
133        # up/down walk along the player's current heading. Turning (rather than
134        # strafing) keeps the rig's rest pose behind the player, so the lag the
135        # camera is showing is the lag of the follow and not of a sideways slide.
136        vf = Input.is_action_pressed("up") - Input.is_action_pressed("down")
137        vt = Input.is_action_pressed("right") - Input.is_action_pressed("left")
138        if vt:
139            self._player.rotate_y(-vt * TURN_RATE * dt)
140        if vf:
141            fwd = self._player.forward
142            mag = math.hypot(fwd.x, fwd.z) or 1.0
143            heading = Vec3(fwd.x / mag, 0.0, fwd.z / mag)
144            self._player.position = self._player.position + heading * (vf * PLAYER_SPEED * dt)
145
146        if Input.is_action_just_pressed("lag_more"):
147            self._cam.half_life = min(float(self._cam.half_life) + 0.05, 1.5)
148            self._update_hud()
149        if Input.is_action_just_pressed("lag_less"):
150            self._cam.half_life = max(float(self._cam.half_life) - 0.05, 0.0)
151            self._update_hud()
152        if Input.is_action_just_pressed("snap"):
153            self._cam.snap()
154
155
156if __name__ == "__main__":
157    App(title="ChaseCamera", width=1280, height=720).run(ChaseCameraScene())