First-Person Camera

WASD movement + mouse-look.

▶ Run in browser

Tags: 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())