Player/TyreMarks.cs

TyreMarks component that renders skid/tire marks behind the rear wheels when the car is drifting. It traces wheels to the ground, samples points into trail segments, prunes by lifetime and count, and renders quad-strip meshes in a SceneCustomObject. Also includes a movie capturer to record playback properties.

Native Interop
using Machines.Events;
using Sandbox.MovieMaker;
using System;

namespace Machines.Player;

/// <summary>
/// Renders skid marks behind the rear wheels while drifting.
/// </summary>
public sealed class TyreMarks : Component, ICarImpactListener, Component.ExecuteInEditor
{
	[RequireComponent]
	public Car Car { get; private set; }

	[Property, Group( "Appearance" )]
	public Material Material { get; set; }

	[Property, Group( "Appearance" )]
	public Color MarkColor { get; set; } = Color.Black.WithAlpha( 0.8f );

	[Property, Group( "Appearance" )]
	public float TyreWidth { get; set; } = 6f;

	[Property, Group( "Appearance" )]
	public float GroundOffset { get; set; } = 0.5f;

	[Property, Group( "Trail" )]
	public float Lifetime { get; set; } = 8f;

	[Property, Group( "Trail" )]
	public float MinSegmentLength { get; set; } = 3f;

	[Property, Group( "Trail" )]
	public int MaxPointsPerWheel { get; set; } = 512;

	[Property, Group( "Threshold" )]
	public float MinSpeed { get; set; } = 30f;

	[Property, Group( "Threshold" )]
	public int FadeInPoints { get; set; } = 3;

	private readonly List<List<TrailPoint>> _leftSegments = new();
	private readonly List<List<TrailPoint>> _rightSegments = new();
	private bool _wasEmitting;
	private TyreMarkSceneObject _sceneObject;
	private GameObject _wheelBL;
	private GameObject _wheelBR;
	private int _leftTotal;
	private int _rightTotal;
	private Vector3 _lastPosition;
	private Vector3 _derivedVelocityDir;
	private bool _localIsGrounded;

	#region Movie Properties

	// These are used for movie recording / playback

	public bool IsEmitting { get; set; }
	public Vector3 LocalLeftWheelPos { get; set; }
	public Vector3 LocalRightWheelPos { get; set; }

	#endregion

	private struct TrailPoint
	{
		public Vector3 Position;
		public Vector3 Right;
		public float CreatedAt;
	}

	protected override void OnEnabled()
	{
		_sceneObject = new TyreMarkSceneObject( this, Scene.SceneWorld );
	}

	protected override void OnDisabled()
	{
		_sceneObject?.Delete();
		_sceneObject = null;
		_leftSegments.Clear();
		_rightSegments.Clear();
		_leftTotal = 0;
		_rightTotal = 0;
	}

	protected override void OnStart()
	{
		ResolveWheels();
	}

	private void ResolveWheels()
	{
		if ( !Car.IsValid() || !Car.Resource.IsValid() || !Car.Renderer.IsValid() )
			return;

		_wheelBL = Car.Renderer.GetBoneObject( Car.Resource.WheelBLBone );
		_wheelBR = Car.Renderer.GetBoneObject( Car.Resource.WheelBRBone );
	}

	protected override void OnUpdate()
	{
		PruneOldSegments();
		UpdateVelocity();
		UpdateIsEmitting();

		if ( IsEmitting )
		{
			if ( !_wasEmitting )
			{
				// Start a new trail segment.
				_leftSegments.Add( new List<TrailPoint>() );
				_rightSegments.Add( new List<TrailPoint>() );
			}

			EmitPoints();
		}

		_wasEmitting = IsEmitting;
	}

	private void UpdateVelocity()
	{
		// Velocity direction from position delta (works on all clients).
		var pos = WorldPosition;
		var delta = pos - _lastPosition;
		if ( delta.WithZ( 0 ).Length > 0.5f )
			_derivedVelocityDir = delta.WithZ( 0 ).Normal;
		_lastPosition = pos;
	}

	private void UpdateIsEmitting()
	{
		// These might be false if we're playing back a movie, which would control IsEmitting anyway
		if ( !Car.IsValid() || !Car.Drift.IsValid() || Car.Movement == null )
			return;

		// Local ground probe.
		ProbeGround();

		IsEmitting = ShouldEmit();
	}

