Skip to content

Instantly share code, notes, and snippets.

@axilirate
Last active July 11, 2024 17:22
Show Gist options
  • Save axilirate/96a3e77d597c2527582dbc79aecbab70 to your computer and use it in GitHub Desktop.
Save axilirate/96a3e77d597c2527582dbc79aecbab70 to your computer and use it in GitHub Desktop.
A Custom 3D trail renderer for Godot 4.
class_name Trail3D extends MeshInstance3D
"""
Original Author: Oussama BOUKHELF
License: MIT
Version: 0.1
Email: o.boukhelf@gmail.com
Description: Advanced 2D/3D Trail system.
"""
enum {VIEW, NORMAL, OBJECT}
enum {X, Y, Z}
@export var emit: bool = true
@export var distance: float= 0.1
@export_range(0, 99999) var segments: int = 20
@export var lifetime: float= 0.5
@export_range(0, 99999) var base_width: float = 0.5
@export var tiled_texture: bool = false
@export var tiling: int = 0
@export var width_profile: Curve
@export_range(0, 3) var smoothing_iterations: int= 0
@export_range(0, 0.5) var smoothing_ratio: float = 0.25
@export_enum(VIEW, NORMAL, OBJECT) var alignment: int = VIEW
@export_enum(X, Y, Z) var axe: int = Y
var points := []
var color := Color(1, 1, 1, 1)
var always_update = false
var _target: Node3D
var _A: Point
var _B: Point
var _C: Point
var _temp_segment := []
var _points := []
class Point:
var transform: Transform3D
var age: float = 0.0
func _init(transform :Transform3D, age :float) -> void:
transform = transform
age = age
func update(delta: float, points: Array) -> void:
age -= delta
if age <= 0:
points.erase(self)
func add_point(transform :Transform3D) -> void:
var point = Point.new(transform, lifetime)
points.push_back(point)
func clear_points() -> void:
points.clear()
func _prepare_geometry(point_prev :Point, point :Point, half_width :float, factor :float) -> Array:
var normal := Vector3()
if alignment == VIEW:
if get_viewport().get_camera_3d():
var cam_pos = get_viewport().get_camera_3d().get_global_transform().origin
var path_direction :Vector3 = (point.transform.origin - point_prev.transform.origin).normalized()
normal = (cam_pos - (point.transform.origin + point_prev.transform.origin)/2).cross(path_direction).normalized()
else:
print("There is no camera in the scene")
elif alignment == NORMAL:
if axe == X:
normal = point.transform.basis.x.normalized()
elif axe == Y:
normal = point.transform.basis.y.normalized()
else:
normal = point.transform.basis.z.normalized()
else:
if axe == X:
normal = _target.global_transform.basis.x.normalized()
elif axe == Y:
normal = _target.global_transform.basis.y.normalized()
else:
normal = _target.global_transform.basis.z.normalized()
var width = half_width
if width_profile:
width = half_width * width_profile.interpolate(factor)
var p1 = point.transform.origin-normal*width
var p2 = point.transform.origin+normal*width
return [p1, p2]
func render(update := false) -> void:
if update:
always_update = update
else:
_render_geometry(points)
func _render_realtime() -> void:
var render_points = _points+_temp_segment+[_C]
_render_geometry(render_points)
func _render_geometry(source: Array) -> void:
var points_count = source.size()
if points_count < 2:
return
# The following section is a hack to make orientation "view" work.
# but it may cause an artifact at the end of the trail.
# You can use transparency in the gradient to hide it for now.
var _d :Vector3 = source[0].transform.origin - source[1].transform.origin
var _t :Transform3D = source[0].transform
_t.origin = _t.origin + _d
var point = Point.new(_t, source[0].age)
var to_be_rendered = [point]+source
points_count += 1
var half_width :float = base_width/2.0
var u := 0.0
mesh.clear_surfaces()
mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLE_STRIP, null)
for i in range(1, points_count):
var factor :float = float(i)/(points_count-1)
var vertices = _prepare_geometry(to_be_rendered[i-1], to_be_rendered[i], half_width, 1.0-factor)
if tiled_texture:
if tiling > 0:
factor *= tiling
else:
var travel = (to_be_rendered[i-1].transform.origin - to_be_rendered[i].transform.origin).length()
u += travel/base_width
factor = u
mesh.surface_set_uv(Vector2(factor, 0))
mesh.surface_add_vertex(vertices[0])
mesh.surface_set_uv(Vector2(factor, 1))
mesh.surface_add_vertex(vertices[1])
mesh.surface_end()
func _update_points() -> void:
var delta = get_process_delta_time()
_A.update(delta, _points)
_B.update(delta, _points)
_C.update(delta, _points)
for point in _points:
point.update(delta, _points)
var size_multiplier = [1, 2, 4, 6][smoothing_iterations]
var max_points_count :int = segments * size_multiplier
if _points.size() > max_points_count:
_points.reverse()
_points.resize(max_points_count)
_points.reverse()
func smooth() -> void:
if points.size() < 3:
return
var output := [points[0]]
for i in range(1, points.size()-1):
output += _chaikin(points[i-1], points[i], points[i+1])
output.push_back(points[-1])
points = output
func _chaikin(A, B, C) -> Array:
if smoothing_iterations == 0:
return [B]
var out := []
var x :float = smoothing_ratio
# Pre-calculate some parameters to improve performance
var xi :float = (1-x)
var xpa :float = (x*x-2*x+1)
var xpb :float = (-x*x+2*x)
# transforms
var A1_t :Transform3D = A.transform.interpolate_with(B.transform, xi)
var B1_t :Transform3D = B.transform.interpolate_with(C.transform, x)
# ages
var A1_a :float = lerp(A.age, B.age, xi)
var B1_a :float = lerp(B.age, C.age, x)
if smoothing_iterations == 1:
out = [Point.new(A1_t, A1_a), Point.new(B1_t, B1_a)]
else:
# transforms
var A2_t :Transform3D = A.transform.interpolate_with(B.transform, xpa)
var B2_t :Transform3D = B.transform.interpolate_with(C.transform, xpb)
var A11_t :Transform3D = A1_t.interpolate_with(B1_t, x)
var B11_t :Transform3D = A1_t.interpolate_with(B1_t, xi)
# ages
var A2_a :float = lerp(A.age, B.age, xpa)
var B2_a :float = lerp(B.age, C.age, xpb)
var A11_a :float = lerp(A1_a, B1_a, x)
var B11_a :float = lerp(A1_a, B1_a, xi)
if smoothing_iterations == 2:
out += [Point.new(A2_t, A2_a), Point.new(A11_t, A11_a),
Point.new(B11_t, B11_a), Point.new(B2_t, B2_a)]
elif smoothing_iterations == 3:
# transforms
var A12_t :Transform3D = A1_t.interpolate_with(B1_t, xpb)
var B12_t :Transform3D = A1_t.interpolate_with(B1_t, xpa)
var A121_t :Transform3D = A11_t.interpolate_with(A2_t, x)
var B121_t :Transform3D = B11_t.interpolate_with(B2_t, x)
# ages
var A12_a :float = lerp(A1_a, B1_a, xpb)
var B12_a :float = lerp(A1_a, B1_a, xpa)
var A121_a :float = lerp(A11_a, A2_a, x)
var B121_a :float = lerp(B11_a, B2_a, x)
out += [Point.new(A2_t, A2_a), Point.new(A121_t, A121_a), Point.new(A12_t, A12_a),
Point.new(B12_t, B12_a), Point.new(B121_t, B121_a), Point.new(B2_t, B2_a)]
return out
func _emit(delta) -> void:
var _transform :Transform3D = _target.global_transform
var point = Point.new(_transform, lifetime)
if not _A:
_A = point
return
elif not _B:
_A.update(delta, _points)
_B = point
return
if _B.transform.origin.distance_squared_to(_transform.origin) >= distance*distance:
_A = _B
_B = point
_points += _temp_segment
_C = point
_update_points()
_temp_segment = _chaikin(_A, _B, _C)
_render_realtime()
func _ready() -> void:
mesh = ImmediateMesh.new()
_target = get_parent()
top_level = true
func _process(delta) -> void:
if emit:
_emit(delta)
return
if always_update:
# This is needed for alignment == view, so it can be updated every frame.
_render_geometry(points)
@Daniel-Repository
Copy link

