Skip to content

Instantly share code, notes, and snippets.

@lucasteles
Created July 16, 2025 22:46
Show Gist options
  • Save lucasteles/1a71968a1c31f8b9cae155804804d67f to your computer and use it in GitHub Desktop.
Save lucasteles/1a71968a1c31f8b9cae155804804d67f to your computer and use it in GitHub Desktop.
Godot C# custom 3D trail renderer
using System;
using Godot;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// Based on
// https://gist.github.com/geegaz/8dfd61f600828c02acbbdaa749a2bbd5
// https://www.youtube.com/watch?v=vKrrxKS-lcA
public partial class Trail3D : MeshInstance3D
{
[Export] public bool Emit = true;
[Export] public float Distance = 0.1f;
[Export(PropertyHint.Range, "0,99999")]
public int Segments = 20;
[Export] public float Lifetime = 0.5f;
[Export(PropertyHint.Range, "0,99999")]
public float BaseWidth = 0.5f;
[Export] public bool TiledTexture;
[Export] public int Tiling = 0;
[Export] public Curve WidthProfile;
[Export(PropertyHint.Enum)] public SmoothingIteration Smoothing = SmoothingIteration.N0;
[Export(PropertyHint.Range, "0,0.5")] public float SmoothingRatio = 0.25f;
[Export(PropertyHint.Enum, "View,Normal,Object")]
Alignment alignment = Alignment.View;
[Export(PropertyHint.Enum, "X,Y,Z")] Axis axis = Axis.Y;
List<Point> _points = [];
List<Point> _tempSegment = [];
Node3D _target;
Point? _A, _B, _C;
(Vector3, Vector3) PrepareGeometry(Point prev, Point point, float halfWidth, float factor)
{
var normal = Vector3.Zero;
switch (alignment)
{
case Alignment.View:
{
var cam = GetViewport().GetCamera3D();
if (cam is not null)
{
var camPos = cam.GlobalTransform.Origin;
var dir = (point.Transform.Origin - prev.Transform.Origin).Normalized();
normal = (camPos - (point.Transform.Origin + prev.Transform.Origin) / 2.0f).Cross(dir).Normalized();
}
else
GD.Print("There is no camera in the scene");
break;
}
case Alignment.Normal:
normal = axis switch
{
Axis.X => point.Transform.Basis.X.Normalized(),
Axis.Y => point.Transform.Basis.Y.Normalized(),
_ => point.Transform.Basis.Z.Normalized()
};
break;
case Alignment.Object:
{
var basis = _target.GlobalTransform.Basis;
normal = axis switch
{
Axis.X => basis.X.Normalized(),
Axis.Y => basis.Y.Normalized(),
_ => basis.Z.Normalized()
};
break;
}
default:
throw new ArgumentOutOfRangeException();
}
var width = halfWidth * WidthProfile?.Sample(factor) ?? halfWidth;
return (
point.Transform.Origin - normal * width,
point.Transform.Origin + normal * width
);
}
void RenderRealtime() => RenderGeometry(true, true);
readonly List<Point> renderPoints = [];
void RenderGeometry(bool addSegments = false, bool addC = false)
{
renderPoints.Clear();
renderPoints.AddRange(_points);
if (addSegments)
renderPoints.AddRange(_tempSegment);
if (addC && _C is not null)
renderPoints.Add(_C);
if (renderPoints.Count < 2) return;
var firstPoint = renderPoints[0];
var delta = firstPoint.Transform.Origin - renderPoints[1].Transform.Origin;
var hackTransform = firstPoint.Transform;
hackTransform.Origin += delta;
renderPoints.Insert(0, new(hackTransform, firstPoint.Age));
var halfWidth = BaseWidth / 2f;
var u = 0f;
var mesh = new ImmediateMesh();
Mesh = mesh;
mesh.SurfaceBegin(Mesh.PrimitiveType.TriangleStrip);
for (var i = 1; i < renderPoints.Count; i++)
{
var factor = (float)i / (renderPoints.Count - 1);
var (verts0, verts1) = PrepareGeometry(renderPoints[i - 1], renderPoints[i], halfWidth, 1.0f - factor);
if (TiledTexture)
{
if (Tiling > 0)
factor *= Tiling;
else
{
var travel = (renderPoints[i - 1].Transform.Origin - renderPoints[i].Transform.Origin).Length();
u += travel / BaseWidth;
factor = u;
}
}
mesh.SurfaceSetUV(new(factor, 0));
mesh.SurfaceAddVertex(verts0);
mesh.SurfaceSetUV(new(factor, 1));
mesh.SurfaceAddVertex(verts1);
}
mesh.SurfaceEnd();
}
void UpdatePoints()
{
var delta = (float)GetProcessDeltaTime();
_A?.Update(delta);
_B?.Update(delta);
_C?.Update(delta);
var span = CollectionsMarshal.AsSpan(_points);
ref var current = ref MemoryMarshal.GetReference(span);
ref var limit = ref Unsafe.Add(ref current, span.Length);
while (Unsafe.IsAddressLessThan(in current, in limit))
{
current.Update(delta);
current = ref Unsafe.Add(ref current, 1)!;
}
_points.RemoveAll(p => p.IsDone());
var maxCount = Segments * (int)Smoothing;
if (_points.Count > maxCount)
_points.RemoveRange(0, _points.Count - maxCount);
}
void Chaikin(List<Point> output, Point a, Point b, Point c)
{
if (Smoothing is SmoothingIteration.N0)
{
output.Add(b);
return;
}
var x = SmoothingRatio;
var xi = 1 - x;
var xpa = x * x - 2 * x + 1;
var xpb = -x * x + 2 * x;
var a1T = a.Transform.InterpolateWith(b.Transform, xi);
var b1T = b.Transform.InterpolateWith(c.Transform, x);
var a1A = Mathf.Lerp(a.Age, b.Age, xi);
var b1A = Mathf.Lerp(b.Age, c.Age, x);
if (Smoothing is SmoothingIteration.N1)
{
output.Add(new(a1T, a1A));
output.Add(new(b1T, b1A));
}
else
{
var a2T = a.Transform.InterpolateWith(b.Transform, xpa);
var b2T = b.Transform.InterpolateWith(c.Transform, xpb);
var a11T = a1T.InterpolateWith(b1T, x);
var b11T = a1T.InterpolateWith(b1T, xi);
var a2A = Mathf.Lerp(a.Age, b.Age, xpa);
var b2A = Mathf.Lerp(b.Age, c.Age, xpb);
var a11A = Mathf.Lerp(a1A, b1A, x);
var b11A = Mathf.Lerp(a1A, b1A, xi);
if (Smoothing is SmoothingIteration.N2)
{
output.Add(new(a2T, a2A));
output.Add(new(a11T, a11A));
output.Add(new(b11T, b11A));
output.Add(new(b2T, b2A));
}
else if (Smoothing is SmoothingIteration.N3)
{
var a12T = a1T.InterpolateWith(b1T, xpb);
var b12T = a1T.InterpolateWith(b1T, xpa);
var a121T = a11T.InterpolateWith(a2T, x);
var b121T = b11T.InterpolateWith(b2T, x);
var a12A = Mathf.Lerp(a1A, b1A, xpb);
var b12A = Mathf.Lerp(a1A, b1A, xpa);
var a121A = Mathf.Lerp(a11A, a2A, x);
var b121A = Mathf.Lerp(b11A, b2A, x);
output.Add(new(a2T, a2A));
output.Add(new(a121T, a121A));
output.Add(new(a12T, a12A));
output.Add(new(b12T, b12A));
output.Add(new(b121T, b121A));
output.Add(new(b2T, b2A));
}
}
}
public override void _Ready()
{
GlobalTransform = Transform3D.Identity;
Mesh = new ImmediateMesh();
_target = GetParent<Node3D>();
TopLevel = true;
}
public override void _Process(double delta)
{
if (!Emit) return;
var transform = _target.GlobalTransform;
var point = new Point(transform, Lifetime);
if (_A is null)
{
_A = point;
return;
}
if (_B is null)
{
_A?.Update((float)delta);
_B = point;
return;
}
if (_B?.Transform.Origin.DistanceSquaredTo(transform.Origin) >= Distance * Distance)
{
_A = _B;
_B = point;
_points.AddRange(_tempSegment);
}
_C = point;
UpdatePoints();
_tempSegment.Clear();
Chaikin(_tempSegment, _A, _B, _C);
RenderRealtime();
}
public enum Alignment
{
View,
Normal,
Object
}
public enum Axis
{
X,
Y,
Z
}
public enum SmoothingIteration
{
N0 = 1,
N1 = 2,
N2 = 4,
N3 = 6,
}
record Point(Transform3D Transform, float Age)
{
public readonly Transform3D Transform = Transform;
public float Age = Age;
public bool IsDone() => Age <= 0;
public void Update(float delta) => Age -= delta;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment