Source code for simvx.core.nodes_3d.camera

"""Camera3D, OrbitCamera3D -- 3D camera nodes."""

import math
from typing import cast

import numpy as np

from ..decorators import on_update
from ..descriptors import Property
from ..input.actions import Axis1D
from ..input.contexts import InputContext
from ..input.controls import Controller, Mouse, Pointer, Touch
from ..input.enums import PointerType
from ..input.state import Input
from ..math.matrices import look_at, oblique_near_plane, orthographic, perspective
from ..math.raycast import Ray
from ..math.types import Vec2, Vec3
from ..properties import Bitmask, Colour, get_mask_bit, set_mask_bit
from .node3d import Node3D

#: The actions an :class:`OrbitCamera3D` declares, namespaced so a game's own
#: names never collide with them. ``drag`` is held while a pointer drag orbits,
#: ``orbit`` is the stick's rate of orbit, ``zoom`` the frame's change of scale
#: and ``pan`` held while a pointer drag pans. The zoom is in the pinch's unit,
#: a fraction of scale, and every other source is put into it by its binding:
#: a wheel notch is a tenth, and a trigger held fully doubles the view a second.
ORBIT_DRAG = "orbit_camera.drag"
ORBIT_STICK = "orbit_camera.orbit"
ORBIT_ZOOM = "orbit_camera.zoom"
ORBIT_PAN = "orbit_camera.pan"
ORBIT_CAMERA_ACTIONS = (ORBIT_DRAG, ORBIT_STICK, ORBIT_ZOOM, ORBIT_PAN)


