ChaseCamera demo¶
3rd-person lag-and-spring follow camera.
▶ Run in browserTags: 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())