Created
July 16, 2025 22:46
-
-
Save lucasteles/1a71968a1c31f8b9cae155804804d67f to your computer and use it in GitHub Desktop.
Godot C# custom 3D trail renderer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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