Path3D, PathFollow3D and splines
Curve3D, Path3D, PathFollow3D — movement along a spline, AI patrols, camera cutscenes.
Godot has built-in Path3D and PathFollow3D for movement along a spline. The curve is stored
in a Curve3D resource. No plugins required — it’s part of the core engine.
Main nodes
- Path3D — a container node with a
Curve3Dresource. Describes a curve in space. - PathFollow3D — a child node of Path3D. Holds a
progressorprogress_ratioproperty from which Godot computes the position along the curve. Anything placed inside PathFollow3D travels along the spline. - Curve3D — a resource with an array of points, each having a position + in/out tangents (for Bezier).
An SVG path with M, L, C commands. Or the GSAP MotionPath plugin for animation along a curve.
Basic workflow
Creating a Path3D in the editor
- Add a Path3D to the scene.
- In the Inspector, create a new
Curve3D(if there isn’t one yet). - In the Scene view, select the Path3D — the Path Tool appears in the toolbar.
- Click to add points. Drag the handles to edit the tangents (Bezier).
Scene structure
Level (Node3D)
└── Path3D ← curve: Curve3D with N points
└── PathFollow3D
└── Cart (CharacterBody3D or Node3D) ← will travel along the spline
Movement from code
extends PathFollow3D
@export var speed: float = 4.0
@export var loop_mode: int = 0 # 0 = loop, 1 = stop at end
func _process(delta: float) -> void:
progress += speed * delta
# progress — distance in meters along the curve
# progress_ratio — normalized 0..1 (convenient for looping)
PathFollow3D properties:
progress: float— the current position along the curve in meters.progress_ratio: float— the normalized parameter[0, 1].loop: bool— whether to loop (defaulttrue).rotation_mode: RotationMode—ROTATION_NONE/_Y/_XY/_XYZ/_ORIENTED. ORIENTED rotates along the curve’s tangent (for a car driving along a road).use_model_front: bool— corrects orientation (if the model faces -Z).
progress is the distance in meters along the spline. With speed in m/s, progress += speed * delta
gives a constant speed. progress_ratio (0..1) is more convenient for UI or duration-based animation
(“travel the whole curve in 5 seconds”), but then the speed depends on the curve’s length.
Creating a curve programmatically
var curve := Curve3D.new()
curve.add_point(Vector3(0, 0, 0))
curve.add_point(Vector3(5, 0, 0))
curve.add_point(Vector3(5, 2, -5),
Vector3(0, 0, 2), # in-tangent — entering the point from the previous one
Vector3(0, 0, -2)) # out-tangent — leaving toward the next one
curve.add_point(Vector3(0, 0, -10))
var path := Path3D.new()
path.curve = curve
add_child(path)
add_point(position, in_handle, out_handle, index) — without in/out it produces straight segments,
with them — Bezier curves.
Sampling without a PathFollow3D node
Curve3D has methods for direct sampling:
var point := curve.sample(0.5) # point at the midpoint (normalized)
var point_baked := curve.sample_baked(distance_m) # point at distance_m meters along
var transform := curve.sample_baked_with_rotation(distance_m, true, true)
sample_baked uses a baked length cache — an internal array of precomputed points that is
updated when the curve changes. Sampling by distance runs in O(log N).
Camera on rails (cinemachine-style)
Godot has no built-in equivalent of Cinemachine SplineDolly, but doing it yourself takes 5 lines:
extends Camera3D
@export var path: Path3D
@export var duration: float = 5.0
@export var look_at: Node3D
var _t: float = 0.0
var _playing: bool = false
func play() -> void:
_t = 0.0
_playing = true
func _process(delta: float) -> void:
if not _playing or path == null:
return
_t += delta / duration
if _t >= 1.0:
_t = 1.0
_playing = false
var distance := path.curve.get_baked_length() * _t
var pos := path.curve.sample_baked(distance)
global_position = path.to_global(pos)
if look_at != null:
look_at(look_at.global_position, Vector3.UP)
Or use the Phantom Camera plugin (mentioned in the camera chapter) — it has a Path follow mode.
Placing objects along a spline
Godot core has no built-in “Instantiate along path” — you have to do it yourself. A simple
tool script via @tool:
@tool
extends Node3D
@export var path: Path3D
@export var prefab: PackedScene
@export var spacing: float = 2.0
@export var update: bool = false:
set(value):
if value: _rebuild()
update = false
func _rebuild() -> void:
for child in get_children():
child.queue_free()
if path == null or prefab == null:
return
var length := path.curve.get_baked_length()
var count := int(length / spacing)
for i in count:
var instance := prefab.instantiate()
var pos := path.curve.sample_baked(i * spacing)
instance.position = pos
add_child(instance)
instance.owner = get_tree().edited_scene_root
Make this node a child of Path3D, drag in the prefab, press update in the Inspector — it places
N instances with a step of spacing.
When to use splines
- AI patrol along a predefined route — PathFollow3D + ROTATION_Y.
- Cars on a track — PathFollow3D + ROTATION_ORIENTED.
- Camera cutscenes — Camera3D on a PathFollow3D + a look_at target.
- Conveyor belts, cargo carts — anything that moves along a fixed trajectory.
- Trail effects — a position behind the player, sampled from the recent trail.
When NOT to use them
- Straight segments — an array of
Vector3is cheaper. - NavMesh navigation over uneven terrain — Path3D has no obstacle avoidance. Use
NavigationAgent3D(see the Navigation chapter). - Hundreds of dynamically created curves per frame — for heavy cases use WorkerThreadPool (see the next chapter) or GDExtension.