First-Person Camera¶
WASD movement + mouse-look.
▶ Run in browserTags: 3d
Demonstrates:
Free-look camera driven by
Input.mouse_delta(yaw + pitch)WASD planar movement relative to camera heading
Click on the window to grab focus and start mouse-look (the cursor is locked / hidden via
MouseCaptureMode.CAPTURED); press Escape to release the cursor and quit.
Controls: Click window - Enable mouse-look (locks the cursor to the window) Mouse - Look around (yaw / pitch) W A S D - Move (forward / left / back / right) Space - Up Ctrl - Down Shift - Sprint Escape - Quit
Run: uv run python examples/features/3d/first_person.py
Source¶
1"""First-Person Camera: WASD movement + mouse-look.
2
3Demonstrates:
4 - Free-look camera driven by ``Input.mouse_delta`` (yaw + pitch)
5 - WASD planar movement relative to camera heading
6 - Click on the window to grab focus and start mouse-look (the cursor is
7 locked / hidden via ``MouseCaptureMode.CAPTURED``); press Escape to
8 release the cursor and quit.
9
10Controls:
11 Click window - Enable mouse-look (locks the cursor to the window)
12 Mouse - Look around (yaw / pitch)
13 W A S D - Move (forward / left / back / right)
14 Space - Up
15 Ctrl - Down
16 Shift - Sprint
17 Escape - Quit
18
19Run: uv run python examples/features/3d/first_person.py
20"""
21
22from __future__ import annotations
23
24import math
25
26from simvx.core import (
27 Camera3D,
28 DirectionalLight3D,
29 Input,
30 Key,
31 Material,
32 Mesh,
33 MeshInstance3D,
34 MouseButton,
35 MouseCaptureMode,
36 Node,
37 Quat,
38 Text2D,
39 Vec3,
40 WorldEnvironment,
41)
42from simvx.graphics import App
43
44MOUSE_SENSITIVITY = 0.0025
45MOVE_SPEED = 4.5
46SPRINT_MULT = 2.0
47PITCH_LIMIT = math.radians(85.0)
48
49
50class FirstPersonScene(Node):
51 input_actions = {
52 "quit": [Key.ESCAPE],
53 "fwd": [Key.W],
54 "back": [Key.S],
55 "left": [Key.A],
56 "right": [Key.D],
57 "up": [Key.SPACE],
58 "down": [Key.LEFT_CONTROL, Key.RIGHT_CONTROL],
59 "sprint": [Key.LEFT_SHIFT, Key.RIGHT_SHIFT],
60 "look_start": [MouseButton.LEFT],
61 }
62
63 def on_ready(self):
64 env = self.add_child(WorldEnvironment())
65 env.bloom_enabled = False
66 env.ambient_light_energy = 0.5
67
68 sun = DirectionalLight3D(position=(5, 12, 4))
69 sun.intensity = 1.2
70 sun.look_at(Vec3(0, 0, 0))
71 self.add_child(sun)
72
73 # Camera state: yaw / pitch tracked explicitly so mouse_delta
74 # accumulates instead of replacing rotation each frame.
75 self._yaw = 0.0
76 self._pitch = 0.0
77 self._looking = False
78 # Spawn in an empty checkerboard cell so the opening frame shows the
79 # coloured obstacle grid rather than the top of the nearest cube.
80 self._cam = self.add_child(
81 Camera3D(
82 position=(1.5, 1.7, 8),
83 fov=70.0,
84 near=0.05,
85 far=200.0,
86 )
87 )
88 self._apply_look()
89
90 # World geometry: a checkerboard of obstacles so motion reads.
91 self.add_child(
92 MeshInstance3D(
93 mesh=Mesh.cube(size=1.0),
94 material=Material(colour=(0.18, 0.2, 0.22, 1.0)),
95 position=(0, -0.05, 0),
96 scale=Vec3(60, 0.1, 60),
97 name="Ground",
98 )
99 )
100 palette = [
101 (0.8, 0.3, 0.3, 1.0),
102 (0.3, 0.7, 0.4, 1.0),
103 (0.3, 0.5, 0.9, 1.0),
104 (0.9, 0.7, 0.2, 1.0),
105 ]
106 for i in range(-4, 5):
107 for j in range(-4, 5):
108 if (i + j) % 2 == 0:
109 continue
110 self.add_child(
111 MeshInstance3D(
112 mesh=Mesh.cube(size=1.0),
113 material=Material(colour=palette[(i * 3 + j) % 4], roughness=0.6),
114 pivot="bottom",
115 position=(i * 3.0, 0.0, j * 3.0),
116 scale=Vec3(1.0, 1.0 + (i + j) % 3 * 0.5, 1.0),
117 )
118 )
119
120 self._hud = self.add_child(
121 Text2D(
122 text=("First-person camera demo\n" "Click window to start looking; WASD move; Shift sprint; Esc quit."),
123 position=(12, 12),
124 font_scale=1.1,
125 colour=(1, 1, 1, 1),
126 )
127 )
128
129 def on_update(self, dt: float):
130 if Input.is_action_just_pressed("quit"):
131 Input.set_mouse_capture_mode(MouseCaptureMode.VISIBLE)
132 self.app.quit()
133 return
134
135 if Input.is_action_just_pressed("look_start"):
136 self._looking = True
137 Input.set_mouse_capture_mode(MouseCaptureMode.CAPTURED)
138
139 if self._looking:
140 md = Input.mouse_delta
141 if md.x or md.y:
142 self._yaw -= float(md.x) * MOUSE_SENSITIVITY
143 self._pitch -= float(md.y) * MOUSE_SENSITIVITY
144 self._pitch = max(-PITCH_LIMIT, min(PITCH_LIMIT, self._pitch))
145 self._apply_look()
146
147 # Movement relative to the camera's horizontal heading.
148 forward_h = Vec3(math.sin(self._yaw), 0.0, math.cos(self._yaw))
149 right_h = Vec3(math.cos(self._yaw), 0.0, -math.sin(self._yaw))
150
151 move = Vec3(0, 0, 0)
152 if Input.is_action_pressed("fwd"):
153 move = move - forward_h
154 if Input.is_action_pressed("back"):
155 move = move + forward_h
156 if Input.is_action_pressed("right"):
157 move = move + right_h
158 if Input.is_action_pressed("left"):
159 move = move - right_h
160 if Input.is_action_pressed("up"):
161 move = move + Vec3(0, 1, 0)
162 if Input.is_action_pressed("down"):
163 move = move - Vec3(0, 1, 0)
164
165 if move.length() > 1e-5:
166 speed = MOVE_SPEED * (SPRINT_MULT if Input.is_action_pressed("sprint") else 1.0)
167 move = move.normalized() * (speed * dt)
168 self._cam.position = self._cam.position + move
169
170 def _apply_look(self) -> None:
171 """Recompute camera world rotation from yaw + pitch state."""
172 # yaw rotates around +Y, pitch around right (post-yaw +X).
173 # Compose by setting world_rotation = Y(yaw) * X(pitch).
174 q_yaw = Quat.from_axis_angle(Vec3(0, 1, 0), self._yaw)
175 q_pitch = Quat.from_axis_angle(Vec3(1, 0, 0), self._pitch)
176 self._cam.world_rotation = q_yaw * q_pitch
177
178
179if __name__ == "__main__":
180 App(title="First-Person Camera", width=1280, height=720).run(FirstPersonScene())