Editor asset preview for .tileset resources. It loads a tileset texture, creates preview and tile-0 textures, and builds a small scene with sprites and text to render as the asset browser thumbnail/inspector preview.
using Editor.Assets;
using Saandy.Tilemapper;
using Sandbox;
using Sandbox.Rendering;
using System;
using System.Threading.Tasks;
namespace Saandy.Editor.Tilemapper;
/// <summary>
/// Asset browser thumbnail / inspector preview for .tileset resources.
///
/// This follows the same sprite path as the built-in image preview:
/// Texture -> Sprite.Frame.Texture -> SpriteRenderer.Sprite.
/// Do not assign SpriteRenderer.Texture directly.
/// </summary>
[AssetPreview( "tileset" )]
public sealed class TilesetResourcePreview : AssetPreview
{
private const float PreviewSize = 16.0f;
// Main atlas preview size.
private const float MaxImageWidth = 15.5f;
private const float MaxImageHeight = 15.5f;
private const float ImageBottomPadding = -5f;
// Big tile 0 preview in the top-right corner.
private const float Tile0PreviewSize = 10f;
private const float Tile0PreviewPadding = 0.45f;
private const float Tile0PreviewBorder = 0.45f;
private TilesetResource _tileset;
private Texture _sourceTexture;
private Texture _previewTexture;
private bool _ownsPreviewTexture;
private Texture _tile0Texture;
private bool _ownsTile0Texture;
private int _sourceTextureWidth = 1;
private int _sourceTextureHeight = 1;
private string _brushText = "Manual";
private SpriteRenderer _imageSprite;
private SpriteRenderer _tile0Sprite;
public override bool IsAnimatedPreview => false;
public override bool UsePixelEvaluatorForThumbs => true;
public TilesetResourcePreview( Asset asset ) : base( asset )
{
}
public override Task InitializeAsset()
{
_tileset = Asset.LoadResource<TilesetResource>();
_brushText = _tileset != null
? GetBrushDisplayName( _tileset.BrushType )
: "No TilesetResource";
_sourceTexture = LoadTilesetTexture( _tileset );
CacheSourceTextureSize( _sourceTexture );
_previewTexture = CreateSafePreviewTexture( _sourceTexture );
_tile0Texture = CreateTilePreviewTexture( _sourceTexture, 0 );
using ( Scene.Push() )
{
CreateBackground();
CreateImage();
CreateTile0Preview();
CreateText(
"Brush",
_brushText,
new Vector3( -12.0f, 0.0f, -6f ),
18.0f,
0.125f,
Color.White
);
SceneCenter = Vector3.Zero;
SceneSize = new Vector3( PreviewSize, PreviewSize, PreviewSize );
}
return Task.CompletedTask;
}
public override void UpdateScene( float cycle, float timeStep )
{
base.UpdateScene( cycle, timeStep );
if ( Camera == null )
return;
Camera.Orthographic = true;
Camera.OrthographicHeight = PreviewSize;
Camera.WorldPosition = Vector3.Forward * -200.0f;
Camera.WorldRotation = Rotation.LookAt( Vector3.Forward );
}
public override void Dispose()
{
if ( _ownsPreviewTexture )
{
_previewTexture?.Dispose();
_previewTexture = null;
_ownsPreviewTexture = false;
}
if ( _ownsTile0Texture )
{
_tile0Texture?.Dispose();
_tile0Texture = null;
_ownsTile0Texture = false;
}
base.Dispose();
}
private void CreateBackground()
{
var backgroundObject = new GameObject( true, "Tileset Preview Background" );
backgroundObject.WorldPosition = Vector3.Forward;
backgroundObject.Flags = backgroundObject.Flags.WithFlag( GameObjectFlags.EditorOnly, true );
var background = AddSprite(
backgroundObject,
GetCheckerboardTexture(),
new Vector2( PreviewSize, PreviewSize )
);
background.Color = Color.White;
}
private void CreateImage()
{
PrimaryObject = new GameObject( true, "Tileset Preview Image" );
PrimaryObject.Flags = PrimaryObject.Flags.WithFlag( GameObjectFlags.EditorOnly, true );
if ( _previewTexture == null || !_previewTexture.IsValid )
{
var missingSize = new Vector2( MaxImageWidth, MaxImageHeight );
PrimaryObject.WorldPosition = GetBottomAlignedImagePosition( missingSize );
_imageSprite = AddSprite( PrimaryObject, Texture.White, missingSize );
_imageSprite.Color = Color.FromBytes( 60, 24, 30 );
CreateText(
"Missing Image",
"NO IMAGE",
new Vector3( -8.0f, 0.0f, 0.0f ),
20.0f,
0.008f,
Color.FromBytes( 255, 130, 130 )
);
return;
}
var imageSize = GetImageWorldSize();
PrimaryObject.WorldPosition = GetBottomAlignedImagePosition( imageSize );
_imageSprite = AddSprite( PrimaryObject, _previewTexture, imageSize );
_imageSprite.Color = Color.White;
}
private Vector3 GetBottomAlignedImagePosition( Vector2 imageSize )
{
float bottom = -(PreviewSize * 0.5f) + ImageBottomPadding;
float centerZ = bottom + imageSize.y * 0.5f;
return new Vector3( 0.0f, 0.0f, centerZ );
}
private void CreateTile0Preview()
{
if ( _tile0Texture == null || !_tile0Texture.IsValid )
return;
float half = PreviewSize * 0.5f;
float y = half - (Tile0PreviewSize * 0.5f) - Tile0PreviewPadding;
float z = half - (Tile0PreviewSize * 0.5f) - Tile0PreviewPadding;
var backgroundObject = new GameObject( true, "Tileset Preview Tile 0 Background" );
backgroundObject.WorldPosition = new Vector3( -6.5f, y, z );
backgroundObject.Flags = backgroundObject.Flags.WithFlag( GameObjectFlags.EditorOnly, true );
var background = AddSprite(
backgroundObject,
Texture.White,
new Vector2( Tile0PreviewSize + Tile0PreviewBorder, Tile0PreviewSize + Tile0PreviewBorder )
);
background.Color = Color.Black.WithAlpha( 0.78f );
var tileObject = new GameObject( true, "Tileset Preview Tile 0" );
tileObject.WorldPosition = new Vector3( -7.0f, y, z );
tileObject.Flags = tileObject.Flags.WithFlag( GameObjectFlags.EditorOnly, true );
_tile0Sprite = AddSprite( tileObject, _tile0Texture, GetTile0WorldSize() );
_tile0Sprite.Color = Color.White;
}
private static SpriteRenderer AddSprite( GameObject gameObject, Texture texture, Vector2 size )
{
var sprite = gameObject.AddComponent<SpriteRenderer>();
sprite.Sprite = MakeSprite( texture );
sprite.Size = size;
sprite.IsSorted = true;
sprite.TextureFilter = FilterMode.Point;
return sprite;
}
private static Sprite MakeSprite( Texture texture ) => new()
{
Animations =
[
new Sprite.Animation
{
Name = "Default",
Frames =
[
new Sprite.Frame
{
Texture = texture
}
]
}
]
};
private Texture LoadTilesetTexture( TilesetResource tileset )
{
if ( tileset == null )
return null;
Texture texture = tileset.GetTexture();
if ( texture != null && texture.IsValid )
return texture;
if ( string.IsNullOrWhiteSpace( tileset.FilePath ) )
return null;
texture = Texture.Load( tileset.FilePath );
if ( texture != null && texture.IsValid )
return texture;
return null;
}
private void CacheSourceTextureSize( Texture texture )
{
_sourceTextureWidth = 1;
_sourceTextureHeight = 1;
if ( texture == null || !texture.IsValid )
return;
_sourceTextureWidth = Math.Max( 1, texture.Width );
_sourceTextureHeight = Math.Max( 1, texture.Height );
}
private Texture CreateSafePreviewTexture( Texture texture )
{
_ownsPreviewTexture = false;
if ( texture == null || !texture.IsValid )
return null;
try
{
using ( EditorUtility.DisableTextureStreaming() )
{
texture.MarkUsed();
using var bitmap = texture.GetBitmap( 0 );
if ( bitmap != null )
{
var preview = bitmap.ToTexture( false );
if ( preview != null && preview.IsValid )
{
_ownsPreviewTexture = true;
return preview;
}
}
}
}
catch
{
// Fall back to the original texture if the editor cannot read a bitmap from it.
}
return texture;
}
private Texture CreateTilePreviewTexture( Texture texture, ushort tileId )
{
_ownsTile0Texture = false;
if ( texture == null || !texture.IsValid )
return null;
Rect sourceRect = GetTileSourceRect( tileId );
if ( sourceRect.Width <= 0 || sourceRect.Height <= 0 )
return null;
try
{
using ( EditorUtility.DisableTextureStreaming() )
{
texture.MarkUsed();
using var bitmap = texture.GetBitmap( 0 );
if ( bitmap == null )
return null;
int sourceWidth = Math.Max( 1, texture.Width );
int sourceHeight = Math.Max( 1, texture.Height );
var pixels = bitmap.GetPixels();
int left = Math.Clamp( (int)MathF.Floor( sourceRect.Left ), 0, sourceWidth - 1 );
int top = Math.Clamp( (int)MathF.Floor( sourceRect.Top ), 0, sourceHeight - 1 );
int right = Math.Clamp( (int)MathF.Ceiling( sourceRect.Right ), left + 1, sourceWidth );
int bottom = Math.Clamp( (int)MathF.Ceiling( sourceRect.Bottom ), top + 1, sourceHeight );
int width = Math.Max( 1, right - left );
int height = Math.Max( 1, bottom - top );
byte[] data = new byte[width * height * 4];
for ( int y = 0; y < height; y++ )
{
for ( int x = 0; x < width; x++ )
{
int sourceX = left + x;
int sourceY = top + y;
int sourceIndex = sourceY * sourceWidth + sourceX;
int targetIndex = (y * width + x) * 4;
if ( sourceIndex < 0 || sourceIndex >= pixels.Length )
continue;
Color color = pixels[sourceIndex];
data[targetIndex + 0] = FloatToByte( color.r );
data[targetIndex + 1] = FloatToByte( color.g );
data[targetIndex + 2] = FloatToByte( color.b );
data[targetIndex + 3] = FloatToByte( color.a );
}
}
var tileTexture = Texture.Create( width, height )
.WithData( data )
.WithName( $"tileset_resource_preview_tile_{tileId}" )
.Finish();
if ( tileTexture != null && tileTexture.IsValid )
{
_ownsTile0Texture = true;
return tileTexture;
}
}
}
catch
{
}
return null;
}
private Rect GetTileSourceRect( ushort tileId )
{
if ( _tileset?.Tiles != null && tileId < _tileset.Tiles.Count )
{
var tile = _tileset.Tiles[tileId];
if ( tile != null && tile.SourceRect.Width > 0 && tile.SourceRect.Height > 0 )
return tile.SourceRect;
}
int tileW = Math.Max( 1, _tileset?.TileSize.x ?? 16 );
int tileH = Math.Max( 1, _tileset?.TileSize.y ?? 16 );
return new Rect( 0, 0, tileW, tileH );
}
private Vector2 GetImageWorldSize()
{
// Keep the readable large atlas-preview behavior.
// The tile 0 overlay gives the real per-tile preview.
float width = 100.0f;
float height = 100.0f;
float scale = Math.Min(
MaxImageWidth / width,
MaxImageHeight / height
);
if ( float.IsNaN( scale ) || float.IsInfinity( scale ) || scale <= 0.0f )
return new Vector2( MaxImageWidth, MaxImageHeight );
return new Vector2(
Math.Max( 0.1f, width * scale ),
Math.Max( 0.1f, height * scale )
);
}
private Vector2 GetTile0WorldSize()
{
if ( _tile0Texture == null || !_tile0Texture.IsValid )
return new Vector2( Tile0PreviewSize, Tile0PreviewSize );
float width = Math.Max( 1.0f, _tile0Texture.Width );
float height = Math.Max( 1.0f, _tile0Texture.Height );
float scale = Math.Min(
Tile0PreviewSize / width,
Tile0PreviewSize / height
);
if ( float.IsNaN( scale ) || float.IsInfinity( scale ) || scale <= 0.0f )
return new Vector2( Tile0PreviewSize, Tile0PreviewSize );
return new Vector2(
Math.Max( 0.1f, width * scale ),
Math.Max( 0.1f, height * scale )
);
}
private void CreateText( string objectName, string textValue, Vector3 worldPosition, float fontSize, float scale, Color color )
{
if ( string.IsNullOrWhiteSpace( textValue ) )
return;
var textObject = new GameObject( true, $"Tileset Preview {objectName}" );
textObject.WorldPosition = worldPosition;
textObject.Flags = textObject.Flags.WithFlag( GameObjectFlags.EditorOnly, true );
var text = textObject.AddComponent<TextRenderer>();
text.Text = textValue;
text.Color = color;
text.FontSize = fontSize;
text.Scale = scale;
text.Billboard = TextRenderer.BillboardMode.Always;
text.HorizontalAlignment = TextRenderer.HAlignment.Center;
text.VerticalAlignment = TextRenderer.VAlignment.Center;
text.FogStrength = 0.0f;
text.TextScope = new TextRendering.Scope()
{
Text = text.Text,
FontSize = text.FontSize,
TextColor = text.Color,
FontWeight = 400,
LineHeight = 100,
Outline = new TextRendering.Outline()
{
Enabled = true,
Color = Color.Black,
Size = 4f
}
};
}
private static byte FloatToByte( float value )
{
return (byte)Math.Clamp( MathF.Round( value * 255.0f ), 0.0f, 255.0f );
}
private static string GetBrushDisplayName( BrushType brushType )
{
return brushType.ToString() switch
{
"Manual" => "Manual",
"Edge2x2" => "2x2 Edge",
"Bitmask2x2Edge" => "2x2 Edge",
"Complex3x3" => "3x3 Complex",
"Bitmask3x3Complex" => "3x3 Complex",
_ => brushType.ToString()
};
}
private static Texture _checkerboardTexture;
private static Texture GetCheckerboardTexture()
{
if ( _checkerboardTexture != null && _checkerboardTexture.IsValid )
return _checkerboardTexture;
const int cells = 16;
const int cellSize = 8;
const int size = cells * cellSize;
var light = new Color32( 82, 82, 82 );
var dark = new Color32( 52, 52, 52 );
var data = new byte[size * size * 4];
for ( int y = 0; y < size; y++ )
{
for ( int x = 0; x < size; x++ )
{
var color = ((x / cellSize) + (y / cellSize)) % 2 == 0 ? light : dark;
int index = (y * size + x) * 4;
data[index + 0] = color.r;
data[index + 1] = color.g;
data[index + 2] = color.b;
data[index + 3] = 255;
}
}
_checkerboardTexture = Texture.Create( size, size )
.WithData( data )
.WithName( "tileset_resource_preview_checkerboard" )
.Finish();
return _checkerboardTexture;
}
}