Component that bakes and stores racing lines for a track. It generates an optimal center line from splines or navmesh+checkpoints, smooths it, builds left/right offsets, detects gap arcs, bakes camera spots at corners, and discovers sampled shortcut splines for bots.
using Sandbox.Navigation;
namespace Machines.Race;
/// <summary>
/// Stores baked racing lines; prefers spline paths, falls back to navmesh between checkpoints.
/// </summary>
public sealed partial class RacingPath : Component
{
/// <summary>
/// Chaikin corner-cutting iterations (more = smoother).
/// </summary>
[Property]
public int SmoothingIterations { get; set; } = 3;
/// <summary>
/// Lateral offset (in units) used to generate inside/outside racing lines.
/// </summary>
[Property]
public float LineOffset { get; set; } = 80f;
/// <summary>
/// Arc-length spacing for sampling spline paths into waypoints (smaller = more faithful, more points).
/// </summary>
[Property]
public float SplineSampleSpacing { get; set; } = 32f;
/// <summary>
/// Vertical offset applied to spline-sampled waypoints (lifts line to road surface when spline is below the mesh).
/// </summary>
[Property]
public float UpOffset { get; set; } = 0f;
/// <summary>
/// Tag that excludes a <see cref="SplineComponent"/> from the racing path.
/// </summary>
public const string NoPathTag = "no_path";
/// <summary>
/// Tag that marks a <see cref="SplineComponent"/> as a shortcut, baked separately from the main line.
/// </summary>
public const string ShortcutTag = "shortcut";
/// <summary>
/// Max endpoint distance before a navmesh-bridged gap section is inserted.
/// </summary>
[Property]
public float GapThreshold { get; set; } = 10f;
/// <summary>
/// Radius (units) for welding spline endpoints into a shared junction node when ordering multi-spline tracks.
/// </summary>
[Property]
public float JunctionRadius { get; set; } = 100f;
/// <summary>
/// Weight of "head toward the next checkpoint" versus "continue straight" when choosing a branch at a junction.
/// </summary>
[Property]
public float JunctionCheckpointBias { get; set; } = 0.35f;
/// <summary>
/// Path/straight-line ratio above which a gap is assumed and the path is replaced with an arc.
/// </summary>
[Property]
public float GapDetectionRatio { get; set; } = 2.0f;
/// <summary>
/// Max backward regression along start->end direction before a backtrack is detected.
/// </summary>
[Property]
public float BacktrackTolerance { get; set; } = 50f;
/// <summary>
/// Arc peak height as a fraction of horizontal gap distance (0.25 = 25% of gap width).
/// </summary>
[Property]
public float GapArcHeightRatio { get; set; } = 0.25f;
/// <summary>
/// Minimum arc peak height (units) for very short gaps.
/// </summary>
[Property]
public float MinGapArcHeight { get; set; } = 30f;
/// <summary>
/// Maximum arc peak height (units) for very long gaps.
/// </summary>
[Property]
public float MaxGapArcHeight { get; set; } = 300f;
/// <summary>
/// Number of interpolated segments in a gap-bridging arc.
/// </summary>
[Property]
public int GapArcSegments { get; set; } = 10;
/// <summary>
/// The optimal (center) racing line.
/// </summary>
public RacingLine Optimal { get; set; } = new();
/// <summary>
/// The left racing line (offset to the left of the path direction).
/// </summary>
public RacingLine Left { get; set; } = new();
/// <summary>
/// The right racing line (offset to the right of the path direction).
/// </summary>
public RacingLine Right { get; set; } = new();
/// <summary>
/// Baked camera spots at high-curvature corners, used by <see cref="Components.TracksideCamera"/>.
/// </summary>
public List<TrackCameraSpot> CameraSpots { get; set; } = new();
/// <summary>
/// Baked shortcut paths bots can take based on personality/difficulty.
/// </summary>
public List<ShortcutPath> Shortcuts { get; set; } = new();
/// <summary>
/// Reverse all baked racing lines (toggle and re-bake if bots go backwards).
/// </summary>
[Property]
public bool ReversePaths { get; set; } = false;
/// <summary>
/// Singleton access to the current track's racing path.
/// </summary>
public static RacingPath Current { get; private set; }
// Gap-bridge point flags for the smoothed optimal path (skip navmesh snap on these).
private List<bool> _isBridgePoint = new();
protected override void OnEnabled()
{
Current = this;
}
protected override void OnDisabled()
{
if ( Current == this )
Current = null;
}
protected override void OnStart()
{
// Bake fresh on start so bots have paths immediately.
BakePaths();
}
/// <summary>
/// Bake all racing lines (spline-first, navmesh+checkpoint fallback).
/// </summary>
[Button( "Bake Paths" )]
public void BakePaths()
{
var sw = System.Diagnostics.Stopwatch.StartNew();
// Baking helpers read tuning from this component's properties.
var bridger = new GapBridger( this );
// Spline-first; navmesh+checkpoint fallback when none contribute.
if ( new SplinePathBaker( this, bridger ).TryBake( out var spline ) )
{
Optimal = new RacingLine { Points = spline.Points };
Optimal.RebuildDistances();
Optimal.RebuildHints( Scene );
// Convert index-based segments to distance ranges now that distances are built.
Optimal.Segments = RacingLineSmoothing.BuildDistanceSegments( Optimal, spline.Sections );
// Mark bridge points so offset generation skips navmesh snapping on them.
_isBridgePoint = [.. new bool[spline.Points.Count]];
foreach ( var seg in spline.Sections )
{
if ( seg.IsGap )
{
for ( int i = seg.StartIndex; i < seg.EndIndex && i < _isBridgePoint.Count; i++ )
_isBridgePoint[i] = true;
}
}
// Inside/outside lines are pure geometric offsets.
Left = RacingLineSmoothing.BuildOffset( Scene, Optimal, _isBridgePoint, LineOffset, useNavMesh: false );
Right = RacingLineSmoothing.BuildOffset( Scene, Optimal, _isBridgePoint, -LineOffset, useNavMesh: false );
if ( ReversePaths )
ApplyReverse();
Log.Info( $"RacingPath: Using {spline.SplineCount} spline(s) for the racing path ({spline.Sections.Count( s => s.IsGap )} gap sections)." );
BakeCameraSpots();
BakeShortcuts();
sw.Stop();
Log.Info( $"RacingPath: Baked from splines in {sw.ElapsedMilliseconds}ms - {Optimal.Points.Count} optimal, " +
$"{Left.Points.Count} left, {Right.Points.Count} right. Total length: {Optimal.TotalLength:F0} units." );
return;
}
var checkpoints = Scene.GetAll<Checkpoint>().OrderBy( c => c.Index ).ToList();
if ( checkpoints.Count < 2 )
{
Log.Warning( "RacingPath: Need at least 2 checkpoints to bake paths." );
return;
}
if ( Scene.NavMesh == null )
{
Log.Warning( "RacingPath: No NavMesh found in scene. Build the navmesh first." );
return;
}
// Optimal line
var optimalPoints = bridger.BakeOptimalPath( checkpoints, out var bridgeFlags );
if ( optimalPoints.Count < 2 )
{
Log.Warning( "RacingPath: Failed to bake optimal path. Check navmesh coverage." );
return;
}
// Smooth (propagates bridge flags through subdivision).
Optimal = new RacingLine { Points = RacingLineSmoothing.Smooth( optimalPoints, ref bridgeFlags, SmoothingIterations ) };
Optimal.RebuildDistances();
Optimal.RebuildHints( Scene );
_isBridgePoint = bridgeFlags;
// Lateral offsets for inside/outside lines.
Left = RacingLineSmoothing.BuildOffset( Scene, Optimal, _isBridgePoint, LineOffset );
Right = RacingLineSmoothing.BuildOffset( Scene, Optimal, _isBridgePoint, -LineOffset );
if ( ReversePaths )
ApplyReverse();
BakeCameraSpots();
BakeShortcuts();
sw.Stop();
Log.Info( $"RacingPath: Baked in {sw.ElapsedMilliseconds}ms - {Optimal.Points.Count} optimal, " +
$"{Left.Points.Count} left, {Right.Points.Count} right. " +
$"Total length: {Optimal.TotalLength:F0} units." );
}
/// <summary>
/// Drivable half-width at the given arc distance (min of left/right offset distances).
/// </summary>
public float GetHalfWidthAtDistance( float distance )
{
if ( Optimal == null || !Optimal.IsValid )
return LineOffset;
var p = Optimal.GetPointAtDistance( distance );
var left = NearestFlatDistance( Left, p );
var right = NearestFlatDistance( Right, p );
return MathF.Min( left, right );
}
/// <summary>
/// Drivable room to each side of the optimal line split by perpendicular direction.
/// </summary>
public void GetSideRoomAtDistance( float distance, out float plusPerpRoom, out float minusPerpRoom )
{
if ( Optimal == null || !Optimal.IsValid )
{
plusPerpRoom = minusPerpRoom = LineOffset;
return;
}
var p = Optimal.GetPointAtDistance( distance );
// Left = +LineOffset along Cross(dir, Up); Right = -LineOffset.
plusPerpRoom = NearestFlatDistance( Left, p );
minusPerpRoom = NearestFlatDistance( Right, p );
}
private float NearestFlatDistance( RacingLine line, Vector3 pos )
{
if ( line == null || !line.IsValid )
return LineOffset;
var d = line.GetDistanceAtPosition( pos );
var closest = line.GetPointAtDistance( d );
var dist = (closest - pos).WithZ( 0f ).Length;
return dist > 1f ? dist : LineOffset;
}
/// <summary>
/// Reverse all racing lines in place; called when <see cref="ReversePaths"/> is set.
/// </summary>
private void ApplyReverse()
{
if ( Optimal?.IsValid == true )
{
Optimal.Points.Reverse();
Optimal.RebuildDistances();
ReverseSegments( Optimal );
}
if ( Left?.IsValid == true )
{
Left.Points.Reverse();
Left.RebuildDistances();
}
if ( Right?.IsValid == true )
{
Right.Points.Reverse();
Right.RebuildDistances();
}
Log.Info( "RacingPath: Reversed all paths." );
}
/// <summary>
/// Max number of trackside camera spots baked at corners.
/// </summary>
private const int MaxCameraSpots = 32;
/// <summary>
/// Lateral offset from the racing line to the camera position (outside of corner).
/// </summary>
private const float CameraLateralOffset = 150f;
/// <summary>
/// Height (units) above the track surface to place the camera.
/// </summary>
private const float CameraHeight = 80f;
/// <summary>
/// Min arc-distance between two baked camera spots (prevents clustering at long corners).
/// </summary>
private const float CameraMinSeparation = 200f;
/// <summary>
/// Bake camera spots at the highest-curvature corners, offset outside and elevated.
/// </summary>
private void BakeCameraSpots()
{
CameraSpots.Clear();
if ( Optimal == null || !Optimal.IsValid || Optimal.Curvatures.Count == 0 )
return;
// Candidates: curvature samples above the corner threshold.
var candidates = new List<(float Distance, float Curvature)>();
for ( int i = 0; i < Optimal.Curvatures.Count; i++ )
{
var k = Optimal.Curvatures[i];
if ( k >= RacingLine.CornerCurvatureThreshold )
candidates.Add( (i * RacingLine.CurvatureStep, k) );
}
if ( candidates.Count == 0 )
{
// No corners: distribute cameras evenly.
var spacing = Optimal.TotalLength / MaxCameraSpots;
for ( int i = 0; i < MaxCameraSpots; i++ )
{
var d = i * spacing;
CameraSpots.Add( BuildCameraSpot( d ) );
}
Log.Info( $"RacingPath: Baked {CameraSpots.Count} camera spots (evenly spaced, no corners detected)." );
return;
}
// Pick top N by curvature with minimum separation.
candidates.Sort( ( a, b ) => b.Curvature.CompareTo( a.Curvature ) );
var selectedDistances = new List<float>();
foreach ( var (dist, _) in candidates )
{
if ( selectedDistances.Count >= MaxCameraSpots )
break;
// Minimum separation check (wrap-aware).
bool tooClose = false;
foreach ( var existing in selectedDistances )
{
var gap = MathF.Abs( dist - existing );
gap = MathF.Min( gap, Optimal.TotalLength - gap );
if ( gap < CameraMinSeparation )
{
tooClose = true;
break;
}
}
if ( !tooClose )
selectedDistances.Add( dist );
}
// Sort by distance for orderly storage.
selectedDistances.Sort();
foreach ( var d in selectedDistances )
CameraSpots.Add( BuildCameraSpot( d ) );
Log.Info( $"RacingPath: Baked {CameraSpots.Count} camera spots at corners." );
}
/// <summary>
/// Build a single camera spot at arc distance, offset outside the corner and elevated.
/// </summary>
private TrackCameraSpot BuildCameraSpot( float distance )
{
var point = Optimal.GetPointAtDistance( distance );
var tangent = Optimal.GetTangentAtDistance( distance );
// Flat rightward perpendicular.
var right = Vector3.Cross( tangent, Vector3.Up ).Normal;
// Determine outside of corner from curvature direction.
var ahead = Optimal.GetPointAtDistance( distance + 50f );
var behind = Optimal.GetPointAtDistance( distance - 50f );
var chord = (ahead - behind).WithZ( 0f ).Normal;
var toCenter = (Optimal.GetPointAtDistance( distance ) - (behind + ahead) * 0.5f).WithZ( 0f );
// Camera on the opposite side from curve center (outside).
var side = Vector3.Dot( toCenter, right ) > 0f ? -1f : 1f;
var offset = right * side * CameraLateralOffset;
var camPos = point + offset + Vector3.Up * CameraHeight;
return new TrackCameraSpot( camPos, distance );
}
/// <summary>
/// Reverse segment distance ranges to match a reversed point list.
/// </summary>
private static void ReverseSegments( RacingLine line )
{
if ( line.Segments.Count == 0 )
return;
var total = line.TotalLength;
foreach ( var seg in line.Segments )
{
var oldStart = seg.StartDistance;
var oldEnd = seg.EndDistance;
seg.StartDistance = total - oldEnd;
seg.EndDistance = total - oldStart;
}
line.Segments.Reverse();
}
/// <summary>
/// Discover "shortcut"-tagged splines, sample them, and match endpoints to the optimal line.
/// </summary>
private void BakeShortcuts()
{
Shortcuts.Clear();
if ( Optimal == null || !Optimal.IsValid )
return;
var shortcutSplines = Scene.GetAll<SplineComponent>()
.Where( s => s.Active
&& s.Spline != null
&& s.Spline.Length > 1f
&& s.GameObject.Tags.Has( ShortcutTag ) )
.ToList();
foreach ( var sc in shortcutSplines )
{
var points = SampleShortcutSpline( sc );
if ( points.Count < 2 )
continue;
var entryDist = Optimal.GetDistanceAtPosition( points[0] );
var exitDist = Optimal.GetDistanceAtPosition( points[^1] );
// If exitDist is behind entryDist on the loop, the shortcut is reversed.
var forwardDist = exitDist - entryDist;
if ( forwardDist < 0f )
forwardDist += Optimal.TotalLength;
// Covers >50% of track going forward -> likely backwards.
if ( forwardDist > Optimal.TotalLength * 0.5f )
{
points.Reverse();
(entryDist, exitDist) = (exitDist, entryDist);
forwardDist = exitDist - entryDist;
if ( forwardDist < 0f )
forwardDist += Optimal.TotalLength;
}
// Total shortcut length
var totalLen = 0f;
for ( int i = 1; i < points.Count; i++ )
totalLen += (points[i] - points[i - 1]).Length;
// Read risk/difficulty from optional ShortcutInfo.
var info = sc.GetComponent<ShortcutInfo>();
var risk = info?.RiskFactor ?? 0.5f;
var minDiff = info?.MinDifficulty ?? Player.BotDifficulty.Medium;
var shortcut = new ShortcutPath
{
Points = points,
EntryDistance = entryDist,
ExitDistance = exitDist,
TotalLength = totalLen,
RiskFactor = risk,
MinDifficulty = minDiff
};
// Build a RacingLine so bots can swap to it and use full steering.
shortcut.Line = new RacingLine { Points = points };
shortcut.Line.RebuildDistances();
// Shortcuts are open paths; override TotalLength to remove the closing segment.
shortcut.Line.TotalLength = totalLen;
shortcut.Line.RebuildCurvatures();
Shortcuts.Add( shortcut );
Log.Info( $"RacingPath: Shortcut '{sc.GameObject.Name}' baked - entry={entryDist:F0}, exit={exitDist:F0}, " +
$"mainDist={forwardDist:F0}, shortLen={totalLen:F0}, {points.Count} pts, risk={risk:F2}" );
}
if ( Shortcuts.Count > 0 )
Log.Info( $"RacingPath: Baked {Shortcuts.Count} shortcut path(s)." );
}
private List<Vector3> SampleShortcutSpline( SplineComponent sc )
{
var result = new List<Vector3>();
var spline = sc.Spline;
var length = spline.Length;
if ( length < 0.001f )
return result;
var tx = sc.WorldTransform;
var spacing = MathF.Max( SplineSampleSpacing, 1f );
for ( float d = 0f; d < length; d += spacing )
result.Add( tx.PointToWorld( spline.SampleAtDistance( d ).Position ) + Vector3.Up * UpOffset );
result.Add( tx.PointToWorld( spline.SampleAtDistance( length ).Position ) + Vector3.Up * UpOffset );
return result;
}
}