Editor window UI that converts image files into a square single-channel raw heightmap (.raw). It lets the user pick an image (including EXR), previews output size/byte estimate based on settings, converts via HeightmapConverter, and saves the resulting raw file.
using System;
using System.IO;
using Sandbox;
namespace Editor.TerrainConvert;
/// <summary>
/// Editor tool that converts an image (.png, .jpg, .tga, .tif, .psd, .exr ...) into a
/// raw single-channel heightmap (.raw) that the s&box terrain importer can load.
/// </summary>
[EditorApp( "Heightmap Converter", "terrain", "Convert an image into a terrain heightmap (.raw)" )]
public class HeightmapConvertWindow : Widget
{
HeightmapConvertSettings Settings { get; set; } = new();
string inputFile;
Bitmap loadedBitmap;
ExrImage loadedExr;
int srcWidth, srcHeight;
bool HasImage => loadedBitmap is not null || loadedExr is not null;
Label inputLabel;
Label outputInfoLabel;
Button convertButton;
public HeightmapConvertWindow() : this( null ) { }
public HeightmapConvertWindow( Widget parent ) : base( parent )
{
WindowFlags = WindowFlags.Dialog | WindowFlags.Customized | WindowFlags.WindowTitle | WindowFlags.CloseButton | WindowFlags.WindowSystemMenuHint;
DeleteOnClose = true;
WindowTitle = "Convert Image to Heightmap";
SetWindowIcon( "terrain" );
Layout = Layout.Column();
Layout.Spacing = 8;
Layout.Margin = 16;
var warning = new WarningBox(
"Converts an image into a raw heightmap (.raw) for the terrain system.\n" +
"Output is square, single-channel, 8 or 16 bit. Import it via the Terrain component's heightmap import.", this );
Layout.Add( warning );
// Input file row
{
var row = Layout.Row();
row.Spacing = 8;
inputLabel = new Label( "No image selected", this );
inputLabel.WordWrap = false;
row.Add( inputLabel, 1 );
var browse = new Button( "Browse Image...", "image", this );
browse.Clicked = PickInputFile;
row.Add( browse );
Layout.Add( row );
}
// Settings sheet
{
var so = EditorUtility.GetSerializedObject( Settings );
so.OnPropertyChanged += _ => UpdateInfo();
var sheet = new ControlSheet();
sheet.AddObject( so );
Layout.Add( sheet );
}
outputInfoLabel = new Label( "", this );
outputInfoLabel.Color = Theme.TextControl.WithAlpha( 0.6f );
Layout.Add( outputInfoLabel );
Layout.AddStretchCell();
// Bottom bar
{
var row = Layout.Row();
row.Margin = new Sandbox.UI.Margin( 0, 8, 0, 0 );
row.AddStretchCell();
convertButton = new Button.Primary( "Convert & Save...", "file_download", this );
convertButton.Clicked = ConvertAndSave;
convertButton.Enabled = false;
row.Add( convertButton );
Layout.Add( row );
}
Width = 440;
MinimumWidth = 380;
Height = 520;
UpdateInfo();
Show();
Focus();
}
void PickInputFile()
{
var fd = new FileDialog( null )
{
Title = "Select Source Image",
};
fd.SetFindFile();
fd.SetModeOpen();
fd.SetNameFilter( "Images (*.png *.jpg *.jpeg *.tga *.tif *.tiff *.psd *.exr *.bmp *.raw)" );
if ( !fd.Execute() )
return;
LoadInput( fd.SelectedFile );
}
void LoadInput( string path )
{
loadedBitmap?.Dispose();
loadedBitmap = null;
loadedExr = null;
inputFile = null;
try
{
var bytes = File.ReadAllBytes( path );
// EXR isn't supported by the Skia-backed Bitmap loader, so decode it ourselves.
bool isExr = path.EndsWith( ".exr", StringComparison.OrdinalIgnoreCase ) || ExrImage.IsExr( bytes );
if ( isExr )
{
loadedExr = ExrImage.Load( bytes );
srcWidth = loadedExr.Width;
srcHeight = loadedExr.Height;
inputFile = path;
}
else
{
var bitmap = Bitmap.CreateFromBytes( bytes );
if ( bitmap is null || !bitmap.IsValid )
{
EditorUtility.DisplayDialog( "Couldn't load image",
$"Failed to decode '{Path.GetFileName( path )}'.\nThis image format may not be supported." );
return;
}
loadedBitmap = bitmap;
srcWidth = bitmap.Width;
srcHeight = bitmap.Height;
inputFile = path;
}
}
catch ( Exception e )
{
EditorUtility.DisplayDialog( "Couldn't load image", e.Message );
return;
}
UpdateInfo();
}
void UpdateInfo()
{
if ( !HasImage )
{
inputLabel.Text = "No image selected";
outputInfoLabel.Text = "Pick an image to begin.";
convertButton.Enabled = false;
return;
}
string kind = loadedExr is not null ? "EXR" : "image";
inputLabel.Text = $"{Path.GetFileName( inputFile )} ({srcWidth}x{srcHeight}, {kind})";
convertButton.Enabled = true;
int res = PreviewResolution();
string depth = Settings.BitDepth == HeightBitDepth.Bit16 ? "16-bit" : "8-bit";
int bytes = res * res * (Settings.BitDepth == HeightBitDepth.Bit16 ? 2 : 1);
outputInfoLabel.Text = $"Output: {res}x{res}, {depth} → {bytes / 1024:n0} KB raw";
}
int PreviewResolution()
{
int res = Settings.Resolution > 0 ? Settings.Resolution : Math.Min( srcWidth, srcHeight );
if ( Settings.PowerOfTwo )
res = HeightmapConverter.RoundDownToPowerOfTwo( res );
return Math.Clamp( res, 4, 16384 );
}
void ConvertAndSave()
{
if ( !HasImage )
return;
var fd = new FileDialog( null )
{
Title = "Save Heightmap",
DefaultSuffix = ".raw",
};
fd.Directory = Path.GetDirectoryName( inputFile );
fd.SelectFile( $"{Path.GetFileNameWithoutExtension( inputFile )}.raw" );
fd.SetFindFile();
fd.SetModeSave();
fd.SetNameFilter( "Raw Heightmap (*.raw *.r16 *.r8)" );
if ( !fd.Execute() )
return;
try
{
int resolution;
var data = loadedExr is not null
? HeightmapConverter.Convert( loadedExr, Settings, out resolution )
: HeightmapConverter.Convert( loadedBitmap, Settings, out resolution );
File.WriteAllBytes( fd.SelectedFile, data );
EditorUtility.DisplayDialog( "Heightmap saved",
$"Wrote {resolution}x{resolution} heightmap to:\n{fd.SelectedFile}\n\n" +
"Import it from a Terrain component: open its heightmap import and choose this .raw file." );
}
catch ( Exception e )
{
EditorUtility.DisplayDialog( "Conversion failed", e.Message );
}
}
public override void OnDestroyed()
{
base.OnDestroyed();
loadedBitmap?.Dispose();
loadedBitmap = null;
loadedExr = null;
}
}