Component that places decorative barrier models alongside a Spline. It samples spline curvature, extracts qualifying distance ranges per side according to placement rules, spawns child SplineComponents and SplineModelRenderers to draw model segments, and draws editor gizmos.
using System;
using Machines.Race;
namespace Sandbox;
public enum BarrierPlacementMode
{
OutsideOfCurve,
InsideOfCurve,
Both,
StraightsOnly
}
/// <summary>
/// Renders barrier models along offset sub-splines where curvature/section rules qualify.
/// </summary>
[Title( "Spline Side Barrier" )]
[Category( "Spline" )]
public sealed class SplineSideBarrier : Component, Component.ExecuteInEditor
{
[Property, Category( "Spline" )]
public SplineComponent Spline { get; set; }
[Property, Category( "Barrier" )]
public Model Model { get; set; }
[Property, Category( "Barrier" )]
public float LateralOffset { get; set; } = 400f;
[Property, Category( "Barrier" )]
public float VerticalOffset { get; set; } = 0f;
[Property, Category( "Barrier" )]
public bool EnableLeft { get; set; } = true;
[Property, Category( "Barrier" )]
public bool EnableRight { get; set; } = true;
[Property, Category( "Barrier" )]
public Rotation ModelRotation { get; set; } = Rotation.Identity;
[Property, Category( "Barrier" )]
public float Spacing { get; set; } = 0f;
[Property, Category( "Barrier" )]
public bool FlexFit { get; set; } = true;
// Offset horizontally, ignore roll and pitch (keeps barriers upright on banked track).
[Property, Category( "Barrier" )]
public bool Flat { get; set; } = false;
[Property, Category( "Rules" )]
public BarrierPlacementMode Mode { get; set; } = BarrierPlacementMode.OutsideOfCurve;
// Curves tighter than this radius qualify (StraightsOnly mode inverts the test).
[Property, Category( "Rules" ), Range( 100f, 20000f )]
public float MinCurveRadius { get; set; } = 2000f;
[Property, Category( "Rules" )]
public List<SplineType> AllowedTypes { get; set; } = new() { SplineType.Road };
[Property, Category( "Segments" )]
public float MinSegmentLength { get; set; } = 256f;
[Property, Category( "Segments" )]
public float MergeGap { get; set; } = 128f;
[Property, Category( "Segments" )]
public float EndPadding { get; set; } = 64f;
private const float SampleStep = 24f;
private const float CurvatureSpan = 80f;
private const float ChildPointSpacing = 48f;
private readonly List<GameObject> _spawned = new();
private readonly List<(float Side, List<Vector3> Points)> _gizmoRanges = new();
private bool _dirty = true;
// Qualifying distance range; End may exceed spline length when a loop range wraps 0.
private record struct DistRange( float Start, float End, bool FullLoop = false );
protected override void OnEnabled()
{
base.OnEnabled();
SubscribeSpline();
_dirty = true;
}
protected override void OnDisabled()
{
UnsubscribeSpline();
ClearSpawned();
base.OnDisabled();
}
protected override void OnValidate()
{
UnsubscribeSpline();
SubscribeSpline();
_dirty = true;
}
protected override void OnUpdate()
{
if ( !_dirty ) return;
_dirty = false;
Rebuild();
}
private void SubscribeSpline()
{
if ( Spline.IsValid() )
Spline.Spline.SplineChanged += OnSplineChanged;
}
private void UnsubscribeSpline()
{
if ( Spline.IsValid() )
Spline.Spline.SplineChanged -= OnSplineChanged;
}
private void OnSplineChanged() => _dirty = true;
private void ClearSpawned()
{
foreach ( var go in _spawned )
if ( go.IsValid() ) go.Destroy();
_spawned.Clear();
_gizmoRanges.Clear();
}
protected override void DrawGizmos()
{
if ( _gizmoRanges.Count == 0 ) return;
Gizmo.Transform = new Transform( 0 );
Gizmo.Draw.IgnoreDepth = true;
foreach ( var (side, points) in _gizmoRanges )
{
Gizmo.Draw.Color = side < 0f ? Color.Red : Color.Blue;
for ( int i = 0; i < points.Count - 1; i++ )
Gizmo.Draw.Line( points[i], points[i + 1] );
}
}
private void Rebuild()
{
ClearSpawned();
if ( !Spline.IsValid() || !Model.IsValid() )
return;
var type = Spline.GetComponent<SplineInfo>()?.Type ?? SplineType.Road;
if ( AllowedTypes is null || !AllowedTypes.Contains( type ) )
return;
var spline = Spline.Spline;
float length = spline.Length;
if ( length <= SampleStep * 2f )
return;
bool isLoop = spline.IsLoop;
var curvatures = SampleCurvatures( spline, length, isLoop );
if ( EnableLeft ) BuildSide( spline, curvatures, length, isLoop, -1f );
if ( EnableRight ) BuildSide( spline, curvatures, length, isLoop, 1f );
}
private void BuildSide( Spline spline, float[] curvatures, float length, bool isLoop, float side )
{
var qualifies = new bool[curvatures.Length];
for ( int i = 0; i < curvatures.Length; i++ )
qualifies[i] = Qualifies( curvatures[i], side );
var ranges = ExtractRanges( qualifies, length, isLoop );
int index = 0;
foreach ( var range in ranges )
SpawnBarrier( spline, range, length, side, index++ );
}
private float[] SampleCurvatures( Spline spline, float length, bool isLoop )
{
int count = Math.Max( 2, (int)MathF.Ceiling( length / SampleStep ) + 1 );
var result = new float[count];
for ( int i = 0; i < count; i++ )
{
float d = Math.Min( i * SampleStep, length );
result[i] = SampleSignedCurvature( spline, d, length, isLoop );
}
return result;
}
private static float Wrap( float d, float length )
{
d %= length;
return d < 0f ? d + length : d;
}
private static float SampleSignedCurvature( Spline spline, float d, float length, bool isLoop )
{
float da = d - CurvatureSpan;
float dc = d + CurvatureSpan;
if ( isLoop ) { da = Wrap( da, length ); dc = Wrap( dc, length ); }
else { da = Math.Clamp( da, 0f, length ); dc = Math.Clamp( dc, 0f, length ); }
var a = spline.SampleAtDistance( da ).Position.WithZ( 0f );
var b = spline.SampleAtDistance( d ).Position.WithZ( 0f );
var c = spline.SampleAtDistance( dc ).Position.WithZ( 0f );
float ab = (b - a).Length, bc = (c - b).Length, ca = (a - c).Length;
float denom = ab * bc * ca;
if ( denom < 0.001f ) return 0f;
// Menger curvature, sign = turn direction (>0 = left turn).
return 2f * Vector3.Cross( b - a, c - a ).z / denom;
}
private bool Qualifies( float signedK, float side )
{
float k = MathF.Abs( signedK );
float threshold = 1f / MathF.Max( MinCurveRadius, 1f );
return Mode switch
{
BarrierPlacementMode.StraightsOnly => k < threshold,
BarrierPlacementMode.Both => k >= threshold,
// Left turn (signedK > 0): outside = right side (+1).
BarrierPlacementMode.OutsideOfCurve => k >= threshold && MathF.Sign( signedK ) == MathF.Sign( side ),
BarrierPlacementMode.InsideOfCurve => k >= threshold && MathF.Sign( signedK ) != MathF.Sign( side ),
_ => false
};
}
private List<DistRange> ExtractRanges( bool[] qualifies, float length, bool isLoop )
{
var ranges = new List<DistRange>();
int n = qualifies.Length;
int runStart = -1;
for ( int i = 0; i < n; i++ )
{
if ( qualifies[i] && runStart < 0 ) runStart = i;
if ( (!qualifies[i] || i == n - 1) && runStart >= 0 )
{
int runEnd = qualifies[i] ? i : i - 1;
ranges.Add( new DistRange( runStart * SampleStep, Math.Min( runEnd * SampleStep, length ) ) );
runStart = -1;
}
}
if ( ranges.Count == 0 ) return ranges;
// Full loop qualifies: one closed barrier ring.
if ( isLoop && ranges.Count == 1 && ranges[0].Start <= 0f && ranges[0].End >= length - SampleStep )
return new List<DistRange> { new( 0f, length, FullLoop: true ) };
// Merge wrap-around run on loops.
if ( isLoop && ranges.Count > 1 && qualifies[0] && qualifies[n - 1] )
{
var first = ranges[0];
var last = ranges[^1];
ranges.RemoveAt( ranges.Count - 1 );
ranges[0] = new DistRange( last.Start - length, first.End );
}
// Merge runs separated by less than MergeGap.
for ( int i = ranges.Count - 2; i >= 0; i-- )
{
if ( ranges[i + 1].Start - ranges[i].End < MergeGap )
{
ranges[i] = new DistRange( ranges[i].Start, ranges[i + 1].End );
ranges.RemoveAt( i + 1 );
}
}
// Pad ends inward, drop segments shorter than MinSegmentLength.
for ( int i = ranges.Count - 1; i >= 0; i-- )
{
var r = new DistRange( ranges[i].Start + EndPadding, ranges[i].End - EndPadding );
if ( r.End - r.Start < MinSegmentLength )
ranges.RemoveAt( i );
else
ranges[i] = r;
}
return ranges;
}
private Spline.Point MakeOffsetPoint( Spline spline, float d, float length, float side )
{
var s = spline.SampleAtDistance( Wrap( d, length ) );
Vector3 up, right;
if ( Flat )
{
up = Vector3.Up;
var flatTangent = s.Tangent.WithZ( 0f );
if ( flatTangent.LengthSquared < 0.0001f ) flatTangent = Vector3.Forward;
right = Vector3.Cross( flatTangent.Normal, up ).Normal;
}
else
{
// Roll-applied up, matching SplineModelRenderer's frame.
up = Rotation.FromAxis( s.Tangent, s.Roll ) * s.Up;
right = Vector3.Cross( s.Tangent, up ).Normal;
}
// Scale with point scale so edge offset tracks road width.
var lateral = LateralOffset * MathF.Abs( s.Scale.y );
return new Spline.Point
{
Position = s.Position + right * side * lateral + up * VerticalOffset,
Mode = Sandbox.Spline.HandleMode.Auto,
Roll = Flat ? 0f : s.Roll,
Up = Flat ? Vector3.Up : s.Up,
Scale = 1f
};
}
private void SpawnBarrier( Spline spline, DistRange range, float length, float side, int index )
{
float rangeLength = range.End - range.Start;
int pointCount = Math.Max( 2, (int)MathF.Ceiling( rangeLength / ChildPointSpacing ) + 1 );
var go = new GameObject( $"Barrier_{(side < 0f ? "L" : "R")}_{index}" );
go.Flags |= GameObjectFlags.NotSaved;
go.Tags.Add( "spline_barrier" );
go.Parent = GameObject;
// Points are in the parent spline's local space.
go.WorldTransform = Spline.WorldTransform;
var sp = go.AddComponent<SplineComponent>();
sp.ShouldRenderGizmos = false;
// Mark as Custom so these decorative barrier sub-splines are excluded from the racing path bake.
go.AddComponent<SplineInfo>().Type = SplineType.Custom;
// Full loop: IsLoop closes the spline instead of duplicating the first point.
int insertCount = range.FullLoop ? pointCount - 1 : pointCount;
var gizmoPoints = new List<Vector3>( insertCount );
for ( int i = 0; i < insertCount; i++ )
{
float d = range.Start + i * rangeLength / (pointCount - 1);
var point = MakeOffsetPoint( spline, d, length, side );
sp.Spline.InsertPoint( sp.Spline.PointCount, point );
gizmoPoints.Add( Spline.WorldTransform.PointToWorld( point.Position ) );
}
// Remove the 3 default points inserted by SplineComponent.
for ( int i = 0; i < 3; i++ )
sp.Spline.RemovePoint( 0 );
if ( range.FullLoop )
sp.Spline.IsLoop = true;
var renderer = go.AddComponent<SplineModelRenderer>();
renderer.Spline = sp;
renderer.Model = Model;
renderer.ModelRotation = ModelRotation;
renderer.Spacing = Spacing;
renderer.FlexFit = FlexFit;
renderer.Flat = Flat;
_spawned.Add( go );
_gizmoRanges.Add( (side, gizmoPoints) );
}
}