First-Person Camera

walk and look around from the player’s eyes.

▶ Run in browser

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