Time-based catch-up Camera3D

Hi! For a 3D game I was searching for a Camera that could follow the player but without catching up immediately. This differs from a spring-based camera because the follow speed depends on time too.

The flow is this:

  • player starts moving → camera follows player but remains a bit “behind”
  • player keeps moving → camera catches up and follows player faster than initially
  • after a bit → camera has caught up and follows player in sync with its movement.

I’ll post the code, which is only about the algorithm for time-base catch-up: no rotation, no look_at().

The code assumes we’re using a State Machine to manage the camera.

The result is achieved by changing camera_pivot.global_position according to the algorithm.

## FollowingState

# Speed when starting to follow target, before catching up
const BASE_FOLLOW_SPEED := 3.0
# Speed when fully caught up
const MAX_FOLLOW_SPEED := 12.0
# Higher => catching up faster
const CATCHUP_RATE := 2.5
# How fast we decay when stopping
const DECAY_RATE := 4.0
# When to consider the target "moving"
const MOVEMENT_THRESHOLD := 0.1
# Max distance pivot - target position
const MAX_LAG_DISTANCE := 3.0

# The node whose position is updated. For instance the parent of a Camera3D
@export var camera_pivot: Node3D
# /!\ target must expose a "velocity" variable (e.g. CharacterBody3D)
@export var target: Node3D

var _smoothed_pos := Vector3.ZERO
# Manages how fast we're catching up or stopping.
# higher -> faster follow
var _movement_time := 0.0

func _enter() -> void:
	# Snap immediately on enter to avoid initial sliding
	_smoothed_pos = target.global_position
	camera_pivot.global_position = _smoothed_pos
	_movement_time = 0.0

func _physics_process(delta):
	if not target:
		return
	var target_pos: Vector3 = target.global_position
	# Movement detection
	var velocity := _get_target_velocity()
	var is_moving := velocity.length() > MOVEMENT_THRESHOLD
	if is_moving:
		_movement_time += delta * CATCHUP_RATE
	else:
		_movement_time -= delta * DECAY_RATE
	_movement_time = clamp(_movement_time, 0.0, 1.0)
	# Interpolate follow speed based on movement time
	var follow_speed = lerp(BASE_FOLLOW_SPEED, MAX_FOLLOW_SPEED, _movement_time)
	# Smooth follow
	_smoothed_pos = _smoothed_pos.lerp(
		target_pos, 1.0 - exp(-follow_speed * delta)
	)
	# We avoid losing vision control
	if _smoothed_pos.distance_to(target_pos) > MAX_LAG_DISTANCE:
		_smoothed_pos = target_pos
	camera_pivot.global_position = _smoothed_pos

func _get_target_velocity() -> Vector3:
	# Safe access to velocity
	if "velocity" in target:
		return target.velocity
	return Vector3.ZERO

Cool idea. You could probably achieve the same thing with a lot less code using a SliderJoint3D.

1 Like

Yes. In the original code where I’m using this algorithm, I have a CameraController that is a CharacterBody3D because it can switch to free roam mode, so I tried to make it simpler.

I made a very simple scene:

Target is the cylinder, StaticBody3D is the floor.

I added a node CameraController but it’s there just for camera_pivot .

Target has the standard generated code for CharacterBody3D.

CatchUpComponent has the code I shared with just a few adaptations:

  • added a class_name CameraCatchUpComponent extends Node at the first line to use _physics_process.
  • _enter became _ready because it’s not a State anymore but a component.

The exported variables are:

  • camera_pivot: CameraController
  • target: Target

Full adapted code, although there really are no other differences beside CATCHUP_RATE with a lower value to see better the camera movement:

class_name CameraCatchUpComponent extends Node
# Speed when starting to follow target, before catching up
const BASE_FOLLOW_SPEED := 3.0
# Speed when fully caught up
const MAX_FOLLOW_SPEED := 12.0
# Higher => catching up faster
const CATCHUP_RATE := 0.5
# How fast we decay when stopping
const DECAY_RATE := 4.0
# When to consider the target "moving"
const MOVEMENT_THRESHOLD := 0.1
# Max distance pivot - target position
const MAX_LAG_DISTANCE := 3.0

# The node whose position is updated. For instance the parent of a Camera3D
@export var camera_pivot: Node3D
# /!\ target must expose a "velocity" variable (e.g. CharacterBody3D)
@export var target: Node3D

var _smoothed_pos := Vector3.ZERO
# Manages how fast we're catching up or stopping.
# higher -> faster follow
var _movement_time := 0.0

func _ready() -> void:
	# Snap immediately on enter to avoid initial sliding
	_smoothed_pos = target.global_position
	camera_pivot.global_position = _smoothed_pos
	_movement_time = 0.0

func _physics_process(delta):
	if not target:
		return
	var target_pos: Vector3 = target.global_position
	# Movement detection
	var velocity := _get_target_velocity()
	var is_moving := velocity.length() > MOVEMENT_THRESHOLD
	if is_moving:
		_movement_time += delta * CATCHUP_RATE
	else:
		_movement_time -= delta * DECAY_RATE
	_movement_time = clamp(_movement_time, 0.0, 1.0)
	# Interpolate follow speed based on movement time
	var follow_speed = lerp(BASE_FOLLOW_SPEED, MAX_FOLLOW_SPEED, _movement_time)
	# Smooth follow
	_smoothed_pos = _smoothed_pos.lerp(
		target_pos, 1.0 - exp(-follow_speed * delta)
	)
	# We avoid losing vision control
	if _smoothed_pos.distance_to(target_pos) > MAX_LAG_DISTANCE:
		_smoothed_pos = target_pos
	camera_pivot.global_position = _smoothed_pos

func _get_target_velocity() -> Vector3:
	# Safe access to velocity
	if "velocity" in target:
		return target.velocity
	return Vector3.ZERO

1 Like

Hi I actually don’t know because it’s a node I’ve never used.

Still, I’m reading there are angular parameters so at this point I wouldn’t try it when I already have a working solution also with other code I didn’t show.