[docs] class Camera3D(Node3D): """3D camera providing view and projection matrices. The first ``Camera3D`` found in the scene tree is used by the renderer. Position and orientation are inherited from ``Node3D``; the camera adds projection parameters (field of view, clip planes). **Conventions** (full reference: ``docs/graphics/cameras.md``): - Right-handed, Y-up. - **Forward = local -Z.** ``camera.forward`` is ``world_rotation * (0, 0, -1)``. Matches Godot / glTF / OpenGL; clashes with Unity / Unreal (+Z forward): assets ported from those engines must negate the forward vector once at import. - ``look_at(target)`` and :meth:`~Node3D.face_along` both align the local -Z axis with the supplied direction. - Internal angles in **radians**; ``fov`` is stored in degrees only as an inspector convenience. - Matrices are row-major; the renderer transposes at the GPU boundary and applies a Vulkan-only Y-flip in :meth:`projection_matrix`. Attributes: projection: ``"perspective"`` (default) or ``"orthographic"``. Selects which of the two parameter sets below builds :meth:`projection_matrix`; the other set is ignored but retained, so toggling back and forth is lossless. fov: Vertical field of view in degrees (1 -- 179). Perspective only. ortho_size: **Full** vertical height of the orthographic view volume in world units, matching Godot's ``Camera3D.size``: at ``20.0`` the camera sees 10 units above and 10 below its centre line, and the horizontal extent follows from the aspect ratio. Orthographic only. near: Near clip plane distance. far: Far clip plane distance. clip_plane: Optional world-space oblique near-clip plane ``(nx, ny, nz, d)`` with equation ``n . x + d = 0``; geometry on the ``n . x + d > 0`` side is kept. ``None`` (default) keeps the standard near plane. Set per frame by :func:`~simvx.core.planar_reflection_camera` so a mirrored camera never renders geometry behind the reflection plane (Lengyel oblique-frustum technique). A plain runtime attribute, not a serialised Property: it is a derived, per-frame value. Example:: camera = Camera3D(position=(0, 5, 10), look_at=(0, 0, 0), fov=75.0) isometric = Camera3D( position=(10, 10, 10), look_at=(0, 0, 0), projection="orthographic", ortho_size=16.0, ) """ projection = Property( "perspective", enum=["perspective", "orthographic"], hint="Perspective foreshortens with distance; orthographic keeps parallel lines parallel", group="Camera", ) fov = Property(60.0, range=(1, 179), hint="Field of view in degrees", group="Camera") ortho_size = Property( 20.0, range=(0.001, 10000.0), hint="Full vertical height of the orthographic view volume, in world units", group="Camera", ) near = Property(0.1, range=(0.001, 100), group="Camera") far = Property(100.0, range=(1, 100000), group="Camera") exposure = Property( 1.0, range=(0.0, 16.0), hint="Per-camera exposure multiplier; composes with WorldEnvironment.tonemap_exposure", group="Rendering", ) cull_mask = Bitmask(0xFFFFFFFF, hint="32 layers, all visible by default") gizmo_colour = Colour((0.8, 0.8, 0.8, 0.5)) def __init__(self, look_at=None, up=None, **kwargs): # Matrix caches. They must exist before ``super().__init__()`` walks # Property kwargs, because a ``position`` or ``rotation`` kwarg triggers # ``_invalidate_transform``, which clears the view cache. self._view_matrix_cache: np.ndarray | None = None self._projection_cache: tuple[tuple, np.ndarray] | None = None super().__init__(**kwargs) self.clip_plane: tuple[float, float, float, float] | None = None self._pending_look_at: tuple[Node3D, Vec3 | None] | None = None if look_at is None: if up is not None: raise ValueError("Camera3D 'up' kwarg requires 'look_at' to also be set") elif isinstance(look_at, Node3D): self._pending_look_at = (look_at, up) else: self.look_at(look_at, up=up) def _invalidate_transform(self, _from_parent: bool = False): """Drop the cached view matrix whenever the transform changes. ``_invalidate_transform`` is the single choke point for transform mutation: direct property writes, in-place vector mutation, ancestor moves and reparenting all pass through it, so clearing here keeps :attr:`view_matrix` exactly as fresh as ``world_position`` itself. """ self._view_matrix_cache = None super()._invalidate_transform(_from_parent=_from_parent)
[docs] def on_enter_tree(self): super().on_enter_tree() if self._pending_look_at is not None: target, up = self._pending_look_at self.look_at(target.world_position, up=up) self._pending_look_at = None
[docs] def set_cull_mask_layer(self, index: int, enabled: bool = True) -> None: """Enable or disable a specific cull mask layer (0-31).""" self.cull_mask = set_mask_bit(self.cull_mask, index, enabled, label="Cull mask layer")
[docs] def is_cull_mask_layer_enabled(self, index: int) -> bool: """Check if a specific cull mask layer is enabled (0-31).""" return get_mask_bit(self.cull_mask, index, label="Cull mask layer")
[docs] def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]: """Return view-volume wireframe lines: a frustum, or a box when orthographic.""" pos = self.world_position fwd = self.forward up_hint = Vec3(0, 1, 0) right_raw = np.cross(fwd, up_hint) rn = np.linalg.norm(right_raw) if rn < 1e-6: right_raw = np.cross(fwd, Vec3(0, 0, 1)) rn = np.linalg.norm(right_raw) right = Vec3(*(right_raw / rn)) up = Vec3(*np.cross(right, fwd)) # Use near=0.5 and far=3.0 as visual proxy (not actual clip planes) nd, fd = 0.5, min(3.0, float(self.far)) aspect = 16.0 / 9.0 if self.projection == "orthographic": # Parallel sides: near and far cross-sections are the same rectangle, # and unlike the fov proxy this one is drawn at true world size. nh = fh = float(self.ortho_size) * 0.5 else: half_fov = math.radians(float(self.fov) * 0.5) nh = nd * math.tan(half_fov) fh = fd * math.tan(half_fov) nw = nh * aspect fw = fh * aspect nc = pos + fwd * nd fc = pos + fwd * fd near_corners = [nc + right * s1 * nw + up * s2 * nh for s1, s2 in [(-1, -1), (1, -1), (1, 1), (-1, 1)]] far_corners = [fc + right * s1 * fw + up * s2 * fh for s1, s2 in [(-1, -1), (1, -1), (1, 1), (-1, 1)]] lines: list[tuple[Vec3, Vec3]] = [] # Near rect for i in range(4): lines.append((near_corners[i], near_corners[(i + 1) % 4])) # Far rect for i in range(4): lines.append((far_corners[i], far_corners[(i + 1) % 4])) # Connecting edges for i in range(4): lines.append((near_corners[i], far_corners[i])) return lines
[docs] @property def view_matrix(self) -> np.ndarray: """View matrix computed from this node's global transform. Cached: the matrix is rebuilt only after the camera's transform changes (its own or an ancestor's), so projecting many points per frame pays for one build. Each read returns a fresh copy that the caller may mutate freely. Returns: 4x4 view matrix as numpy array (row-major) """ if self._view_matrix_cache is None: eye = np.array(self.world_position, dtype=np.float32) center = eye + np.array(self.forward, dtype=np.float32) up = np.array(self.up, dtype=np.float32) self._view_matrix_cache = look_at(eye, center, up) return self._view_matrix_cache.copy()
[docs] def projection_matrix(self, aspect: float = 16 / 9) -> np.ndarray: """Projection matrix for the given aspect ratio (Vulkan clip space). Built from :attr:`fov` when :attr:`projection` is ``"perspective"`` and from :attr:`ortho_size` when it is ``"orthographic"``. In both cases the vertical extent is the one held fixed and the horizontal extent follows from *aspect*, so widening the window reveals more of the scene rather than stretching it. Cached: the matrix is rebuilt only when :attr:`projection`, :attr:`fov`, :attr:`ortho_size`, :attr:`near`, :attr:`far` or *aspect* differ from the previous call. Each read returns a fresh array that the caller may mutate freely. An oblique :attr:`clip_plane` is applied on top of the cached base per call, since it and the view matrix it depends on change per frame. Args: aspect: Aspect ratio (width / height) Returns: 4x4 projection matrix as numpy array (row-major) Includes Y-flip for Vulkan rendering """ key = ( self.projection, float(self.fov), float(self.ortho_size), float(self.near), float(self.far), float(aspect), ) cached = self._projection_cache if cached is None or cached[0] != key: if self.projection == "orthographic": half_h = float(self.ortho_size) * 0.5 half_w = half_h * aspect proj = orthographic(-half_w, half_w, -half_h, half_h, self.near, self.far) else: proj = perspective(np.radians(self.fov), aspect, self.near, self.far) proj[1, 1] *= -1 # Flip Y-axis for Vulkan cached = (key, proj) self._projection_cache = cached if self.clip_plane is not None: # oblique_near_plane never mutates its input and returns a new array. return oblique_near_plane(cached[1], self.view_matrix, self.clip_plane) return cached[1].copy()
[docs] def ray_at(self, screen_pos) -> Ray: """The world-space ray through *screen_pos*, a point in this camera's view. *screen_pos* is in the units of :attr:`viewport_size`, ``(0, 0)`` at the top left, so ``Input.mouse_position`` passes straight in. The ray starts on the near plane; under an orthographic projection every ray is parallel to :attr:`forward`. """ size = self.viewport_size aspect = float(size[0]) / float(size[1]) if size[1] > 0 else 1.0 return Ray.from_screen(screen_pos, size, self.view_matrix, self.projection_matrix(aspect))
[docs] def world_to_screen(self, point) -> Vec2 | None: """Where the world-space *point* lands in this camera's view, or ``None``. The inverse of :meth:`ray_at`, in the same units. A point outside the view volume (off to one side, or past the far plane) still returns its position, off screen, which is what an edge indicator wants. A point level with or behind the camera has no position on screen and returns ``None``. """ p = np.array([*np.asarray(point, dtype=np.float32)[:3], 1.0], dtype=np.float32) view_pos = self.view_matrix @ p if view_pos[2] >= 0.0: # forward is -Z in view space return None size = self.viewport_size w, h = float(size[0]), float(size[1]) proj = self.projection_matrix(w / h if h > 0 else 1.0) clip = proj @ view_pos ndc_x, ndc_y = clip[0] / clip[3], clip[1] / clip[3] if proj[1, 1] < 0: ndc_y = -ndc_y # Vulkan Y-flip return Vec2((ndc_x + 1.0) * 0.5 * w, (1.0 - ndc_y) * 0.5 * h)
[docs] class OrbitCamera3D(Camera3D): """Camera that orbits a pivot point, for editors, inspectors and strategy views. The camera's ``position`` and orientation are *derived*: assigning :attr:`pivot`, :attr:`distance`, :attr:`yaw` or :attr:`pitch`, from code, a scene file or the inspector, recomputes them at once, and the camera always looks at the pivot. Assigning ``position`` directly is therefore pointless, since the next orbit change overwrites it: move :attr:`pivot` instead. Assign the pivot whole (``cam.pivot = target`` or ``cam.pivot += offset``); writing one component in place (``cam.pivot.x = 3``) is not seen. **It takes its own input.** Adding the node is the whole of it: dragging with the primary pointer (a left click, a pen tip, one finger) or pushing the controller's right stick orbits, a two-finger pinch, the mouse wheel and the triggers zoom (one action, in the pinch's unit, each source scaled into it by its binding), and with :attr:`pan_enabled` a secondary or middle drag pans. The bindings are the camera's own ``input_context``, under the names in :data:`ORBIT_CAMERA_ACTIONS`, namespaced so they never take a game's names. A player changes one with ``InputMap.rebind("orbit_camera.drag", Mouse.RIGHT)``; a game replaces the set by assigning ``input_context``, and turns the camera's input off with :attr:`interactive`. A press a UI control claimed never reaches the camera, so dragging a slider does not also orbit. The camera hears input exactly while it can receive it: a paused or disabled camera stands still, and a context that hides everything below it (a pause menu) takes the camera's names too. Two orbit cameras in one tree both answer; set :attr:`interactive` on the one that is not being looked through. :meth:`orbit`, :meth:`pan` and :meth:`zoom` are the controls the input drives, and a game calls them too (an auto-orbit, a scripted fly-in). Angles are radians, like everywhere else in the engine. Yaw sweeps around the world Y axis; negative pitch places the camera above the pivot looking down, which is why the default view is a raised three-quarter angle. Every assignment clamps pitch just short of the poles, where the view direction would be undefined, and distance to at least ``0.01``. :meth:`orbit` further clamps pitch to :attr:`min_pitch` and :attr:`max_pitch`, and :meth:`zoom` distance to :attr:`min_distance` and :attr:`max_distance`; those are the player's limits, and direct assignment is not held to them. Example:: cam = OrbitCamera3D(pivot=Vec3(0, 1, 0), distance=12.0, max_distance=40.0) cam.orbit(math.radians(15.0), 0.0) # sweep 15 degrees to the side cam.zoom(2.0) # move 2 units closer """ input_context: InputContext | None = InputContext( # type: ignore[override] { ORBIT_DRAG: [Pointer.PRIMARY], ORBIT_STICK: [Controller.RIGHT_STICK], ORBIT_ZOOM: [ Touch.PINCH, Mouse.WHEEL * 0.1, Axis1D(low=Controller.LEFT_TRIGGER, high=Controller.RIGHT_TRIGGER).per_second(1.0), ], ORBIT_PAN: [Pointer.SECONDARY, Mouse.MIDDLE], }, name="orbit camera", ) pivot = Property( Vec3(), coerce=Vec3, hint="World-space point the camera looks at and orbits around", on_change="_update_transform", group="Orbit", ) distance = Property( 20.0, range=(0.01, 100000.0), hint="Distance in world units from pivot to camera", on_change="_update_transform", group="Orbit", ) yaw = Property( math.radians(45.0), hint="Horizontal orbit angle in radians around the world Y axis", on_change="_update_transform", group="Orbit", ) pitch = Property( math.radians(-30.0), range=(math.radians(-89.9), math.radians(89.9)), hint="Vertical orbit angle in radians; negative looks down at the pivot from above", on_change="_update_transform", group="Orbit", ) min_distance = Property(1.0, range=(0.0, 100000.0), hint="Closest zoom() brings the camera", group="Limits") max_distance = Property(500.0, range=(0.0, 100000.0), hint="Furthest zoom() takes the camera", group="Limits") min_pitch = Property( math.radians(-89.9), hint="Lowest pitch orbit() allows, in radians; -pi/2 is straight down from above", group="Limits", ) max_pitch = Property( math.radians(89.9), hint="Highest pitch orbit() allows, in radians; pi/2 is straight up from below", group="Limits", ) interactive = Property( True, hint="Whether the camera takes its own input; off, it moves only when code moves it", on_change="_on_interactive_changed", group="Controls", ) drag_sensitivity = Property( math.radians(0.3), range=(0.0, 1.0), hint="Radians of orbit per engine unit the pointer is dragged", group="Controls", ) stick_speed = Property( math.radians(120.0), range=(0.0, 20.0), hint="Radians per second of orbit with the stick fully pushed", group="Controls", ) pan_enabled = Property(False, hint="Whether a secondary or middle drag pans the pivot", group="Controls") pan_speed = Property( 0.002, range=(0.0, 1.0), hint="World units the pivot pans per engine unit dragged, per unit of distance", group="Controls", ) invert_x = Property(False, hint="Reverse the horizontal orbit direction", group="Controls") invert_y = Property(False, hint="Reverse the vertical orbit direction", group="Controls") def __init__(self, **kwargs): super().__init__(**kwargs) # The orbit hooks fire only for values that change, so a camera built # on its defaults places itself here. self._update_transform() def _on_interactive_changed(self) -> None: # The context is set aside rather than dropped, so one a game assigned # comes back with the camera's input. if self.interactive: if self.input_context is None: self.input_context = self.__dict__.pop("_idle_context", type(self)._declared_input_context) elif self.input_context is not None: self._idle_context = self.input_context self.input_context = None
[docs] def orbit(self, dyaw: float, dpitch: float): """Orbit the camera around its pivot point by yaw and pitch deltas (radians).""" self.yaw += dyaw self.pitch = max(self.min_pitch, min(self.max_pitch, self.pitch + dpitch))
[docs] def pan(self, dx: float, dz: float): """Pan the camera pivot horizontally in the XZ plane.""" right = Vec3(math.cos(self.yaw), 0, math.sin(self.yaw)) forward = Vec3(-math.sin(self.yaw), 0, math.cos(self.yaw)) self.pivot += right * dx + forward * dz
[docs] def zoom(self, delta: float): """Zoom the camera by adjusting its distance to the pivot: positive moves closer.""" self.distance = max(self.min_distance, min(self.max_distance, self.distance - delta))
# Decorated rather than named on_update, so a subclass's own on_update # adds to the camera's input instead of replacing it. @on_update def _drive(self, dt: float) -> None: if self.input_context is None: return sx = -1.0 if self.invert_x else 1.0 sy = -1.0 if self.invert_y else 1.0 # A second finger down makes the gesture a pinch, not a one-finger drag. fingers = sum(1 for p in Input.pointers.values() if p.pointer_type is PointerType.TOUCH and p.contact) dragging = fingers < 2 and Input.pressed(ORBIT_DRAG) panning = self.pan_enabled and fingers < 2 and Input.pressed(ORBIT_PAN) dx, dy = (float(v) for v in Input.mouse_delta) if dragging or panning else (0.0, 0.0) dyaw = dpitch = 0.0 if dragging: dyaw -= dx * self.drag_sensitivity * sx dpitch -= dy * self.drag_sensitivity * sy stick = cast(Vec2, Input.value(ORBIT_STICK)) if stick.x or stick.y: dyaw -= float(stick.x) * self.stick_speed * dt * sx dpitch -= float(stick.y) * self.stick_speed * dt * sy if dyaw or dpitch: self.orbit(dyaw, dpitch) if panning and (dx or dy): scale = self.pan_speed * self.distance self.pan(-dx * scale, -dy * scale) # The frame's change of scale: spreading the fingers by a fraction # brings the pivot that much closer, and every other zoom source is # already in that unit. A scroll a UI control took moves nothing, and # one frame zooms out at most tenfold however far the wheel spun. spread = float(Input.value(ORBIT_ZOOM)) if spread: self.zoom(self.distance - self.distance / max(1.0 + spread, 0.1)) def _update_transform(self) -> None: """Place the camera from :attr:`pivot`, :attr:`distance`, :attr:`yaw` and :attr:`pitch`.""" cp = math.cos(self.pitch) # Negate pitch so negative pitch = camera above pivot (looking down) offset = ( Vec3( cp * math.sin(self.yaw), -math.sin(self.pitch), cp * math.cos(self.yaw), ) * self.distance ) self.position = self.pivot + offset # When looking nearly straight down/up, use Z as the up hint # to avoid degenerate cross product (same convention as game cameras) if abs(self.pitch) > math.radians(80): self.look_at(self.pivot, up=Vec3(0, 0, -1)) else: self.look_at(self.pivot)