#51 WheelTrailRenderer

image
WheelTrailRenderer.cs, WheelColliderTrailContactProvider.cs, IWheelContactProvider.cs, WheelTrailMeshBuildJob.cs

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.

WheelTrailRenderer gets points from IWheelContactProvider (optional example WheelColliderTrailContactProvider using built-in WheelCollider.GetGroundHit). Mesh build via Unity Burst Jobs

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

namespace WheelTrail
{
    /// <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]
    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 MonoBehaviour wheelContactProviderBehaviour;
        [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 const int MinRenderablePointCount = 2;

        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 IWheelContactProvider wheelContactProvider;

        public void Clear()
        {
            CompleteScheduledMeshJob();

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

        private void Awake()
        {
            if (!ResolveWheelContactProvider())
            {
                Debug.LogError($"{nameof(WheelTrailRenderer)} on '{name}' could not find an {nameof(IWheelContactProvider)} on this object or children. Disabling component.", this);
                enabled = false;
                return;
            }

            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 bool ResolveWheelContactProvider()
        {
            wheelContactProvider = null;

            if (wheelContactProviderBehaviour is IWheelContactProvider provider)
            {
                wheelContactProvider = provider;
                return true;
            }

            foreach (var monoBehaviour in GetComponentsInChildren<MonoBehaviour>(true))
            {
                if (monoBehaviour == null) continue;
                if (monoBehaviour == this) continue;
                if (monoBehaviour is not IWheelContactProvider contactProvider) continue;

                wheelContactProviderBehaviour = monoBehaviour;
                wheelContactProvider = contactProvider;
                return true;
            }

            return false;
        }

        private void LateUpdate()
        {
            FinishPendingMeshJob();
            CollectTrailData();
            RenderTrail();
        }

        private bool EmitPoint(Vector3 position, Vector3 normal)
        {
            if (!WheelTrailMath.IsFiniteVector3(position) || !WheelTrailMath.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 = WheelTrailMath.GetSafeNormalOrUp(normal);

            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 RemovePointsBeyondMaxDistance()
        {
            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)
            {
                // After trimming old points, force a seam so triangles are not bridged across removed history.
                TrailPoint firstPoint = points[0];
                firstPoint.startsNewSegment = true;
                points[0] = firstPoint;
            }
            else
                shouldStartNewSegment = true;
        }

        private void ScheduleMeshRebuild()
        {
            if (!CanStartRebuild())
                return;

            if (points.Count < MinRenderablePointCount)
            {
                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);
            PopulateSnapshotData(pointCount);

            int generation = BeginRebuildGeneration();

            WheelTrailMeshBuildJob wheelTrailMeshBuildJob = new WheelTrailMeshBuildJob
            {
                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 = wheelTrailMeshBuildJob.Schedule();
            meshJobScheduled = true;
        }

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

            pendingMeshJobHandle.Complete();
            meshJobScheduled = false;
        }

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

            try
            {
                // Drop late job results if a newer rebuild generation is already active.
                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 void FinishPendingMeshJob()
        {
            if (!meshJobScheduled)
                return;

            CompleteScheduledMeshJob();
            ApplyCompletedMeshJob();
        }

        private void CollectTrailData()
        {
            if (!wheelContactProvider.GetWheelContact(out Vector3 point, out Vector3 normal))
            {
                ResetTrailSegmentContinuity();
                return;
            }

            if (!EmitPoint(point + groundContactOffset, normal))
                return;

            RemovePointsBeyondMaxDistance();
            ScheduleMeshRebuild();
        }

        private void ResetTrailSegmentContinuity()
        {
            // Force a discontinuity when emission starts again.
            hasLastEmitPosition = false;
            shouldStartNewSegment = true;
        }

        private void RenderTrail()
        {
            if (!meshHasRenderableData)
                return;

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

        private bool CanStartRebuild()
        {
            lock (rebuildStateLock)
                return !rebuildInProgress && !meshJobScheduled;
        }

        private int BeginRebuildGeneration()
        {
            lock (rebuildStateLock)
            {
                rebuildGeneration += 1;
                activeRebuildGeneration = rebuildGeneration;
                rebuildInProgress = true;
                return activeRebuildGeneration;
            }
        }

        private void PopulateSnapshotData(int pointCount)
        {
            float newestDistance = points[pointCount - 1].distanceFromStart;
            float safeUvSegmentLength = Mathf.Max(WheelTrailMath.MinUvSegmentLength, uvSegmentLength);
            for (int pointIndex = 0; pointIndex < pointCount; pointIndex++)
            {
                TrailPoint point = points[pointIndex];
                nativeSnapshotPoints[pointIndex] = point;

                float age01 = ComputeAge01(newestDistance, point.distanceFromStart);
                float life01 = 1f - age01;
                float width = widthCurve.Evaluate(life01) * widthMultiplier;
                if (!float.IsFinite(width))
                    width = 0f;

                nativeSnapshotWidths[pointIndex] = width;
                nativeSnapshotColors[pointIndex] = colorGradient.Evaluate(age01);
                nativeSnapshotUCoordinates[pointIndex] = ComputeUCoordinate(pointIndex, point.distanceFromStart, newestDistance, safeUvSegmentLength);
            }
        }

        private float ComputeAge01(float newestDistance, float pointDistanceFromStart)
        {
            float distanceFromNewest = newestDistance - pointDistanceFromStart;
            if (maxDistance <= WheelTrailMath.MinDistanceEpsilon)
                return 1f;

            return Mathf.Clamp01(distanceFromNewest / maxDistance);
        }

        private float ComputeUCoordinate(int pointIndex, float pointDistanceFromStart, float newestDistance, float safeUvSegmentLength)
        {
            if (textureMode == LineTextureMode.RepeatPerSegment)
                return pointIndex;

            if (textureMode == LineTextureMode.Tile)
                return pointDistanceFromStart / safeUvSegmentLength;

            if (newestDistance <= WheelTrailMath.MinDistanceEpsilon)
                return 0f;

            return pointDistanceFromStart / newestDistance;
        }
    }
}
WheelTrailMeshBuildJob.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

namespace WheelTrail
{
    internal struct TrailPoint
    {
        public Vector3 position;
        public Vector3 normal;
        public float distanceFromStart;
        public bool startsNewSegment;
    }

    internal static class WheelTrailMath
    {
        public const float MinVectorSqrMagnitude = 0.000001f;
        public const float MinDistanceEpsilon = 0.0001f;
        public const float MinUvSegmentLength = 0.001f;

        public static bool IsFiniteVector3(Vector3 vector)
        {
            return float.IsFinite(vector.x) && float.IsFinite(vector.y) && float.IsFinite(vector.z);
        }

        public static Vector3 GetSafeNormalOrUp(Vector3 normal)
        {
            if (!IsFiniteVector3(normal) || normal.sqrMagnitude < MinVectorSqrMagnitude)
                return Vector3.up;

            return normal.normalized;
        }
    }

    [BurstCompile]
    internal struct WheelTrailMeshBuildJob : 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 = WheelTrailMath.GetSafeNormalOrUp(point.normal);
                Vector3 trailTangent = ComputeTrailTangent(Points, pointIndex, pointCount);
                Vector3 alongTrail = Vector3.ProjectOnPlane(trailTangent, normal);
                if (alongTrail.sqrMagnitude < WheelTrailMath.MinVectorSqrMagnitude)
                {
                    alongTrail = Vector3.ProjectOnPlane(Vector3.forward, normal);
                    if (alongTrail.sqrMagnitude < WheelTrailMath.MinVectorSqrMagnitude)
                        alongTrail = Vector3.ProjectOnPlane(Vector3.right, normal);
                }
                alongTrail.Normalize();

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

                Vector3 leftWorld = point.position - widthDirection * halfWidth;
                Vector3 rightWorld = point.position + widthDirection * halfWidth;
                if (!WheelTrailMath.IsFiniteVector3(leftWorld) || !WheelTrailMath.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;
        }
    }
}
WheelColliderTrailContactProvider.cs
using UnityEngine;

namespace WheelTrail
{
    [DisallowMultipleComponent]
    [RequireComponent(typeof(WheelCollider))]
    public class WheelColliderTrailContactProvider : MonoBehaviour, IWheelContactProvider
    {
        [SerializeField] private WheelCollider wheelCollider;

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

        public bool GetWheelContact(out Vector3 point, out Vector3 normal)
        {
            if (wheelCollider == null)
            {
                point = default;
                normal = Vector3.up;
                return false;
            }

            bool isGrounded = wheelCollider.GetGroundHit(out WheelHit wheelHit);
            point = wheelHit.point;
            normal = wheelHit.normal;
            return isGrounded;
        }
    }
}
IWheelContactProvider.cs
using UnityEngine;

namespace WheelTrail
{
    public interface IWheelContactProvider
    {
        bool GetWheelContact(out Vector3 point, out Vector3 normal);
    }
}
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
image
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)
image
image