First-Person Camera¶
walk and look around from the player’s eyes.
▶ Run in browserTags: 3d
A first-person camera is a plain Camera3D that the scene turns and moves
itself. Keep the heading as two angles, yaw about the vertical and pitch about
the camera’s own right axis, and rebuild the rotation from them whenever they
change:
camera.rotation = Quat.from_euler(pitch, yaw)
Quat.from_euler applies yaw first, then pitch, so looking up never tilts
the horizon. Clamping pitch short of straight up or down stops the view from
flipping over the top.
Looking takes the mouse’s motion, Input.mouse_delta, while the cursor is
captured: a click calls Input.set_mouse_capture_mode(MouseCaptureMode.CAPTURED),
which hides the cursor and locks it to the window so it never runs off the
edge, and Escape sets MouseCaptureMode.VISIBLE to give it back. The arrow
keys and the right stick drive a typed look action whose bindings use
per_second, so Input.value("look") is already this frame’s turn in
radians and adds straight onto the mouse’s share.
Movement is relative to where the player faces but stays level: the move
action (WASD or the left stick) and the rise action (Space and Ctrl) form
one vector, which the yaw alone rotates into the world. Pitch is left out, so
looking at the floor does not walk you into it.
Controls: Click - Capture the cursor and look with the mouse Escape - Release the cursor WASD / left stick - Move Arrows / right stick - Look Space / Ctrl, A / B - Rise / sink Shift, right bumper - Sprint On a touch screen, the on-screen pad has both sticks and these buttons.
Source¶
1"""First-Person Camera: walk and look around from the player's eyes.
2
3A first-person camera is a plain ``Camera3D`` that the scene turns and moves
4itself. Keep the heading as two angles, yaw about the vertical and pitch about
5the camera's own right axis, and rebuild the rotation from them whenever they
6change:
7
8 camera.rotation = Quat.from_euler(pitch, yaw)
9
10``Quat.from_euler`` applies yaw first, then pitch, so looking up never tilts
11the horizon. Clamping pitch short of straight up or down stops the view from
12flipping over the top.
13
14Looking takes the mouse's motion, ``Input.mouse_delta``, while the cursor is
15captured: a click calls ``Input.set_mouse_capture_mode(MouseCaptureMode.CAPTURED)``,
16which hides the cursor and locks it to the window so it never runs off the
17edge, and Escape sets ``MouseCaptureMode.VISIBLE`` to give it back. The arrow
18keys and the right stick drive a typed ``look`` action whose bindings use
19``per_second``, so ``Input.value("look")`` is already this frame's turn in
20radians and adds straight onto the mouse's share.
21
22Movement is relative to where the player faces but stays level: the ``move``
23action (WASD or the left stick) and the ``rise`` action (Space and Ctrl) form
24one vector, which the yaw alone rotates into the world. Pitch is left out, so
25looking at the floor does not walk you into it.
26
27Controls:
28 Click - Capture the cursor and look with the mouse
29 Escape - Release the cursor
30 WASD / left stick - Move
31 Arrows / right stick - Look
32 Space / Ctrl, A / B - Rise / sink
33 Shift, right bumper - Sprint
34 On a touch screen, the on-screen pad has both sticks and these buttons.
35"""
36
37import math
38
39from simvx.core import (
40 Anchor,
41 Axis1D,
42 Axis2D,
43 Camera3D,
44 CanvasLayer,
45 Controller,
46 DirectionalLight3D,
47 Input,
48 Key,
49 Label,
50 Material,
51 Mesh,
52 MeshInstance3D,
53 Mouse,
54 MouseCaptureMode,
55 Node,
56 Quat,
57 TouchController,
58 Vec3,
59 WorldEnvironment,
60 on_input,
61)
62from simvx.graphics import App
63
64MOVE_SPEED = 4.5
65SPRINT_FACTOR = 2.0
66LOOK_RATE = 2.0 # radians per second from the arrow keys or a full stick
67MOUSE_SENSITIVITY = 0.0025 # radians per pixel of mouse motion
68PITCH_LIMIT = math.radians(85.0)
69
70
71class FirstPersonScene(Node):
72 input_context = {
73 "move": [Axis2D.WASD, Controller.LEFT_STICK],
74 # Both sides of Control and Shift spelt out: an action bound to the Key.CTRL or Key.SHIFT group
75 # never re-resolves when either side goes down, so it would read at rest.
76 "rise": [
77 Axis1D(low=Key.LEFT_CONTROL, high=Key.SPACE),
78 Axis1D(low=Key.RIGHT_CONTROL, high=Key.SPACE),
79 Axis1D(low=Controller.B, high=Controller.A),
80 ],
81 "sprint": [Key.LEFT_SHIFT, Key.RIGHT_SHIFT, Controller.RIGHT_BUMPER],
82 "look": [Axis2D.ARROWS.per_second(LOOK_RATE), Controller.RIGHT_STICK.per_second(LOOK_RATE)],
83 # Capture locks the mouse's cursor, so it is the mouse's button; a finger looks with the right stick.
84 "capture": [Mouse.LEFT],
85 "release": [Key.ESCAPE],
86 }
87
88 def on_ready(self):
89 self.add_child(WorldEnvironment(bloom_enabled=False, ambient_light_energy=0.5))
90 self.add_child(DirectionalLight3D(direction=(-5, -12, -4), intensity=1.2))
91 self.add_child(MeshInstance3D(mesh=Mesh.plane(60), material=Material(colour=(0.18, 0.2, 0.22))))
92
93 # A checkerboard of coloured blocks of three heights, so motion reads.
94 colours = ((0.8, 0.3, 0.3), (0.3, 0.7, 0.4), (0.3, 0.5, 0.9), (0.9, 0.7, 0.2))
95 palette = [Material(colour=c, roughness=0.6) for c in colours]
96 for i in range(-4, 5):
97 for j in range(-4, 5):
98 if (i + j) % 2:
99 height = 1.0 + (i + j) % 3 * 0.5
100 self.add_child(
101 MeshInstance3D(
102 mesh=Mesh.box((1.0, height, 1.0)),
103 material=palette[(i * 3 + j) % 4],
104 pivot="bottom",
105 position=(i * 3.0, 0.0, j * 3.0),
106 )
107 )
108
109 # Eye height, standing in an empty cell of the grid.
110 self.camera = self.add_child(Camera3D(position=(1.5, 1.7, 8), fov=70.0, near=0.05, far=200.0))
111 self.yaw = 0.0
112 self.pitch = 0.0
113
114 self.add_child(
115 TouchController(layout="twin_stick", buttons=(Controller.A, Controller.B, Controller.RIGHT_BUMPER))
116 )
117
118 # Example help overlay: not part of the technique shown.
119 hint = "Click to look with the mouse, Esc to release\nWASD move, arrows look, Space/Ctrl up/down, Shift sprint"
120 self.add_child(CanvasLayer()).add_child(Label(hint, anchor=Anchor.TOP_LEFT, inset=12, font_size=18))
121
122 @on_input("capture")
123 def _capture(self, _event):
124 Input.set_mouse_capture_mode(MouseCaptureMode.CAPTURED)
125
126 @on_input("release")
127 def _release(self, _event):
128 Input.set_mouse_capture_mode(MouseCaptureMode.VISIBLE)
129
130 def on_update(self, dt):
131 look = Input.value("look")
132 if Input.is_mouse_captured():
133 look = look + Input.mouse_delta * MOUSE_SENSITIVITY
134 self.yaw -= look.x
135 self.pitch = min(max(self.pitch - look.y, -PITCH_LIMIT), PITCH_LIMIT)
136 self.camera.rotation = Quat.from_euler(self.pitch, self.yaw)
137
138 move = Input.value("move") # +y is back, as down the screen in 2D
139 step = Quat.from_euler(yaw=self.yaw) * Vec3(move.x, Input.value("rise"), move.y)
140 if step.length() > 1.0:
141 step = step.normalized()
142 speed = MOVE_SPEED * (SPRINT_FACTOR if Input.pressed("sprint") else 1.0)
143 self.camera.position += step * speed * dt
144
145
146if __name__ == "__main__":
147 App(title="First-Person Camera", width=1280, height=720).run(FirstPersonScene())