Editor window tool for procedural terrain generation and preview. It builds heightmaps and splatmaps, provides UI for terrain categories/shapes and splat palettes, creates a realtime 3D preview Terrain in an editor scene, exports images, and can apply generated data to scene terrain or per-cell terrains.
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Drawing;
using System.IO;
using System.Linq;
using Editor;
using Editor.ShaderGraph.Nodes;
using Editor.Widgets;
using Sandbox;
using SkiaSharp;
using static Sandbox.Gradient;
using Sturnus.TerrainGenerationTool;
using Sandbox.Utility;
using System.Threading;
using System.Threading.Tasks;
using Parallel = System.Threading.Tasks.Parallel;
using Sturnus.TerrainGenerationTool.RiverStream;
using Sandbox.Services;
using System.Reflection;
using static TerrainGenerationTool;
[EditorApp( "Terrain Pro Generation", "terrain", "Generate procedural terrain with a realtime 3D preview" )]
public class TerrainGenerationTool : BaseWindow
{
public string GenerationPath { get; set; } = Editor.FileSystem.Content.GetFullPath( "" ) + "\\TerrainGenerationTool\\";
public string GenerationLocalPath { get; set; } = "\\TerrainGenerationTool\\";
public string ExportPath { get; set; } = Project.Current.RootDirectory + "\\Assets\\";
HashSet<string> TerrainCategoryArray { get; set; } = new HashSet<string>();
HashSet<string> TerrainShapeArray { get; set; } = new HashSet<string>();
// Per-tile category/shape selections for the tile grid (index = ty * grid + tx)
string[] _tileCategories = new string[1];
string[] _tileShapes = new string[1];
// Per-tile height/scale/seed values (index = ty * grid + tx)
float[] _tileMinHeights = new float[1];
float[] _tileMaxHeights = new float[1];
float[] _tilePlaneScales = new float[1];
long[] _tileSeeds = new long[1];
// Per-tile smoothing/noise values (index = ty * grid + tx)
int[] _tileSmoothingPasses = new int[1];
int[] _tileNoiseLayerStacks = new int[1];
// Per-tile domain warping values (index = ty * grid + tx)
bool[] _tileDomainWarping = new bool[1];
float[] _tileDomainWarpingSizes = new float[1];
float[] _tileDomainWarpingStrengths = new float[1];
// Per-tile splatmap settings (index = ty * grid + tx)
int[] _tileSplatLayerCounts = new int[1];
int[] _tileSplatMapCounts = new int[1];
SplatDispersionMode[] _tileSplatDispersions = new SplatDispersionMode[1];
float[] _tileSplatBlendStrengths = new float[1];
// The tile currently being edited by the Terrain Type page's Category/Shape selectors
int _selectedTileIndex = 0;
bool _syncingTileSelectors;
List<TileGridBox> _tileBoxes = new();
List<Type> terrainCategoryClassesTypes = new List<Type> { typeof( Islands ), typeof( Mountainous ), typeof( Planetary ), typeof( Realistic ), typeof( Sea ), typeof( Volcanic ) };
List<Type> terrainShapeMethodTypes { get; set; }
enum TerrainDimensions : int
{
x512 = 512,
x1024 = 1024,
x2048 = 2048,
x4096 = 4096,
X8192 = 8192
}
/// <summary>
/// How the grid is stored once generated.
/// Combined stitches every cell into one full-res map; PerCell keeps each cell as its own
/// full-resolution heightmap/splatmap (for per-terrain apply, per-cell export and preview).
/// </summary>
public enum GridStorageMode
{
Combined,
PerCell
}
[Step( 1 )] GridStorageMode GridStorage { get; set; } = GridStorageMode.Combined;
// Per-cell full-resolution maps (index = ty * grid + tx). Only filled in PerCell mode.
List<float[,]> _cellHeightmaps = new();
List<float[,]> _cellSplatmaps = new();
public enum SplatDispersionMode
{
Evenly,
Natural
}
//enum TerrainCategoryEnum;
DynamicEnum TerrainCategoryEnum = new DynamicEnum();
DynamicEnum TerrainShapeEnum = new DynamicEnum();
TerrainDimensions TerrainDimensionsEnum { get; set; } = TerrainDimensions.x512;
[Step( 1 ), MinMax( 1, 4 )] int TerrainGridSize { get; set; } = 1;
//TerrainCategoryEnum TerrainShapeEnumSelect { get; set; }
[Step( 0.01f ),MinMax(0.1f,1f)] float TerrainMinHeight { get; set; } = 0.2f;
[Step( 0.01f),MinMax(0.1f,1f)] float TerrainMaxHeight { get; set; } = 0.5f;
[Step( 0.01f),MinMax(0.1f,1f)] float TerrainPlaneScale { get; set; } = 0.5f;
long TerrainSeed { get; set; } = 1234567890;
[Step( 1 ), MinMax( 1,20 )] int SmoothingPasses { get; set; } = 10;
[Group( "Domain Warping" )] bool DomainWarping { get; set; } = true;
[Group( "Domain Warping" )][Step( 0.01f ),MinMax(0.1f,1f)] float DomainWarpingSize { get; set; } = 0.25f;
[Group( "Domain Warping" )][Step( 0.01f ),MinMax(0.1f,1f)] float DomainWarpingStrength { get; set; } = 0.15f;
bool ErosionSimulation { get; set; } = false;
[Step( 1f),MinMax(1f,25f)] int NoiseLayerStacks { get; set; } = 1;
///
/// River Carving Variables
///
[Property][Group( "River & Stream Carving" )] bool RiverCarvingBool { get; set; } = true;
[Property][Group( "River & Stream Carving" )][Step( 0.1f), MinMax( 0.5f, 10f )] float RiverCarvingFrequency { get; set; } = 1.5f;
[Property][Group( "River & Stream Carving" )][Step( 0.01f),MinMax(0.01f,5f)] float RiverCarvingStrength { get; set; } = 0.3f;
[Property][Group( "River & Stream Carving" )][Step( 0.001f),MinMax(0.01f,0.25f)] float RiverCarvingDepth { get; set; } = 0.01f;
[Property][Group( "River & Stream Carving" )][Step( 0.01f),MinMax(0.001f,2f)] float RiverCarvingWidth { get; set; } = 0.25f;
[Property][Group( "River & Stream Carving" )][Step( 0.01f),MinMax(0.05f,1f)] float RiverCarvingSpacing { get; set; } = 0.05f;
[Property][Group( "River & Stream Carving" )][Step( 0.01f),MinMax(0.01f,1f)] float RiverCarvingTurbulenceStrength { get; set; } = 0.01f;
[Property][Group( "River & Stream Carving" )][Step( 0.01f),MinMax(0.01f,10f)] float RiverCarvingTurbulenceFrequency { get; set; } = 0.01f;
///
/// Tool Placement Square
///
[Group( "Tool Placement" )] bool StagingArea { get; set; } = true;
[Group( "Tool Placement" )][Step( 1),MinMax( 1, 100 )] int StagingAreaSize { get; set; } = 10; // Size of the square (in grid units)
[Group( "Tool Placement" )][Step(0.01f),MinMax(0,1)] float StagingAreaHeight { get; set; } = 0.1f; // Height of the flat square
[Group( "Tool Placement" )][Step(0.01f),MinMax(0,1)] float StagingAreaX { get; set; } = 0.1f; // X-center of the square as a ratio
[Group( "Tool Placement" )][Step(0.01f),MinMax(0,1)] float StagingAreaY { get; set; } = 0.1f; // Y-center of the square as a ratio
Gradient SplatMapGradient = new Gradient( new Gradient.ColorFrame( 0.0f, Color.Cyan ), new Gradient.ColorFrame( 0.25f, Color.Red ), new Gradient.ColorFrame( 0.5f, Color.Yellow ), new Gradient.ColorFrame( 0.75f, Color.Green ) );
SKColor[] _splatcolors { get; set; }
[Step( 1 ), MinMax( 2, 32 )] int SplatLayerCount { get; set; } = 8;
[Step( 1 ), MinMax( 1, 8 )] int SplatMapCount { get; set; } = 1;
SplatDispersionMode SplatDispersion { get; set; } = SplatDispersionMode.Evenly;
[Step( 0.05f ), MinMax( 0f, 1f )] float SplatBlendStrength { get; set; } = 0.35f;
[Property] bool PreviewSplatMaterials { get; set; } = false;
float[] _splatthresholds = { 0f, 0.25f, 0.50f, 0.75f };
float[,] _heightmap;
float[,] _splatmap;
float[,] _previewHeightmap;
TerrainMaterial[] _previewMaterials;
int _previewMaterialsGeneration = 0;
List<Editor.Asset> _localTmatAssets;
Texture _preview_image_texture;
Editor.TextureWidget PreviewImage;
Texture _preview_splatmap_texture;
Editor.TextureWidget PreviewSplatmap;
SceneRenderingWidget RenderCanvas;
CameraComponent Camera;
Gizmo.Instance GizmoInstance;
GameObject _previewGO;
Terrain _previewTerrain;
TerrainStorage _previewStorage;
GameObject _splatOverlayGO;
ModelRenderer _splatOverlayRenderer;
Mesh _overlayMesh;
float[] _overlayTargetHeights;
float[] _overlayCurrentHeights;
Color32[] _overlayCurrentColors;
float[,] _overlaySplatmap;
bool _overlayUseSplatColors;
bool _overlayAnimating;
bool _overlayColorAnimating;
List<Color> _splatColorCache = new();
List<float> _currentFrameTimes;
List<float> _targetFrameTimes;
bool _gradientAnimating;
bool _isAnimatingGradient;
const float PreviewMorphSpeed = 8f;
const float MeshMorphSpeed = PreviewMorphSpeed * 0.25f;
float _orbitDistance = 20000f;
float _orbitAngle = 0f;
float _orbitPitch = 30f;
bool _autoSpin = true;
FloatSlider ZoomSlider;
const float SpinSpeed = 8f; // degrees per second
const int PreviewResolution = 512;
const float PreviewTerrainSize = 20000f;
const float PreviewTerrainHeight = 5000f;
SerializedObject _serialized;
bool _previewDirty;
float _lastPreviewRegen = float.MinValue;
bool _isGenerating = false;
int _generationToken = 0;
List<Widget> _domainWarpingWidgets = new();
List<Widget> _riverCarvingWidgets = new();
List<Widget> _stagingAreaWidgets = new();
Dictionary<string, Widget> _propsPages = new();
SegmentedControl _propsTabBar;
Widget _propsContent;
string _activePropsTab;
Widget _tilesContainer;
Button _randomizeMaterialsButton;
Label _materialLoadingLabel;
GradientControlWidget _gradientControlWidget;
WrapSelector ShapeArray;
WrapSelector CategoryArray;
public class DynamicEnum
{
private readonly Dictionary<string, int> _values = new Dictionary<string, int>();
private int _nextValue = 0;
public void Add( string name )
{
if ( !_values.ContainsKey( name ) )
{
_values[name] = _nextValue++;
}
}
public int GetValue( string name )
{
if ( string.IsNullOrEmpty( name ) ) return -1;
return _values.TryGetValue( name, out var value ) ? value : -1; // Return -1 if not found
}
public string GetName( int key )
{
return _values.FirstOrDefault( pair => pair.Value == key ).Key ?? "Unknown"; // Return "Unknown" if not found
}
public string[] GetNames()
{
return _values.Keys.ToArray();
}
}
public static string[] GetMethodsFromClass( string className )
{
// Attempt to get the Type from the class name (fully qualified)
Type classType = Type.GetType( className );
if ( classType == null )
{
throw new ArgumentException( $"Class '{className}' could not be found. Ensure the namespace is included." );
}
List<string> methodNames = new List<string>();
// Get all public methods (static and instance) from the class
MethodInfo[] methods = classType.GetMethods( BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static );
foreach ( var method in methods )
{
// Exclude methods not declared in this class
if ( method.DeclaringType == classType )
{
methodNames.Add( method.Name );
}
}
return methodNames.ToArray();
}
public string[] GetTerrainCategoryClasses( params Type[] CategoryClasses )
{
HashSet<string> methodNames = new HashSet<string>();
foreach ( Type CategoryClass in CategoryClasses )
{
// Get all public static methods from the class
MethodInfo[] methods = CategoryClass.GetMethods( BindingFlags.Public | BindingFlags.Static );
foreach ( MethodInfo method in methods )
{
// Exclude inherited methods or non-relevant ones
if ( method.DeclaringType == CategoryClass )
{
methodNames.Add( $"{CategoryClass.Name}.{method.Name}" );
TerrainCategoryEnum.Add( CategoryClass.Name);
TerrainCategoryArray.Add( CategoryClass.Name );
}
}
}
return methodNames.ToArray();
}
public string[] GetTerrainShapeMethods( Type shapeClass )
{
HashSet<string> methodNames = new HashSet<string>();
MethodInfo[] methods = shapeClass.GetMethods( BindingFlags.Public | BindingFlags.Static );
foreach ( MethodInfo method in methods )
{
// Exclude inherited methods or non-relevant ones
if ( method.DeclaringType == shapeClass )
{
//TerrainShapeArray.Add( shapeClass.Name );
//TerrainShapeEnum.Add( shapeClass.Name );
}
}
return methodNames.ToArray();
}
public static object CallMethod( string className, string methodName, object[] parameters = null )
{
// Get the class type
Type classType = Type.GetType( className );
if ( classType == null )
{
throw new ArgumentException( $"Class '{className}' could not be found." );
}
// Get the method info
MethodInfo method = classType.GetMethod( methodName, BindingFlags.Public | BindingFlags.Static | BindingFlags.Instance );
if ( method == null )
{
throw new ArgumentException( $"Method '{methodName}' could not be found in class '{className}'." );
}
// Check if the method is static or instance
object instance = null;
if ( !method.IsStatic )
{
instance = Activator.CreateInstance( classType );
}
// Invoke the method
object result = method.Invoke( instance, parameters );
// Ensure the return type is compatible
if ( result is not float )
{
throw new InvalidOperationException( $"Method '{methodName}' does not return a float." );
}
return result;
}
public void InitialShapes()
{
ShapeArray.DestroyChildren();
TerrainShapeArray.Clear();
string className = $"Sturnus.TerrainGenerationTool.Islands"; // Fully qualified name
string[] methods = GetMethodsFromClass( className );
// Print the methods
foreach ( string method in methods )
{
TerrainShapeArray.Add( method );
TerrainShapeEnum.Add( method );
}
foreach ( var shape in TerrainShapeArray )
{
ShapeArray.AddOption( shape );
}
}
public TerrainGenerationTool() : base()
{
WindowTitle = "Terrain Generation Tool";
SetWindowIcon( "terrain" );
MinimumSize = new Vector2( 1000, 700 );
Size = new Vector2( 1500, 900 );
StartCentered = true;
_serialized = this.GetSerialized();
_serialized.OnPropertyChanged += OnSerializedPropertyChanged;
string[] terrainCategoryClasses = GetTerrainCategoryClasses( terrainCategoryClassesTypes.ToArray() );
string[] terrainShapeMethods = GetTerrainShapeMethods( typeof(Islands) );
//Create TerrainGenerationTool folder if it doesn't exist.
Directory.CreateDirectory( GenerationPath );
SplatMapGradient.Blending = Gradient.BlendMode.Stepped;
Layout = Layout.Row();
Layout.Margin = 0;
Layout.Spacing = 0;
// ---------- Left: options panel ----------
var scroll = new ScrollArea( this );
scroll.Canvas = new Widget( scroll );
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Margin = 10;
scroll.Canvas.Layout.Spacing = 5;
scroll.MinimumWidth = 400;
scroll.MaximumWidth = 480;
Layout.Add( scroll );
var body = scroll.Canvas.Layout;
// ---------- Left: grouped property tabs ----------
var propsRoot = body.Add( new Widget( null ), 1 );
propsRoot.Layout = Layout.Column();
propsRoot.Layout.Spacing = 5;
_propsTabBar = propsRoot.Layout.Add( new SegmentedControl() );
_propsTabBar.ShowText = true;
_propsTabBar.FixedHeight = Theme.RowHeight * 1.6f;
_propsTabBar.OnSelectedChanged += ( name ) => SelectPropsTab( name );
_propsContent = propsRoot.Layout.Add( new Widget( null ), 1 );
_propsContent.Layout = Layout.Column();
_propsContent.Layout.Margin = 0;
_propsContent.Layout.Alignment = TextFlag.Top;
// --- Terrain Type tab ---
var typePage = CreatePropsPage();
typePage.Layout.Add( new Label( "Terrain Dimensions" ) );
typePage.Layout.Add( new EnumControlWidget( _serialized.GetProperty( nameof( TerrainDimensionsEnum ) ) ) );
typePage.Layout.Add( new Label( "Terrain Category" ) );
CategoryArray = typePage.Layout.Add( new WrapSelector() );
for ( int i = 0; i < TerrainCategoryArray.ToArray().GetLength( 0 ); i++ )
{
List<string> rowValues = new List<string>();
rowValues.Add( TerrainCategoryArray.ToArray()[i] );
CategoryArray.AddOption( rowValues[0] );
}
typePage.Layout.Add( new Label( "Terrain Shape" ) );
ShapeArray = typePage.Layout.Add( new WrapSelector() );
InitialShapes();
CategoryArray.OnSelectedChanged += ( _ ) =>
{
RebuildShapes();
ApplySelectedCategory();
_previewDirty = true;
};
ShapeArray.OnSelectedChanged += ( _ ) =>
{
ApplySelectedShape();
_previewDirty = true;
};
if ( CategoryArray.Children.Count() > 0 )
{
CategoryArray.SelectedIndex = 0;
CategoryArray.Selected = CategoryArray.Children.First().Name;
}
RebuildShapes();
if ( ShapeArray.Children.Count() > 0 )
{
ShapeArray.SelectedIndex = 0;
ShapeArray.Selected = ShapeArray.Children.First().Name;
}
AddPropsTab( "Terrain Type", "terrain", typePage, "Terrain dimensions, category and shape" );
// --- Height / Scale tab ---
var heightPage = CreatePropsPage();
heightPage.Layout.Add( new Label( "Min Height (relative)" ) );
heightPage.Layout.Add( FloatSlider( nameof( TerrainMinHeight ) ) );
heightPage.Layout.Add( new Label( "Max Height (relative)" ) );
heightPage.Layout.Add( FloatSlider( nameof( TerrainMaxHeight ) ) );
heightPage.Layout.Add( new Label( "Terrain Plane Scale" ) );
heightPage.Layout.Add( FloatSlider( nameof( TerrainPlaneScale ) ) );
heightPage.Layout.Add( new Label( "Terrain Seed" ) );
var seedRow = heightPage.Layout.AddRow();
seedRow.Spacing = 4;
var seedControl = seedRow.Add( new IntegerControlWidget( _serialized.GetProperty( nameof( TerrainSeed ) ) ), 1 );
seedRow.Add( new IconButton( "casino", RandomizeSeed, this )
{
ToolTip = "Randomize seed",
IconSize = 16,
FixedSize = new Vector2( 26, 26 )
} );
AddPropsTab( "Height/Scale", "straighten", heightPage, "Terrain height, plane scale and seed" );
// --- Smooth / Noise tab ---
var noisePage = CreatePropsPage();
noisePage.Layout.Add( new Label( "Smoothing Passes" ) );
noisePage.Layout.Add( IntSlider( nameof( SmoothingPasses ) ) );
noisePage.Layout.Add( new Label( "Noise Layer Stacks" ) );
noisePage.Layout.Add( IntSlider( nameof( NoiseLayerStacks ) ) );
AddPropsTab( "Smooth/Noise", "grain", noisePage, "Terrain smoothing and noise layers" );
// --- Splat tab ---
var splatPage = CreatePropsPage();
splatPage.Layout.Add( new Label( "Splat Layer Count" ) );
splatPage.Layout.Add( IntSlider( nameof( SplatLayerCount ) ) );
splatPage.Layout.Add( new Label( "Splat Map Count" ) );
splatPage.Layout.Add( IntSlider( nameof( SplatMapCount ) ) );
splatPage.Layout.Add( new Label( "Dispersion" ) );
splatPage.Layout.Add( new EnumControlWidget( _serialized.GetProperty( nameof( SplatDispersion ) ) ) );
splatPage.Layout.Add( new Label( "Blend Strength" ) );
splatPage.Layout.Add( FloatSlider( nameof( SplatBlendStrength ) ) );
splatPage.Layout.Add( new Label( "Splatmap Colors/Threshold" ) );
_gradientControlWidget = new GradientControlWidget( _serialized.GetProperty( nameof( SplatMapGradient ) ) );
splatPage.Layout.Add( _gradientControlWidget );
splatPage.Layout.Add( new Label( "Preview Materials" ) );
splatPage.Layout.Add( new BoolControlWidget( _serialized.GetProperty( nameof( PreviewSplatMaterials ) ) ) );
var materialHint = new Label( "Assigns random local .tmat terrain materials from your project's assets to the splat layers so you can preview the material blending on the terrain." );
materialHint.SetStyles( "font-size: 10px; color: #888;" );
materialHint.WordWrap = true;
materialHint.MaximumWidth = 260;
splatPage.Layout.Add( materialHint );
var randomizeRow = splatPage.Layout.AddRow();
_randomizeMaterialsButton = randomizeRow.Add( new Button( "Randomize Materials", "casino" ) );
_randomizeMaterialsButton.Clicked += RandomizeMaterials;
_materialLoadingLabel = randomizeRow.Add( new Label( "Loading..." ) );
_materialLoadingLabel.SetStyles( "font-size: 10px; color: #888;" );
_materialLoadingLabel.Visible = false;
_materialLoadingLabel.WordWrap = true;
_materialLoadingLabel.MaximumWidth = 120;
AddPropsTab( "Splat", "palette", splatPage, "Splatmap layers, maps, colors and dispersion" );
// --- Warping tab ---
var warpPage = CreatePropsPage();
warpPage.Layout.Add( new Label( "Domain Warping" ) );
warpPage.Layout.Add( new BoolControlWidget( _serialized.GetProperty( nameof( DomainWarping ) ) ) );
var DomainWarpingSizeLabel = warpPage.Layout.Add( new Label( "Domain Warping (Size)" ) );
var DomainWarpingSizeFloat = warpPage.Layout.Add( FloatSlider( nameof( DomainWarpingSize ) ) );
var DomainWarpingStrengthLabel = warpPage.Layout.Add( new Label( "Domain Warping (Strength)" ) );
var DomainWarpingStrengthFloat = warpPage.Layout.Add( FloatSlider( nameof( DomainWarpingStrength ) ) );
_domainWarpingWidgets.AddRange( new Widget[] { DomainWarpingSizeLabel, DomainWarpingSizeFloat, DomainWarpingStrengthLabel, DomainWarpingStrengthFloat } );
AddPropsTab( "Warping", "blur_on", warpPage, "Domain warping options" );
// --- River tab ---
var riverPage = CreatePropsPage();
riverPage.Layout.Add( new Label( "River Carving" ) );
riverPage.Layout.Add( new BoolControlWidget( _serialized.GetProperty( nameof( RiverCarvingBool ) ) ) );
var RiverCarvingFrequencyLabel = riverPage.Layout.Add( new Label( "RiverCarvingFrequency" ) );
var RiverCarvingFrequencyFloat = riverPage.Layout.Add( FloatSlider( nameof( RiverCarvingFrequency ) ) );
/*var RiverCarvingStrength = riverPage.Layout.Add( new Label("RiverCarvingStrength"));
var RiverCarvingStrengthFloat = riverPage.Layout.Add( FloatSlider( nameof(RiverCarvingStrength) ) );*/
var RiverCarvingDepthLabel = riverPage.Layout.Add( new Label( "RiverCarvingDepth" ) );
var RiverCarvingDepthFloat = riverPage.Layout.Add( FloatSlider( nameof( RiverCarvingDepth ) ) );
var RiverCarvingWidthLabel = riverPage.Layout.Add( new Label( "RiverCarvingWidth" ) );
var RiverCarvingWidthFloat = riverPage.Layout.Add( FloatSlider( nameof( RiverCarvingWidth ) ) );
var RiverCarvingSpacingLabel = riverPage.Layout.Add( new Label( "RiverCarvingSpacing" ) );
var RiverCarvingSpacingFloat = riverPage.Layout.Add( FloatSlider( nameof( RiverCarvingSpacing ) ) );
var RiverCarvingTurbulenceStrengthLabel = riverPage.Layout.Add( new Label( "RiverCarvingTurbulenceStrength" ) );
var RiverCarvingTurbulenceStrengthFloat = riverPage.Layout.Add( FloatSlider( nameof( RiverCarvingTurbulenceStrength ) ) );
var RiverCarvingTurbulenceFrequencyLabel = riverPage.Layout.Add( new Label( "RiverCarvingTurbulenceFrequency" ) );
var RiverCarvingTurbulenceFrequencyFloat = riverPage.Layout.Add( FloatSlider( nameof( RiverCarvingTurbulenceFrequency ) ) );
_riverCarvingWidgets.AddRange( new Widget[]
{
RiverCarvingFrequencyLabel, RiverCarvingFrequencyFloat,
/*RiverCarvingStrength, RiverCarvingStrengthFloat,*/
RiverCarvingDepthLabel, RiverCarvingDepthFloat,
RiverCarvingWidthLabel, RiverCarvingWidthFloat,
RiverCarvingSpacingLabel, RiverCarvingSpacingFloat,
RiverCarvingTurbulenceStrengthLabel, RiverCarvingTurbulenceStrengthFloat,
RiverCarvingTurbulenceFrequencyLabel, RiverCarvingTurbulenceFrequencyFloat
} );
AddPropsTab( "River", "water", riverPage, "River carving options" );
// --- Staging tab ---
var stagingPage = CreatePropsPage();
stagingPage.Layout.Add( new Label( "Staging Area" ) );
stagingPage.Layout.Add( new BoolControlWidget( _serialized.GetProperty( nameof( StagingArea ) ) ) );
var StagingAreaSizeLabel = stagingPage.Layout.Add( new Label( "Staging Area (Size)" ) );
var StagingAreaSizeFloat = stagingPage.Layout.Add( IntSlider( nameof( StagingAreaSize ) ) );
var StagingAreaHeightLabel = stagingPage.Layout.Add( new Label( "Staging Area (Height)" ) );
var StagingAreaHeightFloat = stagingPage.Layout.Add( FloatSlider( nameof( StagingAreaHeight ) ) );
var StagingAreaXLabel = stagingPage.Layout.Add( new Label( "Staging Area (X)" ) );
var StagingAreaXFloat = stagingPage.Layout.Add( FloatSlider( nameof( StagingAreaX ) ) );
var StagingAreaYLabel = stagingPage.Layout.Add( new Label( "Staging Area (Y)" ) );
var StagingAreaYFloat = stagingPage.Layout.Add( FloatSlider( nameof( StagingAreaY ) ) );
_stagingAreaWidgets.AddRange( new Widget[]
{
StagingAreaSizeLabel, StagingAreaSizeFloat,
StagingAreaHeightLabel, StagingAreaHeightFloat,
StagingAreaXLabel, StagingAreaXFloat,
StagingAreaYLabel, StagingAreaYFloat
} );
AddPropsTab( "Staging", "square_foot", stagingPage, "Staging area placement" );
body.AddSpacingCell( 5 );
// ---------- Tile grid section (docked between props and actions) ----------
var tileGridSection = body.Add( new Widget( null ) );
tileGridSection.Layout = Layout.Column();
tileGridSection.Layout.Spacing = 4;
tileGridSection.Layout.Add( new Label( "Tile Grid" ) );
tileGridSection.Layout.Add( IntSlider( nameof( TerrainGridSize ) ) );
tileGridSection.Layout.Add( new Label( "Storage" ) );
tileGridSection.Layout.Add( new EnumControlWidget( _serialized.GetProperty( nameof( GridStorage ) ) ) );
var tileGridHint = new Label( "Click a tile to select which cell the Terrain Type category and shape apply to. Click 'All' to set every tile at once." );
tileGridHint.SetStyles( "font-size: 10px; color: #888;" );
tileGridHint.WordWrap = true;
tileGridHint.MaximumWidth = 260;
tileGridSection.Layout.Add( tileGridHint );
_tilesContainer = new Widget( null );
_tilesContainer.Layout = Layout.Column();
_tilesContainer.Layout.Spacing = 4;
tileGridSection.Layout.Add( _tilesContainer );
body.AddSpacingCell( 5 );
var GenerateButton = body.Add( new Button.Primary( "Generate", "auto_awesome", this ) );
var ExportButton = body.Add( new Button( "Export", "file_download", this ) );
ExportButton.Tint = "#41AF20";
var ApplyButton = body.Add( new Button( "Apply To Terrain", "file_upload", this ) );
ApplyButton.Tint = "#AF2020";
if ( _heightmap == null )
{
ExportButton.Enabled = false;
ApplyButton.Enabled = false;
}
GenerateButton.Clicked += () =>
{
BuildSplatColors();
int fullRes = (int)TerrainDimensionsEnum;
if ( GridStorage == GridStorageMode.PerCell )
{
// Each cell is its own full-resolution map - no stitching.
_cellHeightmaps = BuildPerCellHeightmaps(
fullRes, fullRes,
(string[])_tileCategories.Clone(), (string[])_tileShapes.Clone(),
(long[])_tileSeeds.Clone(), (int[])_tileNoiseLayerStacks.Clone(),
(float[])_tileMinHeights.Clone(), (float[])_tileMaxHeights.Clone(),
(bool[])_tileDomainWarping.Clone(), (float[])_tileDomainWarpingSizes.Clone(), (float[])_tileDomainWarpingStrengths.Clone(),
(int[])_tileSmoothingPasses.Clone(), (float[])_tilePlaneScales.Clone(),
RiverCarvingBool, RiverCarvingFrequency, RiverCarvingWidth, RiverCarvingDepth,
RiverCarvingTurbulenceFrequency, RiverCarvingTurbulenceStrength, RiverCarvingSpacing,
StagingArea, StagingAreaSize, StagingAreaHeight, StagingAreaX, StagingAreaY );
if ( _cellHeightmaps.Count == 0 )
{
Log.Error( "No per-cell heightmaps generated. Aborting." );
return;
}
_cellSplatmaps = BuildPerCellSplatmaps( _cellHeightmaps,
(int[])_tileSplatLayerCounts.Clone(), (SplatDispersionMode[])_tileSplatDispersions.Clone(), (float[])_tileSplatBlendStrengths.Clone() );
// A stitched preview map so the 3D preview still shows the whole grid tiled.
_heightmap = BuildHeightmap(
fullRes, fullRes,
(string[])_tileCategories.Clone(), (string[])_tileShapes.Clone(), TerrainGridSize,
(long[])_tileSeeds.Clone(), (int[])_tileNoiseLayerStacks.Clone(),
(float[])_tileMinHeights.Clone(), (float[])_tileMaxHeights.Clone(),
(bool[])_tileDomainWarping.Clone(), (float[])_tileDomainWarpingSizes.Clone(), (float[])_tileDomainWarpingStrengths.Clone(),
(int[])_tileSmoothingPasses.Clone(), (float[])_tilePlaneScales.Clone(),
RiverCarvingBool, RiverCarvingFrequency, RiverCarvingWidth, RiverCarvingDepth,
RiverCarvingTurbulenceFrequency, RiverCarvingTurbulenceStrength, RiverCarvingSpacing,
StagingArea, StagingAreaSize, StagingAreaHeight, StagingAreaX, StagingAreaY );
}
else
{
_heightmap = BuildHeightmap(
fullRes, fullRes,
(string[])_tileCategories.Clone(), (string[])_tileShapes.Clone(), TerrainGridSize,
(long[])_tileSeeds.Clone(), (int[])_tileNoiseLayerStacks.Clone(),
(float[])_tileMinHeights.Clone(), (float[])_tileMaxHeights.Clone(),
(bool[])_tileDomainWarping.Clone(), (float[])_tileDomainWarpingSizes.Clone(), (float[])_tileDomainWarpingStrengths.Clone(),
(int[])_tileSmoothingPasses.Clone(), (float[])_tilePlaneScales.Clone(),
RiverCarvingBool, RiverCarvingFrequency, RiverCarvingWidth, RiverCarvingDepth,
RiverCarvingTurbulenceFrequency, RiverCarvingTurbulenceStrength, RiverCarvingSpacing,
StagingArea, StagingAreaSize, StagingAreaHeight, StagingAreaX, StagingAreaY );
_cellHeightmaps = null;
_cellSplatmaps = null;
}
if ( _heightmap == null )
{
Log.Error( "Heightmap is not generated. Aborting." );
return;
}
_splatmap = BuildTileGridSplatmap( _heightmap, TerrainGridSize,
(int[])_tileSplatLayerCounts.Clone(), (SplatDispersionMode[])_tileSplatDispersions.Clone(), (float[])_tileSplatBlendStrengths.Clone() );
// Write the preview files for the asset folder, and build fresh textures for the widgets
GeneratePreviewFile( GenerationPath, out var previewBitmap, out var splatBitmap );
_preview_image_texture = TextureFromBitmap( previewBitmap );
PreviewImage.Texture = _preview_image_texture;
_preview_splatmap_texture = TextureFromBitmap( splatBitmap );
PreviewSplatmap.Texture = _preview_splatmap_texture;
ExportButton.Enabled = true;
ApplyButton.Enabled = true;
UpdatePreviewTerrain( _heightmap );
};
ExportButton.Clicked += () =>
{
GenerateImageFiles( ExportPath );
var PopUp = new PopupWindow( "Export Complete", $"Files exported to {ExportPath}", "Okay" );
PopUp.Show();
};
ApplyButton.Clicked += () =>
{
IDictionary<string, Action> WarnDiaglog = new Dictionary<string, Action>(); ;
WarnDiaglog.Add( "Apply", GridStorage == GridStorageMode.PerCell ? UpdatePerCellTerrains : UpdateTerrain );
var PopUpWarn = new PopupWindow( "Warning: Terrain Override", "This will override your current scene's terrain data.","Cancel", WarnDiaglog );
PopUpWarn.Show();
};
body.AddStretchCell();
// ---------- Right: tabbed preview panel ----------
var rightPanel = Layout.Add( new Widget( null ), 1 );
rightPanel.Layout = Layout.Column();
rightPanel.Layout.Spacing = 0;
var PreviewTabs = rightPanel.Layout.Add( new VerticalTabWidget( this ), 1 );
PreviewTabs.StateCookie = "TerrainGenerationTool.PreviewTabs";
RenderCanvas = new SceneRenderingWidget( this );
RenderCanvas.OnPreFrame += OnPreFrame;
RenderCanvas.FocusMode = FocusMode.Click;
RenderCanvas.Scene = Scene.CreateEditorScene();
RenderCanvas.Scene.SceneWorld.AmbientLightColor = Color.FromBytes( 135, 206, 235 ) * 0.45f;
// 3D preview tab
PreviewTabs.AddPage( "3D Preview", "landscape", RenderCanvas, "3D terrain preview" );
// Height/Color maps tab
var mapsPage = new Widget( null );
mapsPage.Layout = Layout.Row();
mapsPage.Layout.Spacing = 5;
var _image_preview = new Editor.TextureWidget();
_image_preview.Texture = _preview_image_texture;
_image_preview.Size = new Vector2( 512, 512 );
PreviewImage = mapsPage.Layout.Add( _image_preview, 50 );
var _splatmap_preview = new Editor.TextureWidget();
_splatmap_preview.Texture = _preview_splatmap_texture;
_splatmap_preview.Size = new Vector2( 512, 512 );
PreviewSplatmap = mapsPage.Layout.Add( _splatmap_preview, 50 );
PreviewTabs.AddPage( "Height/Color Maps", "grid_view", mapsPage, "Heightmap and splatmap preview" );
using ( RenderCanvas.Scene.Push() )
{
Camera = new GameObject( true, "camera" ).GetOrAddComponent<CameraComponent>( false );
Camera.BackgroundColor = Color.FromBytes( 135, 206, 235 );
Camera.ZFar = 100000;
Camera.Enabled = true;
PositionCameraForOrbit();
RenderCanvas.Camera = Camera;
var sun = new GameObject( true, "sun" ).GetOrAddComponent<DirectionalLight>( false );
sun.WorldRotation = Rotation.From( 45, 45, 0 );
sun.LightColor = Color.White;
sun.SkyColor = Color.FromBytes( 135, 206, 235 );
sun.Enabled = true;
var sun2 = new GameObject( true, "sun2" ).GetOrAddComponent<DirectionalLight>( false );
sun2.WorldRotation = Rotation.From( -30, 135, 0 );
sun2.LightColor = Color.White * 0.3f;
sun2.SkyColor = Color.FromBytes( 135, 206, 235 );
sun2.Enabled = true;
}
GizmoInstance = RenderCanvas.GizmoInstance;
// Create the preview terrain - a real Terrain component in the preview scene
using ( RenderCanvas.Scene.Push() )
{
_previewGO = new GameObject( true, "terrain preview" );
_previewTerrain = _previewGO.AddComponent<Terrain>( false );
_previewStorage = new TerrainStorage();
_previewStorage.EmbeddedResource = new Sandbox.Resources.EmbeddedResource { ResourceCompiler = "embed" };
_previewStorage.SetResolution( PreviewResolution );
_previewStorage.TerrainSize = PreviewTerrainSize;
_previewStorage.TerrainHeight = PreviewTerrainHeight;
_previewTerrain.Storage = _previewStorage;
_previewTerrain.TerrainSize = PreviewTerrainSize;
_previewTerrain.TerrainHeight = PreviewTerrainHeight;
// Terrain spans [0, TerrainSize] from its origin - shift it so it's centered on the origin
_previewGO.WorldPosition = new Vector3( -PreviewTerrainSize * 0.5f, -PreviewTerrainSize * 0.5f, 0f );
_previewTerrain.Enabled = true;
// Overlay mesh that shows the splatmap colors when the material preview is off.
// Slightly offset above the terrain so it doesn't z-fight with the terrain surface.
// Parented to the terrain GO (which is centered at origin), so the mesh uses local coords.
_splatOverlayGO = new GameObject( true, "splat overlay" );
_splatOverlayGO.Parent = _previewGO;
_splatOverlayGO.LocalPosition = new Vector3( 0, 0, 1f );
_splatOverlayRenderer = _splatOverlayGO.AddComponent<ModelRenderer>();
_splatOverlayRenderer.MaterialOverride = Material.Load( "materials/default/vertex_color.vmat" );
_splatOverlayGO.Enabled = false;
}
// Zoom slider at the bottom of the preview panel
var zoomRow = rightPanel.Layout.AddRow();
zoomRow.Margin = new Sandbox.UI.Margin( 8, 4, 8, 6 );
zoomRow.Spacing = 8;
zoomRow.Add( new IconButton( "zoom_out", () => ZoomSlider.Value = MathF.Max( ZoomSlider.Minimum, ZoomSlider.Value - 500f ), this ) { IconSize = 16, FixedSize = new Vector2( 22, 22 ) } );
ZoomSlider = zoomRow.Add( new FloatSlider( this ), 1 );
ZoomSlider.Minimum = 10000f;
ZoomSlider.Maximum = 40000f;
ZoomSlider.Step = 500f;
ZoomSlider.Value = 40000f - _orbitDistance + 10000f;
ZoomSlider.OnValueEdited = UpdateOrbitFromZoom;
zoomRow.Add( new IconButton( "zoom_in", () => ZoomSlider.Value = MathF.Min( ZoomSlider.Maximum, ZoomSlider.Value + 500f ), this ) { IconSize = 16, FixedSize = new Vector2( 22, 22 ) } );
ApplyConditionalVisibility();
RebuildTileGridUI();
LoadFacepunchMaterialsAsync();
RegeneratePreview();
Show();
}
void OnSerializedPropertyChanged( SerializedProperty prop )
{
// Intermediate frames written during gradient animation shouldn't retrigger a regen
if ( !_isAnimatingGradient )
_previewDirty = true;
if ( prop is null ) return;
switch ( prop.Name )
{
case nameof( TerrainGridSize ):
RebuildTileGridUI();
break;
case nameof( GridStorage ):
// Reset stored per-cell maps when the storage mode changes so stale data isn't applied/exported
_cellHeightmaps = null;
_cellSplatmaps = null;
break;
case nameof( TerrainMinHeight ):
if ( !_syncingTileSelectors ) WriteSelectedValues( minHeight: TerrainMinHeight );
break;
case nameof( TerrainMaxHeight ):
if ( !_syncingTileSelectors ) WriteSelectedValues( maxHeight: TerrainMaxHeight );
break;
case nameof( TerrainPlaneScale ):
if ( !_syncingTileSelectors ) WriteSelectedValues( planeScale: TerrainPlaneScale );
break;
case nameof( TerrainSeed ):
if ( !_syncingTileSelectors ) WriteSelectedValues( seed: TerrainSeed );
break;
case nameof( SmoothingPasses ):
if ( !_syncingTileSelectors ) WriteSelectedValues( smoothing: SmoothingPasses );
break;
case nameof( NoiseLayerStacks ):
if ( !_syncingTileSelectors ) WriteSelectedValues( noiseLayers: NoiseLayerStacks );
break;
case nameof( DomainWarping ):
SetWidgetsVisible( _domainWarpingWidgets, DomainWarping );
if ( !_syncingTileSelectors ) WriteSelectedValues( warp: DomainWarping );
break;
case nameof( DomainWarpingSize ):
if ( !_syncingTileSelectors ) WriteSelectedValues( warpSize: DomainWarpingSize );
break;
case nameof( DomainWarpingStrength ):
if ( !_syncingTileSelectors ) WriteSelectedValues( warpStrength: DomainWarpingStrength );
break;
case nameof( StagingArea ):
SetWidgetsVisible( _stagingAreaWidgets, StagingArea );
break;
case nameof( SplatLayerCount ):
case nameof( SplatDispersion ):
case nameof( SplatBlendStrength ):
case nameof( SplatMapCount ):
if ( !_syncingTileSelectors )
{
WriteSelectedValues(
splatLayers: prop.Name == nameof( SplatLayerCount ) ? SplatLayerCount : (int?)null,
splatMaps: prop.Name == nameof( SplatMapCount ) ? SplatMapCount : (int?)null,
splatDispersion: prop.Name == nameof( SplatDispersion ) ? SplatDispersion : (SplatDispersionMode?)null,
splatBlend: prop.Name == nameof( SplatBlendStrength ) ? SplatBlendStrength : (float?)null );
}
// Resample the gradient into evenly spaced stops so the colors/thresholds match the layer count
ResampleSplatGradient();
if ( PreviewSplatMaterials )
{
_previewMaterials = null;
RandomizeMaterialsAsync();
}
break;
case nameof( SplatMapGradient ):
// The user edited the gradient colors in the widget - make that the source of
// truth so later resamples keep their colors instead of falling back to the
// stale random cache.
if ( !_isAnimatingGradient )
{
_splatColorCache.Clear();
if ( SplatMapGradient.Colors != null )
{
foreach ( var frame in SplatMapGradient.Colors )
_splatColorCache.Add( frame.Value );
}
}
break;
case nameof( PreviewSplatMaterials ):
if ( PreviewSplatMaterials && ( _previewMaterials == null || _previewMaterials.Length < Math.Max( SplatLayerCount, 2 ) ) )
{
RandomizeMaterials();
}
if ( !PreviewSplatMaterials )
{
_previewMaterials = null;
}
break;
}
}
/// <summary>
/// The largest splat layer count across all tiles (so shared resources like the preview
/// material list and gradient cover every tile). Falls back to the global value.
/// </summary>
int MaxTileSplatLayers()
{
int max = Math.Max( SplatLayerCount, 2 );
if ( _tileSplatLayerCounts != null )
{
foreach ( var lc in _tileSplatLayerCounts )
max = Math.Max( max, lc );
}
return max;
}
void ResampleSplatGradient()
{
int layerCount = MaxTileSplatLayers();
// Seed the persistent color cache from the current gradient first so user edits are kept.
if ( _splatColorCache.Count == 0 && SplatMapGradient.Colors != null && SplatMapGradient.Colors.Count() > 0 )
{
foreach ( var frame in SplatMapGradient.Colors )
_splatColorCache.Add( frame.Value );
}
// Add new colors to the end as layers grow - existing colors keep their index.
while ( _splatColorCache.Count < layerCount )
_splatColorCache.Add( RandomBrightColor() );
// Compute the target stop positions.
float[] thresholds;
var source = _previewHeightmap ?? _heightmap;
if ( SplatDispersion == SplatDispersionMode.Natural && source != null )
{
thresholds = ComputeNaturalThresholds( source, layerCount );
}
else
{
thresholds = new float[layerCount];
for ( int i = 0; i < layerCount; i++ )
{
thresholds[i] = (float)i / (layerCount - 1);
}
}
_splatthresholds = thresholds;
// First build: set the gradient directly.
if ( _currentFrameTimes is null )
{
_currentFrameTimes = thresholds.ToList();
_targetFrameTimes = thresholds.ToList();
ApplyGradientFromFrames();
return;
}
int oldCount = _currentFrameTimes.Count;
// New frames (added layers) enter from the right and slide left to their target.
if ( layerCount > oldCount )
{
for ( int i = oldCount; i < layerCount; i++ )
_currentFrameTimes.Add( 1f );
_targetFrameTimes = thresholds.ToList();
}
// Removed frames slide out to the right (target 1.0) and get dropped when they arrive.
else if ( layerCount < oldCount )
{
// Existing frames keep their current positions; the extra ones head right.
var newTargets = thresholds.ToList();
while ( newTargets.Count < oldCount )
newTargets.Add( 1f );
_targetFrameTimes = newTargets;
}
// Same count - just retarget the existing frames.
else
{
_targetFrameTimes = thresholds.ToList();
}
_gradientAnimating = true;
}
void ApplyGradientFromFrames()
{
// Only include frames that are still "in play" (haven't slid off the right edge yet).
var frames = new List<Gradient.ColorFrame>();
for ( int i = 0; i < _currentFrameTimes.Count && i < _splatColorCache.Count; i++ )
{
float t = Math.Clamp( _currentFrameTimes[i], 0f, 1f );
frames.Add( new Gradient.ColorFrame( t, _splatColorCache[i] ) );
}
SplatMapGradient = new Gradient( frames.ToArray() );
SplatMapGradient.Blending = Gradient.BlendMode.Stepped;
_isAnimatingGradient = true;
try
{
_serialized.GetProperty( nameof( SplatMapGradient ) )?.SetValue( SplatMapGradient );
}
finally
{
_isAnimatingGradient = false;
}
}
/// <summary>
/// Eases the gradient color stops toward their target positions so palette changes
/// slide in/out on the scale instead of snapping.
/// </summary>
void UpdateGradientAnimation()
{
if ( !_gradientAnimating || _currentFrameTimes is null || _targetFrameTimes is null ) return;
float t = 1f - MathF.Exp( -PreviewMorphSpeed * RealTime.Delta );
float maxDelta = 0f;
for ( int i = 0; i < _currentFrameTimes.Count && i < _targetFrameTimes.Count; i++ )
{
float delta = _targetFrameTimes[i] - _currentFrameTimes[i];
_currentFrameTimes[i] += delta * t;
maxDelta = MathF.Max( maxDelta, MathF.Abs( delta ) );
}
// Drop frames that have slid off the right edge (removed layers)
if ( _currentFrameTimes.Count > _targetFrameTimes.Count )
{
while ( _currentFrameTimes.Count > _targetFrameTimes.Count )
{
int last = _currentFrameTimes.Count - 1;
if ( _currentFrameTimes[last] >= 0.999f )
{
_currentFrameTimes.RemoveAt( last );
if ( _splatColorCache.Count > _targetFrameTimes.Count )
_splatColorCache.RemoveAt( _splatColorCache.Count - 1 );
}
else break;
}
}
ApplyGradientFromFrames();
// Force the gradient widget to repaint this frame so the motion is smooth
if ( _gradientControlWidget != null && _gradientControlWidget.IsValid() )
_gradientControlWidget.Update();
// The overlay mesh samples the live gradient, so rebuild it while the palette animates
BuildOverlayMesh();
if ( maxDelta < 0.001f )
{
_currentFrameTimes = _targetFrameTimes.ToList();
_gradientAnimating = false;
}
}
static Color RandomBrightColor()
{
// Pick a hue at random, keep saturation/value high so it stands out
float hue = Random.Shared.NextSingle() * 360f;
return new ColorHsv( hue, 0.8f, 1.0f ).ToColor();
}
void SetWidgetsVisible( List<Widget> widgets, bool visible )
{
foreach ( var widget in widgets )
{
widget.Visible = visible;
widget.Enabled = visible;
}
}
Widget CreatePropsPage()
{
var page = new Widget( null );
page.VerticalSizeMode = SizeMode.CanShrink;
page.HorizontalSizeMode = SizeMode.Flexible;
page.Layout = Layout.Column();
page.Layout.Margin = 10;
page.Layout.Spacing = 5;
page.Layout.Alignment = TextFlag.Top;
return page;
}
FloatControlWidget FloatSlider( string propertyName )
{
var property = _serialized.GetProperty( propertyName );
var control = new FloatControlWidget( property );
MakeRanged( control, property );
return control;
}
IntegerControlWidget IntSlider( string propertyName )
{
var property = _serialized.GetProperty( propertyName );
var control = new IntegerControlWidget( property );
MakeRanged( control, property );
return control;
}
void MakeRanged( FloatControlWidget control, SerializedProperty property )
{
if ( property is null ) return;
property.TryGetAttribute<MinMaxAttribute>( out var minMax );
if ( minMax is null ) return;
float step = 0.01f;
if ( property.TryGetAttribute<StepAttribute>( out var stepAttr ) )
{
step = stepAttr.Step;
}
control.MakeRanged( new Vector2( minMax.MinValue, minMax.MaxValue ), step, true, true );
}
void RandomizeSeed()
{
var property = _serialized.GetProperty( nameof( TerrainSeed ) );
if ( property is null ) return;
TerrainSeed = Random.Shared.NextInt64();
property.SetValue( TerrainSeed );
_previewDirty = true;
}
void LoadFacepunchMaterialsAsync()
{
try
{
// Find all local tmat assets in the project's assets folder (not cloud ones).
// Prefer 1K variants when a material has multiple resolutions.
var allLocal = Editor.AssetSystem.All
.Where( a => a is not null && !a.IsDeleted && !a.IsCloud )
.Where( a => (a.RelativePath?.EndsWith( ".tmat" ) ?? false) )
.ToList();
var with1k = allLocal.Where( a => a.RelativePath.Contains( "_1k" ) ).ToList();
_localTmatAssets = with1k.Count > 0 ? with1k : allLocal;
if ( _localTmatAssets.Count == 0 )
Log.Warning( "No local .tmat terrain materials found in the project's assets folder" );
}
catch ( System.Exception e )
{
Log.Error( $"Failed to find local terrain materials: {e.Message}" );
}
}
void RandomizeMaterials()
{
if ( !PreviewSplatMaterials ) return;
if ( _localTmatAssets is null || _localTmatAssets.Count == 0 )
{
// Load the local tmat list first, then randomize once it's available
_ = LoadFacepunchMaterialsAndRandomize();
return;
}
RandomizeMaterialsAsync();
}
async Task LoadFacepunchMaterialsAndRandomize()
{
try
{
// Find all local tmat assets in the project's assets folder (not cloud ones).
// Prefer 1K variants when a material has multiple resolutions.
var allLocal = Editor.AssetSystem.All
.Where( a => a is not null && !a.IsDeleted && !a.IsCloud )
.Where( a => (a.RelativePath?.EndsWith( ".tmat" ) ?? false) )
.ToList();
var with1k = allLocal.Where( a => a.RelativePath.Contains( "_1k" ) ).ToList();
_localTmatAssets = with1k.Count > 0 ? with1k : allLocal;
if ( PreviewSplatMaterials && _localTmatAssets.Count > 0 )
RandomizeMaterialsAsync();
}
catch ( System.Exception e )
{
Log.Error( $"Failed to find local terrain materials: {e.Message}" );
}
}
async void RandomizeMaterialsAsync()
{
if ( _materialLoadingLabel != null )
{
_materialLoadingLabel.Text = "Loading materials...";
_materialLoadingLabel.Visible = true;
}
int layerCount = MaxTileSplatLayers();
int gen = ++_previewMaterialsGeneration;
try
{
var pool = new List<Editor.Asset>( _localTmatAssets );
var materials = new List<TerrainMaterial>();
// Pull from the pool until we have layerCount usable materials (skipping any
// whose textures fail to compile) or the pool runs out.
while ( materials.Count < layerCount && pool.Count > 0 )
{
int idx = Random.Shared.Next( pool.Count );
var asset = pool[idx];
pool.RemoveAt( idx );
if ( !asset.TryLoadResource<TerrainMaterial>( out var found ) || found is null )
{
Log.Warning( $"Failed to load TerrainMaterial from '{asset.Path}'" );
continue;
}
// Only accept materials whose compiled BCR/NHO textures actually exist -
// otherwise the terrain renders a pink checkerboard.
if ( !IsMaterialUsable( found, asset.Path ) )
continue;
materials.Add( found );
}
if ( gen != _previewMaterialsGeneration ) return;
if ( materials.Count == 0 )
{
_previewMaterials = null;
Log.Warning( "No local terrain materials could be loaded" );
}
else
{
_previewMaterials = materials.ToArray();
}
_previewDirty = true;
}
catch ( System.Exception e )
{
Log.Error( $"Failed to load preview materials: {e.Message}" );
}
finally
{
if ( gen == _previewMaterialsGeneration && _materialLoadingLabel != null )
_materialLoadingLabel.Visible = false;
}
}
/// <summary>
/// Checks that a terrain material's compiled BCR/NHO textures are usable. The terrain shader
/// samples these bindlessly, and a missing/failed compile shows up as a pink checkerboard.
/// </summary>
bool IsMaterialUsable( TerrainMaterial material, string ident )
{
if ( material is null ) return false;
try
{
var bcr = material.BCRTexture;
if ( bcr is null || bcr.IsError || !bcr.IsValid )
{
Log.Warning( $"Skipping '{ident}': BCR texture missing or failed to compile" );
return false;
}
var nho = material.NHOTexture;
if ( nho is null || nho.IsError || !nho.IsValid )
{
Log.Warning( $"Skipping '{ident}': NHO texture missing or failed to compile" );
return false;
}
return true;
}
catch ( System.Exception e )
{
Log.Warning( $"Skipping '{ident}': {e.Message}" );
return false;
}
}
void AddPropsTab( string name, string icon, Widget page, string tooltip )
{
_propsTabBar.AddOption( name, icon );
_propsPages[name] = page;
_propsContent.Layout.Add( page );
page.Visible = false;
page.ToolTip = tooltip;
if ( _propsPages.Count == 1 )
{
SelectPropsTab( name );
}
}
void SelectPropsTab( string name )
{
foreach ( var entry in _propsPages )
{
entry.Value.Visible = entry.Key == name;
}
if ( _activePropsTab != name )
{
_activePropsTab = name;
_previewDirty = true;
}
}
void ApplyConditionalVisibility()
{
SetWidgetsVisible( _domainWarpingWidgets, DomainWarping );
SetWidgetsVisible( _riverCarvingWidgets, RiverCarvingBool );
SetWidgetsVisible( _stagingAreaWidgets, StagingArea );
}
[EditorEvent.Frame]
public void FrameUpdate()
{
// Tick the preview scene so the terrain clipmap builds and updates
if ( RenderCanvas != null && RenderCanvas.Scene.IsValid() )
RenderCanvas.Scene.EditorTick( RealTime.Now, RealTime.Delta );
// Morph the overlay mesh toward its target shape every frame
UpdateOverlayAnimation();
// If a tile was just selected/deselected, keep rebuilding the overlay mesh so the
// grey-out smoothly eases in until the colors settle.
if ( _overlayColorAnimating )
{
BuildOverlayMesh();
}
// Animate the gradient color stops sliding in/out
UpdateGradientAnimation();
if ( !_previewDirty ) return;
if ( RealTime.Now - _lastPreviewRegen < 0.1f ) return;
if ( _isGenerating ) return;
_previewDirty = false;
_lastPreviewRegen = RealTime.Now;
RegeneratePreviewAsync();
}
async void RegeneratePreviewAsync()
{
if ( _previewTerrain is null || !_previewTerrain.IsValid() ) return;
if ( string.IsNullOrEmpty( CategoryArray?.Selected ) || string.IsNullOrEmpty( ShapeArray?.Selected ) ) return;
if ( _isGenerating ) return;
_isGenerating = true;
int token = ++_generationToken;
// Splat colors are read on the main thread into the shared arrays
BuildSplatColors();
// Snapshot UI-driven values on the main thread so the background task doesn't touch widgets
var tileCategories = (string[])_tileCategories.Clone();
var tileShapes = (string[])_tileShapes.Clone();
var tileSeeds = (long[])_tileSeeds.Clone();
var tileMinHeights = (float[])_tileMinHeights.Clone();
var tileMaxHeights = (float[])_tileMaxHeights.Clone();
var tilePlaneScales = (float[])_tilePlaneScales.Clone();
var tileSmoothing = (int[])_tileSmoothingPasses.Clone();
var tileNoiseLayers = (int[])_tileNoiseLayerStacks.Clone();
var tileWarping = (bool[])_tileDomainWarping.Clone();
var tileWarpingSizes = (float[])_tileDomainWarpingSizes.Clone();
var tileWarpingStrengths = (float[])_tileDomainWarpingStrengths.Clone();
var tileSplatLayerCounts = (int[])_tileSplatLayerCounts.Clone();
var tileSplatDispersions = (SplatDispersionMode[])_tileSplatDispersions.Clone();
var tileSplatBlends = (float[])_tileSplatBlendStrengths.Clone();
int gridSize = TerrainGridSize;
bool rivers = RiverCarvingBool;
float riverFrequency = RiverCarvingFrequency;
float riverWidth = RiverCarvingWidth;
float riverDepth = RiverCarvingDepth;
float riverTurbFreq = RiverCarvingTurbulenceFrequency;
float riverTurbStrength = RiverCarvingTurbulenceStrength;
float riverSpacing = RiverCarvingSpacing;
bool staging = StagingArea;
int stagingSize = StagingAreaSize;
float stagingHeight = StagingAreaHeight;
float stagingX = StagingAreaX;
float stagingY = StagingAreaY;
try
{
// CPU-heavy work (noise, smoothing, rivers, splatmap) runs off the main thread
float[,] heightmap = await Task.Run( () => BuildHeightmap(
PreviewResolution, PreviewResolution,
tileCategories, tileShapes, gridSize,
tileSeeds, tileNoiseLayers, tileMinHeights, tileMaxHeights,
tileWarping, tileWarpingSizes, tileWarpingStrengths,
tileSmoothing, tilePlaneScales,
rivers, riverFrequency, riverWidth, riverDepth,
riverTurbFreq, riverTurbStrength, riverSpacing,
staging, stagingSize, stagingHeight, stagingX, stagingY ) );
if ( token != _generationToken ) return;
if ( heightmap is null ) return;
_previewHeightmap = heightmap;
// GPU/scene updates must happen back on the main thread
UpdatePreviewTerrain( heightmap );
}
finally
{
_isGenerating = false;
// If more changes came in while we were busy, regenerate again
if ( _previewDirty && token == _generationToken )
{
_previewDirty = false;
RegeneratePreviewAsync();
}
}
}
void OnPreFrame()
{
GizmoInstance.Input.IsHovered = IsActiveWindow && RenderCanvas.IsUnderMouse;
var isAltHeld = Editor.Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Alt );
var isLeftDown = Editor.Application.MouseButtons.HasFlag( MouseButtons.Left );
var isInteracting = false;
if ( GizmoInstance.OrbitCamera( Camera, RenderCanvas, ref _orbitDistance ) )
{
// User is manually orbiting - don't auto-spin this frame
isInteracting = true;
GizmoInstance.Input.IsHovered = false;
}
else if ( isAltHeld )
{
isInteracting = true;
}
else if ( isLeftDown && GizmoInstance.Input.IsHovered )
{
// Click and drag in the preview to adjust pitch/yaw
isInteracting = true;
var delta = Editor.Application.CursorDelta * 0.1f;
_orbitPitch = Math.Clamp( _orbitPitch + delta.y, 5f, 85f );
_orbitAngle += delta.x;
if ( _orbitAngle >= 360f ) _orbitAngle -= 360f;
if ( _orbitAngle < 0f ) _orbitAngle += 360f;
PositionCameraForOrbit();
}
if ( !isInteracting && _autoSpin )
{
// Slowly rotate the camera around the terrain
_orbitAngle += SpinSpeed * RealTime.Delta;
if ( _orbitAngle >= 360f ) _orbitAngle -= 360f;
PositionCameraForOrbit();
}
// Scroll wheel over the preview controls zoom
if ( !isAltHeld && GizmoInstance.Input.IsHovered && MathF.Abs( Editor.Application.MouseWheelDelta.y ) > 0.001f )
{
var wheelDelta = Editor.Application.MouseWheelDelta.y;
ZoomSlider.Value = Math.Clamp( ZoomSlider.Value + wheelDelta * 500f, ZoomSlider.Minimum, ZoomSlider.Maximum );
UpdateOrbitFromZoom();
}
RenderCanvas.UpdateGizmoInputs( GizmoInstance.Input.IsHovered );
}
void PositionCameraForOrbit()
{
if ( Camera is null || !Camera.IsValid() ) return;
float pitch = _orbitPitch;
float yaw = _orbitAngle;
var offset = new Vector3(
MathF.Sin( MathX.DegreeToRadian( yaw ) ) * MathF.Cos( MathX.DegreeToRadian( pitch ) ),
MathF.Cos( MathX.DegreeToRadian( yaw ) ) * MathF.Cos( MathX.DegreeToRadian( pitch ) ),
MathF.Sin( MathX.DegreeToRadian( pitch ) )
) * _orbitDistance;
Camera.WorldPosition = Vector3.Zero + offset;
Camera.WorldRotation = Rotation.LookAt( (Vector3.Zero - offset).Normal, Vector3.Up );
}
void UpdateOrbitFromZoom()
{
// Higher slider value = closer to terrain (zoom in)
_orbitDistance = ZoomSlider.Maximum + ZoomSlider.Minimum - ZoomSlider.Value;
PositionCameraForOrbit();
}
void BuildSplatColors()
{
// The colors must be sampled at the ACTUAL threshold positions the splatmap uses, not at
// evenly spaced positions. In Natural dispersion the layers sit at slope-weighted stops,
// so even sampling would skip/misalign colors. The gradient's frame times ARE the
// thresholds (ResampleSplatGradient positions them there), so read them directly.
int layerCount = MaxTileSplatLayers();
var frames = SplatMapGradient.Colors;
if ( frames != null && frames.Count() == layerCount && layerCount > 0 )
{
// Frames are ordered by time - use their exact positions and colors so the splatmap
// and shader reflect exactly what the user set in the gradient widget.
var times = new float[layerCount];
var colors = new SKColor[layerCount];
int i = 0;
foreach ( var frame in frames )
{
times[i] = Math.Clamp( frame.Time, 0f, 1f );
var c = frame.Value.ToColor32();
colors[i] = new SKColor( c.r, c.g, c.b, c.a );
i++;
}
_splatthresholds = times;
_splatcolors = colors;
return;
}
// Fallback: no matching frame count yet (e.g. first build) - sample the gradient at the
// same threshold positions the splatmap will use.
float[] stops;
var source = _previewHeightmap ?? _heightmap;
if ( SplatDispersion == SplatDispersionMode.Natural && source != null )
stops = ComputeNaturalThresholds( source, layerCount );
else
stops = MakeEvenThresholds( layerCount );
var thresholdtime = new List<float>();
var mapgradients = new List<SKColor>();
for ( int i = 0; i < layerCount; i++ )
{
thresholdtime.Add( stops[i] );
var color = SplatMapGradient.Evaluate( Math.Clamp( stops[i], 0f, 1f ) ).ToColor32();
mapgradients.Add( new SKColor( color.r, color.g, color.b, color.a ) );
}
_splatthresholds = thresholdtime.ToArray();
_splatcolors = mapgradients.ToArray();
}
void RegeneratePreview()
{
if ( _previewTerrain is null || !_previewTerrain.IsValid() ) return;
if ( string.IsNullOrEmpty( CategoryArray?.Selected ) || string.IsNullOrEmpty( ShapeArray?.Selected ) ) return;
BuildSplatColors();
var heightmap = BuildHeightmap(
PreviewResolution, PreviewResolution,
(string[])_tileCategories.Clone(), (string[])_tileShapes.Clone(), TerrainGridSize,
(long[])_tileSeeds.Clone(), (int[])_tileNoiseLayerStacks.Clone(),
(float[])_tileMinHeights.Clone(), (float[])_tileMaxHeights.Clone(),
(bool[])_tileDomainWarping.Clone(), (float[])_tileDomainWarpingSizes.Clone(), (float[])_tileDomainWarpingStrengths.Clone(),
(int[])_tileSmoothingPasses.Clone(), (float[])_tilePlaneScales.Clone(),
RiverCarvingBool, RiverCarvingFrequency, RiverCarvingWidth, RiverCarvingDepth,
RiverCarvingTurbulenceFrequency, RiverCarvingTurbulenceStrength, RiverCarvingSpacing,
StagingArea, StagingAreaSize, StagingAreaHeight, StagingAreaX, StagingAreaY );
if ( heightmap is null ) return;
_previewHeightmap = heightmap;
UpdatePreviewTerrain( heightmap );
}
void UpdatePreviewTerrain( float[,] heightmap )
{
if ( _previewTerrain is null || !_previewTerrain.IsValid() ) return;
if ( _previewStorage is null ) return;
int res = heightmap.GetLength( 0 );
// Resize the storage to match the incoming heightmap so Generate (full res) and the
// live preview (PreviewResolution) both work without a buffer size mismatch.
if ( _previewStorage.Resolution != res )
_previewStorage.SetResolution( res );
// Write the heightmap into the terrain storage (0..65535 maps across TerrainHeight)
ushort[] heightArray = new ushort[res * res];
for ( int y = 0; y < res; y++ )
{
for ( int x = 0; x < res; x++ )
{
float h = Math.Clamp( heightmap[x, y], 0f, 1f );
heightArray[y * res + x] = (ushort)Math.Clamp( (int)(h * 65535f), 0, 65535 );
}
}
_previewStorage.HeightMap = heightArray;
// Build the control map (which materials go where). When the material preview is
// enabled and we have assigned bluedock materials, blend them by the splat map
// exactly like the exported terrain would. Otherwise use the single default material.
uint[] controlMap = new uint[res * res];
bool useMaterials = _activePropsTab == "Splat" && PreviewSplatMaterials && _previewMaterials != null && _previewMaterials.Length > 0;
if ( useMaterials )
{
float[,] splatmap = BuildTileGridSplatmap( heightmap, TerrainGridSize,
(int[])_tileSplatLayerCounts.Clone(), (SplatDispersionMode[])_tileSplatDispersions.Clone(), (float[])_tileSplatBlendStrengths.Clone() );
int matCount = _previewMaterials.Length;
for ( int y = 0; y < res; y++ )
{
for ( int x = 0; x < res; x++ )
{
float layerPos = Math.Clamp( splatmap[x, y], 0f, matCount - 1f );
int baseId = (int)MathF.Floor( layerPos );
int overlayId = Math.Min( baseId + 1, matCount - 1 );
byte blend = (byte)Math.Clamp( (int)((layerPos - baseId) * 255f), 0, 255 );
controlMap[y * res + x] = new CompactTerrainMaterial( (byte)baseId, (byte)overlayId, blend, false ).Packed;
}
}
}
else
{
// Default: single material, no blending
for ( int i = 0; i < controlMap.Length; i++ )
{
controlMap[i] = new CompactTerrainMaterial( 0, 0, 0, false ).Packed;
}
}
_previewStorage.ControlMap = controlMap;
// Assign the materials and push everything to the GPU
if ( _previewMaterials != null )
{
_previewStorage.Materials.Clear();
_previewStorage.Materials.AddRange( _previewMaterials );
}
// Only the terrain shows when we're on the Splat tab previewing the real materials.
// The terrain must be enabled before touching its GPU state, otherwise SyncGPUTexture
// throws - so sync only when it's going to be visible.
if ( _previewTerrain != null ) _previewTerrain.Enabled = useMaterials;
if ( useMaterials && _previewTerrain != null && _previewTerrain.IsValid() )
{
_previewTerrain.Create();
_previewTerrain.SyncGPUTexture();
_previewTerrain.UpdateMaterialsBuffer();
}
// Overlay logic: on the Splat tab with the material preview off, overlay the splatmap
// colors so you can see the layer layout. On any other tab, overlay the original
// height-based color we used to paint the mesh. When materials are previewing, no overlay.
bool showSplatOverlay = _activePropsTab == "Splat" && !useMaterials;
UpdateSplatOverlay( heightmap, !useMaterials, showSplatOverlay );
}
void UpdateSplatOverlay( float[,] heightmap, bool visible, bool splatColors )
{
if ( _splatOverlayRenderer is null || !_splatOverlayRenderer.IsValid() ) return;
int res = heightmap.GetLength( 0 );
// Store the target heightmap and the desired colors. The mesh itself is animated
// toward this target in FrameUpdate so changes morph smoothly instead of snapping.
if ( _overlayTargetHeights == null || _overlayTargetHeights.Length != res * res )
{
_overlayTargetHeights = new float[res * res];
_overlayCurrentHeights = new float[res * res];
}
bool first = _splatOverlayRenderer.Model is null;
for ( int y = 0; y < res; y++ )
{
for ( int x = 0; x < res; x++ )
{
_overlayTargetHeights[y * res + x] = Math.Clamp( heightmap[x, y], 0f, 1f );
}
}
// If we've never built the mesh, snap to the current values so the first frame is correct.
if ( first )
{
Array.Copy( _overlayTargetHeights, _overlayCurrentHeights, _overlayTargetHeights.Length );
_overlayAnimating = false;
}
else
{
_overlayAnimating = true;
}
// Cache the splatmap (only changes when the heightmap regenerates). The vertex colors
// are evaluated from the LIVE gradient each frame so they stay in sync with the widget.
_overlayUseSplatColors = splatColors;
if ( splatColors )
_overlaySplatmap = BuildTileGridSplatmap( heightmap, TerrainGridSize,
(int[])_tileSplatLayerCounts.Clone(), (SplatDispersionMode[])_tileSplatDispersions.Clone(), (float[])_tileSplatBlendStrengths.Clone() );
if ( first )
BuildOverlayMesh();
// Toggle visibility
if ( _splatOverlayGO != null ) _splatOverlayGO.Enabled = visible;
}
/// <summary>
/// Called every frame - eases the overlay mesh from its current shape toward the target shape.
/// </summary>
void UpdateOverlayAnimation()
{
if ( _splatOverlayRenderer is null || !_splatOverlayRenderer.IsValid() ) return;
if ( !_overlayAnimating || _overlayTargetHeights is null || _overlayCurrentHeights is null ) return;
int count = _overlayTargetHeights.Length;
if ( _overlayCurrentHeights.Length != count ) return;
// Exponential approach - fast at first, settles smoothly
float t = 1f - MathF.Exp( -PreviewMorphSpeed * RealTime.Delta );
float maxDelta = 0f;
for ( int i = 0; i < count; i++ )
{
float delta = _overlayTargetHeights[i] - _overlayCurrentHeights[i];
_overlayCurrentHeights[i] += delta * t;
maxDelta = MathF.Max( maxDelta, MathF.Abs( delta ) );
}
// Rebuild the mesh from the current (eased) heights. Colors are sampled from the
// live gradient inside BuildOverlayMesh so they animate as smoothly as the widget.
BuildOverlayMesh();
// Stop once we're close enough
if ( maxDelta < 0.001f )
{
Array.Copy( _overlayTargetHeights, _overlayCurrentHeights, count );
_overlayAnimating = false;
}
}
void BuildOverlayMesh()
{
if ( _splatOverlayRenderer is null || !_splatOverlayRenderer.IsValid() ) return;
if ( _overlayCurrentHeights is null ) return;
int res = (int)MathF.Sqrt( _overlayCurrentHeights.Length );
int vertexCount = res * res;
const float worldSize = PreviewTerrainSize;
const float worldHeight = PreviewTerrainHeight;
float cellX = worldSize / res;
float cellY = worldSize / res;
// Color ease factor - the mesh morphs at half the gradient speed so the color
// swipe across the terrain is smoother. First build snaps immediately.
// Tile-selection grey/blue changes use a much faster rate so they snap quicker.
float colorMorphSpeed = _overlayColorAnimating ? PreviewMorphSpeed * 4f : MeshMorphSpeed;
float colorT = _overlayCurrentColors is null ? 1f : 1f - MathF.Exp( -colorMorphSpeed * RealTime.Delta );
var vertices = new Vertex[vertexCount];
var colors = _overlayCurrentColors ?? new Color32[vertexCount];
bool useSplat = _overlayUseSplatColors && _overlaySplatmap != null;
// When a specific tile is selected, only that tile keeps its colors - the rest go grey
int grid = Math.Max( TerrainGridSize, 1 );
int tileW = res / grid;
int tileH = res / grid;
// Track how far the colors moved so the refresh can stop once they settle
float[] maxColorDelta = new float[1];
Parallel.For( 0, res, y =>
{
for ( int x = 0; x < res; x++ )
{
int index = y * res + x;
float h = _overlayCurrentHeights[index];
// Local space - the overlay GO is parented to the centered terrain GO
Vector3 position = new Vector3( x * cellX, y * cellY, h * worldHeight );
float hL = _overlayCurrentHeights[y * res + Math.Max( x - 1, 0 )];
float hR = _overlayCurrentHeights[y * res + Math.Min( x + 1, res - 1 )];
float hD = _overlayCurrentHeights[Math.Max( y - 1, 0 ) * res + x];
float hU = _overlayCurrentHeights[Math.Min( y + 1, res - 1 ) * res + x];
float dx = (hR - hL) * worldHeight / (2.0f * cellX);
float dy = (hU - hD) * worldHeight / (2.0f * cellY);
Vector3 normal = new Vector3( -dx, -dy, 1.0f ).Normal;
// Which grid tile does this vertex belong to?
int tileX = Math.Min( x / Math.Max( tileW, 1 ), grid - 1 );
int tileY = Math.Min( y / Math.Max( tileH, 1 ), grid - 1 );
int tileIndex = tileY * grid + tileX;
bool isSelectedTile = _selectedTileIndex < 0 || tileIndex == _selectedTileIndex;
// Sample the target color from the LIVE gradient each frame, then ease the
// mesh color toward it so the swipe lags behind the widget and looks smooth.
Color targetColor;
if ( !isSelectedTile )
{
// Wash out everything outside the selected tile
targetColor = Color.FromBytes( 235, 235, 235 );
}
else if ( useSplat )
{
int tileLayers = _tileSplatLayerCounts != null && tileIndex < _tileSplatLayerCounts.Length
? Math.Max( _tileSplatLayerCounts[tileIndex], 2 ) : Math.Max( SplatLayerCount, 2 );
// Sample the color from the same threshold-aligned color table the splatmap
// image uses, so the 3D preview and the exported splatmap always agree.
float layerPos = Math.Clamp( _overlaySplatmap[x, y], 0f, tileLayers - 1f );
int layer0 = (int)MathF.Floor( layerPos );
int layer1 = Math.Min( layer0 + 1, tileLayers - 1 );
float t = layerPos - layer0;
if ( _splatcolors != null && _splatcolors.Length > layer1 )
{
var col0 = _splatcolors[layer0];
var col1 = _splatcolors[layer1];
targetColor = new Color(
MathX.LerpTo( col0.Red / 255f, col1.Red / 255f, t ),
MathX.LerpTo( col0.Green / 255f, col1.Green / 255f, t ),
MathX.LerpTo( col0.Blue / 255f, col1.Blue / 255f, t ) );
}
else
{
Color c0 = SplatMapGradient.Evaluate( Math.Clamp( layer0 / (float)Math.Max( tileLayers - 1, 1 ), 0f, 1f ) );
Color c1 = SplatMapGradient.Evaluate( Math.Clamp( layer1 / (float)Math.Max( tileLayers - 1, 1 ), 0f, 1f ) );
targetColor = Color.Lerp( c0, c1, t );
}
}
else
{
// The original height-based material color we painted on the mesh
targetColor = Color.Lerp( Color.FromBytes( 30, 90, 200 ), Color.FromBytes( 200, 185, 150 ), h );
}
var target32 = targetColor.ToColor32();
var current = colors[index];
byte r = (byte)MathX.LerpTo( current.r, target32.r, colorT );
byte g = (byte)MathX.LerpTo( current.g, target32.g, colorT );
byte b = (byte)MathX.LerpTo( current.b, target32.b, colorT );
byte a = (byte)MathX.LerpTo( current.a, target32.a, colorT );
colors[index] = new Color32( r, g, b, a );
float delta = MathF.Abs( r - target32.r ) + MathF.Abs( g - target32.g ) + MathF.Abs( b - target32.b );
maxColorDelta[0] = MathF.Max( maxColorDelta[0], delta );
vertices[index] = new Vertex( position, normal, normal, new Vector4( 0, 0, 0, 1 ) );
vertices[index].Color = colors[index];
}
} );
_overlayCurrentColors = colors;
// Stop the color-only refresh once everything has eased to its target
if ( _overlayColorAnimating && maxColorDelta[0] < 1f )
{
_overlayColorAnimating = false;
}
// Build indices once - the grid topology never changes
var indices = new List<int>();
if ( _overlayMesh is null )
{
for ( int y = 0; y < res - 1; y++ )
{
for ( int x = 0; x < res - 1; x++ )
{
int a = x + y * res;
int b = (x + 1) + y * res;
int c = (x + 1) + (y + 1) * res;
int d = x + (y + 1) * res;
indices.Add( a );
indices.Add( b );
indices.Add( c );
indices.Add( a );
indices.Add( c );
indices.Add( d );
}
}
}
if ( _overlayMesh is null || !_overlayMesh.IsValid() )
{
_overlayMesh = new Mesh( _splatOverlayRenderer.MaterialOverride );
_overlayMesh.CreateVertexBuffer( vertices.Length, vertices );
_overlayMesh.CreateIndexBuffer( indices.Count, indices );
_overlayMesh.Bounds = BBox.FromPositionAndSize( new Vector3( worldSize * 0.5f, worldSize * 0.5f, worldHeight * 0.5f ), new Vector3( worldSize, worldSize, worldHeight ) );
_splatOverlayRenderer.Model = Model.Builder.AddMesh( _overlayMesh ).Create();
}
else
{
// Update the existing vertex buffer in place - much faster than rebuilding the model
_overlayMesh.SetVertexBufferData( vertices );
}
}
float[,] BuildHeightmap( int width, int height,
string[] tileCategories,
string[] tileShapes,
int gridSize,
long[] tileSeeds,
int[] tileNoiseLayerStacks,
float[] tileMinHeights,
float[] tileMaxHeights,
bool[] tileDomainWarping,
float[] tileDomainWarpingSizes,
float[] tileDomainWarpingStrengths,
int[] tileSmoothingPasses,
float[] tilePlaneScales,
bool riverCarving,
float riverFrequency,
float riverWidth,
float riverDepth,
float riverTurbulenceFrequency,
float riverTurbulenceStrength,
float minRiverSpacing,
bool stagingArea,
int stagingAreaSize,
float stagingAreaHeight,
float stagingAreaX,
float stagingAreaY )
{
int grid = Math.Max( gridSize, 1 );
int tileW = width / grid;
int tileH = height / grid;
// Build each tile's heightmap, then stitch them together averaging overlapping edges.
float[,] heightmap = BuildTileGrid( width, height, tileW, tileH, grid, tileCategories, tileShapes, tileSeeds, tileNoiseLayerStacks, tileMinHeights, tileMaxHeights, tileDomainWarping, tileDomainWarpingSizes, tileDomainWarpingStrengths, tileSmoothingPasses, tilePlaneScales );
if ( ErosionSimulation )
{
}
if ( heightmap == null )
{
Log.Error( "Heightmap is not generated. Aborting." );
return null;
}
// Rivers are carved across the whole combined map so they flow continuously through the tiles
if ( riverCarving )
{
heightmap = AddTurbulenceForRivers(
heightmap,
seed: tileSeeds != null && tileSeeds.Length > 0 ? tileSeeds[0] : 0,
riverFrequency: riverFrequency,
riverWidth: riverWidth,
riverDepth: riverDepth,
turbulenceFrequency: riverTurbulenceFrequency,
turbulenceStrength: riverTurbulenceStrength,
minRiverSpacing: minRiverSpacing,
slopeSteepness:10f,
terrainNoiseFrequency: 2.0f,
terrainNoiseAmplitude: 0.5f
);
}
if ( stagingArea )
{
heightmap = AddStagingSquare(
heightmap,
stagingAreaSize,
stagingAreaHeight,
stagingAreaX,
stagingAreaY );
}
return heightmap;
}
/// <summary>
/// Generates each tile with its own category/shape/height/scale/seed, then stitches them into
/// one heightmap. Adjacent tiles share a blend band so their edges are averaged and look continuous.
/// </summary>
float[,] BuildTileGrid( int width, int height, int tileW, int tileH, int grid, string[] categories, string[] shapes, long[] seeds, int[] noiseLayersArr, float[] minHeights, float[] maxHeights, bool[] warpingArr, float[] warpingSizesArr, float[] warpingStrengthsArr, int[] smoothingArr, float[] planeScales )
{
float[,] result = new float[width, height];
// Single tile - just generate it directly at the requested size, matching the old behavior exactly.
if ( grid <= 1 )
{
string category = categories != null && categories.Length > 0 ? categories[0] : null;
string shape = shapes != null && shapes.Length > 0 ? shapes[0] : null;
long seed = seeds != null && seeds.Length > 0 ? seeds[0] : 0;
int layerCount = noiseLayersArr != null && noiseLayersArr.Length > 0 ? noiseLayersArr[0] : 1;
float minHeight = minHeights != null && minHeights.Length > 0 ? minHeights[0] : 0.2f;
float maxHeight = maxHeights != null && maxHeights.Length > 0 ? maxHeights[0] : 0.5f;
int smoothingPasses = smoothingArr != null && smoothingArr.Length > 0 ? smoothingArr[0] : 0;
float planeScale = planeScales != null && planeScales.Length > 0 ? planeScales[0] : 0.5f;
bool domainWarping = warpingArr != null && warpingArr.Length > 0 ? warpingArr[0] : true;
float domainWarpingSize = warpingSizesArr != null && warpingSizesArr.Length > 0 ? warpingSizesArr[0] : 0.25f;
float domainWarpingStrength = warpingStrengthsArr != null && warpingStrengthsArr.Length > 0 ? warpingStrengthsArr[0] : 0.15f;
if ( string.IsNullOrEmpty( category ) ) category = "Islands";
if ( string.IsNullOrEmpty( shape ) ) shape = "Default";
var fullclass = Type.GetType( $"Sturnus.TerrainGenerationTool.{category}" );
if ( fullclass is null || fullclass.GetMethod( shape ) is null ) return null;
return GenerateStackedNoise(
width, height,
seed,
layerCount,
1.0f, 2.0f, 1.0f, 0.5f,
( x, y ) => (float)CallMethod( $"Sturnus.TerrainGenerationTool.{category}", shape, new object[] {
x, y,
width, height,
seed,
minHeight,
domainWarping,
domainWarpingSize,
domainWarpingStrength
} ),
maxHeight,
smoothingPasses,
planeScale
);
}
float[,] weight = new float[width, height];
// Overlap width for edge blending - a fraction of the tile size so seams blend smoothly
int blend = Math.Max( 2, Math.Min( tileW, tileH ) / 8 );
for ( int ty = 0; ty < grid; ty++ )
{
for ( int tx = 0; tx < grid; tx++ )
{
int index = ty * grid + tx;
string category = categories != null && index < categories.Length && !string.IsNullOrEmpty( categories[index] ) ? categories[index] : "Islands";
string shape = shapes != null && index < shapes.Length && !string.IsNullOrEmpty( shapes[index] ) ? shapes[index] : "Default";
long tileSeed = seeds != null && index < seeds.Length ? seeds[index] : 0;
int layerCount = noiseLayersArr != null && index < noiseLayersArr.Length ? noiseLayersArr[index] : 1;
float minHeight = minHeights != null && index < minHeights.Length ? minHeights[index] : 0.2f;
float maxHeight = maxHeights != null && index < maxHeights.Length ? maxHeights[index] : 0.5f;
int smoothingPasses = smoothingArr != null && index < smoothingArr.Length ? smoothingArr[index] : 0;
float planeScale = planeScales != null && index < planeScales.Length ? planeScales[index] : 0.5f;
bool domainWarping = warpingArr != null && index < warpingArr.Length ? warpingArr[index] : true;
float domainWarpingSize = warpingSizesArr != null && index < warpingSizesArr.Length ? warpingSizesArr[index] : 0.25f;
float domainWarpingStrength = warpingStrengthsArr != null && index < warpingStrengthsArr.Length ? warpingStrengthsArr[index] : 0.15f;
var fullclass = Type.GetType( $"Sturnus.TerrainGenerationTool.{category}" );
if ( fullclass is null || fullclass.GetMethod( shape ) is null )
continue;
float[,] tile = GenerateStackedNoise(
tileW + blend * 2,
tileH + blend * 2,
tileSeed,
layerCount,
1.0f,
2.0f,
1.0f,
0.5f,
( x, y ) => (float)CallMethod( $"Sturnus.TerrainGenerationTool.{category}", shape, new object[] {
x, y,
tileW + blend * 2,
tileH + blend * 2,
tileSeed,
minHeight,
domainWarping,
domainWarpingSize,
domainWarpingStrength
} ),
maxHeight,
smoothingPasses,
planeScale
);
if ( tile is null ) continue;
// Place the tile into the result with a weighted blend on the edges.
// The tile is generated slightly larger than its slot (tileW + blend*2) so the
// overlap regions between neighbors are averaged.
int slotX = tx * tileW;
int slotY = ty * tileH;
for ( int y = 0; y < tileH + blend * 2; y++ )
{
int outY = slotY - blend + y;
if ( outY < 0 || outY >= height ) continue;
for ( int x = 0; x < tileW + blend * 2; x++ )
{
int outX = slotX - blend + x;
if ( outX < 0 || outX >= width ) continue;
// Edge weight: 1 in the core, fading to 0 across the blend band at each edge
float wx = EdgeBlendWeight( x, tileW + blend * 2, blend );
float wy = EdgeBlendWeight( y, tileH + blend * 2, blend );
float w = wx * wy;
result[outX, outY] += tile[x, y] * w;
weight[outX, outY] += w;
}
}
}
}
// Normalize by the accumulated weights
for ( int y = 0; y < height; y++ )
{
for ( int x = 0; x < width; x++ )
{
if ( weight[x, y] > 0.0001f )
result[x, y] /= weight[x, y];
}
}
return result;
}
/// <summary>
/// Generates each grid cell as its own full-resolution heightmap using that cell's own
/// category/shape/height/scale/seed/smoothing/noise/warp settings. No stitching - each cell
/// is a complete map of width x height. Rivers and staging are applied per cell.
/// </summary>
List<float[,]> BuildPerCellHeightmaps( int width, int height,
string[] categories, string[] shapes, long[] seeds, int[] noiseLayersArr,
float[] minHeights, float[] maxHeights, bool[] warpingArr, float[] warpingSizesArr,
float[] warpingStrengthsArr, int[] smoothingArr, float[] planeScales,
bool riverCarving, float riverFrequency, float riverWidth, float riverDepth,
float riverTurbulenceFrequency, float riverTurbulenceStrength, float minRiverSpacing,
bool stagingArea, int stagingAreaSize, float stagingAreaHeight, float stagingAreaX, float stagingAreaY )
{
var cells = new List<float[,]>();
int grid = Math.Max( TerrainGridSize, 1 );
int count = grid * grid;
for ( int index = 0; index < count; index++ )
{
string category = categories != null && index < categories.Length && !string.IsNullOrEmpty( categories[index] ) ? categories[index] : "Islands";
string shape = shapes != null && index < shapes.Length && !string.IsNullOrEmpty( shapes[index] ) ? shapes[index] : "Default";
long cellSeed = seeds != null && index < seeds.Length ? seeds[index] : 0;
int layerCount = noiseLayersArr != null && index < noiseLayersArr.Length ? noiseLayersArr[index] : 1;
float minHeight = minHeights != null && index < minHeights.Length ? minHeights[index] : 0.2f;
float maxHeight = maxHeights != null && index < maxHeights.Length ? maxHeights[index] : 0.5f;
bool domainWarping = warpingArr != null && index < warpingArr.Length ? warpingArr[index] : true;
float domainWarpingSize = warpingSizesArr != null && index < warpingSizesArr.Length ? warpingSizesArr[index] : 0.25f;
float domainWarpingStrength = warpingStrengthsArr != null && index < warpingStrengthsArr.Length ? warpingStrengthsArr[index] : 0.15f;
int smoothingPasses = smoothingArr != null && index < smoothingArr.Length ? smoothingArr[index] : 0;
float planeScale = planeScales != null && index < planeScales.Length ? planeScales[index] : 0.5f;
var fullclass = Type.GetType( $"Sturnus.TerrainGenerationTool.{category}" );
if ( fullclass is null || fullclass.GetMethod( shape ) is null )
continue;
float[,] map = GenerateStackedNoise(
width, height,
cellSeed,
layerCount,
1.0f, 2.0f, 1.0f, 0.5f,
( x, y ) => (float)CallMethod( $"Sturnus.TerrainGenerationTool.{category}", shape, new object[] {
x, y,
width, height,
cellSeed,
minHeight,
domainWarping,
domainWarpingSize,
domainWarpingStrength
} ),
maxHeight,
smoothingPasses,
planeScale
);
if ( map is null ) continue;
if ( riverCarving )
{
map = AddTurbulenceForRivers( map, cellSeed, riverFrequency, riverWidth, riverDepth,
riverTurbulenceFrequency, riverTurbulenceStrength, minRiverSpacing, 10f, 2.0f, 0.5f );
}
if ( stagingArea )
{
map = AddStagingSquare( map, stagingAreaSize, stagingAreaHeight, stagingAreaX, stagingAreaY );
}
cells.Add( map );
}
return cells;
}
/// <summary>
/// Generates the splatmap cache for per-cell mode - one full-res splatmap per cell using that
/// cell's own layer count / dispersion / blend strength.
/// </summary>
List<float[,]> BuildPerCellSplatmaps( List<float[,]> cellHeightmaps, int[] layerCounts, SplatDispersionMode[] dispersions, float[] blendStrengths )
{
var splats = new List<float[,]>();
if ( cellHeightmaps is null ) return splats;
for ( int i = 0; i < cellHeightmaps.Count; i++ )
{
int layers = layerCounts != null && i < layerCounts.Length ? Math.Max( layerCounts[i], 2 ) : 2;
var dispersion = dispersions != null && i < dispersions.Length ? dispersions[i] : SplatDispersionMode.Evenly;
float blend = blendStrengths != null && i < blendStrengths.Length ? blendStrengths[i] : 0.35f;
splats.Add( GenerateSplatmap( cellHeightmaps[i], MakeEvenThresholds( layers ), TerrainMaxHeight, layers, dispersion, blend ) );
}
return splats;
}
/// <summary>
/// Generates a splatmap where each grid tile uses its own layer count, dispersion mode and
/// blend strength. Each tile's splatmap is computed over its padded region (with the blend
/// overlap) and stitched together using the same edge weights as the heightmap.
/// </summary>
float[,] BuildTileGridSplatmap( float[,] heightmap, int gridSize, int[] layerCounts, SplatDispersionMode[] dispersions, float[] blendStrengths )
{
int width = heightmap.GetLength( 0 );
int height = heightmap.GetLength( 1 );
int grid = Math.Max( gridSize, 1 );
int tileW = width / grid;
int tileH = height / grid;
int maxLayers = 2;
if ( layerCounts != null )
{
foreach ( var lc in layerCounts )
maxLayers = Math.Max( maxLayers, lc );
}
// Single tile - same as before, just uses the tile's settings.
if ( grid <= 1 )
{
int layers = layerCounts != null && layerCounts.Length > 0 ? Math.Max( layerCounts[0], 2 ) : maxLayers;
var dispersion = dispersions != null && dispersions.Length > 0 ? dispersions[0] : SplatDispersionMode.Evenly;
float blendStrength = blendStrengths != null && blendStrengths.Length > 0 ? blendStrengths[0] : 0.35f;
return GenerateSplatmap( heightmap, MakeEvenThresholds( layers ), TerrainMaxHeight, layers, dispersion, blendStrength );
}
float[,] result = new float[width, height];
float[,] weight = new float[width, height];
int blend = Math.Max( 2, Math.Min( tileW, tileH ) / 8 );
for ( int ty = 0; ty < grid; ty++ )
{
for ( int tx = 0; tx < grid; tx++ )
{
int index = ty * grid + tx;
int layers = layerCounts != null && index < layerCounts.Length ? Math.Max( layerCounts[index], 2 ) : maxLayers;
var dispersion = dispersions != null && index < dispersions.Length ? dispersions[index] : SplatDispersionMode.Evenly;
float blendStrength = blendStrengths != null && index < blendStrengths.Length ? blendStrengths[index] : 0.35f;
// Extract the tile's heightmap region (with blend padding) so its splatmap
// normalizes against the tile's own height range.
int tw = tileW + blend * 2;
int th = tileH + blend * 2;
float[,] tileHeight = new float[tw, th];
int slotX = tx * tileW;
int slotY = ty * tileH;
for ( int y = 0; y < th; y++ )
{
int srcY = slotY - blend + y;
if ( srcY < 0 || srcY >= height ) continue;
for ( int x = 0; x < tw; x++ )
{
int srcX = slotX - blend + x;
if ( srcX < 0 || srcX >= width ) continue;
tileHeight[x, y] = heightmap[srcX, srcY];
}
}
float[,] tileSplat = GenerateSplatmap( tileHeight, MakeEvenThresholds( layers ), TerrainMaxHeight, layers, dispersion, blendStrength );
// Stitch with edge weights - same as the heightmap tiles
for ( int y = 0; y < th; y++ )
{
int outY = slotY - blend + y;
if ( outY < 0 || outY >= height ) continue;
for ( int x = 0; x < tw; x++ )
{
int outX = slotX - blend + x;
if ( outX < 0 || outX >= width ) continue;
float wx = EdgeBlendWeight( x, tw, blend );
float wy = EdgeBlendWeight( y, th, blend );
float w = wx * wy;
result[outX, outY] += tileSplat[x, y] * w;
weight[outX, outY] += w;
}
}
}
}
// Normalize by the accumulated weights
for ( int y = 0; y < height; y++ )
{
for ( int x = 0; x < width; x++ )
{
if ( weight[x, y] > 0.0001f )
result[x, y] /= weight[x, y];
}
}
return result;
}
/// <summary>
/// Builds evenly spaced color stop positions for the given layer count.
/// </summary>
float[] MakeEvenThresholds( int layers )
{
var t = new float[layers];
for ( int i = 0; i < layers; i++ )
{
t[i] = layers <= 1 ? 0f : (float)i / (layers - 1);
}
return t;
}
float EdgeBlendWeight( int coord, int size, int blend )
{
if ( blend <= 0 ) return 1f;
if ( coord < blend ) return (float)coord / blend;
if ( coord > size - blend ) return (float)(size - coord) / blend;
return 1f;
}
public void RebuildShapes()
{
ShapeArray.DestroyChildren();
TerrainShapeArray.Clear();
if ( CategoryArray?.Selected is null )
{
return;
}
string className = $"Sturnus.TerrainGenerationTool.{TerrainCategoryEnum.GetName( TerrainCategoryEnum.GetValue( CategoryArray.Selected ) )}"; // Fully qualified name
string[] methods = GetMethodsFromClass( className );
// Print the methods
foreach ( string method in methods )
{
TerrainShapeArray.Add( method );
//Log.Info( method );
}
foreach ( var shape in TerrainShapeArray )
{
ShapeArray.AddOption( shape );
}
ShapeArray.SelectedIndex = 0;
ShapeArray.Selected = ShapeArray.Children.FirstOrDefault().Name;
foreach(var test in ShapeArray.Children )
{
//Log.Info( test.Name );
}
}
/// <summary>
/// Rebuilds the tile grid section: an "All" box plus one box per grid cell. Each box shows
/// which terrain category that tile currently uses and is clickable to select which tile the
/// Terrain Type page's category/shape selectors edit.
/// </summary>
void RebuildTileGridUI()
{
if ( _tilesContainer is null || !_tilesContainer.IsValid() ) return;
_tilesContainer.DestroyChildren();
_tileBoxes.Clear();
int grid = Math.Max( TerrainGridSize, 1 );
int count = grid * grid;
// Preserve existing selections, extend/trim to the new size
var oldCategories = _tileCategories;
var oldShapes = _tileShapes;
var oldMinHeights = _tileMinHeights;
var oldMaxHeights = _tileMaxHeights;
var oldPlaneScales = _tilePlaneScales;
var oldSeeds = _tileSeeds;
var oldSmoothing = _tileSmoothingPasses;
var oldNoiseLayers = _tileNoiseLayerStacks;
var oldWarping = _tileDomainWarping;
var oldWarpingSizes = _tileDomainWarpingSizes;
var oldWarpingStrengths = _tileDomainWarpingStrengths;
var oldSplatLayerCounts = _tileSplatLayerCounts;
var oldSplatMapCounts = _tileSplatMapCounts;
var oldSplatDispersions = _tileSplatDispersions;
var oldSplatBlendStrengths = _tileSplatBlendStrengths;
_tileCategories = new string[count];
_tileShapes = new string[count];
_tileMinHeights = new float[count];
_tileMaxHeights = new float[count];
_tilePlaneScales = new float[count];
_tileSeeds = new long[count];
_tileSmoothingPasses = new int[count];
_tileNoiseLayerStacks = new int[count];
_tileDomainWarping = new bool[count];
_tileDomainWarpingSizes = new float[count];
_tileDomainWarpingStrengths = new float[count];
_tileSplatLayerCounts = new int[count];
_tileSplatMapCounts = new int[count];
_tileSplatDispersions = new SplatDispersionMode[count];
_tileSplatBlendStrengths = new float[count];
string defaultCategory = TerrainCategoryArray.Count > 0 ? TerrainCategoryArray.First() : CategoryArray?.Selected;
string defaultShape = FirstShapeForCategory( defaultCategory );
if ( string.IsNullOrEmpty( defaultShape ) )
defaultShape = ShapeArray?.Selected;
for ( int i = 0; i < count; i++ )
{
_tileCategories[i] = oldCategories != null && i < oldCategories.Length && !string.IsNullOrEmpty( oldCategories[i] )
? oldCategories[i] : defaultCategory;
_tileShapes[i] = oldShapes != null && i < oldShapes.Length && !string.IsNullOrEmpty( oldShapes[i] )
? oldShapes[i] : defaultShape;
_tileMinHeights[i] = oldMinHeights != null && i < oldMinHeights.Length ? oldMinHeights[i] : TerrainMinHeight;
_tileMaxHeights[i] = oldMaxHeights != null && i < oldMaxHeights.Length ? oldMaxHeights[i] : TerrainMaxHeight;
_tilePlaneScales[i] = oldPlaneScales != null && i < oldPlaneScales.Length ? oldPlaneScales[i] : TerrainPlaneScale;
_tileSeeds[i] = oldSeeds != null && i < oldSeeds.Length ? oldSeeds[i] : TerrainSeed;
_tileSmoothingPasses[i] = oldSmoothing != null && i < oldSmoothing.Length ? oldSmoothing[i] : SmoothingPasses;
_tileNoiseLayerStacks[i] = oldNoiseLayers != null && i < oldNoiseLayers.Length ? oldNoiseLayers[i] : NoiseLayerStacks;
_tileDomainWarping[i] = oldWarping != null && i < oldWarping.Length ? oldWarping[i] : DomainWarping;
_tileDomainWarpingSizes[i] = oldWarpingSizes != null && i < oldWarpingSizes.Length ? oldWarpingSizes[i] : DomainWarpingSize;
_tileDomainWarpingStrengths[i] = oldWarpingStrengths != null && i < oldWarpingStrengths.Length ? oldWarpingStrengths[i] : DomainWarpingStrength;
_tileSplatLayerCounts[i] = oldSplatLayerCounts != null && i < oldSplatLayerCounts.Length ? oldSplatLayerCounts[i] : SplatLayerCount;
_tileSplatMapCounts[i] = oldSplatMapCounts != null && i < oldSplatMapCounts.Length ? oldSplatMapCounts[i] : SplatMapCount;
_tileSplatDispersions[i] = oldSplatDispersions != null && i < oldSplatDispersions.Length ? oldSplatDispersions[i] : SplatDispersion;
_tileSplatBlendStrengths[i] = oldSplatBlendStrengths != null && i < oldSplatBlendStrengths.Length ? oldSplatBlendStrengths[i] : SplatBlendStrength;
}
_selectedTileIndex = Math.Clamp( _selectedTileIndex, 0, count - 1 );
// "All tiles" box selects every tile at once
var allBox = new TileGridBox( null, -1, "All", "Select every tile" );
allBox.IsSelected = _selectedTileIndex < 0;
allBox.OnClicked = () => SelectTile( -1 );
_tileBoxes.Add( allBox );
_tilesContainer.Layout.Add( allBox );
// One box per grid cell
for ( int ty = 0; ty < grid; ty++ )
{
var row = _tilesContainer.Layout.AddRow();
row.Spacing = 4;
for ( int tx = 0; tx < grid; tx++ )
{
int index = ty * grid + tx;
var box = new TileGridBox( null, index, $"{_tileCategories[index]}:{_tileShapes[index]}", $"Tile {tx},{ty}" );
box.IsSelected = index == _selectedTileIndex;
box.OnClicked = () => SelectTile( index );
_tileBoxes.Add( box );
row.Add( box, 1 );
}
}
// Sync the category/shape selectors to whichever tile is selected
SyncSelectorsToSelectedTile();
}
/// <summary>
/// Marks the given tile as the one being edited. -1 selects all tiles.
/// </summary>
void SelectTile( int index )
{
if ( _selectedTileIndex == index ) return;
_selectedTileIndex = index;
UpdateTileBoxSelection();
SyncSelectorsToSelectedTile();
// Ease the overlay mesh colors so only the selected tile stays colored
if ( _overlayMesh != null && _overlayMesh.IsValid() )
{
_overlayColorAnimating = true;
}
}
void UpdateTileBoxSelection()
{
foreach ( var box in _tileBoxes )
{
if ( box.Index == _selectedTileIndex )
{
box.IsSelected = true;
box.Update();
}
else
{
box.IsSelected = false;
box.Update();
}
}
}
void UpdateTileBoxText()
{
foreach ( var box in _tileBoxes )
{
if ( box.Index < 0 ) continue;
if ( box.Index < _tileCategories.Length && box.Index < _tileShapes.Length )
box.Text = $"{_tileCategories[box.Index]}:{_tileShapes[box.Index]}";
box.Update();
}
}
/// <summary>
/// Pushes the selected tile's category/shape/height/scale/seed into the Terrain Type and
/// Height/Scale page controls.
/// </summary>
void SyncSelectorsToSelectedTile()
{
if ( CategoryArray is null || ShapeArray is null ) return;
_syncingTileSelectors = true;
int refIndex = Math.Max( _selectedTileIndex, 0 );
if ( refIndex >= _tileCategories.Length ) refIndex = 0;
string category = _tileCategories[refIndex];
if ( CategoryArray.HasOption( category ) )
{
CategoryArray.Selected = category;
}
else if ( CategoryArray.Children.Count() > 0 )
{
CategoryArray.SelectedIndex = 0;
}
string shape = _tileShapes[refIndex];
if ( ShapeArray.HasOption( shape ) )
{
ShapeArray.Selected = shape;
}
else if ( ShapeArray.Children.Count() > 0 )
{
ShapeArray.SelectedIndex = 0;
}
// Push the selected tile's height/scale/seed into the global props so the
// Height/Scale page sliders show the selected tile's values.
_serialized.GetProperty( nameof( TerrainMinHeight ) )?.SetValue( _tileMinHeights[refIndex] );
_serialized.GetProperty( nameof( TerrainMaxHeight ) )?.SetValue( _tileMaxHeights[refIndex] );
_serialized.GetProperty( nameof( TerrainPlaneScale ) )?.SetValue( _tilePlaneScales[refIndex] );
_serialized.GetProperty( nameof( TerrainSeed ) )?.SetValue( _tileSeeds[refIndex] );
// Same for the Smooth/Noise page controls
_serialized.GetProperty( nameof( SmoothingPasses ) )?.SetValue( _tileSmoothingPasses[refIndex] );
_serialized.GetProperty( nameof( NoiseLayerStacks ) )?.SetValue( _tileNoiseLayerStacks[refIndex] );
// Domain warping page controls
_serialized.GetProperty( nameof( DomainWarping ) )?.SetValue( _tileDomainWarping[refIndex] );
_serialized.GetProperty( nameof( DomainWarpingSize ) )?.SetValue( _tileDomainWarpingSizes[refIndex] );
_serialized.GetProperty( nameof( DomainWarpingStrength ) )?.SetValue( _tileDomainWarpingStrengths[refIndex] );
// Splat page controls
_serialized.GetProperty( nameof( SplatLayerCount ) )?.SetValue( _tileSplatLayerCounts[refIndex] );
_serialized.GetProperty( nameof( SplatMapCount ) )?.SetValue( _tileSplatMapCounts[refIndex] );
_serialized.GetProperty( nameof( SplatDispersion ) )?.SetValue( _tileSplatDispersions[refIndex] );
_serialized.GetProperty( nameof( SplatBlendStrength ) )?.SetValue( _tileSplatBlendStrengths[refIndex] );
_syncingTileSelectors = false;
}
/// <summary>
/// Called when the Terrain Type page's category changes. Writes the new category to the
/// selected tile (or every tile if All is selected).
/// </summary>
void ApplySelectedCategory()
{
if ( _syncingTileSelectors ) return;
string category = CategoryArray.Selected;
if ( string.IsNullOrEmpty( category ) ) return;
if ( _selectedTileIndex < 0 )
{
for ( int i = 0; i < _tileCategories.Length; i++ )
{
_tileCategories[i] = category;
_tileShapes[i] = FirstShapeForCategory( category );
}
}
else if ( _selectedTileIndex < _tileCategories.Length )
{
_tileCategories[_selectedTileIndex] = category;
_tileShapes[_selectedTileIndex] = FirstShapeForCategory( category );
}
// Keep the shape selector in sync with the new category's first shape
if ( ShapeArray.HasOption( _tileShapes[Math.Max( _selectedTileIndex, 0 )] ) )
ShapeArray.Selected = _tileShapes[Math.Max( _selectedTileIndex, 0 )];
UpdateTileBoxText();
}
/// <summary>
/// Called when the Terrain Type page's shape changes. Writes the new shape to the selected
/// tile (or every tile if All is selected).
/// </summary>
void ApplySelectedShape()
{
if ( _syncingTileSelectors ) return;
string shape = ShapeArray.Selected;
if ( string.IsNullOrEmpty( shape ) ) return;
if ( _selectedTileIndex < 0 )
{
for ( int i = 0; i < _tileShapes.Length; i++ )
_tileShapes[i] = shape;
}
else if ( _selectedTileIndex < _tileShapes.Length )
{
_tileShapes[_selectedTileIndex] = shape;
}
UpdateTileBoxText();
}
/// <summary>
/// Writes the given global height/scale/seed values into the selected tile (or every tile
/// if All is selected). Nullable params mean "leave unchanged".
/// </summary>
void WriteSelectedValues( float? minHeight = null, float? maxHeight = null, float? planeScale = null, long? seed = null, int? smoothing = null, int? noiseLayers = null,
bool? warp = null, float? warpSize = null, float? warpStrength = null,
int? splatLayers = null, int? splatMaps = null, SplatDispersionMode? splatDispersion = null, float? splatBlend = null )
{
if ( _selectedTileIndex < 0 )
{
for ( int i = 0; i < _tileMinHeights.Length; i++ )
{
if ( minHeight.HasValue ) _tileMinHeights[i] = minHeight.Value;
if ( maxHeight.HasValue ) _tileMaxHeights[i] = maxHeight.Value;
if ( planeScale.HasValue ) _tilePlaneScales[i] = planeScale.Value;
if ( seed.HasValue ) _tileSeeds[i] = seed.Value;
if ( smoothing.HasValue ) _tileSmoothingPasses[i] = smoothing.Value;
if ( noiseLayers.HasValue ) _tileNoiseLayerStacks[i] = noiseLayers.Value;
if ( warp.HasValue ) _tileDomainWarping[i] = warp.Value;
if ( warpSize.HasValue ) _tileDomainWarpingSizes[i] = warpSize.Value;
if ( warpStrength.HasValue ) _tileDomainWarpingStrengths[i] = warpStrength.Value;
if ( splatLayers.HasValue ) _tileSplatLayerCounts[i] = splatLayers.Value;
if ( splatMaps.HasValue ) _tileSplatMapCounts[i] = splatMaps.Value;
if ( splatDispersion.HasValue ) _tileSplatDispersions[i] = splatDispersion.Value;
if ( splatBlend.HasValue ) _tileSplatBlendStrengths[i] = splatBlend.Value;
}
}
else if ( _selectedTileIndex < _tileMinHeights.Length )
{
if ( minHeight.HasValue ) _tileMinHeights[_selectedTileIndex] = minHeight.Value;
if ( maxHeight.HasValue ) _tileMaxHeights[_selectedTileIndex] = maxHeight.Value;
if ( planeScale.HasValue ) _tilePlaneScales[_selectedTileIndex] = planeScale.Value;
if ( seed.HasValue ) _tileSeeds[_selectedTileIndex] = seed.Value;
if ( smoothing.HasValue ) _tileSmoothingPasses[_selectedTileIndex] = smoothing.Value;
if ( noiseLayers.HasValue ) _tileNoiseLayerStacks[_selectedTileIndex] = noiseLayers.Value;
if ( warp.HasValue ) _tileDomainWarping[_selectedTileIndex] = warp.Value;
if ( warpSize.HasValue ) _tileDomainWarpingSizes[_selectedTileIndex] = warpSize.Value;
if ( warpStrength.HasValue ) _tileDomainWarpingStrengths[_selectedTileIndex] = warpStrength.Value;
if ( splatLayers.HasValue ) _tileSplatLayerCounts[_selectedTileIndex] = splatLayers.Value;
if ( splatMaps.HasValue ) _tileSplatMapCounts[_selectedTileIndex] = splatMaps.Value;
if ( splatDispersion.HasValue ) _tileSplatDispersions[_selectedTileIndex] = splatDispersion.Value;
if ( splatBlend.HasValue ) _tileSplatBlendStrengths[_selectedTileIndex] = splatBlend.Value;
}
}
string FirstShapeForCategory( string category )
{
var options = ShapeOptionsForCategory( category );
return options.Length > 0 ? options[0] : null;
}
string[] ShapeOptionsForCategory( string category )
{
if ( string.IsNullOrEmpty( category ) ) return Array.Empty<string>();
string className = $"Sturnus.TerrainGenerationTool.{category}";
try
{
return GetMethodsFromClass( className );
}
catch
{
return Array.Empty<string>();
}
}
private void UpdateTerrain()
{
if ( _heightmap is null ) return;
var ActiveScene = Editor.SceneEditorSession.Active.Scene;
var FirstTerrain = ActiveScene.GetAllComponents<Terrain>().FirstOrDefault();
if ( !FirstTerrain.IsValid() ) return;
int res = _heightmap.GetLength( 0 );
// Resize the scene terrain's storage to match the generated heightmap
if ( FirstTerrain.Storage is null )
FirstTerrain.Storage = new TerrainStorage { EmbeddedResource = new Sandbox.Resources.EmbeddedResource { ResourceCompiler = "embed" } };
FirstTerrain.Storage.SetResolution( res );
// Write the heightmap with the same indexing the preview uses (heightArray[y * res + x] =
// heightmap[x, y]). ConvertFloatArrayToUShortArray stores a transpose, which would mirror
// the terrain against the splatmap and misalign the materials on slopes.
ushort[] heightArray = new ushort[res * res];
for ( int y = 0; y < res; y++ )
{
for ( int x = 0; x < res; x++ )
{
float h = Math.Clamp( _heightmap[x, y], 0f, 1f );
heightArray[y * res + x] = (ushort)Math.Clamp( (int)(h * 65535f), 0, 65535 );
}
}
FirstTerrain.Storage.HeightMap = heightArray;
// Apply the splatmap as a control map so the material blending matches the generated
// splatmap. SetResolution wipes the control map, so we always rewrite it here. The splatmap
// is recomputed from the current settings so dispersion/layer changes made after Generate
// are honoured.
_splatmap = BuildTileGridSplatmap( _heightmap, TerrainGridSize,
(int[])_tileSplatLayerCounts.Clone(), (SplatDispersionMode[])_tileSplatDispersions.Clone(), (float[])_tileSplatBlendStrengths.Clone() );
if ( _splatmap != null )
{
// Resolve which materials to use: the assigned preview materials, else the terrain's
// existing materials, else fall back to loading local tmats.
var materials = _previewMaterials;
if ( materials == null || materials.Length == 0 )
materials = FirstTerrain.Storage.Materials?.ToArray();
if ( materials == null || materials.Length == 0 )
materials = LoadTerrainMaterialsSync();
if ( materials != null && materials.Length > 0 )
{
uint[] controlMap = new uint[res * res];
int matCount = materials.Length;
for ( int y = 0; y < res; y++ )
{
for ( int x = 0; x < res; x++ )
{
float layerPos = Math.Clamp( _splatmap[x, y], 0f, matCount - 1f );
int baseId = (int)MathF.Floor( layerPos );
int overlayId = Math.Min( baseId + 1, matCount - 1 );
byte blend = (byte)Math.Clamp( (int)((layerPos - baseId) * 255f), 0, 255 );
controlMap[y * res + x] = new CompactTerrainMaterial( (byte)baseId, (byte)overlayId, blend, false ).Packed;
}
}
FirstTerrain.Storage.ControlMap = controlMap;
FirstTerrain.Storage.Materials.Clear();
FirstTerrain.Storage.Materials.AddRange( materials );
}
}
FirstTerrain.Create();
FirstTerrain.SyncGPUTexture();
FirstTerrain.UpdateMaterialsBuffer();
}
/// <summary>
/// Synchronously loads usable local .tmat terrain materials so Apply can set the splat
/// control map even when the user never clicked "Randomize Materials".
/// </summary>
TerrainMaterial[] LoadTerrainMaterialsSync()
{
try
{
if ( _localTmatAssets is null || _localTmatAssets.Count == 0 )
{
var allLocal = Editor.AssetSystem.All
.Where( a => a is not null && !a.IsDeleted && !a.IsCloud )
.Where( a => (a.RelativePath?.EndsWith( ".tmat" ) ?? false) )
.ToList();
var with1k = allLocal.Where( a => a.RelativePath.Contains( "_1k" ) ).ToList();
_localTmatAssets = with1k.Count > 0 ? with1k : allLocal;
}
var pool = new List<Editor.Asset>( _localTmatAssets );
var materials = new List<TerrainMaterial>();
int layerCount = MaxTileSplatLayers();
while ( materials.Count < layerCount && pool.Count > 0 )
{
int idx = Random.Shared.Next( pool.Count );
var asset = pool[idx];
pool.RemoveAt( idx );
if ( !asset.TryLoadResource<TerrainMaterial>( out var found ) || found is null )
continue;
if ( !IsMaterialUsable( found, asset.Path ) )
continue;
materials.Add( found );
}
return materials.Count > 0 ? materials.ToArray() : null;
}
catch ( System.Exception e )
{
Log.Error( $"Failed to load terrain materials for apply: {e.Message}" );
return null;
}
}
/// <summary>
/// Applies per-cell mode: spawns one Terrain per grid cell, each at full resolution and
/// positioned so they tile together in the scene.
/// </summary>
private void UpdatePerCellTerrains()
{
if ( _cellHeightmaps == null || _cellHeightmaps.Count == 0 ) return;
var ActiveScene = Editor.SceneEditorSession.Active.Scene;
// Match the size/height of an existing terrain in the scene so the cells line up,
// falling back to the preview constants if the scene has no terrain yet.
var existing = ActiveScene.GetAllComponents<Terrain>().FirstOrDefault();
float cellSize = existing.IsValid() ? existing.TerrainSize : PreviewTerrainSize;
float terrainHeight = existing.IsValid() ? existing.TerrainHeight : PreviewTerrainHeight;
int grid = Math.Max( TerrainGridSize, 1 );
// Terrain size is a property on the component; size each cell so the whole grid spans cellSize*grid.
float sizePerCell = cellSize;
int cellRes = _cellHeightmaps[0].GetLength( 0 );
// Recompute the per-cell splatmaps from the current settings so dispersion/layer changes
// made after Generate are honoured.
_cellSplatmaps = BuildPerCellSplatmaps( _cellHeightmaps,
(int[])_tileSplatLayerCounts.Clone(), (SplatDispersionMode[])_tileSplatDispersions.Clone(), (float[])_tileSplatBlendStrengths.Clone() );
using ( ActiveScene.Push() )
{
for ( int ty = 0; ty < grid; ty++ )
{
for ( int tx = 0; tx < grid; tx++ )
{
int index = ty * grid + tx;
if ( index >= _cellHeightmaps.Count ) continue;
var go = new GameObject( true, $"terrain cell {tx},{ty}" );
var terrain = go.AddComponent<Terrain>( false );
var storage = new TerrainStorage();
storage.EmbeddedResource = new Sandbox.Resources.EmbeddedResource { ResourceCompiler = "embed" };
storage.SetResolution( cellRes );
storage.TerrainSize = sizePerCell;
storage.TerrainHeight = terrainHeight;
// Match the preview's indexing (heightArray[y * res + x] = map[x, y]) so the
// heightmap and splatmap line up on slopes.
ushort[] cellHeight = new ushort[cellRes * cellRes];
var cellMap = _cellHeightmaps[index];
for ( int y = 0; y < cellRes; y++ )
{
for ( int x = 0; x < cellRes; x++ )
{
float h = Math.Clamp( cellMap[x, y], 0f, 1f );
cellHeight[y * cellRes + x] = (ushort)Math.Clamp( (int)(h * 65535f), 0, 65535 );
}
}
storage.HeightMap = cellHeight;
// Add the splat control map so the material blending matches the generated splatmap.
// These are fresh cells, so resolve materials the same way as the combined apply.
if ( _cellSplatmaps != null && index < _cellSplatmaps.Count )
{
var materials = _previewMaterials;
if ( materials == null || materials.Length == 0 )
materials = LoadTerrainMaterialsSync();
if ( materials != null && materials.Length > 0 )
{
uint[] controlMap = new uint[cellRes * cellRes];
int matCount = materials.Length;
var splatmap = _cellSplatmaps[index];
for ( int y = 0; y < cellRes; y++ )
{
for ( int x = 0; x < cellRes; x++ )
{
float layerPos = Math.Clamp( splatmap[x, y], 0f, matCount - 1f );
int baseId = (int)MathF.Floor( layerPos );
int overlayId = Math.Min( baseId + 1, matCount - 1 );
byte blend = (byte)Math.Clamp( (int)((layerPos - baseId) * 255f), 0, 255 );
controlMap[y * cellRes + x] = new CompactTerrainMaterial( (byte)baseId, (byte)overlayId, blend, false ).Packed;
}
}
storage.ControlMap = controlMap;
storage.Materials.Clear();
storage.Materials.AddRange( materials );
}
}
terrain.Storage = storage;
terrain.TerrainSize = sizePerCell;
terrain.TerrainHeight = terrainHeight;
// Each cell is sizePerCell wide - tile them so the whole grid spans cellSize*grid
go.WorldPosition = new Vector3( tx * sizePerCell, ty * sizePerCell, 0f );
terrain.Create();
terrain.SyncGPUTexture();
terrain.UpdateMaterialsBuffer();
}
}
}
}
private float[,] AddStagingSquare( float[,] heightmap, int squareSize, float squareHeight, float centerX, float centerY )
{
int width = heightmap.GetLength( 0 );
int height = heightmap.GetLength( 1 );
// Calculate the center and bounds of the square
int centerXPixel = (int)(centerX * width);
int centerYPixel = (int)(centerY * height);
int halfSize = squareSize / 2;
int startX = Math.Max( centerXPixel - halfSize, 0 );
int startY = Math.Max( centerYPixel - halfSize, 0 );
int endX = Math.Min( centerXPixel + halfSize, width - 1 );
int endY = Math.Min( centerYPixel + halfSize, height - 1 );
// Set the height values inside the square to be completely flat
for ( int y = startY; y <= endY; y++ )
{
for ( int x = startX; x <= endX; x++ )
{
heightmap[x, y] = squareHeight;
}
}
// Add the slope around the square
for ( int y = 0; y < height; y++ )
{
for ( int x = 0; x < width; x++ )
{
// Skip the flat square area
if ( x >= startX && x <= endX && y >= startY && y <= endY )
continue;
// Calculate the distance to the nearest edge of the square
int dx = Math.Max( Math.Abs( x - centerXPixel ) - halfSize, 0 );
int dy = Math.Max( Math.Abs( y - centerYPixel ) - halfSize, 0 );
float distanceToSquare = MathF.Sqrt( dx * dx + dy * dy );
// Calculate the target height for the slope
float slopeHeight = squareHeight - (distanceToSquare * 0.0038f); // 0.0038f is perfect for players
// Ensure the slope transitions smoothly into the existing terrain
heightmap[x, y] = Math.Max( heightmap[x, y], slopeHeight );
}
}
return heightmap;
}
private void GeneratePreviewFile( string path, out SKBitmap image, out SKBitmap splat )
{
//Create TerrainGenerationTool folder if it doesn't exist.
Directory.CreateDirectory( path );
string previewfile = Path.Combine( path, $"TerrainGenerationUtility_preview.png" );
string splatfile = Path.Combine( path, $"TerrainGenerationUtility_splat_preview.png" );
image = HeightmapToBitMap( _heightmap );
SaveImage( image, previewfile );
splat = SplatmapToBitMap( _splatmap, _splatcolors );
SaveSplatmapAsPng( splat, splatfile );
}
/// <summary>
/// Builds a fresh GPU texture from a bitmap so the preview widgets always get new data
/// (the resource cache would otherwise return the same stale texture for the same path).
/// </summary>
Texture TextureFromBitmap( SKBitmap bitmap )
{
if ( bitmap is null ) return Texture.Invalid;
int width = bitmap.Width;
int height = bitmap.Height;
byte[] rgba = new byte[width * height * 4];
for ( int y = 0; y < height; y++ )
{
for ( int x = 0; x < width; x++ )
{
var c = bitmap.GetPixel( x, y );
int i = (y * width + x) * 4;
rgba[i + 0] = c.Red;
rgba[i + 1] = c.Green;
rgba[i + 2] = c.Blue;
rgba[i + 3] = c.Alpha;
}
}
return Texture.Create( width, height )
.WithName( $"TerrainGenerationPreview_{Environment.TickCount}" )
.WithData( rgba )
.Finish();
}
private void GenerateImageFiles( string output_path )
{
string UsingDomainWarping = "";
string UsingErosionEmulation = "";
string UsingWaterCarving = "";
if ( DomainWarping )
{
UsingDomainWarping = "_warp";
}
if ( ErosionSimulation )
{
UsingErosionEmulation = "_erosion";
}
if ( RiverCarvingBool )
{
UsingWaterCarving = "_watercarving";
}
//Create TerrainGenerationTool folder if it doesn't exist.
Directory.CreateDirectory( output_path );
if ( GridStorage == GridStorageMode.PerCell && _cellHeightmaps != null && _cellHeightmaps.Count > 0 )
{
GeneratePerCellFiles( output_path );
return;
}
string rawfile = Path.Combine( output_path, $"TerrainGenerationUtility_export_{/*TerrainShapeEnumSelect*/null}{UsingDomainWarping}{UsingErosionEmulation}{UsingWaterCarving}.raw" );
string previewfile = Path.Combine( output_path, $"TerrainGenerationUtility_preview_{/*TerrainShapeEnumSelect*/null}{UsingDomainWarping}{UsingErosionEmulation}{UsingWaterCarving}.png" );
string splatfile = Path.Combine( output_path, $"TerrainGenerationUtility_splat_export_{/*TerrainShapeEnumSelect*/null}{UsingDomainWarping}{UsingErosionEmulation}{UsingWaterCarving}.png" );
//Export RAW HeightMap file
SaveRaw( _heightmap, rawfile );
Log.Info( $"Raw file generated! - {rawfile}" );
//Generate & Export Preview image for widget
SKBitmap image = HeightmapToBitMap( _heightmap );
SaveImage( image, previewfile );
Log.Info( $"HeightMap preview file generated! - {previewfile}" );
//Generate & Export SplatMap image
float[,] splatmap = BuildTileGridSplatmap( _heightmap, TerrainGridSize,
(int[])_tileSplatLayerCounts.Clone(), (SplatDispersionMode[])_tileSplatDispersions.Clone(), (float[])_tileSplatBlendStrengths.Clone() );
SKBitmap splat = SplatmapToBitMap( splatmap, _splatcolors );
SaveSplatmapAsPng( splat, splatfile );
Log.Info( $"Splatmap file generated! - {splatfile}" );
// Split the layers across the requested number of splat maps (based on the first tile's settings)
int layerCount = _tileSplatLayerCounts != null && _tileSplatLayerCounts.Length > 0 ? Math.Max( _tileSplatLayerCounts[0], 2 ) : 2;
int mapCount = _tileSplatMapCounts != null && _tileSplatMapCounts.Length > 0 ? Math.Max( _tileSplatMapCounts[0], 1 ) : 1;
for ( int m = 0; m < mapCount; m++ )
{
int startLayer = m * layerCount / mapCount;
int endLayer = (m + 1) * layerCount / mapCount;
var mapBitmap = new SKBitmap( splatmap.GetLength( 0 ), splatmap.GetLength( 1 ) );
for ( int y = 0; y < mapBitmap.Height; y++ )
{
for ( int x = 0; x < mapBitmap.Width; x++ )
{
float layerPos = Math.Clamp( splatmap[x, y], 0f, layerCount - 1f );
int layer = (int)MathF.Round( layerPos );
if ( layer >= startLayer && layer < endLayer )
{
float local = (layer - startLayer) / (float)Math.Max( endLayer - startLayer, 1 );
var color = SplatMapGradient.Evaluate( Math.Clamp( local, 0f, 1f ) ).ToColor32();
mapBitmap.SetPixel( x, y, new SKColor( color.r, color.g, color.b, color.a ) );
}
else
{
mapBitmap.SetPixel( x, y, new SKColor( 0, 0, 0, 255 ) );
}
}
}
string mapFile = Path.Combine( output_path, $"TerrainGenerationUtility_splatmap_{m}_{/*TerrainShapeEnumSelect*/null}{UsingDomainWarping}{UsingErosionEmulation}{UsingWaterCarving}.png" );
SaveSplatmapAsPng( mapBitmap, mapFile );
Log.Info( $"Splatmap {m} file generated! - {mapFile}" );
}
Log.Info( $"All export files saved! {output_path}" );
}
/// <summary>
/// Exports each grid cell as its own full-resolution .raw heightmap and .png splatmap,
/// named by grid coordinate.
/// </summary>
private void GeneratePerCellFiles( string output_path )
{
Directory.CreateDirectory( output_path );
int grid = Math.Max( TerrainGridSize, 1 );
for ( int ty = 0; ty < grid; ty++ )
{
for ( int tx = 0; tx < grid; tx++ )
{
int index = ty * grid + tx;
if ( index >= _cellHeightmaps.Count ) continue;
var heightmap = _cellHeightmaps[index];
string rawfile = Path.Combine( output_path, $"TerrainGenerationUtility_cell_{tx}_{ty}.raw" );
SaveRaw( heightmap, rawfile );
Log.Info( $"Cell {tx},{ty} raw file generated! - {rawfile}" );
SKBitmap image = HeightmapToBitMap( heightmap );
string previewfile = Path.Combine( output_path, $"TerrainGenerationUtility_cell_{tx}_{ty}_preview.png" );
SaveImage( image, previewfile );
Log.Info( $"Cell {tx},{ty} preview generated! - {previewfile}" );
if ( _cellSplatmaps != null && index < _cellSplatmaps.Count )
{
SKBitmap splat = SplatmapToBitMap( _cellSplatmaps[index], _splatcolors );
string splatfile = Path.Combine( output_path, $"TerrainGenerationUtility_cell_{tx}_{ty}_splat.png" );
SaveSplatmapAsPng( splat, splatfile );
Log.Info( $"Cell {tx},{ty} splatmap generated! - {splatfile}" );
}
}
}
Log.Info( $"All per-cell export files saved! {output_path}" );
}
public float[,] GenerateHeightmap( int width, int height, Func<int, int, float> generator, float maxHeight, int smoothpasses )
{
float[,] heightmap = new float[width, height];
float actualMaxHeight = float.MinValue;
// Use parallel processing to generate heightmap
object maxLock = new object(); // Lock object for thread safety
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
float value = generator( x, y );
heightmap[x, y] = value;
// Update actual max height (thread-safe)
lock ( maxLock )
{
if ( value > actualMaxHeight )
{
actualMaxHeight = value;
}
}
}
} );
// Scale all values by the actual max height and up to the specified max height
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
heightmap[x, y] = (heightmap[x, y] / actualMaxHeight) * maxHeight;
}
} );
// Apply smoothing if needed
if ( smoothpasses > 0 )
{
return SmoothHeightmap( heightmap, smoothpasses );
}
else
{
return heightmap;
}
}
public static float[,] GenerateStackedNoise(
int width,
int height,
long seed,
int layers,
float initialFrequency,
float frequencyMultiplier,
float initialAmplitude,
float amplitudeMultiplier,
Func<int, int, float> shapeFunction, // Shape function applied after stacking noise
float maxHeight,
int smoothingPasses,
float terrainPlaneScale // New variable to scale the noise
)
{
// Initialize the heightmap with zeros
float[,] heightmap = new float[width, height];
// Random offset generator for noise layers
Random random = new Random( (int)(seed & 0xFFFFFFFF) );
float[] xOffsets = new float[layers];
float[] yOffsets = new float[layers];
for ( int i = 0; i < layers; i++ )
{
xOffsets[i] = random.Next( -100000, 100000 ) / 1000.0f;
yOffsets[i] = random.Next( -100000, 100000 ) / 1000.0f;
}
// Adjust frequency based on TerrainPlaneScale
float scaleFactor = Math.Clamp( terrainPlaneScale, 0.01f, 1.0f );
// Multithreaded noise generation
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
float value = 0.0f;
for ( int layer = 0; layer < layers; layer++ )
{
float frequency = initialFrequency * MathF.Pow( frequencyMultiplier, layer ) / scaleFactor;
float amplitude = initialAmplitude * MathF.Pow( amplitudeMultiplier, layer );
// Normalized coordinates adjusted by scale factor
float nx = (x / (float)width) * frequency;
float ny = (y / (float)height) * frequency;
// Apply random offsets
nx += xOffsets[layer];
ny += yOffsets[layer];
// Generate noise
float noiseValue = OpenSimplex2S.Noise2( (seed + layer) & 0xFFFFFFFF, nx, ny );
value += Math.Clamp( noiseValue, -1.0f, 1.0f ) * amplitude;
}
// Save the computed value to the heightmap
// (each Parallel.For iteration writes its own distinct row - no lock needed)
heightmap[x, y] += value;
}
} );
// Normalize the heightmap to the range [0, 1]
heightmap = NormalizeHeightmap( heightmap );
// Apply the shape function and amplify its contribution if needed
float shapeAmplification = 1.2f; // Adjust for stronger shape effects
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
heightmap[x, y] *= MathF.Pow( shapeFunction( x, y ), shapeAmplification );
}
} );
// Rescale the heightmap to the desired maxHeight
float currentMax = FindMaxHeight( heightmap );
if ( currentMax > 0 )
{
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
heightmap[x, y] = (heightmap[x, y] / currentMax) * maxHeight;
}
} );
}
// Apply smoothing
if ( smoothingPasses > 0 )
{
heightmap = SmoothHeightmap( heightmap, smoothingPasses );
}
return heightmap;
}
// Helper method to find the maximum height in a heightmap
private static float FindMaxHeight( float[,] heightmap )
{
int width = heightmap.GetLength( 0 );
int height = heightmap.GetLength( 1 );
float max = float.MinValue;
object maxLock = new object();
Parallel.For( 0, height, y =>
{
float rowMax = float.MinValue;
for ( int x = 0; x < width; x++ )
{
if ( heightmap[x, y] > rowMax )
{
rowMax = heightmap[x, y];
}
}
if ( rowMax > max )
{
lock ( maxLock )
{
if ( rowMax > max ) max = rowMax;
}
}
} );
return max;
}
private static float[,] NormalizeHeightmap( float[,] heightmap )
{
int width = heightmap.GetLength( 0 );
int height = heightmap.GetLength( 1 );
// Find the min and max values (parallel, per-row reduce)
object lockObject = new object();
float min = float.MaxValue;
float max = float.MinValue;
Parallel.For( 0, height, y =>
{
float rowMin = float.MaxValue;
float rowMax = float.MinValue;
for ( int x = 0; x < width; x++ )
{
float value = heightmap[x, y];
if ( value < rowMin ) rowMin = value;
if ( value > rowMax ) rowMax = value;
}
lock ( lockObject )
{
if ( rowMin < min ) min = rowMin;
if ( rowMax > max ) max = rowMax;
}
} );
float range = max - min;
if ( range <= 0f ) range = 1f;
// Normalize the values (parallel, independent writes)
float[,] normalized = new float[width, height];
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
normalized[x, y] = (heightmap[x, y] - min) / range;
}
} );
return normalized;
}
public static float[,] AddTurbulenceForRivers(
float[,] heightmap,
long seed,
float riverFrequency, // Frequency for river placement
float riverWidth, // Width of the rivers
float riverDepth, // Depth of the rivers
float turbulenceFrequency, // Turbulence frequency
float turbulenceStrength, // Turbulence strength
float minRiverSpacing, // Minimum spacing between rivers
float slopeSteepness, // Controls the gradual slope of the riverbanks
float terrainNoiseFrequency, // Matches terrain surface noise frequency
float terrainNoiseAmplitude // Matches terrain surface noise amplitude
)
{
int width = heightmap.GetLength( 0 );
int height = heightmap.GetLength( 1 );
float[,] newHeightmap = (float[,])heightmap.Clone();
Random random = new Random( (int)(seed & 0xFFFFFFFF) );
float[,] riverPlacementNoise = new float[width, height];
// Generate river placement noise
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
float nx = x / (float)width;
float ny = y / (float)height;
// Noise for river placement
riverPlacementNoise[x, y] = OpenSimplex2S.Noise2( seed, nx * riverFrequency, ny * riverFrequency );
}
} );
// Process heightmap with river carving
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
float nx = x / (float)width;
float ny = y / (float)height;
float riverNoise = MathF.Abs( riverPlacementNoise[x, y] ); // Use absolute noise for placement
// Determine if the point is within the river carving zone
if ( riverNoise < riverWidth )
{
// Calculate the smooth curve effect based on distance from the center
float distanceFactor = 1.0f - (riverNoise / riverWidth); // 1 at center, 0 at edge
float smoothDepthReduction = MathF.Pow( distanceFactor, slopeSteepness ) * riverDepth;
// Add turbulence for a more organic flow
float turbulence = OpenSimplex2S.Noise2( seed + 1, nx * turbulenceFrequency, ny * turbulenceFrequency )
* turbulenceStrength;
// Apply smooth depth reduction and turbulence
float reducedHeight = newHeightmap[x, y] - smoothDepthReduction + turbulence;
// Clamp height to ensure it doesn't rise above the original
newHeightmap[x, y] = MathF.Max( 0, MathF.Min( newHeightmap[x, y], reducedHeight ) );
}
// Enforce minimum spacing between rivers
if ( riverNoise < minRiverSpacing )
{
// Slightly raise the terrain to enforce separation
newHeightmap[x, y] += (minRiverSpacing - riverNoise) * 0.05f;
}
}
} );
// Add base noise to the entire heightmap after carving
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
float nx = x / (float)width;
float ny = y / (float)height;
// Generate base noise
float baseNoise = OpenSimplex2S.Noise2( seed + 2, nx * 6f, ny * 6f )
* 0.02f;
// Add noise to the heightmap
newHeightmap[x, y] = MathF.Max( 0, newHeightmap[x, y] + baseNoise );
}
} );
return newHeightmap;
}
// Smooths the heightmap using a simple box blur with adjustable strength
private static float[,] SmoothHeightmap( float[,] heightmap, int smoothingPasses )
{
int width = heightmap.GetLength( 0 );
int height = heightmap.GetLength( 1 );
float[,] smoothed = new float[width, height];
for ( int pass = 0; pass < smoothingPasses; pass++ )
{
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
float sum = 0;
int count = 0;
// Iterate through neighbors
for ( int dy = -1; dy <= 1; dy++ )
{
for ( int dx = -1; dx <= 1; dx++ )
{
int nx = x + dx;
int ny = y + dy;
if ( nx >= 0 && nx < width && ny >= 0 && ny < height )
{
sum += heightmap[nx, ny];
count++;
}
}
}
smoothed[x, y] = sum / count;
}
} );
// Copy smoothed values back to the original heightmap for the next pass
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
heightmap[x, y] = smoothed[x, y];
}
} );
}
return smoothed;
}
public static ushort[] ConvertFloatArrayToUShortArray( float[,] input, float scale = 65535.0f )
{
// Get the dimensions of the 2D array
int rows = input.GetLength( 0 );
int cols = input.GetLength( 1 );
// Initialize the 1D ushort array
ushort[] output = new ushort[rows * cols];
// Iterate over the 2D array row by row
int index = 0;
for ( int row = 0; row < rows; row++ )
{
for ( int col = 0; col < cols; col++ )
{
// Convert the float to ushort, scaling if necessary
float value = input[row, col];
value = Math.Clamp( value, 0.0f, 1.0f ); // Ensure the float is in the 0 to 1 range
output[index++] = (ushort)(value * scale);
}
}
return output;
}
public static byte[] ConvertRawFloatArrayToByteArray( float[,] rawData, float scale = 65535.0f )
{
if ( rawData == null )
{
throw new ArgumentNullException( nameof( rawData ), "Input rawData cannot be null." );
}
int rows = rawData.GetLength( 0 );
int cols = rawData.GetLength( 1 );
// Create a byte array with 2 bytes per value
byte[] byteArray = new byte[rows * cols * 2]; // 2 bytes per ushort
int index = 0;
for ( int row = 0; row < rows; row++ )
{
for ( int col = 0; col < cols; col++ )
{
float value = rawData[row, col];
value = Math.Clamp( value, 0.0f, 1.0f ); // Ensure value is in the range [0, 1]
// Convert to 16-bit unsigned integer
ushort ushortValue = (ushort)(value * scale);
// Store in byte array (little-endian order)
byteArray[index++] = (byte)(ushortValue & 0xFF); // Lower byte
byteArray[index++] = (byte)((ushortValue >> 8) & 0xFF); // Upper byte
}
}
return byteArray;
}
// Converts a heightmap to a grayscale image using SkiaSharp
public static SKBitmap HeightmapToBitMap( float[,] heightmap )
{
int width = heightmap.GetLength( 0 );
int height = heightmap.GetLength( 1 );
SKBitmap bitmap = new SKBitmap( width, height );
for ( int y = 0; y < height; y++ )
{
for ( int x = 0; x < width; x++ )
{
int intensity = (int)(heightmap[x, y] * 255);
intensity = Math.Clamp( intensity, 0, 255 );
bitmap.SetPixel( x, y, new SKColor( (byte)intensity, (byte)intensity, (byte)intensity ) );
}
}
return bitmap;
}
public byte[] ConvertSKBitmapToBytes( SKBitmap bitmap, SKEncodedImageFormat format, int quality = 100 )
{
// Create an SKImage from the SKBitmap
using ( var image = SKImage.FromBitmap( bitmap ) )
{
// Encode the image to the desired format (e.g., PNG, JPEG)
using ( var data = image.Encode( format, quality ) )
{
// Convert SKData to a byte array
return data.ToArray();
}
}
}
public static void SaveImage(
SKBitmap bitmap,
string filename,
float rotationDegrees = 270f,
bool reverseHorizontal = false,
bool reverseVertical = true
)
{
int width = bitmap.Width;
int height = bitmap.Height;
// Create a new bitmap to hold the transformed image
using var transformedBitmap = new SKBitmap( width, height );
// Create a canvas to draw the transformed image
using var canvas = new SKCanvas( transformedBitmap );
// Clear the canvas with transparency
canvas.Clear( SKColors.Transparent );
// Apply transformations
canvas.Save();
// Translate to the center of the canvas for rotation and flipping
canvas.Translate( width / 2f, height / 2f );
// Apply flipping first
float scaleX = reverseHorizontal ? -1f : 1f;
float scaleY = reverseVertical ? -1f : 1f;
canvas.Scale( scaleX, scaleY );
// Apply rotation
if ( rotationDegrees != 0 )
{
canvas.RotateDegrees( rotationDegrees );
}
// Translate back to ensure the image is drawn correctly
canvas.Translate( -width / 2f, -height / 2f );
// Draw the original bitmap onto the transformed canvas
canvas.DrawBitmap( bitmap, 0, 0 );
// Restore the canvas to finalize the transformations
canvas.Restore();
// Flush the canvas
canvas.Flush();
// Save the transformed bitmap as a PNG file
using var pixmap = transformedBitmap.PeekPixels();
using var image = SKImage.FromPixels( pixmap );
using var data = image.Encode( SKEncodedImageFormat.Png, 100 );
using var stream = File.OpenWrite( filename );
data.SaveTo( stream );
}
public static void SaveRaw( float[,] heightmap, string filename, int rotationDegrees = 270, bool reverseHorizontal = true, bool reverseVertical = false )
{
int width = heightmap.GetLength( 0 );
int height = heightmap.GetLength( 1 );
// Rotate the heightmap if requested
if ( rotationDegrees != 0 )
{
heightmap = RotateHeightmap( heightmap, rotationDegrees );
if ( rotationDegrees == 90 || rotationDegrees == 270 )
{
// Swap width and height for 90° or 270° rotations
(width, height) = (height, width);
}
}
// Reverse the heightmap if requested
if ( reverseHorizontal || reverseVertical )
{
heightmap = ReverseHeightmap( heightmap, reverseHorizontal, reverseVertical );
}
using var fileStream = new FileStream( filename, FileMode.Create, FileAccess.Write );
using var binaryWriter = new BinaryWriter( fileStream );
for ( int y = 0; y < height; y++ )
{
for ( int x = 0; x < width; x++ )
{
// Scale image data to 16-bit
ushort value = (ushort)(Math.Clamp( heightmap[x, y], 0, 1 ) * 65535);
binaryWriter.Write( value );
}
}
}
// Helper method to rotate the heightmap by 90°, 180°, or 270°
private static float[,] RotateHeightmap( float[,] original, int rotationDegrees )
{
int originalWidth = original.GetLength( 0 );
int originalHeight = original.GetLength( 1 );
float[,] rotated;
switch ( rotationDegrees )
{
case 90:
rotated = new float[originalHeight, originalWidth];
for ( int y = 0; y < originalHeight; y++ )
{
for ( int x = 0; x < originalWidth; x++ )
{
rotated[y, originalWidth - 1 - x] = original[x, y];
}
}
break;
case 180:
rotated = new float[originalWidth, originalHeight];
for ( int y = 0; y < originalHeight; y++ )
{
for ( int x = 0; x < originalWidth; x++ )
{
rotated[originalWidth - 1 - x, originalHeight - 1 - y] = original[x, y];
}
}
break;
case 270:
rotated = new float[originalHeight, originalWidth];
for ( int y = 0; y < originalHeight; y++ )
{
for ( int x = 0; x < originalWidth; x++ )
{
rotated[originalHeight - 1 - y, x] = original[x, y];
}
}
break;
default:
throw new ArgumentException( "Rotation must be 0, 90, 180, or 270 degrees." );
}
return rotated;
}
// Helper method to reverse the heightmap horizontally and/or vertically
private static float[,] ReverseHeightmap( float[,] original, bool reverseHorizontal, bool reverseVertical )
{
int width = original.GetLength( 0 );
int height = original.GetLength( 1 );
float[,] reversed = new float[width, height];
for ( int y = 0; y < height; y++ )
{
for ( int x = 0; x < width; x++ )
{
int targetX = reverseHorizontal ? width - 1 - x : x;
int targetY = reverseVertical ? height - 1 - y : y;
reversed[targetX, targetY] = original[x, y];
}
}
return reversed;
}
public static float[,] GenerateSplatmap( float[,] heightmap, float[] thresholds, float maxHeight, int layerCount = -1, SplatDispersionMode dispersion = SplatDispersionMode.Evenly, float blendStrength = 0.35f )
{
int width = heightmap.GetLength( 0 );
int height = heightmap.GetLength( 1 );
int layers = layerCount > 0 ? layerCount : Math.Max( thresholds.Length, 2 );
float[,] splatmap = new float[width, height];
// Build normalized height bounds from the data itself (robust to maxHeight being lower than peaks)
float minH = float.MaxValue, maxH = float.MinValue;
object minMaxLock = new object();
Parallel.For( 0, height, y =>
{
float rowMin = float.MaxValue;
float rowMax = float.MinValue;
for ( int x = 0; x < width; x++ )
{
if ( heightmap[x, y] < rowMin ) rowMin = heightmap[x, y];
if ( heightmap[x, y] > rowMax ) rowMax = heightmap[x, y];
}
lock ( minMaxLock )
{
if ( rowMin < minH ) minH = rowMin;
if ( rowMax > maxH ) maxH = rowMax;
}
} );
float range = MathF.Max( maxH - minH, 0.0001f );
// Thresholds are the height positions of each color stop in [0,1].
// Evenly mode uses equally spaced stops; Natural mode uses a slope-weighted
// distribution so colors bunch on flat/common terrain and spread on steep slopes.
float[] stops = thresholds;
if ( dispersion == SplatDispersionMode.Natural )
{
stops = ComputeNaturalThresholds( heightmap, layers );
}
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
// Normalized height in [0,1]
float normalizedHeight = Math.Clamp( (heightmap[x, y] - minH) / range, 0f, 1f );
// Interpolated layer position from the color stop positions
float layerPos = HeightToLayer( normalizedHeight, stops );
// Soft snap to the nearest layer governed by blend strength
float center = MathF.Round( layerPos );
float distance = layerPos - center;
float factor;
if ( MathF.Abs( distance ) <= blendStrength * 0.5f )
{
factor = layerPos;
}
else
{
factor = center;
}
splatmap[x, y] = Math.Clamp( factor, 0f, layers - 1 );
}
} );
return splatmap;
}
static float HeightToLayer( float normalizedHeight, float[] stops )
{
int count = stops.Length;
if ( count <= 1 ) return 0f;
if ( normalizedHeight <= stops[0] ) return 0f;
if ( normalizedHeight >= stops[count - 1] ) return count - 1f;
for ( int i = 0; i < count - 1; i++ )
{
if ( normalizedHeight >= stops[i] && normalizedHeight <= stops[i + 1] )
{
float t = (normalizedHeight - stops[i]) / MathF.Max( stops[i + 1] - stops[i], 0.0001f );
return i + t;
}
}
return count - 1f;
}
/// <summary>
/// Places color stop thresholds based on the terrain's slope-weighted height distribution.
/// Flat, common heights get many stops (lots of color blending); steep, rare heights get few
/// stops (few color changes), matching how terrain materials naturally appear.
/// </summary>
static float[] ComputeNaturalThresholds( float[,] heightmap, int layerCount )
{
int width = heightmap.GetLength( 0 );
int height = heightmap.GetLength( 1 );
float minH = float.MaxValue, maxH = float.MinValue;
object minMaxLock = new object();
Parallel.For( 0, height, y =>
{
float rowMin = float.MaxValue;
float rowMax = float.MinValue;
for ( int x = 0; x < width; x++ )
{
if ( heightmap[x, y] < rowMin ) rowMin = heightmap[x, y];
if ( heightmap[x, y] > rowMax ) rowMax = heightmap[x, y];
}
lock ( minMaxLock )
{
if ( rowMin < minH ) minH = rowMin;
if ( rowMax > maxH ) maxH = rowMax;
}
} );
float range = MathF.Max( maxH - minH, 0.0001f );
// Histogram of normalized heights, weighted by flatness (1 - slope).
// Use a per-thread local histogram, then merge, to avoid lock contention.
const int bins = 128;
float[] hist = new float[bins];
Parallel.For( 0, height, y =>
{
float[] localHist = new float[bins];
for ( int x = 0; x < width; x++ )
{
float h = heightmap[x, y];
float hL = heightmap[Math.Max( x - 1, 0 ), y];
float hR = heightmap[Math.Min( x + 1, width - 1 ), y];
float hD = heightmap[x, Math.Max( y - 1, 0 )];
float hU = heightmap[x, Math.Min( y + 1, height - 1 )];
float localDiff = (MathF.Abs( hR - hL ) + MathF.Abs( hU - hD )) * 0.5f;
float slope = Math.Clamp( localDiff / MathF.Max( range * 0.1f, 0.0001f ), 0f, 1f );
float weight = MathF.Max( 1f - slope, 0.05f );
float normalizedHeight = Math.Clamp( (h - minH) / range, 0f, 1f );
int bin = Math.Clamp( (int)(normalizedHeight * (bins - 1)), 0, bins - 1 );
localHist[bin] += weight;
}
lock ( minMaxLock )
{
for ( int i = 0; i < bins; i++ ) hist[i] += localHist[i];
}
} );
// Cumulative distribution
float total = hist.Sum();
if ( total <= 0f )
{
total = 1f;
for ( int i = 0; i < bins; i++ ) hist[i] = 1f;
}
float[] thresholds = new float[layerCount];
thresholds[0] = 0f;
thresholds[layerCount - 1] = 1f;
float cum = 0f;
int binIndex = 0;
for ( int i = 1; i < layerCount - 1; i++ )
{
float target = (i / (float)(layerCount - 1)) * total;
while ( binIndex < bins - 1 && cum < target )
{
cum += hist[binIndex];
binIndex++;
}
thresholds[i] = binIndex / (float)(bins - 1);
}
// Ensure monotonic
for ( int i = 1; i < layerCount; i++ )
{
thresholds[i] = MathF.Max( thresholds[i], thresholds[i - 1] );
}
return thresholds;
}
public static SKBitmap SplatmapToBitMap( float[,] splatmap, SKColor[] colors )
{
int width = splatmap.GetLength( 0 );
int height = splatmap.GetLength( 1 );
SKBitmap bitmap = new SKBitmap( width, height );
Parallel.For( 0, height, y =>
{
for ( int x = 0; x < width; x++ )
{
// Map the splatmap value to a valid layer position
float layerPos = Math.Clamp( splatmap[x, y], 0f, colors.Length - 1f );
int layer0 = (int)MathF.Floor( layerPos );
int layer1 = Math.Min( layer0 + 1, colors.Length - 1 );
float t = layerPos - layer0;
// Blend between the two nearest layer colors
var c0 = colors[layer0];
var c1 = colors[layer1];
var color = new SKColor(
(byte)MathX.LerpTo( c0.Red, c1.Red, t ),
(byte)MathX.LerpTo( c0.Green, c1.Green, t ),
(byte)MathX.LerpTo( c0.Blue, c1.Blue, t ),
(byte)MathX.LerpTo( c0.Alpha, c1.Alpha, t ) );
bitmap.SetPixel( x, y, color );
}
} );
return bitmap;
}
public static void SaveSplatmapAsPng(
SKBitmap bitmap,
string filename,
float rotationDegrees = 270f,
bool reverseHorizontal = false,
bool reverseVertical = true
)
{
int width = bitmap.Width;
int height = bitmap.Height;
using var transformedBitmap = new SKBitmap( width, height );
using var canvas = new SKCanvas( transformedBitmap );
// Clear the canvas with transparency
canvas.Clear( SKColors.Transparent );
// Apply transformations
canvas.Save();
// Translate to the center of the canvas for rotation and flipping
canvas.Translate( width / 2f, height / 2f );
// Apply flipping first
float scaleX = reverseHorizontal ? -1f : 1f;
float scaleY = reverseVertical ? -1f : 1f;
canvas.Scale( scaleX, scaleY );
// Apply rotation
if ( rotationDegrees != 0 )
{
canvas.RotateDegrees( rotationDegrees );
}
// Translate back to ensure the image is drawn correctly
canvas.Translate( -width / 2f, -height / 2f );
// Draw the original bitmap onto the transformed canvas
canvas.DrawBitmap( bitmap, 0, 0 );
// Restore the canvas to finalize the transformations
canvas.Restore();
// Flush the canvas
canvas.Flush();
// Save the transformed bitmap as a PNG file
using var pixmap = transformedBitmap.PeekPixels();
using var image = SKImage.FromPixels( pixmap );
using var data = image.Encode( SKEncodedImageFormat.Png, 100 );
using var stream = File.OpenWrite( filename );
data.SaveTo( stream );
}
}
/// <summary>
/// An icon + label picker whose options wrap onto multiple lines.
/// Mimics the interface of <see cref="Editor.SegmentedControl"/> (AddOption, Selected, SelectedIndex, OnSelectedChanged).
/// </summary>
public class WrapSelector : Widget
{
readonly List<WrapOption> _buttons = new();
readonly List<string> _names = new();
public string Selected
{
get
{
for ( int i = 0; i < _buttons.Count; i++ )
{
if ( _buttons[i].IsActive )
return _names[i];
}
return null;
}
set
{
SetSelected( value );
}
}
public int SelectedIndex
{
get
{
for ( int i = 0; i < _buttons.Count; i++ )
{
if ( _buttons[i].IsActive )
return i;
}
return -1;
}
set
{
if ( value >= 0 && value < _names.Count )
SetSelected( _names[value] );
}
}
public Action<string> OnSelectedChanged { get; set; }
public WrapSelector( Widget parent = null ) : base( parent )
{
Layout = Layout.Row();
Layout.Spacing = 4;
SetSizeMode( SizeMode.CanGrow, SizeMode.CanGrow );
HorizontalSizeMode = SizeMode.Flexible;
}
public void AddOption( string name, string icon = null, int? count = null, string label = null )
{
if ( _names.Contains( name ) ) return;
if ( string.IsNullOrEmpty( name ) )
{
// Special "clear" option, shown with the close icon
icon ??= "close";
}
else
{
icon ??= IconFor( name );
}
var option = new WrapOption( this, label ?? (string.IsNullOrEmpty( name ) ? "Clear" : name), icon );
option.IsActive = false;
option.MouseLeftPress = () => SetSelected( name );
_names.Add( name );
_buttons.Add( option );
Layout.Add( option );
}
public bool HasOption( string name ) => _names.Contains( name );
public new void DestroyChildren()
{
foreach ( var b in _buttons )
{
if ( b.IsValid() )
b.Destroy();
}
_buttons.Clear();
_names.Clear();
}
void SetSelected( string name )
{
bool changed = Selected != name;
for ( int i = 0; i < _buttons.Count; i++ )
{
_buttons[i].IsActive = _names[i] == name;
}
if ( changed )
{
OnSelectedChanged?.Invoke( name );
}
}
static string IconFor( string name )
{
switch ( name )
{
case "Islands": return "landscape";
case "Mountainous": return "terrain";
case "Planetary": return "public";
case "Realistic": return "photo";
case "Sea": return "water";
case "Volcanic": return "volcano";
case "Default": return "shapes";
case "Archipelagos": return "scatter_plot";
case "Atoll": return "crop_square";
case "Islets": return "blur_on";
case "Oceanic": return "waves";
case "Cliff": return "terrain";
case "Craters": return "brightness_low";
case "Hills": return "landscape";
case "Plateau": return "square_foot";
case "SeaBed": return "water";
case "Sharded": return "dashboard";
default: return "shapes";
}
}
}
/// <summary>
/// A single option in a <see cref="WrapSelector"/>: an icon with a text label underneath.
/// </summary>
public class WrapOption : Widget
{
public string Icon { get; }
public string Text { get; }
public bool IsActive { get; set; }
public WrapOption( Widget parent, string text, string icon ) : base( parent )
{
Text = text;
Icon = icon;
Cursor = CursorShape.Finger;
ToolTip = text;
MinimumSize = new Vector2( 52, 44 );
}
protected override Vector2 SizeHint()
{
Paint.SetDefaultFont( 7 );
var textRect = Paint.MeasureText( new Rect( 0, 0, 60, 100 ), Text, TextFlag.WordWrap );
return new Vector2( MathF.Max( textRect.Size.x + 10, 52 ), textRect.Size.y + 24 );
}
protected override void OnPaint()
{
base.OnPaint();
Paint.Antialiasing = true;
Paint.ClearPen();
var rect = LocalRect;
var background = IsActive ? Theme.Primary.WithAlpha( 0.25f ) : Theme.ControlBackground.WithAlpha( 0.6f );
if ( Paint.HasMouseOver ) background = background.Lighten( 0.1f );
Paint.SetBrush( background );
Paint.DrawRect( rect, Theme.ControlRadius );
var iconRect = new Rect( rect.Left, rect.Top + 4, rect.Width, rect.Height * 0.55f );
var color = IsActive ? Theme.Primary : Theme.Text.WithAlpha( 0.8f );
Paint.SetPen( color );
Paint.DrawIcon( iconRect, Icon, 18, TextFlag.Center );
var textRect = new Rect( rect.Left + 2, rect.Top + rect.Height * 0.55f, rect.Width - 4, rect.Height * 0.45f );
Paint.SetDefaultFont( 7 );
Paint.DrawText( textRect, Text, TextFlag.Center | TextFlag.WordWrap );
}
}
/// <summary>
/// A clickable box representing one terrain grid cell (or the "All" box). Shows the tile's
/// current category name and is highlighted when selected.
/// </summary>
public class TileGridBox : Widget
{
public int Index { get; }
public string Text { get; set; }
string _subtitle;
public bool IsSelected { get; set; }
public Action OnClicked { get; set; }
public TileGridBox( Widget parent, int index, string text, string subtitle = null ) : base( parent )
{
Index = index;
Text = text;
_subtitle = subtitle;
Cursor = CursorShape.Finger;
ToolTip = subtitle ?? text;
MinimumSize = new Vector2( 44, 40 );
MouseLeftPress = () => OnClicked?.Invoke();
}
protected override Vector2 SizeHint()
{
return new Vector2( 48, 44 );
}
protected override void OnPaint()
{
base.OnPaint();
Paint.Antialiasing = true;
Paint.ClearPen();
var rect = LocalRect;
var bg = IsSelected ? Theme.Primary.WithAlpha( 0.3f ) : Theme.ControlBackground.WithAlpha( 0.6f );
if ( Paint.HasMouseOver ) bg = bg.Lighten( 0.1f );
Paint.SetBrush( bg );
Paint.DrawRect( rect, Theme.ControlRadius );
if ( IsSelected )
{
Paint.SetPen( Theme.Primary, 2 );
Paint.DrawRect( rect, Theme.ControlRadius );
}
var textRect = new Rect( rect.Left + 3, rect.Top + 2, rect.Width - 6, rect.Height - 4 );
Paint.SetDefaultFont( 7 );
Paint.SetPen( IsSelected ? Theme.Primary : Theme.Text.WithAlpha( 0.9f ) );
Paint.DrawText( textRect, Text ?? "?", TextFlag.Center | TextFlag.WordWrap );
}
}
/// <summary>
/// A compact dropdown-style picker used in the tile grid. Shows a button with the current value
/// and opens a popup listing the available options.
/// </summary>
public class TileDropdownPicker : Widget
{
string[] _options;
Button _button;
string _label;
public string Selected { get; private set; }
public Action<string> OnPicked { get; set; }
public TileDropdownPicker( Widget parent, string label, string[] options ) : base( parent )
{
_label = label;
_options = options ?? Array.Empty<string>();
Layout = Layout.Column();
Layout.Spacing = 2;
var labelWidget = new Label( label );
labelWidget.SetStyles( "font-size: 9px; color: #999;" );
Layout.Add( labelWidget );
_button = new Button( Selected ?? "None", this );
_button.FixedHeight = Theme.RowHeight;
_button.Clicked += OpenMenu;
Layout.Add( _button );
}
public void SetOptions( string[] options )
{
_options = options ?? Array.Empty<string>();
}
public void SetSelected( string value )
{
Selected = value;
if ( _button != null && _button.IsValid() )
_button.Text = value ?? "None";
}
void OpenMenu()
{
var popup = new PopupWidget( null );
popup.Layout = Layout.Column();
popup.Layout.Margin = 4;
popup.Width = Math.Max( 180, _button.ScreenRect.Width );
var scroller = popup.Layout.Add( new ScrollArea( this ), 1 );
scroller.Canvas = new Widget( scroller )
{
Layout = Layout.Column(),
VerticalSizeMode = SizeMode.CanGrow | SizeMode.Expand
};
foreach ( var option in _options )
{
var item = scroller.Canvas.Layout.Add( new Button( option ) );
item.MouseLeftPress = () =>
{
SetSelected( option );
OnPicked?.Invoke( option );
popup.Close();
};
}
popup.Position = _button.ScreenRect.BottomLeft;
popup.Visible = true;
popup.AdjustSize();
popup.ConstrainToScreen();
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( LocalRect, Theme.ControlRadius );
base.OnPaint();
}
}