Component for editor/runtime that carves or flattens terrain along a SplineComponent path. It samples the spline, projects sample points into terrain UV space, adjusts the terrain heightmap values with a blend falloff, then syncs GPU texture and updates collision for the modified region.
using System;
namespace Sandbox;
public enum CarveMode
{
/// <summary>
/// Flatten terrain to match the spline height (both raises and lowers).
/// </summary>
Flatten,
/// <summary>
/// Only carve down — terrain below the spline is untouched.
/// </summary>
CarveDown
}
/// <summary>
/// Carves (flattens/lowers) terrain along a spline path, useful for embedding track geometry into terrain.
/// Attach this to the same GameObject as a SplineComponent.
/// </summary>
[Title( "Spline Terrain Carver" )]
[Category( "Track" )]
[Icon( "terrain" )]
public sealed class SplineTerrainCarver : Component, Component.ExecuteInEditor
{
/// <summary>
/// The terrain to carve into. If not set, will try to find one in the scene.
/// </summary>
[Property] public Terrain Terrain { get; set; }
/// <summary>
/// The spline to follow for carving.
/// </summary>
[Property] public SplineComponent Spline { get; set; }
/// <summary>
/// How the terrain is modified along the spline.
/// </summary>
[Property] public CarveMode Mode { get; set; } = CarveMode.Flatten;
/// <summary>
/// Width of the carved path (in units) on each side of the spline center.
/// </summary>
[Property, Range( 32f, 1024f )] public float CarveWidth { get; set; } = 128f;
/// <summary>
/// How far below the spline point the terrain should be carved (depth offset).
/// </summary>
[Property, Range( 0f, 128f )] public float CarveDepth { get; set; } = 8f;
/// <summary>
/// Width of the blend/falloff zone at the edges of the carve path.
/// </summary>
[Property, Range( 0f, 256f )] public float BlendWidth { get; set; } = 64f;
/// <summary>
/// Number of sample points along the spline to use for carving.
/// Higher = more accurate but slower.
/// </summary>
[Property, Range( 10, 500 )] public int SampleCount { get; set; } = 100;
/// <summary>
/// Number of lateral samples across the width of the carve path.
/// </summary>
[Property, Range( 4, 64 )] public int WidthSamples { get; set; } = 16;
/// <summary>
/// If true, applies the carve operation on start. Otherwise call ApplyCarve() manually.
/// </summary>
[Property] public bool CarveOnStart { get; set; } = false;
protected override void OnStart()
{
if ( CarveOnStart )
ApplyCarve();
}
private SplineComponent ResolveSpline()
{
if ( Spline.IsValid() ) return Spline;
Spline = GetComponent<SplineComponent>();
return Spline;
}
private Terrain ResolveTerrain()
{
if ( Terrain.IsValid() ) return Terrain;
Terrain = Scene.GetAllComponents<Terrain>().FirstOrDefault();
return Terrain;
}
/// <summary>
/// Applies the terrain carve along the spline. Call this from editor tools or at runtime.
/// </summary>
[Button( "Apply Carve" )]
public void ApplyCarve()
{
var splineComp = ResolveSpline();
var terrain = ResolveTerrain();
if ( !splineComp.IsValid() || !terrain.IsValid() )
{
Log.Warning( "SplineTerrainCarver: Missing Spline or Terrain reference." );
return;
}
var storage = terrain.Storage;
if ( storage is null )
{
Log.Warning( "SplineTerrainCarver: Terrain has no storage." );
return;
}
var spline = splineComp.Spline;
var splineLength = spline.Length;
if ( splineLength <= 0f )
{
Log.Warning( "SplineTerrainCarver: Spline length is zero." );
return;
}
var splineTransform = splineComp.WorldTransform;
var terrainTransform = terrain.WorldTransform;
int resolution = storage.Resolution;
float terrainSize = storage.TerrainSize;
float terrainHeight = storage.TerrainHeight;
var heightMap = storage.HeightMap;
int minDirtyX = resolution;
int minDirtyY = resolution;
int maxDirtyX = 0;
int maxDirtyY = 0;
int modifiedPixels = 0;
int skippedOutOfBounds = 0;
// Sample points along the spline
for ( int i = 0; i <= SampleCount; i++ )
{
float distance = (float)i / SampleCount * splineLength;
var sample = spline.SampleAtDistance( distance );
// Convert spline local position to world
var worldPos = splineTransform.PointToWorld( sample.Position );
var worldTangent = (splineTransform.Rotation * sample.Tangent).Normal;
// Get the right vector (perpendicular to tangent, on the horizontal plane)
var right = Vector3.Cross( worldTangent, Vector3.Up ).Normal;
if ( right.LengthSquared < 0.001f )
right = Vector3.Cross( worldTangent, Vector3.Forward ).Normal;
// Target height: the spline world Z minus carve depth, relative to terrain origin
float targetLocalHeight = (worldPos.z - terrainTransform.Position.z) - CarveDepth;
float targetNormalized = targetLocalHeight / terrainHeight;
targetNormalized = Math.Clamp( targetNormalized, 0f, 1f );
// Log first sample for debugging
if ( i == 0 )
{
var debugLocal = terrainTransform.PointToLocal( worldPos );
var debugUV = new Vector2( debugLocal.x, debugLocal.y ) / terrainSize;
Log.Info( $"SplineTerrainCarver: First sample worldPos={worldPos}, terrainLocal={debugLocal}, uv={debugUV}, targetNorm={targetNormalized:F4}" );
}
// Carve across the width
float totalWidth = CarveWidth + BlendWidth;
for ( int w = 0; w <= WidthSamples; w++ )
{
float wt = (float)w / WidthSamples;
float lateralOffset = MathX.Lerp( -totalWidth, totalWidth, wt );
var sampleWorldPos = worldPos + right * lateralOffset;
// Calculate blend factor (1.0 = full carve, 0.0 = no carve)
float distFromCenter = MathF.Abs( lateralOffset );
float blend = 1f;
if ( distFromCenter > CarveWidth )
{
blend = 1f - ((distFromCenter - CarveWidth) / BlendWidth);
blend = Math.Clamp( blend, 0f, 1f );
// Smoothstep
blend = blend * blend * (3f - 2f * blend);
}
if ( blend <= 0f )
continue;
// Convert world position to terrain local space
var localPos = terrainTransform.PointToLocal( sampleWorldPos );
// Compute UV on the terrain (x/y are horizontal, z is height)
var uv = new Vector2( localPos.x, localPos.y ) / terrainSize;
if ( uv.x < 0f || uv.x > 1f || uv.y < 0f || uv.y > 1f )
{
skippedOutOfBounds++;
continue;
}
// Map UV to heightmap pixel coordinates
int px = (int)MathF.Floor( resolution * uv.x );
int py = (int)MathF.Floor( resolution * uv.y );
px = Math.Clamp( px, 0, resolution - 1 );
py = Math.Clamp( py, 0, resolution - 1 );
int idx = px + py * resolution;
// Current height as normalized value (0-1)
float currentNormalized = heightMap[idx] / 65535f;
bool shouldModify = Mode switch
{
CarveMode.Flatten => MathF.Abs( currentNormalized - targetNormalized ) > 0.0001f,
CarveMode.CarveDown => currentNormalized > targetNormalized,
_ => false
};
if ( shouldModify )
{
float newNormalized = MathX.Lerp( currentNormalized, targetNormalized, blend );
heightMap[idx] = (ushort)(Math.Clamp( newNormalized, 0f, 1f ) * 65535f);
// Expand dirty region
minDirtyX = Math.Min( minDirtyX, px );
minDirtyY = Math.Min( minDirtyY, py );
maxDirtyX = Math.Max( maxDirtyX, px + 1 );
maxDirtyY = Math.Max( maxDirtyY, py + 1 );
modifiedPixels++;
}
}
}
// Sync changes to the GPU and update collision
if ( modifiedPixels > 0 )
{
var dirtyRegion = new RectInt( minDirtyX, minDirtyY, maxDirtyX - minDirtyX, maxDirtyY - minDirtyY );
terrain.SyncGPUTexture();
terrain.UpdateCollision( Terrain.SyncFlags.Height, dirtyRegion );
Log.Info( $"SplineTerrainCarver: Carved {modifiedPixels} pixels in region ({minDirtyX},{minDirtyY})-({maxDirtyX},{maxDirtyY})." );
}
else
{
Log.Warning( $"SplineTerrainCarver: No pixels modified. Skipped {skippedOutOfBounds} out-of-bounds samples. Mode={Mode}" );
Log.Warning( $" Terrain pos={terrainTransform.Position}, size={terrainSize}, height={terrainHeight}, resolution={resolution}" );
Log.Warning( $" Spline pos={splineTransform.Position}, length={splineLength:F1}" );
}
}
/// <summary>
/// Resets the terrain to its original state in the carved area.
/// </summary>
[Button( "Reset Carve" )]
public void ResetCarve()
{
Log.Info( "SplineTerrainCarver: Reset not yet implemented. Undo via editor." );
}
}