Editor terrain tool and landscape generator. Draws a rectangle on a terrain and writes hills, mountains, a valley, terraced plateau, lake bed or river bed into the heightmap, with soft edge blending, optional slope and water-bed material overlays, and undo snapshots.
namespace AdvancedMappingTools;
public enum TerrainLandscape
{
Hills,
Mountains,
Valley,
Plateau,
Lake,
River
}
public enum TerrainBlend
{
[Title( "Replace" )] Replace,
[Title( "Add To Ground" )] Add
}
/// <summary>
/// Settings of a generated landscape. Heights are in world units above the height of the first click.
/// </summary>
public sealed class TerrainLandscapeSettings
{
public TerrainLandscape Landscape { get; set; } = TerrainLandscape.Hills;
public float Height { get; set; } = 384;
public float FeatureSize { get; set; } = 1536;
public float Roughness { get; set; } = .5f;
public int Seed { get; set; } = 1;
public float EdgeBlend { get; set; } = .25f;
public TerrainBlend Blend { get; set; } = TerrainBlend.Replace;
public int Terraces { get; set; } = 4;
public float WaterDepth { get; set; } = 160;
public float RiverWidth { get; set; } = 192;
public float Meander { get; set; } = .5f;
public bool PaintSlopes { get; set; }
public int SlopeMaterial { get; set; } = 1;
public float SlopeAngle { get; set; } = 35;
public bool PaintBed { get; set; }
public int BedMaterial { get; set; } = 2;
}
/// <summary>
/// Writes a landscape into the heightmap of an s&box terrain, inside a rectangle given by a frame
/// (corner, +X along the length, +Y across) and its size. Painting uses the control map: base and
/// overlay material slot and a blend, as TerrainSplatFormat.hlsl packs them.
/// </summary>
public static class TerrainLandscapes
{
// Sandbox.Utility.Noise.Perlin has one bump about every 36 units of its input (measured in the lab);
// one feature of the landscape is this many units of noise.
const float NoiseUnit = 40.0f;
public readonly record struct Result( int Cells, int Painted, string Note );
public static Result Apply( Terrain terrain, Transform frame, float length, float depth, TerrainLandscapeSettings s )
{
var storage = terrain.Storage;
if ( storage?.HeightMap is null || length < 1 || depth < 1 )
return new( 0, 0, "No terrain storage." );
var size = storage.Resolution;
var cell = storage.TerrainSize / (size - 1.0f);
var scale = storage.TerrainHeight / ushort.MaxValue;
var world = terrain.WorldTransform;
var baseLevel = world.PointToLocal( frame.Position ).z;
var feature = Math.Clamp( float.IsFinite( s.FeatureSize ) ? s.FeatureSize : 1536, 64, 65536 );
var height = Math.Clamp( float.IsFinite( s.Height ) ? s.Height : 0, 0, 65536 );
var octaves = 1 + (int)MathF.Round( Math.Clamp( float.IsFinite( s.Roughness ) ? s.Roughness : 0, 0, 1 ) * 5 );
var blendWidth = Math.Clamp( float.IsFinite( s.EdgeBlend ) ? s.EdgeBlend : 0, 0, 1 ) * MathF.Min( length, depth ) * .5f;
var seed = new Vector2( (s.Seed * 129.898f) % 9973, (s.Seed * 782.33f) % 9901 );
// Corners of the rectangle in terrain cells, to visit only what can change.
var corners = new[] { Vector3.Zero, new Vector3( length, 0, 0 ), new Vector3( length, depth, 0 ), new Vector3( 0, depth, 0 ) }
.Select( c => world.PointToLocal( frame.PointToWorld( c ) ) ).ToArray();
var x0 = Math.Clamp( (int)MathF.Floor( corners.Min( c => c.x ) / cell ), 0, size - 1 );
var x1 = Math.Clamp( (int)MathF.Ceiling( corners.Max( c => c.x ) / cell ), 0, size - 1 );
var y0 = Math.Clamp( (int)MathF.Floor( corners.Min( c => c.y ) / cell ), 0, size - 1 );
var y1 = Math.Clamp( (int)MathF.Ceiling( corners.Max( c => c.y ) / cell ), 0, size - 1 );
var cells = 0;
var weights = new Dictionary<int, (float Weight, float Bed)>();
for ( var y = y0; y <= y1; y++ )
for ( var x = x0; x <= x1; x++ )
{
var local = new Vector3( x * cell, y * cell, 0 );
var at = frame.PointToLocal( world.PointToWorld( local ) );
if ( at.x < 0 || at.y < 0 || at.x > length || at.y > depth )
continue;
var edge = MathF.Min( MathF.Min( at.x, length - at.x ), MathF.Min( at.y, depth - at.y ) );
var weight = blendWidth <= 0.001f ? 1 : Smooth( edge / blendWidth );
if ( weight <= 0.0001f )
continue;
var (relief, bed) = Relief( s, at.x, at.y, length, depth, feature, height, octaves, seed );
var index = x + y * size;
var old = storage.HeightMap[index] * scale;
var target = s.Blend == TerrainBlend.Add ? old + relief : baseLevel + relief;
var next = old + (target - old) * weight;
storage.HeightMap[index] = (ushort)Math.Clamp( MathF.Round( next / MathF.Max( scale, 0.0001f ) ), 0, ushort.MaxValue );
weights[index] = (weight, bed);
cells++;
}
var painted = Paint( storage, s, weights, size, cell, scale );
var note = PaintNote( storage, s );
return new( cells, painted, note );
}
/// <summary>Height above the base and how much of a water bed this point is (0 to 1).</summary>
static (float Relief, float Bed) Relief( TerrainLandscapeSettings s, float u, float v, float length, float depth, float feature, float height, int octaves, Vector2 seed )
{
float Fbm( float a, float b )
{
var sum = 0.0f;
var amplitude = 1.0f;
var frequency = 1.0f;
var total = 0.0f;
for ( var i = 0; i < octaves; i++ )
{
sum += (Sandbox.Utility.Noise.Perlin( a * NoiseUnit * frequency + seed.x + i * 173f, b * NoiseUnit * frequency + seed.y - i * 91f ) - .5f) * 2 * amplitude;
total += amplitude;
amplitude *= .5f;
frequency *= 2.03f;
}
return Math.Clamp( sum / total * 1.6f, -1, 1 );
}
var n = Fbm( u / feature, v / feature );
switch ( s.Landscape )
{
case TerrainLandscape.Mountains:
{
// Ridged noise: sharp crests where the noise crosses zero.
var ridge = 1 - MathF.Abs( n );
return (height * ridge * ridge, 0);
}
case TerrainLandscape.Valley:
{
var across = MathF.Abs( v - depth * .5f ) / MathF.Max( depth * .5f, 1 );
return (height * MathF.Pow( across, 1.5f ) + height * .12f * n, 0);
}
case TerrainLandscape.Plateau:
{
// One calm, wide noise: fine detail would break every terrace up into little hills.
var steps = Math.Clamp( s.Terraces, 1, 16 );
var wide = Math.Clamp( (Sandbox.Utility.Noise.Perlin( u / feature * .6f * NoiseUnit + seed.x, v / feature * .6f * NoiseUnit + seed.y ) - .5f) * 3, -1, 1 );
var raw = Math.Clamp( .5f + .5f * wide, 0, .999f ) * steps;
var floor = MathF.Floor( raw );
// Flat terraces with short steep risers between them.
var riser = Smooth( Math.Clamp( (raw - floor - .85f) * 6.67f, 0, 1 ) );
return (height * (floor + riser) / steps, 0);
}
case TerrainLandscape.Lake:
{
var dx = (u - length * .5f) / MathF.Max( length * .5f, 1 );
var dy = (v - depth * .5f) / MathF.Max( depth * .5f, 1 );
var r = MathF.Sqrt( dx * dx + dy * dy ) * (1 + .15f * n);
var bowl = Smooth( Math.Clamp( 1 - r, 0, 1 ) * 1.4f );
var water = Math.Clamp( s.WaterDepth, 0, 65536 );
return (-water * bowl + height * .15f * (1 - bowl) * (.5f + .5f * n), bowl);
}
case TerrainLandscape.River:
{
var bend = (Sandbox.Utility.Noise.Perlin( u / feature * .7f * NoiseUnit + seed.x, seed.y ) - .5f) * 2.5f;
var center = depth * .5f + bend * 2 * Math.Clamp( s.Meander, 0, 1 ) * depth * .3f;
var half = Math.Clamp( s.RiverWidth, 8, 65536 ) * .5f;
var t = MathF.Abs( v - center ) / half;
var channel = t < 1 ? 1 - t * t : 0;
var bank = Smooth( Math.Clamp( (t - 1) / 2, 0, 1 ) );
var water = Math.Clamp( s.WaterDepth, 0, 65536 );
return (-water * channel + height * .35f * bank * (.5f + .5f * n), channel > 0 ? Math.Clamp( channel * 2, 0, 1 ) : 0);
}
default:
return (height * Math.Clamp( .5f + .5f * n, 0, 1 ), 0);
}
}
static float Smooth( float t )
{
t = Math.Clamp( t, 0, 1 );
return t * t * (3 - 2 * t);
}
static string PaintNote( TerrainStorage storage, TerrainLandscapeSettings s )
{
var count = storage.Materials?.Count ?? 0;
if ( s.PaintSlopes && s.SlopeMaterial >= count )
return $"Slope material slot {s.SlopeMaterial} does not exist: the terrain has {count} material(s).";
if ( s.PaintBed && s.BedMaterial >= count )
return $"Bed material slot {s.BedMaterial} does not exist: the terrain has {count} material(s).";
return "";
}
/// <summary>Steep ground and water beds get their material as overlay over the base material.</summary>
static int Paint( TerrainStorage storage, TerrainLandscapeSettings s, Dictionary<int, (float Weight, float Bed)> weights, int size, float cell, float scale )
{
var count = storage.Materials?.Count ?? 0;
var slopes = s.PaintSlopes && s.SlopeMaterial >= 0 && s.SlopeMaterial < count;
var beds = s.PaintBed && s.BedMaterial >= 0 && s.BedMaterial < count;
if ( (!slopes && !beds) || storage.ControlMap is null || storage.ControlMap.Length < size * size )
return 0;
var limit = MathF.Tan( Math.Clamp( s.SlopeAngle, 1, 89 ) * MathF.PI / 180 );
var painted = 0;
foreach ( var (index, (weight, bed)) in weights )
{
var x = index % size;
var y = index / size;
float H( int ax, int ay ) => storage.HeightMap[Math.Clamp( ax, 0, size - 1 ) + Math.Clamp( ay, 0, size - 1 ) * size] * scale;
var gx = (H( x + 1, y ) - H( x - 1, y )) / (2 * cell);
var gy = (H( x, y + 1 ) - H( x, y - 1 )) / (2 * cell);
var slope = MathF.Sqrt( gx * gx + gy * gy );
var amount = 0.0f;
var slot = -1;
if ( beds && bed > 0.01f )
{
amount = bed;
slot = s.BedMaterial;
}
else if ( slopes && slope > limit * .8f )
{
amount = Smooth( (slope - limit * .8f) / (limit * .4f) );
slot = s.SlopeMaterial;
}
amount *= weight;
if ( slot < 0 || amount < 0.02f )
continue;
var old = new CompactTerrainMaterial( storage.ControlMap[index] );
storage.ControlMap[index] = new CompactTerrainMaterial( old.BaseTextureId, (byte)slot, (byte)Math.Clamp( MathF.Round( amount * 255 ), 0, 255 ), old.IsHole ).Packed;
painted++;
}
return painted;
}
}
/// <summary>
/// Draws a rectangle on a terrain and writes a landscape into it: hills, mountains, a valley, a
/// plateau of terraces, a lake bed or a winding river bed, blended softly into the ground around.
/// </summary>
[ToolCategory( "Terrain" )]
[Title( "Terrain Builder" ), Icon( "terrain" )]
public sealed class TerrainBuilderEditor( PrimitiveTool tool ) : RectEditor( tool )
{
protected override bool PlacesObjects => true;
protected override IEnumerable<string> Hints => [.. base.Hints, "Click on the terrain: the landscape starts at that height"];
[Property, Group( "Landscape" ), WideMode, Description( "What grows in the rectangle." )]
public TerrainLandscape Landscape { get; set; } = TerrainLandscape.Hills;
[Property, Group( "Landscape" ), Range( 0, 8192, slider: false ), WideMode, Description( "Highest rise above the first click. Valleys rise this high at their sides." )]
public float Height { get; set; } = 384;
[Property, Group( "Landscape" ), Title( "Feature Size" ), Range( 64, 16384, slider: false ), WideMode, Description( "How far apart hills and crests are." )]
public float FeatureSize { get; set; } = 1536;
[Property, Group( "Landscape" ), Range( 0, 1 ), WideMode, Description( "Zero gives smooth rolling ground, one adds small bumps on the big shapes." )]
public float Roughness { get; set; } = .5f;
[Property, Group( "Landscape" ), WideMode, Description( "Another number gives other hills." )]
public int Seed { get; set; } = 1;
[Property, Group( "Landscape" ), Title( "Edge Blend" ), Range( 0, 1 ), WideMode, Description( "How softly the landscape runs out into the ground around. Zero ends hard at the rectangle." )]
public float EdgeBlend { get; set; } = .25f;
[Property, Group( "Landscape" ), WideMode, Description( "Replace sets the ground to the landscape, Add raises the ground that is there by it." )]
public TerrainBlend Blend { get; set; } = TerrainBlend.Replace;
[Property, ShowIf( nameof( Landscape ), TerrainLandscape.Plateau ), Group( "Landscape" ), Range( 1, 16, slider: false ), WideMode, Description( "Number of flat steps." )]
public int Terraces { get; set; } = 4;
[Property, Group( "Water" ), Title( "Water Depth" ), Range( 0, 4096, slider: false ), WideMode, Description( "How deep the lake or river bed goes below the first click." )]
public float WaterDepth { get; set; } = 160;
[Property, ShowIf( nameof( Landscape ), TerrainLandscape.River ), Group( "Water" ), Title( "River Width" ), Range( 8, 4096, slider: false ), WideMode]
public float RiverWidth { get; set; } = 192;
[Property, ShowIf( nameof( Landscape ), TerrainLandscape.River ), Group( "Water" ), Range( 0, 1 ), WideMode, Description( "Zero runs straight along the rectangle, one winds widely." )]
public float Meander { get; set; } = .5f;
[Property, Group( "Paint" ), Title( "Paint Slopes" ), WideMode, Description( "Steep ground gets a material of the terrain as overlay." )]
public bool PaintSlopes { get; set; }
[Property, ShowIf( nameof( PaintSlopes ), true ), Group( "Paint" ), Title( "Slope Material Slot" ), Range( 0, 31, slider: false ), WideMode, Description( "Index in the material list of the terrain, starting at 0." )]
public int SlopeMaterial { get; set; } = 1;
[Property, ShowIf( nameof( PaintSlopes ), true ), Group( "Paint" ), Title( "Slope Angle" ), Range( 5, 85, slider: false ), WideMode, Description( "From this steepness on, in degrees." )]
public float SlopeAngle { get; set; } = 35;
[Property, Group( "Paint" ), Title( "Paint Water Bed" ), WideMode, Description( "The lake or river bed gets a material of the terrain as overlay." )]
public bool PaintBed { get; set; }
[Property, ShowIf( nameof( PaintBed ), true ), Group( "Paint" ), Title( "Bed Material Slot" ), Range( 0, 31, slider: false ), WideMode, Description( "Index in the material list of the terrain, starting at 0." )]
public int BedMaterial { get; set; } = 2;
public override IReadOnlySet<string> UnusedOptions
{
get
{
var hidden = new HashSet<string>( base.UnusedOptions ) { "Surface", "Trim", "DockToVertices", "DockRadius", "ConnectFinished" };
if ( Landscape is not (TerrainLandscape.Lake or TerrainLandscape.River) )
hidden.UnionWith( [nameof( WaterDepth ), nameof( PaintBed ), nameof( BedMaterial )] );
return hidden;
}
}
public override bool ChangesOtherSettings( string property ) => property is nameof( Landscape ) or nameof( PaintSlopes ) or nameof( PaintBed );
public TerrainLandscapeSettings LandscapeSettings() => new()
{
Landscape = Landscape,
Height = Height,
FeatureSize = FeatureSize,
Roughness = Roughness,
Seed = Seed,
EdgeBlend = EdgeBlend,
Blend = Blend,
Terraces = Terraces,
WaterDepth = WaterDepth,
RiverWidth = RiverWidth,
Meander = Meander,
PaintSlopes = PaintSlopes,
SlopeMaterial = SlopeMaterial,
SlopeAngle = SlopeAngle,
PaintBed = PaintBed,
BedMaterial = BedMaterial
};
protected override void BuildMesh( MeshKit kit ) { }
/// <summary>The outline of the rectangle on the ground; the terrain itself changes on Create.</summary>
protected override PolygonMesh BuildPreview()
{
var kit = new MeshKit { Material = SurfaceMaterial };
var frame = RectFrame;
var line = 4.0f;
kit.Frame = frame;
kit.Box( new Vector3( 0, 0, 0 ), new Vector3( RectLength, line, line ) );
kit.Box( new Vector3( 0, RectDepth - line, 0 ), new Vector3( RectLength, RectDepth, line ) );
kit.Box( new Vector3( 0, line, 0 ), new Vector3( line, RectDepth - line, line ) );
kit.Box( new Vector3( RectLength - line, line, 0 ), new Vector3( RectLength, RectDepth - line, line ) );
return kit.Finish();
}
protected override void PlaceObjects()
{
var session = SceneEditorSession.Active;
if ( session is null )
return;
var settings = LandscapeSettings();
var frame = RectFrame;
var found = false;
foreach ( var terrain in session.Scene.GetAllComponents<Terrain>() )
{
if ( terrain.Storage?.HeightMap is null )
continue;
found = true;
var heights = terrain.Storage.HeightMap.ToArray();
var control = terrain.Storage.ControlMap?.ToArray();
var result = TerrainLandscapes.Apply( terrain, frame, RectLength, RectDepth, settings );
if ( result.Cells == 0 )
continue;
if ( !string.IsNullOrEmpty( result.Note ) )
Log.Warning( $"Terrain Builder: {result.Note}" );
var afterHeights = terrain.Storage.HeightMap.ToArray();
var afterControl = terrain.Storage.ControlMap?.ToArray();
Refresh( terrain );
session.AddUndo( $"Terrain {Landscape}", () => Restore( terrain, heights, control ), () => Restore( terrain, afterHeights, afterControl ) );
}
if ( !found )
Log.Warning( "Terrain Builder: there is no terrain in the scene. Add a Terrain component first." );
}
static void Restore( Terrain terrain, ushort[] heights, uint[] control )
{
heights.CopyTo( terrain.Storage.HeightMap, 0 );
control?.CopyTo( terrain.Storage.ControlMap, 0 );
Refresh( terrain );
}
static void Refresh( Terrain terrain )
{
var size = terrain.Storage.Resolution;
terrain.Storage.StateHasChanged();
// All flags: heights and the control map, also for a terrain without textures.
terrain.ApplyStorageChanges( (Terrain.SyncFlags)(-1), new RectInt( new Vector2Int( 0, 0 ), new Vector2Int( size, size ) ) );
}
}