Skip to content

Camera Follow

BulletLab's CameraFollow keeps the PyBullet 3D camera locked on a moving robot with a single update() call per step. Three modes are available.

Quickstart

from bulletlab import Simulation, Robot, CameraFollow

sim = Simulation(mode="gui").start()
robot = Robot.load("husky/husky.urdf", sim=sim, position=(0, 0, 0.3))

cam = CameraFollow(robot, sim)          # defaults: smooth mode, 4 m back

while sim.is_connected:
    sim.step()
    cam.update()                        # ← one call per step

Modes

Mode Constant Behaviour
Snap "snap" Camera target locks to the robot instantly every frame
Smooth "smooth" Camera target glides toward the robot (lerp). Cinematic feel
Chase "chase" Camera yaw rotates with the robot's heading — always behind it
# Snap – no lag
cam = CameraFollow(robot, sim, mode="snap")

# Smooth – gliding (default)
cam = CameraFollow(robot, sim, mode="smooth", lerp=0.08)

# Chase – 3rd-person, always behind the robot
cam = CameraFollow(robot, sim, mode="chase", distance=5.0, pitch=-20)

Parameters

Parameter Type Default Description
robot Robot required Robot to follow
sim Simulation required Simulation instance
mode str "smooth" "snap", "smooth", or "chase"
distance float 4.0 Camera distance from robot (metres)
pitch float -25.0 Vertical camera angle (degrees, negative = looking down)
yaw float 45.0 Horizontal angle (snap/smooth only, degrees)
lerp float 0.08 Smooth/chase glide speed (0 = frozen, 1 = instant)
height_offset float 0.2 Extra height above robot base (metres)

Runtime Control

All parameters are writable at any time — change mode or settings on the fly:

cam.mode     = "chase"   # switch mode mid-run
cam.distance = 6.0       # zoom out
cam.pitch    = -35.0     # look more steeply down
cam.lerp     = 0.15      # glide faster

API Reference

Robot-tracking camera controller.

Wraps sim.set_camera() and updates the PyBullet debug camera every time :meth:update is called — typically once per simulation step.

Parameters:

Name Type Description Default
robot 'Robot'

The robot to follow.

required
sim 'Simulation'

The :class:~bulletlab.core.simulation.Simulation instance.

required
mode str

Tracking mode – "snap", "smooth", or "chase".

'smooth'
distance float

Camera distance from the robot in metres. Default 4.0.

4.0
pitch float

Camera pitch angle in degrees (negative = looking down). Default -25.0.

-25.0
yaw float

Initial yaw angle in degrees. Only used in snap and smooth modes (in chase mode yaw is automatic). Default 45.0.

45.0
lerp float

Lerp factor used in smooth mode – fraction of the remaining gap closed per step (0 < lerp ≤ 1). Default 0.08 (≈ 8 % per step ≈ ~2 s settling at 240 Hz).

0.08
height_offset float

Extra height added to the camera target above the robot's base position (metres). Default 0.2.

0.2
Modes
  • "snap" – target snaps to the robot instantly each step.
  • "smooth" – target slides toward the robot using lerp.
  • "chase" – camera yaw tracks the robot's heading so the camera always looks at the robot from behind.

Example::

from bulletlab import Simulation, Robot
from bulletlab.core.camera import CameraFollow

sim = Simulation(mode="gui").start()
robot = Robot.load("husky/husky.urdf", sim=sim)

# One-liner: camera that smoothly follows the robot
cam = CameraFollow(robot, sim, mode="smooth")

while sim.is_connected:
    sim.step()
    cam.update()