	private void ProbeGround()
	{
		var tr = Scene.Trace.Ray( WorldPosition + Vector3.Up * 10f, WorldPosition - Vector3.Up * 50f )
			.WithoutTags( "player", "car" )
			.Run();

		_localIsGrounded = tr.Hit;
	}

	private bool ShouldEmit()
	{
		if ( Car.Movement.CurrentSpeed < MinSpeed )
			return false;

		if ( !_localIsGrounded )
			return false;

		// Only emit while drifting (not during the hop).
		if ( Car.Drift.IsDrifting && !Car.Drift.IsHopping )
			return true;

		return false;
	}

	/// <summary>
	/// Call this to break the current trail (e.g. on collision or respawn).
	/// </summary>
	public void BreakTrail()
	{
		_wasEmitting = false;
	}

	public void OnCarImpact( CarImpact impact )
	{
		BreakTrail();
	}

	private void UpdateWheelPositions()
	{
		if ( _wheelBL.IsValid() )
		{
			LocalLeftWheelPos = WorldTransform.PointToLocal( _wheelBL.WorldPosition );
		}

		if ( _wheelBR.IsValid() )
		{
			LocalRightWheelPos = WorldTransform.PointToLocal( _wheelBR.WorldPosition );
		}
	}

	private void EmitPoints()
	{
		UpdateWheelPositions();

		// Use position-delta velocity; fall back to car yaw if not moving yet.
		var velocityDir = _derivedVelocityDir.LengthSquared > 0.01f
			? _derivedVelocityDir
			: Rotation.FromYaw( Car.Movement.Yaw ).Forward;
		var right = Vector3.Cross( velocityDir, Vector3.Up ).Normal;

		EmitWheel( _leftSegments, ref _leftTotal, LocalLeftWheelPos, right );
		EmitWheel( _rightSegments, ref _rightTotal, LocalRightWheelPos, right );
	}

	private void EmitWheel( List<List<TrailPoint>> segments, ref int total, Vector3 localWheelPos, Vector3 right )
	{
		if ( segments.Count <= 0 ) return;
		if ( !TraceToGround( WorldTransform.PointToWorld( localWheelPos ), out var groundPos ) ) return;

		var seg = segments[^1];

		if ( seg.Count > 0 && groundPos.Distance( seg[^1].Position ) < MinSegmentLength ) return;

		seg.Add( new TrailPoint { Position = groundPos, Right = right, CreatedAt = Time.Now } );
		total++;

		EnforceLimit( segments, ref total );
	}

	private bool TraceToGround( Vector3 wheelPos, out Vector3 groundPos )
	{
		var tr = Scene.Trace.Ray( wheelPos + Vector3.Up * 10f, wheelPos - Vector3.Up * 50f )
			.WithoutTags( "player", "car" )
			.Run();

		if ( tr.Hit )
		{
			groundPos = tr.HitPosition + tr.Normal * GroundOffset;
			return true;
		}

		groundPos = default;
		return false;
	}

	private void EnforceLimit( List<List<TrailPoint>> segments, ref int total )
	{
		while ( total > MaxPointsPerWheel && segments.Count > 0 )
		{
			var first = segments[0];
			if ( first.Count == 0 ) { segments.RemoveAt( 0 ); continue; }
			first.RemoveAt( 0 );
			total--;
			if ( first.Count == 0 ) segments.RemoveAt( 0 );
		}
	}

	private void PruneOldSegments()
	{
		float cutoff = Time.Now - Lifetime;
		PruneSegmentList( _leftSegments, cutoff, ref _leftTotal );
		PruneSegmentList( _rightSegments, cutoff, ref _rightTotal );
	}

	private static void PruneSegmentList( List<List<TrailPoint>> segments, float cutoff, ref int total )
	{
		while ( segments.Count > 0 )
		{
			var seg = segments[0];
			while ( seg.Count > 0 && seg[0].CreatedAt < cutoff )
			{
				seg.RemoveAt( 0 );
				total--;
			}
			if ( seg.Count == 0 )
				segments.RemoveAt( 0 );
			else
				break;
		}
	}

	/// <summary>
	/// Renders trail segments as quad strip meshes.
	/// </summary>
	private class TyreMarkSceneObject : SceneCustomObject
	{
		private readonly TyreMarks _owner;

		public TyreMarkSceneObject( TyreMarks owner, SceneWorld world ) : base( world )
		{
			_owner = owner;
			Bounds = BBox.FromPositionAndSize( Vector3.Zero, float.MaxValue );
			Flags.CastShadows = false;
		}

