Code/TextureAtlas.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SpriteTools;
/// <summary>
/// A class that combines multiple textures into a single texture.
/// </summary>
public class TextureAtlas
{
public int Size { get; private set; } = 1;
public Vector2 FrameSize => ( MaxFrameSize.y == 0 ) ? new Vector2( Texture.Width, Texture.Height ) : MaxFrameSize - ( Vector2.One * 2 );
Texture Texture;
Vector2 MaxFrameSize = Vector2.Zero;
Dictionary<int, Texture> FrameCache = new();
static Dictionary<string, TextureAtlas> Cache = new();
/// <summary>
/// Returns the aspect ratio of a frame from the texture atlas.
/// </summary>
public float AspectRatio => ( MaxFrameSize.y == 0 ) ? ( ( Texture.Height == 0 ) ? 1 : ( (float)Texture.Width / Texture.Height ) ) : ( MaxFrameSize.x / MaxFrameSize.y );
/// <summary>
/// Returns the UV tiling for the texture atlas.
/// </summary>
public Vector2 GetFrameTiling ()
{
if ( MaxFrameSize.x == 0 || MaxFrameSize.y == 0 )
{
return Vector2.One;
}
// inset by 1 pixel to avoid bleeding
return ( MaxFrameSize - Vector2.One * 2f ) / ( MaxFrameSize * (float)Size );
}
/// <summary>
/// Returns the UV offset for a specific frame in the texture atlas.
/// </summary>
/// <param name="index">The index of the frame</param>
public Vector2 GetFrameOffset ( int index )
{
if ( MaxFrameSize.x == 0 || MaxFrameSize.y == 0 )
{
return Vector2.Zero;
}
int x = index * (int)MaxFrameSize.x % ( Size * (int)MaxFrameSize.x );
int y = index * (int)MaxFrameSize.y / ( Size * (int)MaxFrameSize.y ) * (int)MaxFrameSize.y;
x += 1;
y += 1;
return new Vector2( x, y ) / ( Size * MaxFrameSize );
}
/// <summary>
/// Returns the texture for a specific frame in the texture atlas.
/// </summary>
/// <param name="index"></param>
/// <returns></returns>
public Texture GetTextureFromFrame ( int index )
{
if ( FrameCache.TryGetValue( index, out var cachedTexture ) )
{
return cachedTexture;
}
int xx = index % Size;
int yy = index / Size;
int x = xx * (int)MaxFrameSize.x;
int y = yy * (int)MaxFrameSize.y;
int outputSizeX = (int)MaxFrameSize.x - 2;
int outputSizeY = (int)MaxFrameSize.y - 2;
x += 1;
y += 1;
byte[] textureData = new byte[(int)( outputSizeX * outputSizeY * 4 )];
var pixels = Texture.GetPixels();
for ( int i = 0; i < outputSizeX; i++ )
{
for ( int j = 0; j < outputSizeY; j++ )
{
var ind = ( i + j * outputSizeX ) * 4;
var sampleIndex = x + i + ( y + j ) * Texture.Width;
var color = pixels[sampleIndex];
textureData[ind + 0] = color.r;
textureData[ind + 1] = color.g;
textureData[ind + 2] = color.b;
textureData[ind + 3] = color.a;
}
}
var builder = Texture.Create( outputSizeX, outputSizeY );
builder.WithData( textureData );
builder.WithMips( 0 );
var texture = builder.Finish();
FrameCache[index] = texture;
return texture;
}
// Cast to texture
public static implicit operator Texture ( TextureAtlas atlas )
{
return atlas?.Texture ?? null;
}
//////////////////////////// STATIC METHODS ////////////////////////////
/// <summary>
/// Returns a cached texture atlas given a sprite animation. Creates one if not in the cache. Returns null if there was an error and the atlas could not be created.
/// </summary>
/// <param name="animation">The sprite animation to create the atlas from</param>
public static TextureAtlas FromAnimation ( SpriteAnimation animation )
{
if ( animation is null ) return null;
var key = "anim." + animation.Name + ".";
foreach ( var frame in animation.Frames )
{
key += frame.FilePath + frame.SpriteSheetRect.ToString() + ".";
}
if ( Cache.TryGetValue( key, out var cachedAtlas ) )
{
return cachedAtlas;
}
var atlas = new TextureAtlas();
atlas.Size = (int)Math.Ceiling( Math.Sqrt( animation.Frames.Count ) );
if ( animation.Frames.Count == 1 )
{
var frame = animation.Frames[0];
if ( frame is null ) return null;
if ( frame.SpriteSheetRect.Width == 0 && frame.SpriteSheetRect.Height == 0 )
{
if ( !FileSystem.Mounted.FileExists( frame.FilePath ) )
{
Log.Error( $"TextureAtlas: Texture file not found: {frame.FilePath}" );
return null;
}
var texture = Texture.LoadFromFileSystem( frame.FilePath, FileSystem.Mounted );
atlas.Texture = texture;
return atlas;
}
}
List<(Texture, Rect)> textures = new();
atlas.MaxFrameSize = 0;
foreach ( var frame in animation.Frames )
{
if ( frame is null ) continue;
if ( !FileSystem.Mounted.FileExists( frame.FilePath ) )
{
Log.Error( $"TextureAtlas: Texture file not found: {frame.FilePath}" );
continue;
}
var texture = Texture.LoadFromFileSystem( frame.FilePath, FileSystem.Mounted );
var rect = frame.SpriteSheetRect;
if ( rect.Width == 0 || rect.Height == 0 )
{
rect = new Rect( 0, 0, texture.Width, texture.Height );
}
textures.Add( (texture, rect) );
atlas.MaxFrameSize = new Vector2(
Math.Max( atlas.MaxFrameSize.x, Math.Max( rect.Width, atlas.MaxFrameSize.x ) ),
Math.Max( atlas.MaxFrameSize.y, Math.Max( rect.Height, atlas.MaxFrameSize.y ) )
);
}
atlas.MaxFrameSize += 2;
Vector2 imageSize = atlas.Size * atlas.MaxFrameSize;
int x = 0;
int y = 0;
int size = (int)( imageSize.x * imageSize.y * 4 );
if ( size == 0 )
{
return null;
}
byte[] textureData = new byte[size];
foreach ( var (texture, rect) in textures )
{
if ( x + rect.Width > imageSize.x )
{
x = 0;
y += (int)atlas.MaxFrameSize.y;
}
if ( y + rect.Height > imageSize.y )
{
Log.Error( "TextureAtlas: Texture too large for atlas" );
continue;
}
try
{
var pixels = texture.GetPixels();
for ( int i = 0; i < rect.Width; i++ )
{
for ( int j = 0; j < rect.Height; j++ )
{
var index = ( x + 1 + i + ( y + 1 + j ) * (int)imageSize.x ) * 4;
var textureIndex = (int)( rect.Left + i + ( rect.Top + j ) * texture.Width );
textureData[index] = pixels[textureIndex].r;
textureData[index + 1] = pixels[textureIndex].g;
textureData[index + 2] = pixels[textureIndex].b;
textureData[index + 3] = pixels[textureIndex].a;
}
}
}
catch ( Exception e ) { Log.Info( e ); }
x += (int)atlas.MaxFrameSize.x;
}
var builder = Texture.Create( (int)imageSize.x, (int)imageSize.y );
builder.WithData( textureData );
builder.WithMips( 0 );
atlas.Texture = builder.Finish();
Cache[key] = atlas;
return atlas;
}
/// <summary>
/// Returns a cached texture atlas given a list of texture paths. Creates one if not in the cache. Returns null if there was an error and the atlas could not be created.
/// </summary>
/// <param name="texturePaths">A list containing a path to each frame</param>
public static TextureAtlas FromTextures ( List<string> texturePaths )
{
var key = string.Join( ",", texturePaths.OrderBy( x => x ) );
if ( Cache.TryGetValue( key, out var cachedAtlas ) )
{
return cachedAtlas;
}
var atlas = new TextureAtlas();
atlas.Size = (int)Math.Ceiling( Math.Sqrt( texturePaths.Count ) );
List<Texture> textures = new();
atlas.MaxFrameSize = 0;
foreach ( var path in texturePaths )
{
if ( !FileSystem.Mounted.FileExists( path ) )
{
Log.Error( $"TextureAtlas: Texture file not found: {path}" );
continue;
}
var texture = Texture.LoadFromFileSystem( path, FileSystem.Mounted );
textures.Add( texture );
atlas.MaxFrameSize = new Vector2(
Math.Max( atlas.MaxFrameSize.x, texture.Width ),
Math.Max( atlas.MaxFrameSize.y, texture.Height )
);
}
atlas.MaxFrameSize += 2;
Vector2 imageSize = atlas.Size * atlas.MaxFrameSize;
int x = 0;
int y = 0;
byte[] textureData = new byte[(int)( imageSize.x * imageSize.y * 4 )];
foreach ( var texture in textures )
{
if ( x + texture.Width > imageSize.x )
{
x = 0;
y += (int)atlas.MaxFrameSize.y;
}
if ( y + texture.Height > imageSize.y )
{
Log.Error( "TextureAtlas: Texture too large for atlas" );
continue;
}
var pixels = texture.GetPixels();
for ( int i = 0; i < texture.Width; i++ )
{
for ( int j = 0; j < texture.Height; j++ )
{
var index = ( x + 1 + i + ( y + 1 + j ) * (int)imageSize.x ) * 4;
var textureIndex = i + j * texture.Width;
textureData[index] = pixels[textureIndex].r;
textureData[index + 1] = pixels[textureIndex].g;
textureData[index + 2] = pixels[textureIndex].b;
textureData[index + 3] = pixels[textureIndex].a;
}
}
x += (int)atlas.MaxFrameSize.x;
}
var builder = Texture.Create( (int)imageSize.x, (int)imageSize.y );
builder.WithData( textureData );
builder.WithMips( 0 );
atlas.Texture = builder.Finish();
Cache[key] = atlas;
return atlas;
}
/// <summary>
/// Returns a cached texture atlas given a spritesheet path and a list of sprite rects. Creates one if not in the cache. Returns null if there was an error and the atlas could not be created.
/// </summary>
/// <param name="path">The path to the spritesheet texture</param>
/// <param name="spriteRects">A list of rectangles representing the position of each sprite in the spritesheet</param>
public static TextureAtlas FromSpritesheet ( string path, List<Rect> spriteRects )
{
var key = path + string.Join( ",", spriteRects );
if ( Cache.TryGetValue( key, out var cachedAtlas ) )
{
return cachedAtlas;
}
var atlas = new TextureAtlas();
atlas.Size = (int)Math.Ceiling( Math.Sqrt( spriteRects.Count ) );
if ( !FileSystem.Mounted.FileExists( path ) )
{
Log.Error( $"TextureAtlas: Texture file not found: {path}" );
return null;
}
foreach ( var rect in spriteRects )
{
atlas.MaxFrameSize = new Vector2(
Math.Max( atlas.MaxFrameSize.x, rect.Width ),
Math.Max( atlas.MaxFrameSize.y, rect.Height )
);
}
atlas.MaxFrameSize += 2;
var spritesheet = Texture.LoadFromFileSystem( path, FileSystem.Mounted );
var pixels = spritesheet.GetPixels();
Vector2 imageSize = atlas.Size * atlas.MaxFrameSize;
byte[] textureData = new byte[(int)( imageSize.x * imageSize.y * 4 )];
int x = 0;
int y = 0;
foreach ( var rect in spriteRects )
{
if ( x + rect.Width > imageSize.x )
{
x = 0;
y += (int)atlas.MaxFrameSize.x;
}
if ( y + rect.Height > imageSize.y )
{
Log.Error( "TextureAtlas: Texture too large for atlas" );
continue;
}
for ( int i = 0; i < rect.Width; i++ )
{
for ( int j = 0; j < rect.Height; j++ )
{
var index = ( x + 1 + i + ( y + 1 + j ) * (int)imageSize.x ) * 4;
var textureIndex = (int)( rect.Left + i + ( rect.Top + j ) * spritesheet.Width );
textureData[index] = pixels[textureIndex].r;
textureData[index + 1] = pixels[textureIndex].g;
textureData[index + 2] = pixels[textureIndex].b;
textureData[index + 3] = pixels[textureIndex].a;
}
}
x += (int)atlas.MaxFrameSize.x;
}
var builder = Texture.Create( (int)imageSize.x, (int)imageSize.y );
builder.WithData( textureData );
builder.WithMips( 0 );
atlas.Texture = builder.Finish();
Cache[key] = atlas;
return atlas;
}
/// <summary>
/// Clears the cache of texture atlases. If a path is provided, only the atlases that contain that path will be removed.
/// </summary>
/// <param name="path">The path to remove from the cache</param>
public static void ClearCache ( string path = "" )
{
if ( path.StartsWith( "/" ) ) path = path.Substring( 1 );
if ( string.IsNullOrEmpty( path ) )
{
Cache.Clear();
}
else
{
Cache = Cache.Where( x => !x.Key.Contains( path ) ).ToDictionary( x => x.Key, x => x.Value );
}
}
}