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