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.