2020-12-20 00:11:10 +01:00
using System ;
using System.Collections.Generic ;
using System.Diagnostics ;
using System.Linq ;
using Microsoft.Xna.Framework ;
using Microsoft.Xna.Framework.Graphics ;
2024-07-19 20:02:28 +02:00
using MLEM.Maths ;
2020-12-20 00:11:10 +01:00
using MLEM.Textures ;
2024-07-19 20:02:28 +02:00
using static MLEM . Textures . TextureExtensions ;
2020-12-20 00:11:10 +01:00
namespace MLEM.Data {
/// <summary>
/// A runtime texture packer provides the user with the ability to combine multiple <see cref="Texture2D"/> instances into a single texture.
/// Packing textures in this manner allows for faster rendering, as fewer texture swaps are required.
/// The resulting texture segments are returned as <see cref="TextureRegion"/> instances.
/// </summary>
2021-02-11 01:35:27 +01:00
public class RuntimeTexturePacker : IDisposable {
2020-12-20 00:11:10 +01:00
/// <summary>
/// The generated packed texture.
/// This value is null before <see cref="Pack"/> is called.
/// </summary>
public Texture2D PackedTexture { get ; private set ; }
/// <summary>
/// The time that it took to calculate the required areas the last time that <see cref="Pack"/> was called
/// </summary>
public TimeSpan LastCalculationTime { get ; private set ; }
/// <summary>
/// The time that it took to copy the texture data from the invidiual textures onto the <see cref="PackedTexture"/> the last time that <see cref="Pack"/> was called
/// </summary>
public TimeSpan LastPackTime { get ; private set ; }
/// <summary>
/// The time that <see cref="Pack"/> took the last time it was called
/// </summary>
public TimeSpan LastTotalTime = > this . LastCalculationTime + this . LastPackTime ;
2023-05-15 18:36:23 +02:00
/// <summary>
/// The amount of currently packed texture regions.
/// </summary>
public int PackedTextures = > this . packedTextures . Count ;
2021-11-01 16:00:13 +01:00
2022-05-27 11:41:21 +02:00
private readonly List < Request > texturesToPack = new List < Request > ( ) ;
2022-10-20 23:59:42 +02:00
private readonly List < Request > packedTextures = new List < Request > ( ) ;
2023-05-15 17:50:41 +02:00
private readonly Dictionary < Point , Request > occupiedPositions = new Dictionary < Point , Request > ( ) ;
2023-05-15 18:36:23 +02:00
private readonly Dictionary < Point , Point > initialPositions = new Dictionary < Point , Point > ( ) ;
2022-05-25 13:09:30 +02:00
private readonly Dictionary < Texture2D , TextureData > dataCache = new Dictionary < Texture2D , TextureData > ( ) ;
2021-11-01 16:00:13 +01:00
private readonly bool autoIncreaseMaxWidth ;
private readonly bool forcePowerOfTwo ;
private readonly bool forceSquare ;
private readonly bool disposeTextures ;
2021-01-18 01:25:20 +01:00
private int maxWidth ;
2020-12-20 00:11:10 +01:00
/// <summary>
2022-05-25 12:37:51 +02:00
/// Creates a new runtime texture packer with the given settings.
2020-12-20 00:11:10 +01:00
/// </summary>
/// <param name="maxWidth">The maximum width that the packed texture can have. Defaults to 2048.</param>
2023-04-15 15:11:50 +02:00
/// <param name="autoIncreaseMaxWidth">Whether the maximum width should be increased if there is a texture to be packed that is wider than the maximum width specified in the constructor. Defaults to false.</param>
2022-05-25 12:37:51 +02:00
/// <param name="forcePowerOfTwo">Whether the resulting <see cref="PackedTexture"/> should have a width and height that is a power of two.</param>
/// <param name="forceSquare">Whether the resulting <see cref="PackedTexture"/> should be square regardless of required size.</param>
/// <param name="disposeTextures">Whether the original textures submitted to this texture packer should be disposed after packing.</param>
2022-05-25 13:09:30 +02:00
public RuntimeTexturePacker ( int maxWidth = 2048 , bool autoIncreaseMaxWidth = false , bool forcePowerOfTwo = false , bool forceSquare = false , bool disposeTextures = false ) {
2020-12-20 00:11:10 +01:00
this . maxWidth = maxWidth ;
2021-01-18 01:25:20 +01:00
this . autoIncreaseMaxWidth = autoIncreaseMaxWidth ;
2021-02-11 01:09:07 +01:00
this . forcePowerOfTwo = forcePowerOfTwo ;
this . forceSquare = forceSquare ;
2021-11-01 16:00:13 +01:00
this . disposeTextures = disposeTextures ;
2020-12-20 00:11:10 +01:00
}
/// <summary>
2022-05-25 12:37:51 +02:00
/// Adds a new <see cref="UniformTextureAtlas"/> to this texture packer to be packed.
/// The passed <see cref="Action{T}"/> is invoked in <see cref="Pack"/> and provides the caller with the resulting dictionary of texture regions on the <see cref="PackedTexture"/>, mapped to their x and y positions on the original <see cref="UniformTextureAtlas"/>.
/// Note that the resulting data cannot be converted back into a <see cref="UniformTextureAtlas"/>, since the resulting texture regions might be scattered throughout the <see cref="PackedTexture"/>.
/// </summary>
/// <param name="atlas">The texture atlas to pack.</param>
/// <param name="result">The result callback which will receive the resulting texture regions.</param>
2022-05-25 13:09:30 +02:00
/// <param name="padding">The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding.</param>
/// <param name="padWithPixels">Whether the texture's padding should be filled with a copy of the texture's border, rather than transparent pixels. This value only has an effect if <paramref name="padding"/> is greater than 0.</param>
2022-05-27 11:16:16 +02:00
/// <param name="ignoreTransparent">Whether completely transparent texture regions in the <paramref name="atlas"/> should be ignored. If this is true, they will not be part of the <paramref name="result"/> collection either.</param>
2022-10-20 23:59:42 +02:00
/// <exception cref="InvalidOperationException">Thrown when trying to add a texture width a width greater than the defined max width.</exception>
2022-05-27 11:16:16 +02:00
public void Add ( UniformTextureAtlas atlas , Action < Dictionary < Point , TextureRegion > > result , int padding = 0 , bool padWithPixels = false , bool ignoreTransparent = false ) {
var addedRegions = new List < TextureRegion > ( ) ;
2022-05-25 12:37:51 +02:00
var resultRegions = new Dictionary < Point , TextureRegion > ( ) ;
for ( var x = 0 ; x < atlas . RegionAmountX ; x + + ) {
for ( var y = 0 ; y < atlas . RegionAmountY ; y + + ) {
var pos = new Point ( x , y ) ;
2022-05-27 11:16:16 +02:00
var region = atlas [ pos ] ;
if ( ignoreTransparent ) {
2022-06-15 11:38:11 +02:00
if ( this . IsTransparent ( region ) )
2022-05-27 11:16:16 +02:00
continue ;
}
this . Add ( region , r = > {
2022-05-25 12:37:51 +02:00
resultRegions . Add ( pos , r ) ;
2022-05-27 11:16:16 +02:00
if ( resultRegions . Count > = addedRegions . Count )
2022-05-25 12:37:51 +02:00
result . Invoke ( resultRegions ) ;
2022-05-25 13:09:30 +02:00
} , padding , padWithPixels ) ;
2022-05-27 11:16:16 +02:00
addedRegions . Add ( region ) ;
2022-05-25 12:37:51 +02:00
}
}
}
/// <summary>
/// Adds a new <see cref="DataTextureAtlas"/> to this texture packer to be packed.
/// The passed <see cref="Action{T}"/> is invoked in <see cref="Pack"/> and provides the caller with the resulting dictionary of texture regions on the <see cref="PackedTexture"/>, mapped to their name on the original <see cref="DataTextureAtlas"/>.
/// Note that the resulting data cannot be converted back into a <see cref="DataTextureAtlas"/>, since the resulting texture regions might be scattered throughout the <see cref="PackedTexture"/>.
/// </summary>
/// <param name="atlas">The texture atlas to pack.</param>
/// <param name="result">The result callback which will receive the resulting texture regions.</param>
2022-05-25 13:09:30 +02:00
/// <param name="padding">The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding.</param>
/// <param name="padWithPixels">Whether the texture's padding should be filled with a copy of the texture's border, rather than transparent pixels. This value only has an effect if <paramref name="padding"/> is greater than 0.</param>
2022-10-20 23:59:42 +02:00
/// <exception cref="InvalidOperationException">Thrown when trying to add a texture width a width greater than the defined max width.</exception>
2022-05-25 13:09:30 +02:00
public void Add ( DataTextureAtlas atlas , Action < Dictionary < string , TextureRegion > > result , int padding = 0 , bool padWithPixels = false ) {
2022-05-25 12:37:51 +02:00
var atlasRegions = atlas . RegionNames . ToArray ( ) ;
var resultRegions = new Dictionary < string , TextureRegion > ( ) ;
foreach ( var region in atlasRegions ) {
this . Add ( atlas [ region ] , r = > {
resultRegions . Add ( region , r ) ;
if ( resultRegions . Count > = atlasRegions . Length )
result . Invoke ( resultRegions ) ;
2022-05-25 13:09:30 +02:00
} , padding , padWithPixels ) ;
2022-05-25 12:37:51 +02:00
}
}
/// <summary>
/// Adds a new <see cref="Texture2D"/> to this texture packer to be packed.
2020-12-20 00:11:10 +01:00
/// The passed <see cref="Action{T}"/> is invoked in <see cref="Pack"/> and provides the caller with the resulting texture region on the <see cref="PackedTexture"/>.
/// </summary>
2022-05-25 12:37:51 +02:00
/// <param name="texture">The texture to pack.</param>
/// <param name="result">The result callback which will receive the resulting texture region.</param>
2022-05-25 13:09:30 +02:00
/// <param name="padding">The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding.</param>
/// <param name="padWithPixels">Whether the texture's padding should be filled with a copy of the texture's border, rather than transparent pixels. This value only has an effect if <paramref name="padding"/> is greater than 0.</param>
2022-10-20 23:59:42 +02:00
/// <exception cref="InvalidOperationException">Thrown when trying to add a texture width a width greater than the defined max width.</exception>
2022-05-25 13:09:30 +02:00
public void Add ( Texture2D texture , Action < TextureRegion > result , int padding = 0 , bool padWithPixels = false ) {
this . Add ( new TextureRegion ( texture ) , result , padding , padWithPixels ) ;
2020-12-20 00:11:10 +01:00
}
/// <summary>
/// Adds a new <see cref="TextureRegion"/> to this texture packer to be packed.
/// The passed <see cref="Action{T}"/> is invoked in <see cref="Pack"/> and provides the caller with the resulting texture region on the <see cref="PackedTexture"/>.
/// </summary>
2022-05-25 12:37:51 +02:00
/// <param name="texture">The texture region to pack.</param>
/// <param name="result">The result callback which will receive the resulting texture region.</param>
2022-05-25 13:09:30 +02:00
/// <param name="padding">The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding.</param>
/// <param name="padWithPixels">Whether the texture's padding should be filled with a copy of the texture's border, rather than transparent pixels. This value only has an effect if <paramref name="padding"/> is greater than 0.</param>
2022-10-20 23:59:42 +02:00
/// <exception cref="InvalidOperationException">Thrown when trying to add a texture width a width greater than the defined max width.</exception>
2022-05-25 13:09:30 +02:00
public void Add ( TextureRegion texture , Action < TextureRegion > result , int padding = 0 , bool padWithPixels = false ) {
var paddedWidth = texture . Width + 2 * padding ;
if ( paddedWidth > this . maxWidth ) {
2021-01-18 01:25:20 +01:00
if ( this . autoIncreaseMaxWidth ) {
2022-05-25 13:09:30 +02:00
this . maxWidth = paddedWidth ;
2021-01-18 01:25:20 +01:00
} else {
throw new InvalidOperationException ( $"Cannot add texture with width {texture.Width} to a texture packer with max width {this.maxWidth}" ) ;
}
}
2022-05-27 11:41:21 +02:00
this . texturesToPack . Add ( new Request ( texture , result , padding , padWithPixels ) ) ;
2020-12-20 00:11:10 +01:00
}
/// <summary>
2022-10-20 23:59:42 +02:00
/// Packs all of the textures and texture regions added using <see cref="Add(Microsoft.Xna.Framework.Graphics.Texture2D,System.Action{MLEM.Textures.TextureRegion},int,bool)"/> into one texture, which can be retrieved using <see cref="PackedTexture"/>.
2020-12-20 00:11:10 +01:00
/// All of the result callbacks that were added will also be invoked.
2022-10-20 23:59:42 +02:00
/// This method can be called multiple times if regions are added after <see cref="Pack"/> has already been called. When doing so, result callbacks of previous regions may be invoked again if the resulting <see cref="PackedTexture"/> has to be resized to accommodate newly added regions.
2020-12-20 00:11:10 +01:00
/// </summary>
/// <param name="device">The graphics device to use for texture generation</param>
public void Pack ( GraphicsDevice device ) {
// set pack areas for each request
2022-05-27 11:41:21 +02:00
// we pack larger textures first, so that smaller textures can fit in the gaps that larger ones leave
2020-12-20 00:11:10 +01:00
var stopwatch = Stopwatch . StartNew ( ) ;
2022-05-27 11:41:21 +02:00
foreach ( var request in this . texturesToPack . OrderByDescending ( t = > t . Texture . Width * t . Texture . Height ) ) {
2023-05-15 17:50:41 +02:00
request . PackedArea = this . OccupyFreeArea ( request ) ;
2022-10-20 23:59:42 +02:00
this . packedTextures . Add ( request ) ;
2022-05-27 11:41:21 +02:00
}
2020-12-20 00:11:10 +01:00
stopwatch . Stop ( ) ;
this . LastCalculationTime = stopwatch . Elapsed ;
2022-10-20 23:59:42 +02:00
// figure out texture size and regenerate texture if necessary
var width = this . packedTextures . Max ( t = > t . PackedArea . Right ) ;
var height = this . packedTextures . Max ( t = > t . PackedArea . Bottom ) ;
2021-02-11 01:09:07 +01:00
if ( this . forcePowerOfTwo ) {
2022-06-15 11:38:11 +02:00
width = RuntimeTexturePacker . ToPowerOfTwo ( width ) ;
height = RuntimeTexturePacker . ToPowerOfTwo ( height ) ;
2021-02-11 01:09:07 +01:00
}
if ( this . forceSquare )
width = height = Math . Max ( width , height ) ;
2022-10-20 23:59:42 +02:00
// if we don't need to regenerate, we only need to add newly added regions
IEnumerable < Request > texturesToCopy = this . texturesToPack ;
if ( this . PackedTexture = = null | | this . PackedTexture . Width ! = width | | this . PackedTexture . Height ! = height ) {
this . PackedTexture ? . Dispose ( ) ;
this . PackedTexture = new Texture2D ( device , width , height ) ;
// if we need to regenerate, we need to copy all regions since the old ones were deleted
texturesToCopy = this . packedTextures ;
}
2021-03-24 22:44:39 +01:00
2020-12-20 00:11:10 +01:00
// copy texture data onto the packed texture
stopwatch . Restart ( ) ;
using ( var data = this . PackedTexture . GetTextureData ( ) ) {
2022-10-20 23:59:42 +02:00
foreach ( var request in texturesToCopy )
2022-05-25 12:37:51 +02:00
this . CopyRegion ( data , request ) ;
2020-12-20 00:11:10 +01:00
}
stopwatch . Stop ( ) ;
this . LastPackTime = stopwatch . Elapsed ;
2022-10-20 23:59:42 +02:00
// invoke callbacks for textures we copied
foreach ( var request in texturesToCopy ) {
2022-06-24 14:01:26 +02:00
var packedArea = request . PackedArea . Shrink ( new Point ( request . Padding , request . Padding ) ) ;
2022-08-03 10:37:59 +02:00
request . Result . Invoke ( new TextureRegion ( this . PackedTexture , packedArea ) {
Pivot = request . Texture . Pivot ,
2022-11-14 11:49:47 +01:00
Name = request . Texture . Name ,
Source = request . Texture
2022-08-03 10:37:59 +02:00
} ) ;
2021-11-01 16:00:13 +01:00
if ( this . disposeTextures )
request . Texture . Texture . Dispose ( ) ;
}
2022-10-20 23:59:42 +02:00
this . texturesToPack . Clear ( ) ;
this . dataCache . Clear ( ) ;
2021-02-11 01:35:27 +01:00
}
/// <summary>
2022-10-20 23:59:42 +02:00
/// Resets this texture packer entirely, disposing its <see cref="PackedTexture"/>, clearing all previously added requests, and readying it to be re-used.
2021-02-11 01:35:27 +01:00
/// </summary>
public void Reset ( ) {
this . PackedTexture ? . Dispose ( ) ;
this . PackedTexture = null ;
this . LastCalculationTime = TimeSpan . Zero ;
this . LastPackTime = TimeSpan . Zero ;
2022-10-20 23:59:42 +02:00
this . texturesToPack . Clear ( ) ;
this . packedTextures . Clear ( ) ;
2023-05-15 18:36:23 +02:00
this . initialPositions . Clear ( ) ;
2023-05-15 17:50:41 +02:00
this . occupiedPositions . Clear ( ) ;
2022-10-20 23:59:42 +02:00
this . dataCache . Clear ( ) ;
2021-02-11 01:35:27 +01:00
}
2021-11-22 19:25:18 +01:00
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
2021-02-11 01:35:27 +01:00
public void Dispose ( ) {
this . Reset ( ) ;
2020-12-20 00:11:10 +01:00
}
2023-05-15 17:50:41 +02:00
private Rectangle OccupyFreeArea ( Request request ) {
2022-05-25 13:09:30 +02:00
var size = new Point ( request . Texture . Width , request . Texture . Height ) ;
size . X + = request . Padding * 2 ;
size . Y + = request . Padding * 2 ;
2022-05-25 12:37:51 +02:00
2023-05-15 17:50:41 +02:00
// exit early if the texture doesn't need to find a free location
if ( size . X < = 0 | | size . Y < = 0 )
return Rectangle . Empty ;
2023-05-15 18:36:23 +02:00
var pos = this . initialPositions . TryGetValue ( size , out var first ) ? first : Point . Zero ;
var area = new Rectangle ( pos . X , pos . Y , size . X , size . Y ) ;
2020-12-20 00:11:10 +01:00
var lowestY = int . MaxValue ;
while ( true ) {
2023-05-15 17:50:41 +02:00
// check if the current area is already occupied
if ( ! this . occupiedPositions . TryGetValue ( area . Location , out var existing ) ) {
existing = this . packedTextures . FirstOrDefault ( t = > t . PackedArea . Intersects ( area ) ) ;
2023-05-15 18:36:23 +02:00
// if no texture is occupying this space, we have found a free area
2023-05-15 17:50:41 +02:00
if ( existing = = null ) {
2023-05-15 18:36:23 +02:00
// if this is the first position that this request fit in, no other requests of the same size will find a position before it
2023-05-15 18:41:45 +02:00
this . initialPositions [ new Point ( area . Width , area . Height ) ] = area . Location ;
2023-05-15 17:50:41 +02:00
this . occupiedPositions . Add ( area . Location , request ) ;
return area ;
2020-12-20 00:11:10 +01:00
}
2023-05-15 17:50:41 +02:00
// also cache the existing texture for this position, in case we check it again in the future
this . occupiedPositions . Add ( area . Location , existing ) ;
2020-12-20 00:11:10 +01:00
}
2023-05-15 17:50:41 +02:00
// move to the right by the existing texture's width
area . X = existing . PackedArea . Right ;
// remember the smallest intersecting texture's height for when we move down
if ( lowestY > existing . PackedArea . Bottom )
lowestY = existing . PackedArea . Bottom ;
// move down a row if we exceed our maximum width
if ( area . Right > this . maxWidth ) {
area . X = 0 ;
area . Y = lowestY ;
2020-12-20 00:11:10 +01:00
lowestY = int . MaxValue ;
}
}
}
2022-05-25 13:09:30 +02:00
private void CopyRegion ( TextureData destination , Request request ) {
2022-05-27 11:19:29 +02:00
var data = this . GetCachedTextureData ( request . Texture . Texture ) ;
2022-06-24 14:01:26 +02:00
var location = request . PackedArea . Location + new Point ( request . Padding , request . Padding ) ;
2022-05-25 13:09:30 +02:00
for ( var x = - request . Padding ; x < request . Texture . Width + request . Padding ; x + + ) {
for ( var y = - request . Padding ; y < request . Texture . Height + request . Padding ; y + + ) {
Color srcColor ;
if ( ! request . PadWithPixels & & ( x < 0 | | y < 0 | | x > = request . Texture . Width | | y > = request . Texture . Height ) ) {
// if we're out of bounds and not padding with pixels, we make it transparent
srcColor = Color . Transparent ;
} else {
// otherwise, we just use the closest pixel that is actually in bounds, causing the border pixels to be doubled up
2022-08-20 11:39:28 +02:00
var src = new Point ( ( int ) MathHelper . Clamp ( x , 0F , request . Texture . Width - 1 ) , ( int ) MathHelper . Clamp ( y , 0F , request . Texture . Height - 1 ) ) ;
2022-05-25 13:09:30 +02:00
srcColor = data [ request . Texture . Position + src ] ;
2020-12-20 00:11:10 +01:00
}
2022-05-25 13:09:30 +02:00
destination [ location + new Point ( x , y ) ] = srcColor ;
2020-12-20 00:11:10 +01:00
}
}
}
2022-05-27 11:19:29 +02:00
private TextureData GetCachedTextureData ( Texture2D texture ) {
// we cache texture data in case multiple requests use the same underlying texture
// this collection doesn't need to be disposed since we don't actually edit these textures
if ( ! this . dataCache . TryGetValue ( texture , out var data ) ) {
data = texture . GetTextureData ( ) ;
this . dataCache . Add ( texture , data ) ;
}
return data ;
2021-02-11 01:09:07 +01:00
}
2022-05-27 11:19:29 +02:00
private bool IsTransparent ( TextureRegion region ) {
var data = this . GetCachedTextureData ( region . Texture ) ;
2022-05-27 11:16:16 +02:00
for ( var rX = 0 ; rX < region . Width ; rX + + ) {
for ( var rY = 0 ; rY < region . Height ; rY + + ) {
if ( data [ region . U + rX , region . V + rY ] ! = Color . Transparent )
return false ;
}
}
return true ;
}
2022-05-27 11:19:29 +02:00
private static int ToPowerOfTwo ( int value ) {
var ret = 1 ;
while ( ret < value )
ret < < = 1 ;
return ret ;
}
2020-12-20 00:11:10 +01:00
private class Request {
public readonly TextureRegion Texture ;
public readonly Action < TextureRegion > Result ;
2022-05-25 13:09:30 +02:00
public readonly int Padding ;
2022-05-25 12:37:51 +02:00
public readonly bool PadWithPixels ;
2020-12-20 00:11:10 +01:00
public Rectangle PackedArea ;
2022-05-25 13:09:30 +02:00
public Request ( TextureRegion texture , Action < TextureRegion > result , int padding , bool padWithPixels ) {
2020-12-20 00:11:10 +01:00
this . Texture = texture ;
this . Result = result ;
2022-05-25 13:09:30 +02:00
this . Padding = padding ;
2022-05-25 12:37:51 +02:00
this . PadWithPixels = padWithPixels ;
2020-12-20 00:11:10 +01:00
}
}
}
2022-06-17 18:23:47 +02:00
}