What do arrows, swords, ships, and spaceships have in common? Motion! When we want to emphasize movement, we can use trails.
In this chapter, we’re going to implement what’s commonly called a trail or ribbon effect. This effect has a line that tracks a target position, plots it over time, and connects the points.
To achieve this effect we’re going to:
tool
feature to communicate dependencies in the editorWe’re also going to use the trail we create in four different use cases to understand its capabilities and limitations.
A trail behaves like a particle system. It has an emission point and creates points in space that are connected using a mesh. You can check Xenko’s ribbons and trails manual to learn more about a standard trail implementation.
Since Godot doesn’t have a built-in implementation for ribbons and trails using particles, we need to simulate them manually. For that, we’ll use a Line2D.
Create a new scene and add a Line2D node as its root. Name it Trail2D and add a script to it.
One of the main issues of using a Line2D by itself is that it doesn’t provide a good emission point. This is because the line it draws is relative to its position, rotation, and scale. We want our trail to ignore these transforms.
To work around the trail’s transforms, we’re going to track a target that can be any 2D node. To allow a default target to be set through the Inspector, we’re going to export a target_path
variable and try to fetch our target using get_node(target_path)
.
To make it easier to use our Trail2D, if we can’t get the target using the target_path
variable, we can use its parent as a fallback.
class_name Trail2D extends Line2D export var target_path: NodePath onready var target: Node2D = get_node(target_path) func _ready() -> void: if not target: target = get_parent() as Node2D
By default, our Trail2D follows its parent’s transform, which breaks the purpose of tracking the target.
Call set_as_toplevel(true)
in the _ready
function to make the trail’s transforms global. When doing so, setting the node’s position
or rotation
becomes equivalent to using global_position
and global_rotation
.
Let’s make sure our Trail2D’s position is Vector2(0, 0)
to reset any offset we may accidentally add in the editor. Add position = Vector2.ZERO
at the bottom of the _ready
callback and clear the line using clear_points()
to prevent drawing when the game starts.
func _ready() -> void: # ... set_as_toplevel(true) position = Vector2.ZERO clear_points()
Now that we have a target to track and a line to draw, every frame, we need to add a point at the target’s position. We can do that in the _process
callback.
Line2D has a method that adds a point to the line: add_point()
. Let’s use that to add our target’s current position.
Since Line2D draws the line in local space, we need to convert our target’s position using to_local(global_point: Vector2)
. We’ll use its global_position
which ignores its local offset.
func _process(delta: float) -> void: add_point(to_local(target.global_position))
We can’t store an infinite amount of points in our trail because we have hardware limitations, and we want the trail to have a limited length. So let’s export a max_points
variable and set it to 100
by default.
We can use Line2D.get_point_count()
to check how many points we have in our trail. Let’s remove the oldest point as we add new ones.
class_name Trail2D extends Line2D export var max_points := 100 # ... func _process(delta: float) -> void: add_point(to_local(target.global_position)) if get_point_count() > max_points: remove_point(0)
To prevent drawing additional points, we can check the distance from the last point to the current target position. Then we use a resolution
variable as a threshold to decide if we should plot this desired point.
Export a new resolution
variable and create a variable to store the _last_point
we’ve added. Initially, this is our target’s global position converted to local coordinates.
Now we only plot a point if it’s necessary.
class_name Trail2D extends Line2D export var resolution := 5.0 # ... var _last_point := Vector2.ZERO func _ready() -> void: # ... _last_point = to_local(target.global_position) func _process(delta: float) -> void: var desired_point := to_local(target.global_position) var distance := _last_point.distance_to(desired_point) if distance > resolution: add_point(desired_point) if get_point_count() > max_points: remove_point(0)
The trail should fade after some time. To achieve this, we need each point to have its lifetime by storing each point’s creation time in a list.
Let’s create a new array to store this data. We’ll name it _points_creation_time
and add a _clock
variable to save the current time in seconds.
Using this, we can compare each point’s creation time to the current time and decide if it should be deleted. To know if a point should be removed, let’s export a lifetime
variable so we can set this property through the Inspector.
export var lifetime := 0.5 var _points_creation_time := [] var _clock := 0.0
Let’s update our clock using the delta
value we get in the _process
callback.
Since we not only add a point to the trail but also track its creation time, let’s create a method that does that and change our add_point
call.
We’ll name this method add_timed_point
and it receives two arguments: the point
and its creation time
. Every time we add a new point, we append its creation time to the _points_creation_time
list.
We also want to remove the first point if our line has more points than its max_points
variable. Every time we remove a point, we need to remove its time entry from the _points_creation_time
array. So let’s create a remove_first_point
method that removes the first point of our line and its time entry.
func _process(delta: float) -> void: _clock += delta # Adding new points if necessary. var desired_point := to_local(target.global_position) var distance: float = _last_point.distance_to(desired_point) if distance > resolution: add_timed_point(desired_point, _clock) func add_timed_point(point: Vector2, time: float) -> void: add_point(point) _points_creation_time.append(time) _last_point = point if get_point_count() > max_points: remove_first_point() # Removes the first point in the line and the corresponding time. func remove_first_point() -> void: if get_point_count() > 1: remove_point(0) _points_creation_time.pop_front()
Finally, we’re going to remove every point in the line that is older than our lifetime
.
Let’s create a remove_older
method that iterates through the _points_creation_time
list, checks if the current point is older than lifetime
, and removes its time entry and the point from the trail.
We can break
the iteration if the first index doesn’t match this condition because since the points are added in chronological order, the points after are going to be newer.
# Remove points older than `lifetime`. func remove_older() -> void: for creation_time in _points_creation_time: var delta = _clock - creation_time if delta > lifetime: remove_first_point() # Points in `_points_creation_time` are ordered from oldest to newest so as soon as a point # isn't older than `lifetime`, we know all remaining points should stay as well. else: break
Let’s call this method below our _clock
increment in the _process
callback.
func _process(delta: float) -> void: _clock += delta remove_older() ...
With that, we have our basic trail implementation working. You can test it with a moving object!
Here’s a snippet for a Node2D that moves towards the mouse. Attach it to a Node2D and add our Trail2D as a child of this Node2D.
extends Node2D export var speed := 300.0 var _direction := Vector2.RIGHT func _process(delta: float) -> void: _direction = global_position.direction_to(get_global_mouse_position()) rotation = _direction.angle() translate((_direction * speed) * delta)
We may want to activate the trail effect only in some parts of the game. For instance, with drifting lines, we don’t want the car to always leave tire marks.
Let’s add a variable to control the emission of the trail and let’s call it is_emitting
and using the setget
keyword, let’s encapsulate it in a set_emitting
method. Inside this method, we are going to receive a new value and assign it to the is_emitting
variable. Since we can call this method before we get our target
node, we need to yield for the “ready” signal. Doing so ensures that we don’t run into errors when we just add a new trail in our game.
After that, we clear the points, and their creation time to generate an entirely new trail from the point we start emitting again. For that, we are also going to update the _last_point
to this new emitting point.
export var is_emitting := false setget set_emitting # ... func set_emitting(emitting: bool) -> void: is_emitting = emitting if not is_inside_tree(): yield(self, "ready") if is_emitting: clear_points() _points_creation_time.clear() _last_point = to_local(target.global_position)
Now it’s time to make our trail usable by our teammates and our future self.
First of all, we need to communicate that our trail effect has a dependency, it needs a target to track, and this target needs a position to be tracked, in other words, it needs to inherit from Node2D.
We can use Godot’s _get_notification_warning
to display a warning sign next to the trail in the SceneTree if the trail failed fetched a target, this warning is like the one CollisionShape
nodes display when they don’t have a Shape
defined or when Areas
don’t have a CollisionShape
.
To implement this feature, we need to turn our trail script in a tool script by adding the tool
keyword at the top of the file. The tool mode makes the code run in the editor, which is dangerous. In _ready()
, after getting the target node, we should turn off the _process()
callback if we are in the editor.
func _ready() -> void: if not target: target = get_parent() as Node2D # Disables the trail's behavior in the Editor if Engine.editor_hint: set_process(false) return
We do the same thing in the set_emitting
method after assigning the value to the is_emitting
variable to prevent the clear_points()
call to clear points in the editor.
func set_emitting(emitting: bool) -> void: is_emitting = emitting if Engine.editor_hint: return # ...
Using the _get_notification_warning()
callback, we can display a warning in the editor when the trail lacks a target.
func _get_configuration_warning() -> String: var warning := "Missing Target node: assign a Node that extends Node2D in the Target Path or make the trail a child of a parent that extends Node2D" if target: warning = "" return warning
To prevent logging an error in the output console every time the get_node
method fails, let’s change the onready var target = get_node(target_path)
line to onready var target = get_node_or_null(target_path)
.
It’s common to move the trail a bit away from the target’s actual position. For instance, in our demo scene, it is at the bottom of the spaceship instead of its center. To maintain this offset we are going to add an _offset
to the plotted points and add a rotation offset if the user rotates the trail.
func calculate_offset() -> Vector2: return -polar2cartesian(1.0, target.rotation) * _offset
Remember, since we are using set_as_toplevel(true)
the original transform of the trail gets ignored after the game starts, so before we call this method, we need to store the initial offset in the _offset
variable.
When we add a new point, and when we first set the _last_point
variable, we should add this offset: in the _ready
, set_emitting
, and add_timed_point
methods.
func _ready() -> void: ... _offset = position.length() clear_points() set_as_toplevel(true) position = Vector2.ZERO _last_point = to_local(target.global_position) + calculate_offset() func add_timed_point(point: Vector2, time: float) -> void: add_point(point + calculate_offset()) # ... func set_emitting(emitting: bool) -> void: # ... if is_emitting: clear_points() _points_creation_time.clear() _last_point = to_local(target.global_position) + calculate_offset()
Now that we have our effect in place, let’s look at some common use cases.
We want the trail to match the arrow’s motion, providing visual feedback to help players track its movement. For that, let’s add our trail at the arrow’s back, the fletching.
Now let’s match the trail’s width to the arrow’s. We are going to design a stylized movement trail; one technique commonly used is to narrow the trail as the target moves. To achieve that, in the trail’s Width Curve, create a new curve, and make a line like the one below.
This chart represents the width in the vertical axis and the point in the horizontal axis, in our case we want the last point to be wider than the first one. They represent, respectively, the older and newest points of our trail.
This chart represents the vertical axis’s width and the point in the horizontal axis; in our case, we want the line to be thicker when it’s closer to the last point.
To smooth the fading of our trail, let’s make the older points get more transparent. In Fill > Gradient, pick the color that best represents your arrow and add it at the beginning and end of the Gradient, make sure to make the color on the left, that represents the oldest point, full transparent while the rightmost one completely opaque.
You can use the same approach as the Flying Arrows. But instead of tracking the sword directly, we track a point in the middle of it. We then need to adjust the trail Width to match the part that we want to track, such as the blade.