Hi there! Just wondering if you have any plans to update this for the release version of Godot 4 (Echoing the points @DISN-kolo has made).

@DISN-kolo - did you end up getting this to work? Otherwise, did you find a seperate solution for a trail in 3D?

Cheers! 😄

@DISN-kolo
Copy link

@Krydan nope, I gave up XD

I've sort of done it with manually tracking an object through space and passing those coordinates to a subdivided path so that it trails behind. Not very intuitive or reliable, but seems to work OK mostly...

@lumenwrites
Copy link

I'd also really love to figure out how to make this work. If anyone finds a solution - please post it here!

@axilirate
Copy link
Author

@geegaz
Copy link

geegaz commented Aug 29, 2023

Here's an updated version that works with Godot 4.1.1
https://gist.github.com/geegaz/8dfd61f600828c02acbbdaa749a2bbd5

heya! this looks super promising, but doesn't work on release version of godot4. for example, .interpolate() was changed into .sample(), and @export_enum should now be done with strings, like so:

@export_enum("VIEW", "NORMAL", "OBJECT") var alignment: int = 0
@export_enum("X", "Y", "Z") var axe: int = 1

I added these, and the last issues was apparently in the Point class where the transform and age parameters of the _init() method were shadowing the transform and age properties of Point.

image

@Daniel-Repository
Copy link

