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