#51 WheelTrailRenderer

https://github.com/user-attachments/assets/53163955-41b9-4754-8334-579e677080b6

WheelTrailRenderer.cs
using System.Collections.Generic;
using Unity.Collections;
using Unity.Jobs;
using Unity.Burst;
using UnityEngine;
using UnityEngine.Rendering;

/// <summary>
/// A world-space trail renderer that stores the contact surface normal per point and builds a stable ribbon
/// from path tangent so vertex normals follow the surface without twist from the wheel transform.
/// 
/// made by StCost for COLLAPSE MACHINE. details in blog: https://dreamingsaints.github.io/blog/51-wheeltrailrenderer/
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(WheelCollider))]
public class WheelTrailRenderer : MonoBehaviour
{
    [SerializeField, Min(0.1f)] private float maxDistance = 50f;
    [SerializeField, Min(0.001f)] private float minVertexDistance = 0.1f;
    [SerializeField] private float widthMultiplier = 0.35f;
    [SerializeField] private AnimationCurve widthCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f);
    [SerializeField] private Gradient colorGradient = new();
    [SerializeField] private bool generateLightingData = true;
    [SerializeField] private LineTextureMode textureMode = LineTextureMode.RepeatPerSegment;
    [SerializeField] private float uvSegmentLength = 0.5f;
    [SerializeField] private Material trailMaterial;
    [SerializeField] private WheelCollider wheelCollider;
    [SerializeField] private Vector3 groundContactOffset = new Vector3(0f, 0.02f, 0f);

    private readonly List<TrailPoint> points = new();
    private static readonly List<Vector3> emptyNormals = new();
    private readonly object rebuildStateLock = new();

    private Mesh trailMesh;
    private Vector3 lastEmitPosition;
    private bool hasLastEmitPosition;
    private bool shouldStartNewSegment = true;
    private bool meshHasRenderableData;
    private int rebuildGeneration;
    private int activeRebuildGeneration;
    private bool rebuildInProgress;
    private JobHandle pendingMeshJobHandle;
    private bool meshJobScheduled;
    private int scheduledMeshJobGeneration;

    private NativeArray<TrailPoint> nativeSnapshotPoints;
    private NativeArray<float> nativeSnapshotWidths;
    private NativeArray<Color> nativeSnapshotColors;
    private NativeArray<float> nativeSnapshotUCoordinates;
    private NativeArray<Vector3> nativeVertices;
    private NativeArray<Vector3> nativeNormals;
    private NativeArray<Color> nativeVertexColors;
    private NativeArray<Vector2> nativeUvs;
    private NativeArray<int> nativeIndices;
    private NativeArray<int> nativeMeshIndexCount;
    private NativeArray<byte> nativeMeshHasInvalidData;
    private NativeArray<Vector3> nativeNormalsEmpty;
    private int scheduledMeshPointCount;

    private struct TrailPoint
    {
        public Vector3 position;
        public Vector3 normal;
        public float distanceFromStart;
        public bool startsNewSegment;
    }

    [BurstCompile]
    private struct MeshBuildJob : IJob
    {
        public int PointCount;
        public bool IncludeLightingData;

        [ReadOnly] public NativeArray<TrailPoint> Points;
        [ReadOnly] public NativeArray<float> Widths;
        [ReadOnly] public NativeArray<Color> Colors;
        [ReadOnly] public NativeArray<float> UCoordinates;

        [WriteOnly] public NativeArray<Vector3> Vertices;
        [WriteOnly] public NativeArray<Vector3> Normals;
        [WriteOnly] public NativeArray<Color> OutColors;
        [WriteOnly] public NativeArray<Vector2> Uvs;
        public NativeArray<int> Indices;

        public NativeArray<int> OutIndexCount;
        public NativeArray<byte> OutHasInvalidData;

        public void Execute()
        {
            OutHasInvalidData[0] = 0;

            int pointCount = PointCount;
            int vertexCount = pointCount * 2;

            if (pointCount < 2 || vertexCount <= 0)
            {
                OutIndexCount[0] = 0;
                return;
            }

            bool includeLightingData = IncludeLightingData;
            bool normalsWritable = includeLightingData && Normals.IsCreated && Normals.Length >= vertexCount;

            for (int pointIndex = 0; pointIndex < pointCount; pointIndex++)
            {
                TrailPoint point = Points[pointIndex];
                float halfWidth = Widths[pointIndex] * 0.5f;

                Vector3 normal = point.normal;
                if (!IsFiniteVector3(normal) || normal.sqrMagnitude < 0.000001f)
                    normal = Vector3.up;
                else
                    normal.Normalize();

                Vector3 trailTangent = ComputeTrailTangent(Points, pointIndex, pointCount);
                Vector3 alongTrail = Vector3.ProjectOnPlane(trailTangent, normal);
                if (alongTrail.sqrMagnitude < 0.000001f)
                {
                    alongTrail = Vector3.ProjectOnPlane(Vector3.forward, normal);
                    if (alongTrail.sqrMagnitude < 0.000001f)
                        alongTrail = Vector3.ProjectOnPlane(Vector3.right, normal);
                }
                alongTrail.Normalize();

                Vector3 widthDirection = Vector3.Cross(normal, alongTrail);
                if (!IsFiniteVector3(widthDirection) || widthDirection.sqrMagnitude < 0.000001f)
                    widthDirection = Vector3.right;
                else
                    widthDirection.Normalize();

                Vector3 leftWorld = point.position - widthDirection * halfWidth;
                Vector3 rightWorld = point.position + widthDirection * halfWidth;
                if (!IsFiniteVector3(leftWorld) || !IsFiniteVector3(rightWorld))
                {
                    OutHasInvalidData[0] = 1;
                    OutIndexCount[0] = 0;
                    return;
                }

                int vertexBaseIndex = pointIndex * 2;
                Vertices[vertexBaseIndex] = leftWorld;
                Vertices[vertexBaseIndex + 1] = rightWorld;
                OutColors[vertexBaseIndex] = Colors[pointIndex];
                OutColors[vertexBaseIndex + 1] = Colors[pointIndex];
                Uvs[vertexBaseIndex] = new Vector2(UCoordinates[pointIndex], 0f);
                Uvs[vertexBaseIndex + 1] = new Vector2(UCoordinates[pointIndex], 1f);

                if (normalsWritable)
                {
                    Normals[vertexBaseIndex] = normal;
                    Normals[vertexBaseIndex + 1] = normal;
                }
            }

            int indexWritePosition = 0;
            for (int pointIndex = 0; pointIndex < pointCount - 1; pointIndex++)
            {
                if (Points[pointIndex + 1].startsNewSegment)
                    continue;

                int baseVertex = pointIndex * 2;
                Indices[indexWritePosition] = baseVertex;
                Indices[indexWritePosition + 1] = baseVertex + 2;
                Indices[indexWritePosition + 2] = baseVertex + 1;
                Indices[indexWritePosition + 3] = baseVertex + 1;
                Indices[indexWritePosition + 4] = baseVertex + 2;
                Indices[indexWritePosition + 5] = baseVertex + 3;
                indexWritePosition += 6;
            }

            OutIndexCount[0] = indexWritePosition;
        }

        private static Vector3 ComputeTrailTangent(NativeArray<TrailPoint> points, int index, int pointCount)
        {
            if (pointCount < 2)
                return Vector3.forward;

            if (index == 0)
            {
                if (!points[1].startsNewSegment)
                    return points[1].position - points[0].position;
                return Vector3.forward;
            }

            if (index == pointCount - 1)
                return points[index].position - points[index - 1].position;

            if (!points[index + 1].startsNewSegment && !points[index].startsNewSegment)
                return points[index + 1].position - points[index - 1].position;

            if (!points[index + 1].startsNewSegment)
                return points[index + 1].position - points[index].position;

            if (!points[index].startsNewSegment)
                return points[index].position - points[index - 1].position;

            return points[index + 1].position - points[index].position;
        }

        private static bool IsFiniteVector3(Vector3 vector) =>
            float.IsFinite(vector.x) && float.IsFinite(vector.y) && float.IsFinite(vector.z);
    }

    private void Reset()
    {
        wheelCollider = GetComponent<WheelCollider>();
    }

    public void Clear()
    {
        CompleteScheduledMeshJob();

        points.Clear();
        hasLastEmitPosition = false;
        shouldStartNewSegment = true;
        rebuildInProgress = false;
        rebuildGeneration += 1;
        activeRebuildGeneration = rebuildGeneration;
        trailMesh?.Clear();
        meshHasRenderableData = false;
    }

    private void Awake()
    {
        trailMesh = new Mesh();
        const string MESH_NAME = "WheelTrailRendererMesh";
        trailMesh.name = MESH_NAME;
        trailMesh.MarkDynamic();
    }

    private void OnDisable()
    {
        Clear();
    }

    private void OnDestroy()
    {
        CompleteScheduledMeshJob();

        DisposeNativeMeshBuffers();

        if (trailMesh != null)
        {
            Destroy(trailMesh);
            trailMesh = null;
        }
    }

    private void LateUpdate()
    {
        if (meshJobScheduled)
        {
            CompleteScheduledMeshJob();
            ApplyCompletedMeshJob();
        }

        bool isGrounded = wheelCollider.GetGroundHit(out WheelHit wheelHit);

        if (isGrounded)
        {
            if (TryEmitPoint(wheelHit.point + groundContactOffset, wheelHit.normal))
            {
                RemoveExpiredPoints();
                RebuildMesh();
            }
        }
        else
        {
            // Force a discontinuity when emission starts again.
            hasLastEmitPosition = false;
            shouldStartNewSegment = true;
        }

        if (meshHasRenderableData)
            Graphics.DrawMesh(
                trailMesh,
                Matrix4x4.identity,
                trailMaterial,
                gameObject.layer,
                null,
                0,
                null,
                ShadowCastingMode.Off,
                false
            );
    }

    private bool TryEmitPoint(Vector3 position, Vector3 normal)
    {
        if (!IsFiniteVector3(position) || !IsFiniteVector3(normal))
            return false;

        if (!hasLastEmitPosition)
        {
            AppendPoint(position, normal);
            return true;
        }

        float movedDistance = Vector3.Distance(lastEmitPosition, position);
        if (movedDistance < minVertexDistance)
            return false;

        AppendPoint(position, normal);
        return true;
    }

    private void AppendPoint(Vector3 position, Vector3 normal)
    {
        normal = normal.normalized;
        if (normal.sqrMagnitude < 0.000001f)
            normal = Vector3.up;

        float distanceFromStart = 0f;
        int lastIndex = points.Count - 1;
        if (lastIndex >= 0)
        {
            distanceFromStart = points[lastIndex].distanceFromStart + Vector3.Distance(points[lastIndex].position, position);
            if (!float.IsFinite(distanceFromStart))
                distanceFromStart = points[lastIndex].distanceFromStart;
        }

        points.Add(new TrailPoint
        {
            position = position,
            normal = normal,
            distanceFromStart = distanceFromStart,
            startsNewSegment = shouldStartNewSegment || points.Count == 0
        });

        lastEmitPosition = position;
        hasLastEmitPosition = true;
        shouldStartNewSegment = false;
    }

    private void RemoveExpiredPoints()
    {
        if (points.Count == 0)
        {
            hasLastEmitPosition = false;
            shouldStartNewSegment = true;
            return;
        }

        float newestDistance = points[points.Count - 1].distanceFromStart;
        float oldestAllowedDistance = newestDistance - maxDistance;
        while (points.Count > 0 && points[0].distanceFromStart < oldestAllowedDistance)
            points.RemoveAt(0);

        hasLastEmitPosition = points.Count > 0;
        if (points.Count > 0)
        {
            TrailPoint firstPoint = points[0];
            firstPoint.startsNewSegment = true;
            points[0] = firstPoint;
        }
        else
            shouldStartNewSegment = true;
    }

    private void RebuildMesh()
    {
        lock (rebuildStateLock)
            if (rebuildInProgress || meshJobScheduled)
                return;

        if (points.Count < 2)
        {
            trailMesh.Clear();
            meshHasRenderableData = false;
            return;
        }

        int pointCount = points.Count;
        int vertexCount = pointCount * 2;
        int maxIndexCount = (pointCount - 1) * 6;
        EnsureNativeSnapshotCapacity(pointCount);
        EnsureNativeJobMetadataCapacity();
        EnsureNativeWorkerCapacity(vertexCount, maxIndexCount, generateLightingData);

        float newestDistance = points[pointCount - 1].distanceFromStart;
        float safeUvSegmentLength = Mathf.Max(0.001f, uvSegmentLength);
        for (int pointIndex = 0; pointIndex < pointCount; pointIndex++)
        {
            TrailPoint point = points[pointIndex];
            nativeSnapshotPoints[pointIndex] = point;
            float distanceFromNewest = newestDistance - point.distanceFromStart;
            float age01 = maxDistance > 0.0001f ? Mathf.Clamp01(distanceFromNewest / maxDistance) : 1f;
            float life01 = 1f - age01;

            float width = widthCurve.Evaluate(life01) * widthMultiplier;
            if (!float.IsFinite(width))
                width = 0f;
            nativeSnapshotWidths[pointIndex] = width;
            nativeSnapshotColors[pointIndex] = colorGradient.Evaluate(age01);

            float uCoordinate = 0f;
            if (textureMode == LineTextureMode.RepeatPerSegment)
                uCoordinate = pointIndex;
            else if (textureMode == LineTextureMode.Tile)
                uCoordinate = point.distanceFromStart / safeUvSegmentLength;
            else
                uCoordinate = newestDistance > 0.0001f ? point.distanceFromStart / newestDistance : 0f;
            nativeSnapshotUCoordinates[pointIndex] = uCoordinate;
        }

        int generation;
        lock (rebuildStateLock)
        {
            rebuildGeneration += 1;
            activeRebuildGeneration = rebuildGeneration;
            rebuildInProgress = true;
            generation = activeRebuildGeneration;
        }

        MeshBuildJob meshBuildJob = new MeshBuildJob
        {
            PointCount = pointCount,
            IncludeLightingData = generateLightingData,
            Points = nativeSnapshotPoints,
            Widths = nativeSnapshotWidths,
            Colors = nativeSnapshotColors,
            UCoordinates = nativeSnapshotUCoordinates,
            Vertices = nativeVertices,
            Normals = generateLightingData ? nativeNormals : nativeNormalsEmpty,
            OutColors = nativeVertexColors,
            Uvs = nativeUvs,
            Indices = nativeIndices,
            OutIndexCount = nativeMeshIndexCount,
            OutHasInvalidData = nativeMeshHasInvalidData
        };

        scheduledMeshJobGeneration = generation;
        scheduledMeshPointCount = pointCount;
        pendingMeshJobHandle = meshBuildJob.Schedule();
        meshJobScheduled = true;
    }

    private void CompleteScheduledMeshJob()
    {
        if (!meshJobScheduled)
            return;

        pendingMeshJobHandle.Complete();
        meshJobScheduled = false;
    }

    private void ApplyCompletedMeshJob()
    {
        bool generationMismatch;
        lock (rebuildStateLock)
            generationMismatch = scheduledMeshJobGeneration != activeRebuildGeneration;

        try
        {
            if (generationMismatch)
                return;

            if (trailMesh == null)
                return;

            bool hasInvalidData = nativeMeshHasInvalidData[0] != 0;
            int indexCount = nativeMeshIndexCount[0];
            bool hasRenderableData = indexCount > 0;
            int vertexCount = scheduledMeshPointCount * 2;

            if (hasInvalidData)
            {
                trailMesh.Clear();
                meshHasRenderableData = false;
                return;
            }

            trailMesh.Clear();
            trailMesh.SetVertices(nativeVertices, 0, vertexCount);

            if (generateLightingData && nativeNormals.IsCreated && nativeNormals.Length >= vertexCount)
                trailMesh.SetNormals(nativeNormals, 0, vertexCount);
            else
                trailMesh.SetNormals(emptyNormals);

            trailMesh.SetColors(nativeVertexColors, 0, vertexCount);
            trailMesh.SetUVs(0, nativeUvs, 0, vertexCount);
            trailMesh.SetIndices(nativeIndices, 0, indexCount, MeshTopology.Triangles, 0, true);
            trailMesh.RecalculateBounds();
            meshHasRenderableData = hasRenderableData;
        }
        finally
        {
            lock (rebuildStateLock)
                rebuildInProgress = false;
        }
    }

    private void DisposeNativeMeshBuffers()
    {
        DisposeIfCreated(ref nativeSnapshotPoints);
        DisposeIfCreated(ref nativeSnapshotWidths);
        DisposeIfCreated(ref nativeSnapshotColors);
        DisposeIfCreated(ref nativeSnapshotUCoordinates);
        DisposeIfCreated(ref nativeVertices);
        DisposeIfCreated(ref nativeNormals);
        DisposeIfCreated(ref nativeNormalsEmpty);
        DisposeIfCreated(ref nativeVertexColors);
        DisposeIfCreated(ref nativeUvs);
        DisposeIfCreated(ref nativeIndices);
        DisposeIfCreated(ref nativeMeshIndexCount);
        DisposeIfCreated(ref nativeMeshHasInvalidData);
    }

    private void EnsureNativeJobMetadataCapacity()
    {
        if (nativeMeshIndexCount.IsCreated)
            return;

        nativeMeshIndexCount = new NativeArray<int>(1, Allocator.Persistent);
        nativeMeshHasInvalidData = new NativeArray<byte>(1, Allocator.Persistent);
        nativeNormalsEmpty = new NativeArray<Vector3>(0, Allocator.Persistent);
    }

    private void EnsureNativeSnapshotCapacity(int pointCount)
    {
        int currentCapacity = nativeSnapshotPoints.IsCreated ? nativeSnapshotPoints.Length : 0;
        if (nativeSnapshotPoints.IsCreated && currentCapacity >= pointCount)
            return;

        int newCapacity = GetNextCapacity(currentCapacity, pointCount);
        DisposeIfCreated(ref nativeSnapshotPoints);
        DisposeIfCreated(ref nativeSnapshotWidths);
        DisposeIfCreated(ref nativeSnapshotColors);
        DisposeIfCreated(ref nativeSnapshotUCoordinates);

        nativeSnapshotPoints = new NativeArray<TrailPoint>(newCapacity, Allocator.Persistent);
        nativeSnapshotWidths = new NativeArray<float>(newCapacity, Allocator.Persistent);
        nativeSnapshotColors = new NativeArray<Color>(newCapacity, Allocator.Persistent);
        nativeSnapshotUCoordinates = new NativeArray<float>(newCapacity, Allocator.Persistent);
    }

    private void EnsureNativeWorkerCapacity(int vertexCount, int indexCount, bool includeLightingData)
    {
        int currentVertexCapacity = nativeVertices.IsCreated ? nativeVertices.Length : 0;
        if (!nativeVertices.IsCreated || currentVertexCapacity < vertexCount)
        {
            int newVertexCapacity = GetNextCapacity(currentVertexCapacity, vertexCount);
            DisposeIfCreated(ref nativeVertices);
            DisposeIfCreated(ref nativeVertexColors);
            DisposeIfCreated(ref nativeUvs);
            DisposeIfCreated(ref nativeNormals);

            nativeVertices = new NativeArray<Vector3>(newVertexCapacity, Allocator.Persistent);
            nativeVertexColors = new NativeArray<Color>(newVertexCapacity, Allocator.Persistent);
            nativeUvs = new NativeArray<Vector2>(newVertexCapacity, Allocator.Persistent);
            nativeNormals = new NativeArray<Vector3>(includeLightingData ? newVertexCapacity : 0, Allocator.Persistent);
        }
        else if (includeLightingData && (!nativeNormals.IsCreated || nativeNormals.Length < vertexCount))
        {
            DisposeIfCreated(ref nativeNormals);
            int newNormalCapacity = GetNextCapacity(0, vertexCount);
            nativeNormals = new NativeArray<Vector3>(newNormalCapacity, Allocator.Persistent);
        }
        else if (!includeLightingData && nativeNormals.IsCreated && nativeNormals.Length > 0)
        {
            DisposeIfCreated(ref nativeNormals);
            nativeNormals = new NativeArray<Vector3>(0, Allocator.Persistent);
        }

        int currentIndexCapacity = nativeIndices.IsCreated ? nativeIndices.Length : 0;
        if (!nativeIndices.IsCreated || currentIndexCapacity < indexCount)
        {
            int newIndexCapacity = GetNextCapacity(currentIndexCapacity, indexCount);
            DisposeIfCreated(ref nativeIndices);
            nativeIndices = new NativeArray<int>(newIndexCapacity, Allocator.Persistent);
        }
    }

    private static int GetNextCapacity(int currentCapacity, int requiredCapacity)
    {
        int nextCapacity = currentCapacity > 0 ? currentCapacity : 8;
        while (nextCapacity < requiredCapacity)
            nextCapacity *= 2;

        return nextCapacity;
    }

    private static void DisposeIfCreated<T>(ref NativeArray<T> array) where T : unmanaged
    {
        if (!array.IsCreated)
            return;

        array.Dispose();
    }

    private static bool IsFiniteVector3(Vector3 vector) => float.IsFinite(vector.x) && float.IsFinite(vector.y) && float.IsFinite(vector.z);
}
Features
  • Single script to add onto WheelCollider
  • Places points on wheel ground contact. If jumped - straight cut gap in trail
  • Removes old points based on distance. Allowing 1KM+ long trails to exist
  • Optimized Unity Burst Jobs, 0 GC allocations
  • Stores position + direction for each point, to render accurate bending of trail

https://github.com/user-attachments/assets/827f454e-001b-4f51-af14-7a1f16a01d14

Issues in built-in TrailRenderer
  • Start and end of trail has triangle merging 2 points into 1. Instead of desired straights cut (see screenshot below, 2 trails)
  • After jump - end or start of trail slightly points upwards from ground. Making visible triangle barely detach from ground (on same screenshot, barely visible bend upwards)
  • All points follow same Z direction. Making impossible to closely follow terrain shapes (on same screenshot, see both trails have rotation misaligned from ground. causing right-side trail to clip into terrain. while left-side hovering over terrain)

https://github.com/user-attachments/assets/f8ab4076-2eca-4c7a-bc22-fd814c555e8c

https://github.com/user-attachments/assets/f7913772-5fa8-464b-94c1-3ec73581418b