		public override void RenderSceneObject()
		{
			if ( !_owner.IsValid() || _owner.Material == null )
				return;

			var leftSegs = _owner._leftSegments;
			var rightSegs = _owner._rightSegments;

			if ( leftSegs.Count == 0 && rightSegs.Count == 0 )
				return;

			var vb = new VertexBuffer();
			vb.Init( true );

			int vertCount = 0;

			foreach ( var seg in leftSegs )
				vertCount += BuildTrailStrip( vb, seg, vertCount );

			foreach ( var seg in rightSegs )
				vertCount += BuildTrailStrip( vb, seg, vertCount );

			if ( vertCount >= 4 )
				vb.Draw( _owner.Material );
		}

		private int BuildTrailStrip( VertexBuffer vb, List<TrailPoint> points, int startVert )
		{
			if ( points.Count < 2 )
				return 0;

			float halfWidth = _owner.TyreWidth * 0.5f;
			float now = Time.Now;
			float lifetime = _owner.Lifetime;
			var baseColor = _owner.MarkColor;
			int fadeIn = _owner.FadeInPoints;

			for ( int i = 0; i < points.Count; i++ )
			{
				var pt = points[i];
				float age = now - pt.CreatedAt;
				float alpha = MathF.Max( 0f, 1f - (age / lifetime) );

				// Fade in at start and out at end of segment.
				float edgeFade = 1f;
				if ( i < fadeIn )
					edgeFade = (float)(i + 1) / (fadeIn + 1);
				else if ( i >= points.Count - fadeIn )
					edgeFade = (float)(points.Count - i) / (fadeIn + 1);

				alpha *= edgeFade;

				var color = (Color32)(baseColor.WithAlphaMultiplied( alpha ));
				var leftEdge = pt.Position - pt.Right * halfWidth;
				var rightEdge = pt.Position + pt.Right * halfWidth;

				float v = (float)i / (points.Count - 1);

				vb.Default.Normal = Vector3.Up;
				vb.Default.Tangent = new Vector4( pt.Right.x, pt.Right.y, pt.Right.z, 1f );
				vb.Default.Color = color;

				vb.Default.TexCoord0 = new Vector4( 0f, v, 0f, 0f );
				vb.Default.Position = leftEdge;
				vb.Add( vb.Default );

				vb.Default.TexCoord0 = new Vector4( 1f, v, 0f, 0f );
				vb.Default.Position = rightEdge;
				vb.Add( vb.Default );

				if ( i > 0 )
				{
					int baseIdx = startVert + (i - 1) * 2;
					vb.AddRawIndex( baseIdx );
					vb.AddRawIndex( baseIdx + 1 );
					vb.AddRawIndex( baseIdx + 2 );

					vb.AddRawIndex( baseIdx + 1 );
					vb.AddRawIndex( baseIdx + 3 );
					vb.AddRawIndex( baseIdx + 2 );
				}
			}

			return points.Count * 2;
		}
	}
}

file sealed class TyreMarksCapturer : ComponentCapturer<TyreMarks>
{
	[DefaultMovieRecorderOptions]
	private static MovieRecorderOptions BuildDefaultMovieRecorderOptions( MovieRecorderOptions options )
	{
		return options.WithCaptureAll<TyreMarks>();
	}

	protected override void OnCapture( IMovieTrackRecorder recorder, TyreMarks component )
	{
		recorder.Property( nameof(TyreMarks.Material) ).Capture();
		recorder.Property( nameof( TyreMarks.MarkColor ) ).Capture();
		recorder.Property( nameof( TyreMarks.TyreWidth ) ).Capture();
		recorder.Property( nameof( TyreMarks.GroundOffset ) ).Capture();
		recorder.Property( nameof( TyreMarks.Lifetime ) ).Capture();
		recorder.Property( nameof( TyreMarks.MinSegmentLength ) ).Capture();
		recorder.Property( nameof( TyreMarks.MaxPointsPerWheel ) ).Capture();
		recorder.Property( nameof( TyreMarks.MinSpeed ) ).Capture();
		recorder.Property( nameof( TyreMarks.FadeInPoints ) ).Capture();
		recorder.Property( nameof( TyreMarks.IsEmitting ) ).Capture();
		recorder.Property( nameof( TyreMarks.LocalLeftWheelPos ) ).Capture();
		recorder.Property( nameof( TyreMarks.LocalRightWheelPos ) ).Capture();
	}
}