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.
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();
}
}