First-person camera demo

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 + 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())