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