First-person camera demo¶
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 + start mouse-look (cursor stays visible; Vulkan/GLFW cursor lock is not yet wired in the engine, tracked in TODO.md). Press Escape to release / quit.
Controls: Click window - Enable mouse-look 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 demo: 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 + start mouse-look (cursor stays
7 visible; Vulkan/GLFW cursor lock is not yet wired in the engine,
8 tracked in TODO.md). Press Escape to release / quit.
9
10Controls:
11 Click window - Enable mouse-look
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 InputMap,
31 Key,
32 Material,
33 Mesh,
34 MeshInstance3D,
35 MouseButton,
36 Node,
37 Text2D,
38 Vec3,
39 WorldEnvironment,
40)
41from simvx.graphics import App
42
43MOUSE_SENSITIVITY = 0.0025
44MOVE_SPEED = 4.5
45SPRINT_MULT = 2.0
46PITCH_LIMIT = math.radians(85.0)
47
48
49class FirstPersonScene(Node):
50 def on_ready(self):
51 InputMap.add_action("quit", [Key.ESCAPE])
52 InputMap.add_action("fwd", [Key.W])
53 InputMap.add_action("back", [Key.S])
54 InputMap.add_action("left", [Key.A])
55 InputMap.add_action("right", [Key.D])
56 InputMap.add_action("up", [Key.SPACE])
57 InputMap.add_action("down", [Key.LEFT_CONTROL, Key.RIGHT_CONTROL])
58 InputMap.add_action("sprint", [Key.LEFT_SHIFT, Key.RIGHT_SHIFT])
59 InputMap.add_action("look_start", [MouseButton.LEFT])
60
61 env = self.add_child(WorldEnvironment())
62 env.bloom_enabled = False
63 env.ambient_light_energy = 0.5
64
65 sun = DirectionalLight3D(position=(5, 12, 4))
66 sun.intensity = 1.2
67 sun.look_at(Vec3(0, 0, 0))
68 self.add_child(sun)
69
70 # Camera state: yaw / pitch tracked explicitly so mouse_delta
71 # accumulates instead of replacing rotation each frame.
72 self._yaw = 0.0
73 self._pitch = 0.0
74 self._looking = False
75 self._cam = self.add_child(Camera3D(
76 position=(0, 1.7, 4), fov=70.0, near=0.05, far=200.0,
77 ))
78 self._apply_look()
79
80 # World geometry: a checkerboard of obstacles so motion reads.
81 self.add_child(MeshInstance3D(
82 mesh=Mesh.cube(size=1.0),
83 material=Material(colour=(0.18, 0.2, 0.22, 1.0)),
84 position=(0, -0.05, 0),
85 scale=Vec3(60, 0.1, 60),
86 name="Ground",
87 ))
88 palette = [
89 (0.8, 0.3, 0.3, 1.0),
90 (0.3, 0.7, 0.4, 1.0),
91 (0.3, 0.5, 0.9, 1.0),
92 (0.9, 0.7, 0.2, 1.0),
93 ]
94 for i in range(-4, 5):
95 for j in range(-4, 5):
96 if (i + j) % 2 == 0:
97 continue
98 self.add_child(MeshInstance3D(
99 mesh=Mesh.cube(size=1.0),
100 material=Material(colour=palette[(i * 3 + j) % 4], roughness=0.6),
101 pivot="bottom",
102 position=(i * 3.0, 0.0, j * 3.0),
103 scale=Vec3(1.0, 1.0 + (i + j) % 3 * 0.5, 1.0),
104 ))
105
106 self._hud = self.add_child(Text2D(
107 text="", x=12, y=12, font_scale=1.1, colour=(1, 1, 1, 1),
108 ))
109
110 def on_process(self, dt: float):
111 if Input.is_action_just_pressed("quit"):
112 self.app.quit()
113 return
114
115 if Input.is_action_just_pressed("look_start"):
116 self._looking = True
117
118 if self._looking:
119 md = Input.mouse_delta
120 if md.x or md.y:
121 self._yaw -= float(md.x) * MOUSE_SENSITIVITY
122 self._pitch -= float(md.y) * MOUSE_SENSITIVITY
123 self._pitch = max(-PITCH_LIMIT, min(PITCH_LIMIT, self._pitch))
124 self._apply_look()
125
126 # Movement relative to the camera's horizontal heading.
127 forward_h = Vec3(math.sin(self._yaw), 0.0, math.cos(self._yaw))
128 right_h = Vec3(math.cos(self._yaw), 0.0, -math.sin(self._yaw))
129
130 move = Vec3(0, 0, 0)
131 if Input.is_action_pressed("fwd"):
132 move = move - forward_h
133 if Input.is_action_pressed("back"):
134 move = move + forward_h
135 if Input.is_action_pressed("right"):
136 move = move + right_h
137 if Input.is_action_pressed("left"):
138 move = move - right_h
139 if Input.is_action_pressed("up"):
140 move = move + Vec3(0, 1, 0)
141 if Input.is_action_pressed("down"):
142 move = move - Vec3(0, 1, 0)
143
144 if move.length() > 1e-5:
145 speed = MOVE_SPEED * (SPRINT_MULT if Input.is_action_pressed("sprint") else 1.0)
146 move = move.normalized() * (speed * dt)
147 self._cam.position = self._cam.position + move
148
149 self._hud.text = (
150 "First-person camera demo\n"
151 "Click window to start looking; WASD move; Shift sprint; Esc quit."
152 )
153
154 def _apply_look(self) -> None:
155 """Recompute camera world rotation from yaw + pitch state."""
156 # yaw rotates around +Y, pitch around right (post-yaw +X).
157 # Compose by setting world_rotation = Y(yaw) * X(pitch).
158 from simvx.core import Quat
159
160 q_yaw = Quat.from_axis_angle(Vec3(0, 1, 0), self._yaw)
161 q_pitch = Quat.from_axis_angle(Vec3(1, 0, 0), self._pitch)
162 self._cam.world_rotation = q_yaw * q_pitch
163
164
165if __name__ == "__main__":
166 App(title="First-Person Camera", width=1280, height=720).run(FirstPersonScene())