Source code for simvx.core.nodes_3d.chase_camera

"""ChaseCamera -- lag-and-spring 3rd-person follow camera.

Wraps a Camera3D in a smoothed follow rig that tracks a target node's
world position with critically-damped spring dynamics. Surfaced by the
HexGL port (~50 LOC of hand-rolled smoothing) and the Q1K3 port (a
near-identical pattern). Promoted to engine-level because every 3D port
ends up reinventing it.

Usage::

    self.player = self.add_child(MeshInstance3D(...))
    cam = ChaseCamera(target=self.player, offset=Vec3(0, 3, 6), half_life=0.15)
    self.add_child(cam)

The camera positions itself at ``target.world_position + offset`` rotated
by the target's heading (so the camera trails *behind* the target along
its local -Z), and looks at ``target.world_position + look_offset``.
"""

from __future__ import annotations

import math
from typing import TYPE_CHECKING

import numpy as np

from ..descriptors import Property
from ..math.types import Vec3
from .camera import Camera3D

if TYPE_CHECKING:
    from .node3d import Node3D


__all__ = ["ChaseCamera"]


def _smooth_damp(
    current: np.ndarray,
    target: np.ndarray,
    half_life: float,
    dt: float,
) -> np.ndarray:
    """Frame-rate-independent exponential follow.

    ``half_life`` is the time (seconds) it takes the gap to halve.
    Returns the new position; pass it back as ``current`` next frame.
    """
    if half_life <= 1e-6 or dt <= 0.0:
        return np.asarray(target, dtype=np.float32).copy()
    alpha = 1.0 - math.exp(-dt * math.log(2.0) / half_life)
    return (current + (np.asarray(target, dtype=np.float32) - current) * alpha).astype(np.float32)


[docs] class ChaseCamera(Camera3D): """3rd-person follow camera with lag-and-spring smoothing. Properties: target: The :class:`Node3D` to follow. ``None`` makes the camera stationary (useful while assets load). offset: Camera position relative to the target, in the target's local frame (X = right, Y = up, Z = behind). look_offset: Optional offset added to ``target.world_position`` when computing the look-at point (useful for aiming above / below the target's pivot). half_life: Smoothing half-life (seconds). 0 = instant snap. Position and look point share the same half-life so the rig stays critically damped. rotate_with_target: When ``True`` (default), the camera's ``offset`` rotates with the target's yaw so the camera stays *behind* a moving target. When ``False``, ``offset`` is treated as a world-space vector: useful for fixed isometric or god-view rigs. """ offset = Property(default_factory=lambda: Vec3(0.0, 2.5, 6.0)) look_offset = Property(default_factory=lambda: Vec3(0.0, 0.0, 0.0)) half_life = Property(0.15, range=(0.0, 5.0), hint="Follow smoothing half-life (s); 0 = snap") rotate_with_target = Property(True) def __init__(self, target: Node3D | None = None, **kwargs): super().__init__(**kwargs) self._target: Node3D | None = target self._smoothed_position: np.ndarray | None = None self._smoothed_look: np.ndarray | None = None @property def target(self) -> Node3D | None: return self._target
[docs] @target.setter def target(self, value: Node3D | None) -> None: self._target = value # Reset smoothing so the camera snaps to the new target's # rest pose on the next process tick. self._smoothed_position = None self._smoothed_look = None
[docs] def snap(self) -> None: """Teleport the camera to the target's current rest pose. Use after a scene transition or warp to avoid the smoother interpolating through walls. """ if self._target is None: return self._smoothed_position = np.asarray(self._desired_position(), dtype=np.float32) self._smoothed_look = np.asarray(self._desired_look(), dtype=np.float32) self.world_position = Vec3(*self._smoothed_position) self.look_at(Vec3(*self._smoothed_look))
# -- Lifecycle hooks --
[docs] def on_enter_tree(self) -> None: super().on_enter_tree() if self._target is not None: self.snap()
[docs] def on_process(self, dt: float) -> None: if self._target is None: return desired_pos = np.asarray(self._desired_position(), dtype=np.float32) desired_look = np.asarray(self._desired_look(), dtype=np.float32) if self._smoothed_position is None: self._smoothed_position = desired_pos.copy() self._smoothed_look = desired_look.copy() else: self._smoothed_position = _smooth_damp( self._smoothed_position, desired_pos, float(self.half_life), dt, ) self._smoothed_look = _smooth_damp( self._smoothed_look, desired_look, float(self.half_life), dt, ) self.world_position = Vec3(*self._smoothed_position) self.look_at(Vec3(*self._smoothed_look))
# -- Internal -- def _desired_position(self) -> Vec3: """Where the camera *wants* to be this frame.""" assert self._target is not None base = self._target.world_position off = self.offset if not self.rotate_with_target: return Vec3(base.x + off.x, base.y + off.y, base.z + off.z) # Rotate the local-frame offset by the target's world rotation so # the camera trails behind regardless of heading. rotated = self._target.world_rotation * Vec3(off.x, off.y, off.z) return Vec3(base.x + rotated.x, base.y + rotated.y, base.z + rotated.z) def _desired_look(self) -> Vec3: assert self._target is not None base = self._target.world_position lo = self.look_offset return Vec3(base.x + lo.x, base.y + lo.y, base.z + lo.z)