ChaseCamera demo

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 : Increase / decrease 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"""ChaseCamera demo: 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            : Increase / decrease 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
 44def _red_blue_sky_faces(size: int = 64):
 45    """Build 6 cubemap faces with a horizontal red(+X) ↔ blue(-X) gradient.
 46
 47    Drives the IBL ambient so it's obvious which way light is bouncing from:
 48    as the camera orbits, surfaces facing +X pick up warm red sky-bounce and
 49    those facing -X pick up cool blue. Each texel is coloured by its world
 50    direction's x component (matching the Vulkan +X,-X,+Y,-Y,+Z,-Z face order).
 51    """
 52    import numpy as np
 53
 54    grid = (np.arange(size, dtype=np.float32) + 0.5) / size * 2.0 - 1.0
 55    sx, sy = np.meshgrid(grid, grid)
 56    one = np.ones_like(sx)
 57    face_dirs = [
 58        np.stack([one, -sy, -sx], axis=-1),   # +X
 59        np.stack([-one, -sy, sx], axis=-1),   # -X
 60        np.stack([sx, one, sy], axis=-1),     # +Y
 61        np.stack([sx, -one, -sy], axis=-1),   # -Y
 62        np.stack([sx, -sy, one], axis=-1),    # +Z
 63        np.stack([-sx, -sy, -one], axis=-1),  # -Z
 64    ]
 65    red = np.array([0.90, 0.15, 0.12], dtype=np.float32)
 66    blue = np.array([0.12, 0.20, 0.90], dtype=np.float32)
 67    faces = []
 68    for d in face_dirs:
 69        d = d / np.linalg.norm(d, axis=-1, keepdims=True)
 70        t = ((d[..., 0] + 1.0) * 0.5)[..., None]  # 0 at -X (blue) → 1 at +X (red)
 71        c = blue * (1.0 - t) + red * t
 72        rgba = np.empty((size, size, 4), dtype=np.float32)
 73        rgba[..., :3] = c
 74        rgba[..., 3] = 1.0
 75        faces.append(np.ascontiguousarray(rgba))
 76    return faces
 77
 78
 79class ChaseCameraScene(Node):
 80    def on_ready(self):
 81        InputMap.add_action("quit", [Key.ESCAPE])
 82        InputMap.add_action("up", [Key.W, Key.UP])
 83        InputMap.add_action("down", [Key.S, Key.DOWN])
 84        InputMap.add_action("left", [Key.A, Key.LEFT])
 85        InputMap.add_action("right", [Key.D, Key.RIGHT])
 86        InputMap.add_action("lag_more", [Key.E])
 87        InputMap.add_action("lag_less", [Key.Q])
 88        InputMap.add_action("snap", [Key.R])
 89
 90        env = self.add_child(WorldEnvironment())
 91        env.bloom_enabled = False
 92        env.ambient_light_energy = 0.6
 93        # Directional red(+X) ↔ blue(-X) sky so the sky-driven IBL ambient is
 94        # unmistakable: as the camera orbits, one side of each object picks up
 95        # warm red sky-bounce and the other cool blue. An explicit environment
 96        # map (custom cube faces) drives the IBL; it takes precedence over the
 97        # default colour-gradient sky.
 98        env.environment_map = {"faces": _red_blue_sky_faces()}
 99
100        self.add_child(DirectionalLight3D(position=(5, 10, 5)))
101
102        # Target the camera will chase. The player rotates by 90° each
103        # time it changes axis so the camera-with-target rotation reads.
104        self._player = self.add_child(MeshInstance3D(
105            mesh=Mesh.cube(size=1.0),
106            material=Material(colour=(0.9, 0.3, 0.2, 1.0)),
107            pivot="bottom",
108            position=(0, 0, 0),
109            name="Player",
110        ))
111
112        # Ground reference: a checkerboard would be nicer but the cube
113        # primitive scaled flat is enough for the lag readout.
114        self.add_child(MeshInstance3D(
115            mesh=Mesh.cube(size=1.0),
116            material=Material(colour=(0.18, 0.2, 0.22, 1.0)),
117            position=(0, -0.05, 0),
118            scale=Vec3(40, 0.1, 40),
119            name="Ground",
120        ))
121
122        # A few static obstacles so the camera lag has geometry to slide past.
123        for i in range(-2, 3):
124            for j in range(-2, 3):
125                if (i + j) % 2 == 0:
126                    continue
127                self.add_child(MeshInstance3D(
128                    mesh=Mesh.cube(size=0.8),
129                    material=Material(colour=(0.4, 0.5, 0.6, 1.0)),
130                    pivot="bottom",
131                    position=(i * 4, 0, j * 4),
132                ))
133
134        self._cam = self.add_child(ChaseCamera(
135            target=self._player,
136            offset=Vec3(0, 2.5, 6.0),
137            look_offset=Vec3(0, 0.5, 0),
138            half_life=0.18,
139            fov=65.0,
140        ))
141
142        self._hud = self.add_child(Text2D(
143            text="", x=12, y=12, font_scale=1.1, colour=(1, 1, 1, 1),
144        ))
145
146    def on_process(self, dt: float):
147        if Input.is_action_just_pressed("quit"):
148            self.app.quit()
149            return
150
151        # Third-person tank controls: left/right rotate the player in place,
152        # up/down walk along the player's current heading. Strafe was clunky:
153        # the camera kept catching up to "behind" while the player slid
154        # sideways; turning feels right for a chase rig.
155        vf = (Input.is_action_pressed("up") - Input.is_action_pressed("down"))
156        vt = (Input.is_action_pressed("right") - Input.is_action_pressed("left"))
157        if vt:
158            self._player.rotate_y(-vt * TURN_RATE * dt)
159        if vf:
160            fwd = self._player.forward
161            mag = math.hypot(fwd.x, fwd.z) or 1.0
162            heading = Vec3(fwd.x / mag, 0.0, fwd.z / mag)
163            self._player.position = self._player.position + heading * (vf * PLAYER_SPEED * dt)
164
165        if Input.is_action_just_pressed("lag_more"):
166            self._cam.half_life = min(float(self._cam.half_life) + 0.05, 1.5)
167        if Input.is_action_just_pressed("lag_less"):
168            self._cam.half_life = max(float(self._cam.half_life) - 0.05, 0.0)
169        if Input.is_action_just_pressed("snap"):
170            self._cam.snap()
171
172        self._hud.text = (
173            f"ChaseCamera demo: half_life={float(self._cam.half_life):.2f} s\n"
174            "WASD / arrows move; Q/E lag less/more; R snap; Esc quit."
175        )
176
177
178if __name__ == "__main__":
179    App(title="ChaseCamera", width=1280, height=720).run(ChaseCameraScene())