Code/TerrainDebrisFx.cs
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using System.Collections.Generic;
using Sandbox;
namespace SubZero.SubTerrain;
/// <summary>
/// Dirt spray and physics clods for crater / dig stamps. Listens to
/// TerrainSculpt.OnApplied after GPU and collision so debris lands in the bowl.
/// Prefabs use engine meshes (models/dev/box.vmdl).
/// </summary>
[Title( "Terrain Debris FX" )]
[Category( "SubTerrain" )]
[Icon( "grain" )]
public sealed class TerrainDebrisFx : Component
{
const string DefaultClodPath = "prefabs/subterrain/dirt_clod.prefab";
const string DefaultSprayPath = "prefabs/subterrain/dirt_spray.prefab";
const FindMode RigidbodyFind = FindMode.Enabled | FindMode.InSelf | FindMode.InDescendants;
[Property, Group( "Source" )]
public TerrainSculpt Sculpt { get; set; }
[Property, Group( "Prefabs" )]
public PrefabFile ClodPrefab { get; set; }
[Property, Group( "Prefabs" )]
public PrefabFile SprayPrefab { get; set; }
[Property, Group( "Kinds" )]
public bool Craters { get; set; } = true;
[Property, Group( "Kinds" )]
public bool Digs { get; set; } = true;
[Property, Group( "Clods" ), Range( 0, 48 )]
public int MinClods { get; set; } = 8;
[Property, Group( "Clods" ), Range( 1, 64 )]
public int MaxClods { get; set; } = 24;
[Property, Group( "Clods" ), Range( 0f, 0.5f )]
public float ClodsPerRadius { get; set; } = 0.12f;
[Property, Group( "Clods" ), Range( 0, 128 )]
public int MaxAlive { get; set; } = 64;
[Property, Group( "Launch" ), Range( 0f, 4000f )]
public float Impulse { get; set; } = 640f;
[Property, Group( "Launch" ), Range( 0f, 3f )]
public float UpBias { get; set; } = 0.45f;
[Property, Group( "Launch" ), Range( 0.5f, 4f )]
public float Outward { get; set; } = 2.2f;
[Property, Group( "Launch" ), Range( 0.4f, 1.4f )]
public float RimMin { get; set; } = 0.78f;
[Property, Group( "Launch" ), Range( 0.5f, 1.6f )]
public float RimMax { get; set; } = 1.12f;
[Property, Group( "Launch" ), Range( 0f, 40f )]
public float Spin { get; set; } = 14f;
[Property, Group( "Launch" ), Range( 0f, 48f )]
public float SpawnLift { get; set; } = 20f;
[Property, Group( "Launch" ), Range( 0.05f, 1.5f )]
public float ScaleMin { get; set; } = 0.7f;
[Property, Group( "Launch" ), Range( 0.05f, 2.5f )]
public float ScaleMax { get; set; } = 1.35f;
[Property, Group( "Dig" ), Range( 0.1f, 1f )]
public float DigImpulseScale { get; set; } = 0.5f;
[Property, Group( "Debug" )]
public bool DebugLog { get; set; }
readonly List<GameObject> _alive = new();
protected override void OnStart()
{
if ( !Game.IsPlaying )
return;
Sculpt ??= Components.Get<TerrainSculpt>( FindMode.EnabledInSelf );
ClodPrefab ??= ResourceLibrary.Get<PrefabFile>( DefaultClodPath );
SprayPrefab ??= ResourceLibrary.Get<PrefabFile>( DefaultSprayPath );
if ( Sculpt == null || !Sculpt.IsValid() )
{
Log.Warning( $"[Debris] {GameObject.Name}: no TerrainSculpt on this object" );
return;
}
Sculpt.OnApplied += OnTerrainApplied;
Sculpt.OnReset += ClearDebris;
}
protected override void OnDisabled()
{
Unhook();
ClearDebris();
}
protected override void OnDestroy()
{
Unhook();
}
void Unhook()
{
if ( Sculpt == null || !Sculpt.IsValid() )
return;
Sculpt.OnApplied -= OnTerrainApplied;
Sculpt.OnReset -= ClearDebris;
}
void OnTerrainApplied( TerrainEditResult result )
{
if ( result.Kind == TerrainEditKind.Flatten )
return;
if ( result.Kind == TerrainEditKind.Crater && !Craters )
return;
if ( result.Kind == TerrainEditKind.Dig && !Digs )
return;
SpawnSpray( result );
SpawnClods( result );
}
void SpawnSpray( TerrainEditResult result )
{
if ( SprayPrefab == null )
return;
var aimUp = Rotation.LookAt( Vector3.Up, Vector3.Forward );
var spray = GameObject.Clone( SprayPrefab, new Transform( result.Origin, aimUp ) );
if ( !spray.IsValid() )
return;
Track( spray );
}
void SpawnClods( TerrainEditResult result )
{
if ( ClodPrefab == null )
return;
var count = (int)Math.Clamp( result.Radius * ClodsPerRadius, MinClods, MaxClods );
if ( result.Kind == TerrainEditKind.Dig )
count = Math.Max( 3, count / 2 );
var impulse = Impulse;
var up = UpBias;
if ( result.Kind == TerrainEditKind.Dig )
{
impulse *= DigImpulseScale;
up *= 0.65f;
}
for ( var i = 0; i < count; i++ )
SpawnClod( result, impulse, up, i, count );
Dbg( $"spawned {count} clods kind={result.Kind} r={result.Radius:0.#}" );
}
void SpawnClod( TerrainEditResult result, float impulse, float up, int index, int count )
{
var yaw = (360f * index / Math.Max( count, 1 )) + Random.Shared.Float( -14f, 14f );
var outward = Rotation.FromYaw( yaw ).Forward;
var rimMin = result.Kind == TerrainEditKind.Dig
? Math.Clamp( RimMin * 0.7f, 0.4f, 1.2f )
: Math.Clamp( RimMin, 0.4f, 1.4f );
var rimMax = Math.Max( rimMin + 0.05f, RimMax );
var radial = result.Radius * Random.Shared.Float( rimMin, rimMax );
var pos = result.Origin + outward * radial + Vector3.Up * SpawnLift;
var scale = Random.Shared.Float( ScaleMin, ScaleMax );
var rot = Rotation.From( new Angles(
Random.Shared.Float( 0f, 360f ),
Random.Shared.Float( 0f, 360f ),
Random.Shared.Float( 0f, 360f )
) );
var clod = GameObject.Clone( ClodPrefab, new Transform( pos, rot ) );
if ( !clod.IsValid() )
return;
clod.WorldScale *= scale;
Track( clod );
var body = clod.Components.Get<Rigidbody>( RigidbodyFind );
if ( !body.IsValid() )
return;
var dir = (outward * Outward + Vector3.Up * up).Normal;
var speed = impulse * Random.Shared.Float( 0.85f, 1.25f );
body.Sleeping = false;
body.Velocity = dir * speed;
body.AngularVelocity = new Vector3(
Random.Shared.Float( -1f, 1f ),
Random.Shared.Float( -1f, 1f ),
Random.Shared.Float( -1f, 1f )
) * Spin;
}
void Track( GameObject go )
{
Prune();
_alive.Add( go );
while ( MaxAlive > 0 && _alive.Count > MaxAlive )
{
var oldest = _alive[0];
_alive.RemoveAt( 0 );
if ( oldest.IsValid() )
oldest.Destroy();
}
}
void ClearDebris()
{
for ( var i = _alive.Count - 1; i >= 0; i-- )
{
if ( _alive[i].IsValid() )
_alive[i].Destroy();
}
_alive.Clear();
}
void Prune()
{
for ( var i = _alive.Count - 1; i >= 0; i-- )
{
if ( !_alive[i].IsValid() )
_alive.RemoveAt( i );
}
}
void Dbg( string message )
{
if ( !DebugLog )
return;
Log.Info( $"[Debris] {message}" );
}
}