Code/TerrainSculpt.cs
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using System.Collections.Generic;
using Sandbox;
using Sandbox.Clutter;
namespace SubZero.SubTerrain;
/// <summary>
/// Runtime heightfield editor: queue CPU stamps, then SyncGPUTexture + region UpdateCollision.
/// Play-only. Add this next to a Terrain component. One sculpt per Terrain.
/// </summary>
[Title( "Terrain Sculpt" )]
[Category( "SubTerrain" )]
[Icon( "landslide" )]
public sealed class TerrainSculpt : Component
{
const int MinTexelsAcross = 8;
[Property, Group( "Limits" ), Range( 16f, 512f )]
public float MaxRadius { get; set; } = 240f;
[Property, Group( "Limits" ), Range( 0f, 64f )]
public float FloorMargin { get; set; } = 16f;
[Property, Group( "Limits" ), Range( 64f, 2048f )]
public float GroundDrop { get; set; } = 512f;
[Property, Group( "Crater" )]
public CraterBrushSettings Crater { get; set; } = new();
[Property, Group( "Dig" )]
public DigBrushSettings Dig { get; set; } = new();
[Property, Group( "Flatten" )]
public FlattenBrushSettings Flatten { get; set; } = new();
[Property, Group( "Paint" )]
public bool PaintDirt { get; set; }
[Property, Group( "Paint" ), Range( 0, 31 )]
public int PaintMaterialIndex { get; set; } = 1;
[Property, Group( "Paint" ), Range( 0f, 1f )]
public float PaintStrength { get; set; } = 0.85f;
[Property, Group( "Perf" ), Range( 1, 16 )]
public int StampsPerFrame { get; set; } = 4;
[Property, Group( "Debug" )]
public bool DebugLog { get; set; } = true;
public Action<TerrainEditResult> OnApplied { get; set; }
/// <summary>Fired after a successful play-mode reset (Clear Edits / ResetAll). Debris listens here.</summary>
public Action OnReset { get; set; }
readonly List<TerrainEditRequest> _queue = new();
readonly List<TerrainEditResult> _appliedThisFlush = new();
ushort[] _assetHeight;
uint[] _assetControl;
ushort[] _baseHeight;
uint[] _baseControl;
float _assetZ;
bool _floorRaised;
bool _warnedLowRes;
bool _paintedThisApply;
ushort[] _mergeScratch;
protected override void OnStart()
{
if ( !Game.IsPlaying )
return;
var ok = EnsureSnapshot();
Dbg( ok
? $"snapshot ready res={GetTerrain()?.Storage?.Resolution} size={GetTerrain()?.Storage?.TerrainSize} height={GetTerrain()?.Storage?.TerrainHeight} texels={_baseHeight?.Length}"
: "snapshot FAILED — no Terrain/Storage on this object" );
}
protected override void OnDisabled()
{
RestoreFromSnapshot();
}
protected override void OnDestroy()
{
RestoreFromSnapshot();
}
protected override void OnFixedUpdate()
{
if ( !Game.IsPlaying )
return;
ApplyQueued();
}
/// <summary>Queue an edit. Returns whether it was accepted (flushes next fixed update).</summary>
public static bool TryApply( Scene scene, TerrainEditRequest request )
{
if ( scene == null || !scene.IsValid() )
{
Log.Warning( "[Sculpt] TryApply — scene invalid" );
return false;
}
if ( !Game.IsPlaying )
{
Log.Info( "[Sculpt] TryApply ignored — not playing" );
return false;
}
if ( request.Radius <= 0f )
{
Log.Info( $"[Sculpt] TryApply ignored — radius={request.Radius}" );
return false;
}
TerrainSculpt best = null;
var bestDist = float.MaxValue;
var candidates = 0;
foreach ( var sculpt in scene.GetAllComponents<TerrainSculpt>() )
{
candidates++;
if ( !sculpt.Active )
continue;
var terrain = sculpt.GetTerrain();
if ( terrain == null || !terrain.IsValid() || terrain.Storage == null )
{
sculpt.Dbg( "TryApply skip — no Terrain/Storage" );
continue;
}
if ( !sculpt.TryProjectToTerrain( terrain, request.WorldPos, out var local ) )
{
sculpt.Dbg( $"TryApply miss project pos={request.WorldPos} drop={sculpt.GroundDrop}" );
continue;
}
var hitWorld = terrain.WorldTransform.PointToWorld( local );
var dist = request.WorldPos.DistanceSquared( hitWorld );
if ( dist >= bestDist )
continue;
bestDist = dist;
best = sculpt;
}
if ( best == null )
{
Log.Warning( $"[Sculpt] TryApply — no terrain under {request.WorldPos} (components={candidates})" );
return false;
}
if ( request.Seed == 0 )
request.Seed = Game.Random.Int( 1, 1_000_000_000 );
best.Dbg( $"TryApply hit kind={request.Kind} dist={MathF.Sqrt( bestDist ):0.#} pos={request.WorldPos}" );
best._queue.Add( request );
return true;
}
public static int ResetAll( Scene scene )
{
if ( scene == null || !scene.IsValid() )
{
Log.Warning( "[Sculpt] ResetAll — scene invalid" );
return 0;
}
var cleared = 0;
foreach ( var sculpt in scene.GetAllComponents<TerrainSculpt>() )
{
if ( sculpt.ResetEdits() )
cleared++;
}
Log.Info( $"[Sculpt] ResetAll — cleared {cleared} terrain(s)" );
return cleared;
}
[Button( "Clear Edits" )]
public void ClearEdits()
{
ResetEdits();
}
[Button( "Clear All Scene Edits" )]
public void ClearAllSceneEdits()
{
ResetAll( Scene );
}
public bool ResetEdits()
{
if ( _baseHeight == null )
{
Log.Warning( $"[Sculpt] {GameObject.Name}: Clear Edits — no snapshot (play first, or already restored)" );
return false;
}
var terrain = GetTerrain();
if ( terrain == null || !terrain.IsValid() || terrain.Storage == null )
{
Log.Warning( $"[Sculpt] {GameObject.Name}: Clear Edits — no Terrain/Storage" );
return false;
}
var storage = terrain.Storage;
if ( storage.HeightMap.Length != _baseHeight.Length )
{
Log.Warning( $"[Sculpt] {GameObject.Name}: Clear Edits — size mismatch snapshot={_baseHeight.Length} map={storage.HeightMap.Length}" );
return false;
}
_baseHeight.CopyTo( storage.HeightMap, 0 );
if ( _baseControl != null && storage.ControlMap.Length == _baseControl.Length )
_baseControl.CopyTo( storage.ControlMap, 0 );
PushToEngine( terrain, FullRect( storage.Resolution ), painted: _baseControl != null );
_queue.Clear();
OnReset?.Invoke();
Log.Info( $"[Sculpt] {GameObject.Name}: edits cleared" );
return true;
}
void ApplyQueued()
{
if ( _queue.Count == 0 )
return;
var terrain = GetTerrain();
if ( terrain == null || !terrain.IsValid() || terrain.Storage == null )
{
Dbg( $"ApplyQueued — dropped {_queue.Count} stamps (no Terrain/Storage)" );
_queue.Clear();
return;
}
if ( !EnsureSnapshot() )
{
Dbg( "ApplyQueued — snapshot failed" );
return;
}
var limit = Math.Clamp( StampsPerFrame, 1, 16 );
var take = Math.Min( limit, _queue.Count );
RectInt dirty = default;
var any = false;
_paintedThisApply = false;
_appliedThisFlush.Clear();
for ( var i = 0; i < take; i++ )
{
if ( !ApplyStamp( terrain, _queue[i], out var rect, out var result ) )
continue;
_appliedThisFlush.Add( result );
if ( !any )
{
dirty = rect;
any = true;
}
else
{
dirty.Add( rect );
}
}
_queue.RemoveRange( 0, take );
if ( !any )
{
Dbg( "ApplyQueued — all stamps missed terrain" );
return;
}
PushToEngine( terrain, dirty, _paintedThisApply );
Dbg( $"pushed dirty=({dirty.Left},{dirty.Top},{dirty.Width}x{dirty.Height}) painted={_paintedThisApply} remaining={_queue.Count}" );
var applied = OnApplied;
if ( applied == null )
return;
for ( var i = 0; i < _appliedThisFlush.Count; i++ )
applied( _appliedThisFlush[i] );
}
bool ApplyStamp( Terrain terrain, TerrainEditRequest request, out RectInt dirty, out TerrainEditResult result )
{
dirty = default;
result = default;
var storage = terrain.Storage;
if ( storage == null || _baseHeight == null )
return false;
var radius = Math.Clamp( request.Radius, 1f, MaxRadius );
var depth = ResolveDepth( request.Kind, radius, request.Depth );
if ( request.Kind != TerrainEditKind.Flatten && depth <= 0f )
{
Dbg( $"ApplyStamp — depth 0 kind={request.Kind}" );
return false;
}
if ( !TryProjectToTerrain( terrain, request.WorldPos, out var local ) )
{
Dbg( $"ApplyStamp — project miss world={request.WorldPos}" );
return false;
}
var size = storage.TerrainSize;
var height = storage.TerrainHeight;
var res = storage.Resolution;
if ( size <= 0f || height <= 0f || res <= 0 )
return false;
var uv = new Vector2( local.x, local.y ) / size;
if ( uv.x < 0f || uv.y < 0f || uv.x > 1f || uv.y > 1f )
return false;
var texelSize = size / res;
var across = radius * 2f / texelSize;
if ( across < MinTexelsAcross && !_warnedLowRes )
{
_warnedLowRes = true;
Log.Warning( $"[TerrainSculpt] Radius {radius:0} spans {across:0.0} texels (need >={MinTexelsAcross} to hide a character). texelSize={texelSize:0.0}." );
}
var cx = (int)Math.Floor( res * uv.x );
var cy = (int)Math.Floor( res * uv.y );
var irreg = request.Kind switch
{
TerrainEditKind.Dig => Math.Clamp( Dig.Irregularity, 0f, 0.4f ),
TerrainEditKind.Flatten => 0f,
_ => Math.Clamp( Crater.Irregularity, 0f, 0.7f )
};
var stretchAmt = request.Kind switch
{
TerrainEditKind.Dig => Math.Clamp( Dig.Stretch, 0f, 0.35f ),
TerrainEditKind.Flatten => 0f,
_ => Math.Clamp( Crater.Stretch, 0f, 0.5f )
};
var blend = request.Kind switch
{
TerrainEditKind.Dig => Math.Clamp( Dig.Blend, 0f, 1f ),
TerrainEditKind.Flatten => 0f,
_ => Math.Clamp( Crater.Blend, 0f, 1f )
};
var shapePad = 1f + irreg + stretchAmt + 0.25f + blend * 0.15f;
var radTexels = Math.Max( 1, (int)Math.Ceiling( radius / texelSize * shapePad ) + 3 );
dirty = new RectInt( cx - radTexels, cy - radTexels, radTexels * 2 + 1, radTexels * 2 + 1 );
var x0 = Math.Clamp( dirty.Left, 0, res - 1 );
var y0 = Math.Clamp( dirty.Top, 0, res - 1 );
var x1 = Math.Clamp( dirty.Right, 0, res );
var y1 = Math.Clamp( dirty.Bottom, 0, res );
if ( x1 <= x0 || y1 <= y0 )
return false;
dirty = new RectInt( x0, y0, x1 - x0, y1 - y0 );
var crater = Crater;
var dig = Dig;
var maxDepth = request.Kind == TerrainEditKind.Dig ? dig.MaxDepth : crater.MaxDepth;
var maxRaise = request.Kind == TerrainEditKind.Dig ? dig.MaxRaise : crater.MaxRaise;
var bowlFrac = request.Kind == TerrainEditKind.Dig
? Math.Clamp( dig.BowlRadius, 0.7f, 0.98f )
: Math.Clamp( crater.BowlRadius, 0.4f, 0.95f );
var depthU = WorldToUshort( depth, height );
var maxDepthU = WorldToUshort( maxDepth, height );
var raiseU = WorldToUshort( maxRaise, height );
var heights = storage.HeightMap;
var control = storage.ControlMap;
var paint = PaintDirt && PaintMaterialIndex >= 0
&& storage.Materials != null
&& PaintMaterialIndex < storage.Materials.Count;
if ( paint )
EnsureControlSnapshot();
var originX = uv.x * res;
var originY = uv.y * res;
var radiusTexels = radius / texelSize;
if ( radiusTexels < 0.5f )
return false;
var rng = new Random( request.Seed == 0 ? 1 : request.Seed );
var rot = rng.Float( 0f, MathF.PI * 2f );
var stretchX = Math.Clamp( 1f + rng.Float( -stretchAmt, stretchAmt ), 0.55f, 1.7f );
if ( stretchAmt <= 0.001f )
stretchX = 1f;
var sampleX = Math.Clamp( cx, x0, x1 - 1 );
var sampleY = Math.Clamp( cy, y0, y1 - 1 );
var sampleI = sampleX + sampleY * res;
var heightBefore = heights[sampleI];
var flattenTargetU = (int)heightBefore;
if ( request.Kind == TerrainEditKind.Flatten && request.HasTargetZ )
{
var targetLocal = terrain.WorldTransform.PointToLocal( new Vector3( request.WorldPos.x, request.WorldPos.y, request.TargetZ ) );
flattenTargetU = WorldToUshort( targetLocal.z, height );
}
var ctx = new TerrainBrushes.Context
{
Heights = heights,
Base = _baseHeight,
Control = control,
Res = res,
X0 = x0,
Y0 = y0,
X1 = x1,
Y1 = y1,
OriginX = originX,
OriginY = originY,
RadiusTexels = radiusTexels,
Ca = MathF.Cos( rot ),
Sa = MathF.Sin( rot ),
StretchX = stretchX,
StretchY = 1f / stretchX,
Ox = rng.Float( 0f, 64f ),
Oy = rng.Float( 0f, 64f ),
Oz = rng.Float( 0f, 64f ),
Freq = 2.1f + rng.Float( 0f, 2.4f ),
Irreg = irreg,
DepthU = depthU,
MaxDepthU = maxDepthU,
RaiseU = raiseU,
BowlFrac = bowlFrac,
SmoothK = blend * depthU * 0.55f,
DugU = Math.Max( 2, WorldToUshort( 8f, height ) ),
Paint = paint,
PaintIndex = PaintMaterialIndex,
PaintStrength = PaintStrength,
FlattenTargetU = flattenTargetU,
FlattenStrength = Math.Clamp( Flatten.Strength, 0.05f, 1f ),
FlattenMaxDeltaU = WorldToUshort( Flatten.MaxDelta, height ),
SpoilAngle = rng.Float( 0f, MathF.PI * 2f )
};
if ( request.Kind != TerrainEditKind.Flatten )
{
var eccA = rng.Float( 0f, MathF.PI * 2f );
var ecc = rng.Float( 0f, irreg * 0.22f ) * radiusTexels;
ctx.OriginX = originX + MathF.Cos( eccA ) * ecc;
ctx.OriginY = originY + MathF.Sin( eccA ) * ecc;
}
switch ( request.Kind )
{
case TerrainEditKind.Dig:
TerrainBrushes.ApplyDig( ref ctx );
break;
case TerrainEditKind.Flatten:
TerrainBrushes.ApplyFlatten( ref ctx );
break;
default:
TerrainBrushes.ApplyCrater( ref ctx );
break;
}
if ( ctx.Painted )
_paintedThisApply = true;
if ( blend > 0.01f && request.Kind != TerrainEditKind.Flatten )
BreakSaddles( storage, x0, y0, x1, y1, maxDepthU, ctx.DugU, blend, paint );
var heightAfter = heights[sampleI];
var dropWorld = (heightBefore - heightAfter) / (float)ushort.MaxValue * height;
var volume = ctx.VolumeU / (float)ushort.MaxValue * height * texelSize * texelSize;
Dbg( $"stamp kind={request.Kind} uv={uv.x:0.000},{uv.y:0.000} texel={cx},{cy} across={across:0.0} r={radius:0.#} d={depth:0.#} blend={blend:0.00} center {heightBefore}->{heightAfter} drop={dropWorld:0.0}u wrote={ctx.Written} rect=({dirty.Left},{dirty.Top},{dirty.Width}x{dirty.Height})" );
result = new TerrainEditResult
{
Kind = request.Kind,
Origin = request.WorldPos,
Radius = radius,
Depth = depth,
Dirty = dirty,
Texels = ctx.Written,
VolumeRemoved = volume
};
return true;
}
void BreakSaddles( TerrainStorage storage, int x0, int y0, int x1, int y1, int maxDepthU, int dugU, float blend, bool paint )
{
if ( _baseHeight == null )
return;
var res = storage.Resolution;
var w = x1 - x0;
var hgt = y1 - y0;
if ( w < 3 || hgt < 3 )
return;
var need = w * hgt;
if ( _mergeScratch == null || _mergeScratch.Length < need )
_mergeScratch = new ushort[need];
var heights = storage.HeightMap;
var control = storage.ControlMap;
var scratch = _mergeScratch;
for ( var y = 0; y < hgt; y++ )
{
var src = (y0 + y) * res + x0;
Array.Copy( heights, src, scratch, y * w, w );
}
for ( var y = 1; y < hgt - 1; y++ )
{
for ( var x = 1; x < w - 1; x++ )
{
var i = (y0 + y) * res + (x0 + x);
var row = y * w + x;
var left = scratch[row - 1];
var right = scratch[row + 1];
var down = scratch[row - w];
var up = scratch[row + w];
var leftD = (int)_baseHeight[i - 1] - left >= dugU;
var rightD = (int)_baseHeight[i + 1] - right >= dugU;
var downD = (int)_baseHeight[i - res] - down >= dugU;
var upD = (int)_baseHeight[i + res] - up >= dugU;
if ( !((leftD && rightD) || (downD && upD)) )
continue;
var minN = int.MaxValue;
if ( leftD ) minN = Math.Min( minN, left );
if ( rightD ) minN = Math.Min( minN, right );
if ( downD ) minN = Math.Min( minN, down );
if ( upD ) minN = Math.Min( minN, up );
var self = scratch[row];
if ( minN >= self )
continue;
var b = (int)_baseHeight[i];
var merged = (int)MathF.Round( self + (minN - self) * blend );
merged = Math.Max( merged, b - maxDepthU );
heights[i] = (ushort)Math.Clamp( merged, 0, ushort.MaxValue );
if ( !paint || control == null )
continue;
var mat = new CompactTerrainMaterial( control[i] );
if ( mat.IsHole )
continue;
mat.OverlayTextureId = (byte)PaintMaterialIndex;
mat.BlendFactor = (byte)Math.Clamp( mat.BlendFactor + (int)(blend * 80f), 0, 255 );
control[i] = mat.Packed;
_paintedThisApply = true;
}
}
}
bool TryProjectToTerrain( Terrain terrain, Vector3 worldPos, out Vector3 local )
{
local = default;
var origin = worldPos + Vector3.Up * 8f;
var ray = new Ray( origin, Vector3.Down );
if ( terrain.RayIntersects( ray, GroundDrop + 8f, out local ) )
return true;
local = terrain.WorldTransform.PointToLocal( worldPos );
var size = terrain.Storage?.TerrainSize ?? 0f;
return local.x >= 0f && local.y >= 0f && local.x <= size && local.y <= size;
}
void PushToEngine( Terrain terrain, RectInt region, bool painted )
{
if ( region.Width < 1 || region.Height < 1 )
return;
if ( terrain.HeightMap != null )
terrain.SyncGPUTexture();
var flags = Terrain.SyncFlags.Height;
if ( painted )
flags |= Terrain.SyncFlags.Control;
terrain.UpdateCollision( flags, region );
InvalidateClutter( terrain, region );
}
void InvalidateClutter( Terrain terrain, RectInt region )
{
if ( !Scene.IsValid() )
return;
var clutter = Scene.GetSystem<ClutterGridSystem>();
if ( clutter == null )
return;
var storage = terrain.Storage;
if ( storage == null )
return;
var res = storage.Resolution;
var size = storage.TerrainSize;
var minLocal = new Vector3( region.Left / (float)res * size, region.Top / (float)res * size, -1000f );
var maxLocal = new Vector3( region.Right / (float)res * size, region.Bottom / (float)res * size, 1000f );
var a = terrain.WorldTransform.PointToWorld( minLocal );
var b = terrain.WorldTransform.PointToWorld( maxLocal );
clutter.InvalidateTilesInBounds( new BBox( a, b ) );
}
bool EnsureSnapshot()
{
var terrain = GetTerrain();
if ( terrain == null || !terrain.IsValid() || terrain.Storage?.HeightMap == null )
return false;
var map = terrain.Storage.HeightMap;
if ( _baseHeight != null && _baseHeight.Length == map.Length )
return true;
_assetZ = WorldPosition.z;
_assetHeight = new ushort[map.Length];
map.CopyTo( _assetHeight, 0 );
_baseHeight = new ushort[map.Length];
map.CopyTo( _baseHeight, 0 );
SnapshotControl();
RaiseFloor( terrain );
return true;
}
void SnapshotControl()
{
var terrain = GetTerrain();
if ( terrain?.Storage?.ControlMap == null )
return;
var control = terrain.Storage.ControlMap;
_assetControl = new uint[control.Length];
control.CopyTo( _assetControl, 0 );
_baseControl = new uint[control.Length];
control.CopyTo( _baseControl, 0 );
}
void EnsureControlSnapshot()
{
if ( _baseControl != null )
return;
SnapshotControl();
}
void RaiseFloor( Terrain terrain )
{
var storage = terrain.Storage;
if ( storage == null || _baseHeight == null )
return;
var height = storage.TerrainHeight;
var needWorld = MathF.Max( Crater.MaxDepth, Dig.MaxDepth ) + FloorMargin;
var needU = WorldToUshort( needWorld, height );
var min = ushort.MaxValue;
for ( var i = 0; i < _baseHeight.Length; i++ )
{
if ( _baseHeight[i] < min )
min = _baseHeight[i];
}
if ( min >= needU )
{
Dbg( $"floor ok min={min} need={needU} ({needWorld:0.#}u of {height:0.#})" );
return;
}
var pad = needU - min;
for ( var i = 0; i < _baseHeight.Length; i++ )
{
var v = (int)_baseHeight[i] + pad;
_baseHeight[i] = (ushort)Math.Min( v, ushort.MaxValue );
}
_baseHeight.CopyTo( storage.HeightMap, 0 );
var padWorld = pad / (float)ushort.MaxValue * height;
WorldPosition = WorldPosition.WithZ( _assetZ - padWorld );
_floorRaised = true;
PushToEngine( terrain, FullRect( storage.Resolution ), painted: false );
Dbg( $"raised floor pad={pad} ({padWorld:0.#}u) min {min}->{min + pad} need={needU} z {_assetZ:0.#}->{WorldPosition.z:0.#}" );
}
void RestoreFromSnapshot()
{
if ( _assetHeight == null )
return;
var terrain = GetTerrain();
if ( terrain == null || !terrain.IsValid() || terrain.Storage == null )
return;
var storage = terrain.Storage;
if ( storage.HeightMap.Length != _assetHeight.Length )
return;
_assetHeight.CopyTo( storage.HeightMap, 0 );
if ( _assetControl != null && storage.ControlMap.Length == _assetControl.Length )
_assetControl.CopyTo( storage.ControlMap, 0 );
if ( _floorRaised )
{
WorldPosition = WorldPosition.WithZ( _assetZ );
_floorRaised = false;
}
PushToEngine( terrain, FullRect( storage.Resolution ), painted: _assetControl != null );
_queue.Clear();
_assetHeight = null;
_assetControl = null;
_baseHeight = null;
_baseControl = null;
Dbg( "RestoreFromSnapshot — disk heightmap restored" );
}
float ResolveDepth( TerrainEditKind kind, float radius, float depth )
{
if ( kind == TerrainEditKind.Flatten )
return 0f;
var scale = kind == TerrainEditKind.Dig ? Dig.DepthScale : Crater.DepthScale;
var max = kind == TerrainEditKind.Dig ? Dig.MaxDepth : Crater.MaxDepth;
var d = depth > 0f ? depth : radius * scale;
return Math.Clamp( d, 0f, max );
}
Terrain GetTerrain()
{
return Components.Get<Terrain>( FindMode.EverythingInSelfAndDescendants );
}
static RectInt FullRect( int resolution ) => new( 0, 0, resolution, resolution );
static int WorldToUshort( float world, float terrainHeight )
{
if ( terrainHeight <= 0f )
return 0;
return (int)Math.Clamp( MathF.Round( world / terrainHeight * ushort.MaxValue ), 0f, (float)ushort.MaxValue );
}
void Dbg( string message )
{
if ( !DebugLog )
return;
Log.Info( $"[Sculpt] {GameObject.Name}: {message}" );
}
}