Source code for simvx.core.nodes_3d.camera

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

import math

import numpy as np

from ..descriptors import Property
from ..math.matrices import look_at, oblique_near_plane, orthographic, perspective
from ..math.types import Vec3
from ..properties import Bitmask, Colour, get_mask_bit, set_mask_bit
from .node3d import Node3D


[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] class OrbitCamera3D(Camera3D): """Camera that orbits a pivot point, for editors, inspectors and strategy views. The camera's ``position`` and orientation are *derived*: they are recomputed from :attr:`pivot`, :attr:`distance`, :attr:`yaw` and :attr:`pitch` by :meth:`update_transform`, and the camera always looks at the pivot. Assigning ``position`` directly is therefore pointless, since the next :meth:`update_transform` overwrites it: move :attr:`pivot` instead. :meth:`orbit`, :meth:`pan` and :meth:`zoom` are the intended controls and call :meth:`update_transform` for you. After assigning the orbit properties by hand (including from a scene file or the inspector), call :meth:`update_transform` yourself. 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. :meth:`orbit` clamps pitch just short of the poles, but direct assignment does not. Example:: cam = OrbitCamera3D(pivot=Vec3(0, 1, 0), distance=12.0) cam.orbit(math.radians(15.0), 0.0) # sweep 15 degrees to the side cam.zoom(2.0) # move 2 units closer """ pivot = Property(Vec3(), hint="World-space point the camera looks at and orbits around", group="Orbit") distance = Property( 20.0, range=(1.0, 500.0), clamp=False, hint="Distance in world units from pivot to camera", group="Orbit", ) yaw = Property( math.radians(45.0), hint="Horizontal orbit angle in radians around the world Y axis", group="Orbit", ) pitch = Property( math.radians(-30.0), hint="Vertical orbit angle in radians; negative looks down at the pivot from above", group="Orbit", ) def __init__(self, **kwargs): super().__init__(**kwargs) self.update_transform()
[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(math.radians(-89.9), min(math.radians(89.9), self.pitch + dpitch)) self.update_transform()
[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 self.update_transform()
[docs] def zoom(self, delta: float): """Zoom the camera by adjusting its distance to the pivot.""" self.distance = max(1.0, self.distance - delta) self.update_transform()
[docs] def update_transform(self): """Recompute :attr:`position` and look-at orientation from the orbit parameters (:attr:`pivot`, :attr:`distance`, :attr:`yaw`, :attr:`pitch`). Call this after assigning orbit parameters directly so the world-space transform reflects the new values immediately. :meth:`orbit`, :meth:`pan`, and :meth:`zoom` invoke it for you. """ 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)