editor/AdvancedMappingTools/Editing/TerrainBuilderEditor.cs

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&amp;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 ) ) );
	}
}