Legend! Thank you!

@AurelienCaille
Copy link

Hi,
I have a spaceship CharacterBody3D which I can fly in space... but the trail is out of place and I can't get it to the right position. In the scene the trail is at the engine exhaust, but when I run the trail starts somewhere in space and doesn't follow the movement of the ship. Any idea how to fix this? I use Godot 4 Beta 7 Mono.
Screenshot 2022-12-13 081242 Screenshot 2022-12-13 081320

Try to put the trail inside a Node3D, move the Node3D to your desired position and make sure the position of the trail is 0, 0, 0.

I had a similar issue

func _ready() -> void:
	global_transform = Transform3D.IDENTITY
	mesh = ImmediateMesh.new()
	_target = get_parent()
	top_level = true

add global_transform = Transform3D.IDENTITY seems to fix it, I don't really know why

@SadToothpaste
Copy link

Hi,
I have a spaceship CharacterBody3D which I can fly in space... but the trail is out of place and I can't get it to the right position. In the scene the trail is at the engine exhaust, but when I run the trail starts somewhere in space and doesn't follow the movement of the ship. Any idea how to fix this? I use Godot 4 Beta 7 Mono.
Screenshot 2022-12-13 081242 Screenshot 2022-12-13 081320

Try to put the trail inside a Node3D, move the Node3D to your desired position and make sure the position of the trail is 0, 0, 0.

I had a similar issue

func _ready() -> void:
	global_transform = Transform3D.IDENTITY
	mesh = ImmediateMesh.new()
	_target = get_parent()
	top_level = true

add global_transform = Transform3D.IDENTITY seems to fix it, I don't really know why

You are a bloody life saver!

@SomeRanDev
Copy link

I ported this code to Rust here; developed and running it on Godot 4.3 dev 6 build.

It works just like a normal native addon, and it is compatible with GDScript and C#.

On top of the performance benefits of using Rust, I've also optimized a lot of the original code to avoid unnecessary array copying.

@nan0m
Copy link

nan0m commented Jul 11, 2024

A small part of the trail mesh does not disappear when the trail3D node stops moving. Anyone else having this issue?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment