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 ;
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>
2024-11-07 16:18:51 +01:00
/// <remarks>
/// The algorithm used by this implementation is based on the blog post "Binary Tree Bin Packing Algorithm", which can be found at https://codeincomplete.com/articles/bin-packing/.
/// </remarks>
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>
2024-11-07 16:18:51 +01:00
public int PackedTextures = > this . PackedTexture ! = null ? this . requests . Count : 0 ;
2021-11-01 16:00:13 +01:00
2024-11-07 16:18:51 +01:00
private readonly List < Request > requests = new List < Request > ( ) ;
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 forcePowerOfTwo ;
private readonly bool forceSquare ;
private readonly bool disposeTextures ;
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>
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>
2024-11-07 16:18:51 +01:00
public RuntimeTexturePacker ( bool forcePowerOfTwo = false , bool forceSquare = false , bool disposeTextures = false ) {
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 ) {
2024-11-07 16:18:51 +01:00
this . requests . 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 ) {
2024-11-07 16:18:51 +01:00
// set pack areas for each request based on the algo in https://codeincomplete.com/articles/bin-packing/
2020-12-20 00:11:10 +01:00
var stopwatch = Stopwatch . StartNew ( ) ;
2024-11-07 16:18:51 +01:00
RequestNode root = null ;
foreach ( var request in this . requests . OrderByDescending ( t = > Math . Max ( t . Texture . Width , t . Texture . Height ) + t . Padding * 2 ) ) {
var size = new Point ( request . Texture . Width , request . Texture . Height ) ;
size . X + = request . Padding * 2 ;
size . Y + = request . Padding * 2 ;
if ( root = = null )
root = new RequestNode ( 0 , 0 , size . X , size . Y ) ;
var node = RuntimeTexturePacker . FindNode ( size , root ) ;
if ( node = = null ) {
root = RuntimeTexturePacker . GrowNode ( size , root ) ;
node = RuntimeTexturePacker . FindNode ( size , root ) ;
}
request . Node = node ;
node . Split ( size ) ;
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
2024-11-07 16:18:51 +01:00
var width = root . Area . Width ;
var height = root . Area . Height ;
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
if ( this . PackedTexture = = null | | this . PackedTexture . Width ! = width | | this . PackedTexture . Height ! = height ) {
this . PackedTexture ? . Dispose ( ) ;
this . PackedTexture = new Texture2D ( device , width , height ) ;
}
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 ( ) ) {
2024-11-07 16:18:51 +01:00
foreach ( var request in this . requests )
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
2024-11-07 16:18:51 +01:00
foreach ( var request in this . requests ) {
var packedLoc = request . Node . Area . Location + new Point ( request . Padding , request . Padding ) ;
var packedArea = new Rectangle ( packedLoc . X , packedLoc . Y , request . Texture . Width , request . Texture . Height ) ;
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 . 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 ;
2024-11-07 16:18:51 +01:00
this . requests . 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
}
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 ) ;
2024-11-07 16:18:51 +01:00
var location = request . Node . Area . 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 ;
}
2024-11-07 16:18:51 +01:00
private static RequestNode FindNode ( Point requestSize , RequestNode node ) {
if ( node . Down ! = null & & node . Right ! = null ) {
return RuntimeTexturePacker . FindNode ( requestSize , node . Right ) ? ? RuntimeTexturePacker . FindNode ( requestSize , node . Down ) ;
} else if ( requestSize . X < = node . Area . Width & & requestSize . Y < = node . Area . Height ) {
return node ;
} else {
return null ;
}
}
private static RequestNode GrowNode ( Point requestSize , RequestNode node ) {
var canGrowDown = requestSize . X < = node . Area . Width ;
var canGrowRight = requestSize . Y < = node . Area . Height ;
var shouldGrowRight = canGrowRight & & node . Area . Height > = node . Area . Width + requestSize . X ;
var shouldGrowDown = canGrowDown & & node . Area . Width > = node . Area . Height + requestSize . Y ;
if ( shouldGrowRight ) {
return RuntimeTexturePacker . GrowNodeRight ( requestSize , node ) ;
} else if ( shouldGrowDown ) {
return RuntimeTexturePacker . GrowNodeDown ( requestSize , node ) ;
} else if ( canGrowRight ) {
return RuntimeTexturePacker . GrowNodeRight ( requestSize , node ) ;
} else if ( canGrowDown ) {
return RuntimeTexturePacker . GrowNodeDown ( requestSize , node ) ;
} else {
return null ;
}
}
private static RequestNode GrowNodeRight ( Point requestSize , RequestNode node ) {
return new RequestNode ( 0 , 0 , node . Area . Width + requestSize . X , node . Area . Height ) {
Right = new RequestNode ( node . Area . Width , 0 , requestSize . X , node . Area . Height ) ,
Down = node
} ;
}
private static RequestNode GrowNodeDown ( Point requestSize , RequestNode node ) {
return new RequestNode ( 0 , 0 , node . Area . Width , node . Area . Height + requestSize . Y ) {
Right = node ,
Down = new RequestNode ( 0 , node . Area . Height , node . Area . Width , requestSize . Y )
} ;
}
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 ;
2024-11-07 16:18:51 +01:00
public RequestNode Node ;
2020-12-20 00:11:10 +01:00
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
}
}
2024-11-07 16:18:51 +01:00
private class RequestNode {
public readonly Rectangle Area ;
public RequestNode Down ;
public RequestNode Right ;
public RequestNode ( int x , int y , int width , int height ) {
this . Area = new Rectangle ( x , y , width , height ) ;
}
public void Split ( Point requestSize ) {
this . Down = new RequestNode ( this . Area . X , this . Area . Y + requestSize . Y , this . Area . Width , this . Area . Height - requestSize . Y ) ;
this . Right = new RequestNode ( this . Area . X + requestSize . X , this . Area . Y , this . Area . Width - requestSize . X , requestSize . Y ) ;
}
}
2020-12-20 00:11:10 +01:00
}
2022-06-17 18:23:47 +02:00
}