Source code in bulletlab/core/camera.py
class CameraFollow:
    """Robot-tracking camera controller.

    Wraps ``sim.set_camera()`` and updates the PyBullet debug camera every
    time :meth:`update` is called — typically once per simulation step.

    Args:
        robot: The robot to follow.
        sim: The :class:`~bulletlab.core.simulation.Simulation` instance.
        mode: Tracking mode – ``"snap"``, ``"smooth"``, or ``"chase"``.
        distance: Camera distance from the robot in metres. Default ``4.0``.
        pitch: Camera pitch angle in degrees (negative = looking down).
              Default ``-25.0``.
        yaw: Initial yaw angle in degrees.  Only used in ``snap`` and
             ``smooth`` modes (in ``chase`` mode yaw is automatic).
             Default ``45.0``.
        lerp: Lerp factor used in ``smooth`` mode – fraction of the
              remaining gap closed per step (0 < lerp ≤ 1).  Default
              ``0.08`` (≈ 8 % per step ≈ ~2 s settling at 240 Hz).
        height_offset: Extra height added to the camera target above the
                       robot's base position (metres). Default ``0.2``.

    Modes:
        * ``"snap"``   – target snaps to the robot instantly each step.
        * ``"smooth"`` – target slides toward the robot using *lerp*.
        * ``"chase"``  – camera yaw tracks the robot's heading so the
                         camera always looks at the robot from behind.

    Example::

        from bulletlab import Simulation, Robot
        from bulletlab.core.camera import CameraFollow

        sim = Simulation(mode="gui").start()
        robot = Robot.load("husky/husky.urdf", sim=sim)

        # One-liner: camera that smoothly follows the robot
        cam = CameraFollow(robot, sim, mode="smooth")

        while sim.is_connected:
            sim.step()
            cam.update()
    """

    SNAP   = "snap"
    SMOOTH = "smooth"
    CHASE  = "chase"

    _VALID_MODES = {SNAP, SMOOTH, CHASE}

    def __init__(
        self,
        robot: "Robot",
        sim: "Simulation",
        *,
        mode: str = "smooth",
        distance: float = 4.0,
        pitch: float = -25.0,
        yaw: float = 45.0,
        lerp: float = 0.08,
        height_offset: float = 0.2,
    ) -> None:
        mode = mode.lower()
        if mode not in self._VALID_MODES:
            raise ValueError(
                f"Unknown camera mode {mode!r}. "
                f"Choose from: {sorted(self._VALID_MODES)}"
            )

        self._robot   = robot
        self._sim     = sim
        self._mode    = mode
        self._dist    = float(distance)
        self._pitch   = float(pitch)
        self._yaw     = float(yaw)
        self._lerp    = float(max(1e-4, min(1.0, lerp)))
        self._h_off   = float(height_offset)
        self._enabled = True   # toggle on/off without destroying the object

        # Smoothed target (initialised to robot spawn position)
        pos = robot.base_position
        self._target  = [pos[0], pos[1], pos[2] + self._h_off]

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def update(self) -> None:
        """Update the camera for the current simulation frame.

        Call once per simulation step in your main loop.  When
        :attr:`enabled` is ``False`` the call is a no-op and the camera
        stays fixed at its last position.

        Example::

            while sim.is_connected:
                sim.step()
                cam.update()
        """
        if not self._enabled:
            return
        pos = self._robot.base_position

        if self._mode == self.SNAP:
            self._update_snap(pos)
        elif self._mode == self.SMOOTH:
            self._update_smooth(pos)
        elif self._mode == self.CHASE:
            self._update_chase(pos)

    # ------------------------------------------------------------------
    # Properties
    # ------------------------------------------------------------------

    @property
    def enabled(self) -> bool:
        """Whether the camera follow is active.

        Set to ``False`` to freeze the camera at its current position.
        Set to ``True`` to resume tracking.

        Example::

            cam.enabled = False   # freeze
            cam.enabled = True    # resume
        """
        return self._enabled

    @enabled.setter
    def enabled(self, value: bool) -> None:
        self._enabled = bool(value)

    @property
    def mode(self) -> str:
        """Current tracking mode (``"snap"``, ``"smooth"``, or ``"chase"``)."""
        return self._mode

    @mode.setter
    def mode(self, value: str) -> None:
        value = value.lower()
        if value not in self._VALID_MODES:
            raise ValueError(f"Unknown mode {value!r}.")
        self._mode = value

    @property
    def distance(self) -> float:
        """Camera distance from the robot in metres."""
        return self._dist

    @distance.setter
    def distance(self, value: float) -> None:
        self._dist = float(value)

    @property
    def pitch(self) -> float:
        """Camera pitch in degrees."""
        return self._pitch

    @pitch.setter
    def pitch(self, value: float) -> None:
        self._pitch = float(value)

    @property
    def yaw(self) -> float:
        """Camera yaw in degrees (snap/smooth modes only)."""
        return self._yaw

    @yaw.setter
    def yaw(self, value: float) -> None:
        self._yaw = float(value)

    @property
    def lerp(self) -> float:
        """Lerp factor used in smooth mode (0 < lerp ≤ 1)."""
        return self._lerp

    @lerp.setter
    def lerp(self, value: float) -> None:
        self._lerp = float(max(1e-4, min(1.0, value)))

    # ------------------------------------------------------------------
    # Internal per-mode updaters
    # ------------------------------------------------------------------

    def _update_snap(self, pos: tuple) -> None:
        """Snap: target locks instantly to robot position."""
        target = (pos[0], pos[1], pos[2] + self._h_off)
        self._sim.set_camera(
            distance=self._dist,
            yaw=self._yaw,
            pitch=self._pitch,
            target=target,
        )
        self._target = list(target)

    def _update_smooth(self, pos: tuple) -> None:
        """Smooth: lerp camera target toward robot each frame."""
        lp = self._lerp
        self._target[0] += (pos[0]                  - self._target[0]) * lp
        self._target[1] += (pos[1]                  - self._target[1]) * lp
        self._target[2] += (pos[2] + self._h_off    - self._target[2]) * lp
        self._sim.set_camera(
            distance=self._dist,
            yaw=self._yaw,
            pitch=self._pitch,
            target=tuple(self._target),
        )

    def _update_chase(self, pos: tuple) -> None:
        """Chase: camera yaw mirrors the robot's heading (always behind)."""
        heading_deg = math.degrees(self._robot.yaw)
        # Place camera 180° behind the robot's current heading
        chase_yaw = heading_deg + 180.0

        lp = self._lerp
        self._target[0] += (pos[0]                  - self._target[0]) * lp
        self._target[1] += (pos[1]                  - self._target[1]) * lp
        self._target[2] += (pos[2] + self._h_off    - self._target[2]) * lp

        self._sim.set_camera(
            distance=self._dist,
            yaw=chase_yaw,
            pitch=self._pitch,
            target=tuple(self._target),
        )

    # ------------------------------------------------------------------
    # Repr
    # ------------------------------------------------------------------

    def __repr__(self) -> str:
        return (
            f"CameraFollow(robot={self._robot.name!r}, mode={self._mode!r}, "
            f"dist={self._dist}, pitch={self._pitch}°)"
        )

