Editor and runtime component that scatters prop GameObjects along a Spline. It samples the spline into vertices, computes offset polylines to left/right at an edge offset, then places props at intervals using seeded RNG, spawning GameObjects with Prop and Rigidbody components and network-spawning them on the host.
using Sandbox.Diagnostics;
using System;
using System.Linq;
namespace Sandbox;
public sealed class PropEntry
{
[Property] public Model Model { get; set; }
[Property, Range( 0.1f, 5f )] public float ScaleMin { get; set; } = 1f;
[Property, Range( 0.1f, 5f )] public float ScaleMax { get; set; } = 1f;
[Property] public bool RandomYaw { get; set; } = false;
[Property, Range( -180f, 180f )] public float YawOffset { get; set; } = 0f;
[Property, Range( 0f, 180f )] public float RandomYawLimit { get; set; } = 0f;
[Property] public float RandomForwardOffset { get; set; } = 0f;
[Property] public bool MassOverride { get; set; } = false;
[Property, Range( 0f, 10000f ), ShowIf( "MassOverride", true )] public float Mass { get; set; } = 100f;
}
[Title( "Spline Prop Scatter" )]
[Category( "Spline" )]
public sealed class SplinePropScatter : Component, Component.ExecuteInEditor
{
[Property, Category( "Spline" )]
public SplineComponent Spline { get; set; }
[Property, Category( "Props" )]
public List<PropEntry> Props { get; set; } = new();
[Property, Category( "Props" )]
public RangedFloat Gap { get; set; } = new RangedFloat( 20f );
[Property, Category( "Props" )]
public float EdgeOffset { get; set; } = 400f;
[Property, Category( "Props" )]
public bool SpawnLeft { get; set; } = true;
[Property, Category( "Props" )]
public bool SpawnRight { get; set; } = true;
[Property, Category( "Props" )]
public int Seed { get; set; } = 1337;
[Property, Category( "Props" ), Range( 0f, 45f )]
public float CornerAngle { get; set; } = 0f;
private readonly List<GameObject> _spawned = new();
private bool _dirty = true;
protected override void DrawGizmos()
{
if ( !Spline.IsValid() ) return;
Gizmo.Transform = new Transform( 0 );
Gizmo.Draw.IgnoreDepth = true;
var spline = Spline.Spline;
var splineXform = Spline.WorldTransform;
float length = spline.Length;
if ( length <= 0f ) return;
var vertices = ExtractVertices( spline, splineXform, length );
if ( vertices.Count < 2 ) return;
if ( SpawnLeft )
{
var path = ComputeOffsetPolyline( vertices, -1f );
Gizmo.Draw.Color = Color.Red;
for ( int i = 0; i < path.Count - 1; i++ )
Gizmo.Draw.Line( path[i], path[i + 1] );
}
if ( SpawnRight )
{
var path = ComputeOffsetPolyline( vertices, 1f );
Gizmo.Draw.Color = Color.Blue;
for ( int i = 0; i < path.Count - 1; i++ )
Gizmo.Draw.Line( path[i], path[i + 1] );
}
}
protected override void OnEnabled()
{
if ( Game.IsPlaying && !Networking.IsHost ) return;
SubscribeSpline();
_dirty = true;
}
protected override void OnDisabled()
{
if ( Game.IsPlaying && !Networking.IsHost ) return;
UnsubscribeSpline();
ClearSpawned();
}
protected override void OnValidate()
{
if ( Game.IsPlaying && !Networking.IsHost ) return;
UnsubscribeSpline();
SubscribeSpline();
_dirty = true;
}
protected override void OnUpdate()
{
if ( !_dirty ) return;
_dirty = false;
if ( Game.IsPlaying && !Networking.IsHost ) return;
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 child in GameObject.Children.ToList() )
if ( child.IsValid() ) child.Destroy();
_spawned.Clear();
}
private void Rebuild()
{
ClearSpawned();
if ( !Spline.IsValid() || Props is null || Props.Count == 0 )
return;
var validProps = Props.Where( e => e?.Model.IsValid() == true ).ToList();
if ( validProps.Count == 0 ) return;
var spline = Spline.Spline;
var splineXform = Spline.WorldTransform;
float length = spline.Length;
if ( length <= 0f ) return;
var rng = new Random( Seed );
var vertices = ExtractVertices( spline, splineXform, length );
if ( vertices.Count < 2 ) return;
if ( SpawnLeft )
{
var offsetPath = ComputeOffsetPolyline( vertices, -1f );
PlaceAlongPath( offsetPath, validProps, rng, isLeft: true );
}
if ( SpawnRight )
{
var offsetPath = ComputeOffsetPolyline( vertices, 1f );
PlaceAlongPath( offsetPath, validProps, rng, isLeft: false );
}
}
private List<Vector3> ExtractVertices( Spline spline, Transform splineXform, float length )
{
var vertices = new List<Vector3>();
const float step = 2f;
float threshold = MathF.Cos( CornerAngle * (MathF.PI / 180f) );
var startSample = spline.SampleAtDistance( 0f );
vertices.Add( splineXform.PointToWorld( startSample.Position ) );
var prevTangent = (splineXform.Rotation * startSample.Tangent).Normal;
for ( float d = step; d <= length; d += step )
{
var sample = spline.SampleAtDistance( d );
var tangent = (splineXform.Rotation * sample.Tangent).Normal;
float dot = Vector3.Dot( tangent, prevTangent );
if ( dot < threshold )
{
vertices.Add( splineXform.PointToWorld( sample.Position ) );
}
prevTangent = tangent;
}
var endSample = spline.SampleAtDistance( length );
var endPos = splineXform.PointToWorld( endSample.Position );
if ( endPos.Distance( vertices[^1] ) > 1f )
{
vertices.Add( endPos );
}
return vertices;
}
private List<Vector3> ComputeOffsetPolyline( List<Vector3> vertices, float side )
{
int n = vertices.Count;
if ( n < 2 ) return new List<Vector3>();
var directions = new List<Vector3>();
var rights = new List<Vector3>();
for ( int i = 0; i < n - 1; i++ )
{
var dir = (vertices[i + 1] - vertices[i]).Normal;
directions.Add( dir );
var right = Vector3.Cross( dir, Vector3.Up ).Normal;
if ( right.LengthSquared < 0.001f )
right = Vector3.Cross( dir, Vector3.Forward ).Normal;
rights.Add( right );
}
var result = new List<Vector3>();
result.Add( vertices[0] + rights[0] * side * EdgeOffset );
for ( int i = 1; i < n - 1; i++ )
{
var P1 = vertices[i] + rights[i - 1] * side * EdgeOffset;
var D1 = directions[i - 1];
var P2 = vertices[i] + rights[i] * side * EdgeOffset;
var D2 = directions[i];
var miter = LineLineIntersection( P1, D1, P2, D2 );
result.Add( miter ?? (P1 + P2) * 0.5f );
}
result.Add( vertices[n - 1] + rights[n - 2] * side * EdgeOffset );
return result;
}
private static Vector3? LineLineIntersection( Vector3 P1, Vector3 D1, Vector3 P2, Vector3 D2 )
{
var cross = Vector3.Cross( D1, D2 );
float denom = cross.LengthSquared;
if ( denom < 0.0001f ) return null;
var diff = P2 - P1;
float t = Vector3.Dot( Vector3.Cross( diff, D2 ), cross ) / denom;
return P1 + D1 * t;
}
private void PlaceAlongPath( List<Vector3> path, List<PropEntry> validProps, Random rng, bool isLeft )
{
if ( path.Count < 2 ) return;
float carried = 0f;
for ( int i = 0; i < path.Count - 1; i++ )
{
var segStart = path[i];
var segEnd = path[i + 1];
var segDir = (segEnd - segStart).Normal;
float segLen = segStart.Distance( segEnd );
if ( segLen < 0.1f ) continue;
var faceDir = isLeft
? Vector3.Cross( segDir, Vector3.Up ).Normal
: -Vector3.Cross( segDir, Vector3.Up ).Normal;
if ( faceDir.LengthSquared < 0.001f )
faceDir = isLeft
? Vector3.Cross( segDir, Vector3.Forward ).Normal
: -Vector3.Cross( segDir, Vector3.Forward ).Normal;
float d = carried;
while ( d < segLen )
{
var pos = segStart + segDir * d;
var entry = validProps[rng.Next( validProps.Count )];
float scale = MathX.Lerp( entry.ScaleMin, entry.ScaleMax, (float)rng.NextDouble() );
SpawnProp( pos, faceDir, entry, scale, rng );
// Seeded rng keeps rebuilds deterministic.
float gap = MathX.Lerp( Gap.Min, Gap.Max, (float)rng.NextDouble() );
d += Footprint( entry.Model, scale ) + gap;
}
carried = d - segLen;
}
}
private static float Footprint( Model model, float scale )
{
var s = model.Bounds.Size;
return MathF.Max( s.x, s.y ) * scale;
}
private void SpawnProp( Vector3 worldPos, Vector3 forward, PropEntry entry, float scale, Random rng )
{
Assert.True( Networking.IsHost, "Only the host can spawn scatter props" );
Rotation rotation;
if ( entry.RandomYaw )
{
rotation = Rotation.FromYaw( (float)rng.NextDouble() * 360f );
}
else
{
float yaw = entry.YawOffset;
if ( entry.RandomYawLimit > 0f )
yaw += ((float)rng.NextDouble() * 2f - 1f) * entry.RandomYawLimit;
rotation = Rotation.LookAt( forward, Vector3.Up ) * Rotation.FromYaw( yaw );
}
if ( entry.RandomForwardOffset > 0f )
{
float offset = ((float)rng.NextDouble() * 2f - 1f) * entry.RandomForwardOffset;
worldPos += forward * offset;
}
var go = new GameObject( entry.Model.Name );
go.Tags.Add( "spline_prop" );
go.Parent = GameObject;
go.WorldTransform = new Transform( worldPos, rotation, scale );
go.AddComponent<Prop>().Model = entry.Model;
if ( entry.MassOverride )
go.GetComponent<Rigidbody>()?.MassOverride = entry.Mass;
// Spawn last: network snapshot captures state at spawn time.
go.NetworkSpawn();
_spawned.Add( go );
}
}