Editor/SubTerrainBaseBrushTool.cs
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using Sandbox;
using Editor.TerrainEditor;
namespace Editor.SubTerrain;
public struct SubTerrainPaintParameters
{
public Vector3 HitPosition { get; set; }
public Vector2 HitUV { get; set; }
public float FlattenHeight { get; set; }
public SubTerrainBrushSettings BrushSettings { get; set; }
}
/// <summary>
/// Shared stroke logic for SubTerrain sculpt tools.
/// </summary>
public abstract class SubTerrainBaseBrushTool : EditorTool
{
protected SubTerrainTool _parent;
protected bool _dragging;
protected RectInt _dirtyRegion;
protected SubTerrainSculptMode Mode { get; set; }
protected Vector2 SlopeStartUV;
protected float SlopeStartHeight01;
protected bool AllowBrushInvert { get; set; }
protected bool AllowHeightSample { get; set; }
protected Plane StrokePlane;
bool _samplingHeight;
GpuBuffer<BrushData> _engineBrushBuffer;
Vector3 _lastHitWorldPos;
Vector3 _lastPaintLocal;
Transform _lastHitTx;
Vector2? _cursorLockPosition;
float _raiseStampAccum;
public SubTerrainBaseBrushTool( SubTerrainTool parent )
{
_parent = parent;
}
public override void OnEnabled()
{
AllowGameObjectSelection = false;
_parent.SetBrushMode( GetType().Name );
}
public override void OnDisabled()
{
_parent.DetachSidebarForReuse();
}
public virtual bool GetHitPosition( Terrain terrain, out Vector3 position )
{
return terrain.RayIntersects( Gizmo.CurrentRay, Gizmo.RayDepth, out position );
}
public override void OnUpdate()
{
var terrain = GetSelectedComponent<Terrain>();
if ( !terrain.IsValid() )
{
_parent.ClearStatus();
return;
}
if ( !GetHitPosition( terrain, out var hitPosition ) )
{
_parent.ClearStatus();
return;
}
var tx = terrain.WorldTransform;
_parent.UpdateStatus( terrain, hitPosition );
if ( Application.MouseButtons.HasFlag( MouseButtons.Middle ) )
{
_cursorLockPosition ??= Application.UnscaledCursorPosition;
var d = Application.UnscaledCursorPosition - _cursorLockPosition.Value;
var brush = _parent.BrushSettings;
if ( Gizmo.IsShiftPressed )
{
brush.Size = SubTerrainBrushSize.Clamp( (int)(brush.Size + d.x * 0.25f) );
brush.Opacity = Math.Clamp( brush.Opacity - d.y * 0.002f, 0f, 1f );
_parent.SyncBrushSizeWidget();
}
else if ( Gizmo.IsCtrlPressed && !brush.RandomRotation )
{
brush.Rotation = ((brush.Rotation + d.x * 0.5f) % 360f + 360f) % 360f;
_parent.SyncBrushRotationRows();
}
Application.UnscaledCursorPosition = _cursorLockPosition.Value;
SceneOverlay.Parent.Cursor = CursorShape.Blank;
DrawBrushAdjustText();
_parent.DrawBrushPreview( new Transform( _lastHitWorldPos, _lastHitTx.Rotation ), terrain );
return;
}
if ( _cursorLockPosition.HasValue )
{
SceneOverlay.Parent.Cursor = CursorShape.None;
_cursorLockPosition = null;
}
_lastHitWorldPos = tx.PointToWorld( hitPosition );
_lastHitTx = tx;
if ( AllowHeightSample && Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Alt ) )
{
if ( Gizmo.IsLeftMouseDown )
{
if ( !_samplingHeight )
{
_samplingHeight = true;
_parent.BrushSettings.TargetHeight = hitPosition.z;
}
}
else
{
_samplingHeight = false;
}
_parent.DrawBrushPreview( new Transform( _lastHitWorldPos, tx.Rotation ), terrain );
DrawToolOverlay( terrain, _lastHitWorldPos );
return;
}
_samplingHeight = false;
if ( Gizmo.IsLeftMouseDown )
{
bool moved = !Application.CursorDelta.IsNearZeroLength;
bool shouldSculpt = !_dragging || moved;
if ( !_dragging )
{
if ( _parent.BrushSettings.RandomRotation )
_parent.BrushSettings.Rotation = Random.Shared.NextSingle() * 360f;
StrokePlane = new Plane( _lastHitWorldPos, tx.Rotation.Up );
_dragging = true;
_raiseStampAccum = 0f;
var uv = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize;
var x = (int)Math.Floor( terrain.Storage.Resolution * uv.x );
var y = (int)Math.Floor( terrain.Storage.Resolution * uv.y );
_dirtyRegion = new( new Vector2Int( x, y ) );
_lastPaintLocal = hitPosition;
OnStrokeStart( terrain, hitPosition );
OnPaint( terrain, MakePaintParams( hitPosition, terrain ) );
}
else if ( Mode == SubTerrainSculptMode.RaiseLower )
{
if ( moved )
{
StrokeSculpt( terrain, hitPosition );
_raiseStampAccum = 0f;
}
else
{
// Hold in place: keep raising/lowering at Speed stamps per second.
float t = Math.Clamp( (_parent.BrushSettings.Speed - 1) / 99f, 0f, 1f );
float stampsPerSec = 2f + t * 58f;
_raiseStampAccum += Math.Clamp( RealTime.Delta, 0f, 0.1f ) * stampsPerSec;
while ( _raiseStampAccum >= 1f )
{
_raiseStampAccum -= 1f;
OnPaint( terrain, MakePaintParams( hitPosition, terrain ) );
_lastPaintLocal = hitPosition;
}
}
}
else if ( shouldSculpt )
{
StrokeSculpt( terrain, hitPosition );
}
}
else if ( _dragging )
{
_dragging = false;
_raiseStampAccum = 0f;
OnPaintEnded( terrain );
}
_parent.DrawBrushPreview( new Transform( _lastHitWorldPos, tx.Rotation ), terrain );
DrawToolOverlay( terrain, _lastHitWorldPos );
}
SubTerrainPaintParameters MakePaintParams( Vector3 hitPosition, Terrain terrain )
{
return new SubTerrainPaintParameters
{
HitPosition = hitPosition,
HitUV = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize,
FlattenHeight = hitPosition.z / terrain.Storage.TerrainHeight,
BrushSettings = _parent.BrushSettings
};
}
/// <summary>
/// World-space stamp fill so fast / zoomed-out drags do not leave dashed strokes.
/// </summary>
void StrokeSculpt( Terrain terrain, Vector3 hitLocal )
{
var from = _lastPaintLocal;
var to = hitLocal;
float dist = (to - from).Length;
float step = Math.Max( _parent.BrushSettings.Size * 0.2f, 8f );
if ( dist < 0.001f )
return;
if ( dist <= step )
{
OnPaint( terrain, MakePaintParams( to, terrain ) );
_lastPaintLocal = to;
return;
}
const int maxStamps = 64;
int stamps = (int)Math.Ceiling( dist / step );
if ( stamps > maxStamps )
{
stamps = maxStamps;
step = dist / stamps;
}
for ( int i = 1; i <= stamps; i++ )
{
float t = i / (float)stamps;
OnPaint( terrain, MakePaintParams( Vector3.Lerp( from, to, t ), terrain ) );
}
_lastPaintLocal = to;
}
void DrawBrushAdjustText()
{
var textScope = new TextRendering.Scope
{
TextColor = Color.White,
FontSize = 16 * Gizmo.Settings.GizmoScale * Application.DpiScale,
FontName = "Roboto Mono",
FontWeight = 600,
LineHeight = 1,
Outline = new TextRendering.Outline() { Color = Color.Black, Enabled = true, Size = 3 }
};
var offset = Vector2.Up * 24;
var brush = _parent.BrushSettings;
if ( Gizmo.IsShiftPressed )
{
textScope.Text = $"Size: {brush.Size}";
Gizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset );
textScope.Text = $"Opacity: {brush.Opacity:0.##}";
Gizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset * 2 );
if ( Mode == SubTerrainSculptMode.RaiseLower )
{
textScope.Text = $"Speed: {brush.Speed}";
Gizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset * 3 );
}
}
else if ( Gizmo.IsCtrlPressed && !brush.RandomRotation )
{
textScope.Text = $"Rotation: {brush.Rotation:0.#}";
Gizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset );
}
}
protected virtual void DrawToolOverlay( Terrain terrain, Vector3 worldCenter )
{
}
protected virtual void OnStrokeStart( Terrain terrain, Vector3 hitPosition )
{
}
/// <summary>
/// World Size is brush radius; dispatch covers the diameter (Size * 2 in world units).
/// </summary>
protected int BrushTexelSize( Terrain terrain, float worldSize )
{
int size = (int)Math.Floor( worldSize * 2.0f / terrain.Storage.TerrainSize * terrain.Storage.Resolution );
return Math.Max( size, 1 );
}
protected void GrowDirtyRegion( Terrain terrain, Vector2 hitUV, int size )
{
var x = (int)Math.Floor( terrain.Storage.Resolution * hitUV.x ) - size / 2;
var y = (int)Math.Floor( terrain.Storage.Resolution * hitUV.y ) - size / 2;
_dirtyRegion.Add( new RectInt( x, y, size + 1, size + 1 ) );
}
protected virtual void OnPaint( Terrain terrain, SubTerrainPaintParameters paint )
{
var brushTexture = _parent.SelectedBrushTexture;
if ( brushTexture is null )
return;
int size = BrushTexelSize( terrain, paint.BrushSettings.Size );
var amount = paint.BrushSettings.Opacity;
if ( Mode == SubTerrainSculptMode.RaiseLower )
{
// Speed 50 = historical 1x height per stamp; 100 ≈ 4x. Hold rate is separate.
amount *= Math.Clamp( paint.BrushSettings.Speed / 50f, 0.02f, 4f );
}
var strength = amount * (AllowBrushInvert && Gizmo.IsCtrlPressed ? -1.0f : 1.0f);
DispatchSculpt( terrain, paint, brushTexture, size, strength );
}
protected void DispatchSculpt( Terrain terrain, SubTerrainPaintParameters paint, Texture brushTexture, int size, float strength )
{
if ( (int)Mode <= (int)SubTerrainSculptMode.Noise )
{
DispatchEngineSculpt( terrain, paint, brushTexture, size, strength );
return;
}
var cs = new ComputeShader( "terrain/cs_subterrain_sculpt" );
cs.Attributes.SetComboEnum( "D_SCULPT_MODE", Mode );
cs.Attributes.Set( "Heightmap", terrain.HeightMap );
cs.Attributes.Set( "ControlMap", terrain.ControlMap );
cs.Attributes.Set( "HeightUV", paint.HitUV );
cs.Attributes.Set( "FlattenHeight", paint.FlattenHeight );
cs.Attributes.Set( "SlopeStartUV", SlopeStartUV );
cs.Attributes.Set( "SlopeEndUV", paint.HitUV );
cs.Attributes.Set( "SlopeStartHeight", SlopeStartHeight01 );
cs.Attributes.Set( "SlopeEndHeight", paint.FlattenHeight );
cs.Attributes.Set( "BrushStrength", strength );
cs.Attributes.Set( "BrushSize", size );
cs.Attributes.Set( "Brush", brushTexture );
ApplyExtraAttributes( cs, paint );
cs.Dispatch( size, size, 1 );
GrowDirtyRegion( terrain, paint.HitUV, size );
}
/// <summary>
/// Modes 0-4 match the engine sculpt shader, which already supports brush rotation.
/// Avoids recompiling cs_subterrain_sculpt (that compile has been crashing the editor).
/// </summary>
void DispatchEngineSculpt( Terrain terrain, SubTerrainPaintParameters paint, Texture brushTexture, int size, float strength )
{
var cs = new ComputeShader( "terrain/cs_terrain_sculpt" );
cs.Attributes.SetComboEnum( "D_SCULPT_MODE", (SculptMode)(int)Mode );
cs.Attributes.Set( "Heightmap", terrain.HeightMap );
cs.Attributes.Set( "ControlMap", terrain.ControlMap );
_engineBrushBuffer ??= new GpuBuffer<BrushData>( 1 );
_engineBrushBuffer.SetData( new[]
{
new BrushData
{
UV = paint.HitUV,
Strength = strength,
Size = size,
Rotation = paint.BrushSettings.Rotation * MathF.PI / 180f,
FlattenHeight = paint.FlattenHeight,
}
} );
cs.Attributes.Set( "BrushSettings", _engineBrushBuffer );
cs.Attributes.Set( "Brush", brushTexture );
cs.Dispatch( size, size, 1 );
GrowDirtyRegion( terrain, paint.HitUV, size );
}
/// <summary>
/// Set any attributes a sub-tool needs on top of the shared sculpt ones.
/// Runs after the shared attributes, so it can override them.
/// </summary>
protected virtual void ApplyExtraAttributes( ComputeShader cs, SubTerrainPaintParameters paint )
{
}
private static T[] CopyRegion<T>( T[] data, int stride, RectInt rect ) where T : unmanaged
{
T[] region = new T[rect.Width * rect.Height];
for ( int y = 0; y < rect.Height; y++ )
{
for ( int x = 0; x < rect.Width; x++ )
{
region[x + y * rect.Width] = data[rect.Left + x + (rect.Top + y) * stride];
}
}
return region;
}
private static Action CreateUndoAction<T>( Terrain terrain, T[] dest, T[] region, RectInt dirtyRegion, Terrain.SyncFlags flags ) => () =>
{
if ( !terrain.IsValid() )
return;
for ( int y = 0; y < dirtyRegion.Height; y++ )
{
for ( int x = 0; x < dirtyRegion.Width; x++ )
{
dest[dirtyRegion.Left + x + (dirtyRegion.Top + y) * terrain.Storage.Resolution] = region[x + y * dirtyRegion.Width];
}
}
terrain.SyncGPUTexture();
terrain.UpdateCollision( flags, dirtyRegion );
};
protected virtual void OnPaintEnded( Terrain terrain )
{
_dirtyRegion.Left = Math.Clamp( _dirtyRegion.Left, 0, terrain.Storage.Resolution - 1 );
_dirtyRegion.Right = Math.Clamp( _dirtyRegion.Right, 0, terrain.Storage.Resolution - 1 );
_dirtyRegion.Top = Math.Clamp( _dirtyRegion.Top, 0, terrain.Storage.Resolution - 1 );
_dirtyRegion.Bottom = Math.Clamp( _dirtyRegion.Bottom, 0, terrain.Storage.Resolution - 1 );
var name = $"Terrain {DisplayInfo.For( this ).Name}";
if ( Mode != SubTerrainSculptMode.Hole )
{
var regionBefore = CopyRegion( terrain.Storage.HeightMap, terrain.Storage.Resolution, _dirtyRegion );
terrain.SyncCPUTexture( Terrain.SyncFlags.Height, _dirtyRegion );
var regionAfter = CopyRegion( terrain.Storage.HeightMap, terrain.Storage.Resolution, _dirtyRegion );
SceneEditorSession.Active.UndoSystem.Insert( name,
CreateUndoAction( terrain, terrain.Storage.HeightMap, regionBefore, _dirtyRegion, Terrain.SyncFlags.Height ),
CreateUndoAction( terrain, terrain.Storage.HeightMap, regionAfter, _dirtyRegion, Terrain.SyncFlags.Height ) );
}
else
{
var regionBefore = CopyRegion( terrain.Storage.ControlMap, terrain.Storage.Resolution, _dirtyRegion );
terrain.SyncCPUTexture( Terrain.SyncFlags.Control, _dirtyRegion );
var regionAfter = CopyRegion( terrain.Storage.ControlMap, terrain.Storage.Resolution, _dirtyRegion );
SceneEditorSession.Active.UndoSystem.Insert( name,
CreateUndoAction( terrain, terrain.Storage.ControlMap, regionBefore, _dirtyRegion, Terrain.SyncFlags.Control ),
CreateUndoAction( terrain, terrain.Storage.ControlMap, regionAfter, _dirtyRegion, Terrain.SyncFlags.Control ) );
}
}
}