distance: float property writable

Camera distance from the robot in metres.

enabled: bool property writable

Whether the camera follow is active.

Set to False to freeze the camera at its current position. Set to True to resume tracking.

Example::

cam.enabled = False   # freeze
cam.enabled = True    # resume

lerp: float property writable

Lerp factor used in smooth mode (0 < lerp ≤ 1).

mode: str property writable

Current tracking mode ("snap", "smooth", or "chase").

pitch: float property writable

Camera pitch in degrees.

yaw: float property writable

Camera yaw in degrees (snap/smooth modes only).

update() -> None

Update the camera for the current simulation frame.

Call once per simulation step in your main loop. When :attr:enabled is False the call is a no-op and the camera stays fixed at its last position.

Example::

while sim.is_connected:
    sim.step()
    cam.update()
Source code in bulletlab/core/camera.py
def update(self) -> None:
    """Update the camera for the current simulation frame.

    Call once per simulation step in your main loop.  When
    :attr:`enabled` is ``False`` the call is a no-op and the camera
    stays fixed at its last position.

    Example::

        while sim.is_connected:
            sim.step()
            cam.update()
    """
    if not self._enabled:
        return
    pos = self._robot.base_position

    if self._mode == self.SNAP:
        self._update_snap(pos)
    elif self._mode == self.SMOOTH:
        self._update_smooth(pos)
    elif self._mode == self.CHASE:
        self._update_chase(pos)