using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using MLEM.Textures; using static MLEM.Textures.TextureExtensions; namespace MLEM.Data { /// /// A runtime texture packer provides the user with the ability to combine multiple 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 instances. /// /// /// 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/. /// public class RuntimeTexturePacker : IDisposable { /// /// The generated packed texture. /// This value is null before is called. /// public Texture2D PackedTexture { get; private set; } /// /// The time that it took to calculate the required areas the last time that was called /// public TimeSpan LastCalculationTime { get; private set; } /// /// The time that it took to copy the texture data from the invidiual textures onto the the last time that was called /// public TimeSpan LastPackTime { get; private set; } /// /// The time that took the last time it was called /// public TimeSpan LastTotalTime => this.LastCalculationTime + this.LastPackTime; /// /// The amount of currently packed texture regions. /// public int PackedTextures => this.PackedTexture != null ? this.requests.Count : 0; private readonly List requests = new List(); private readonly Dictionary dataCache = new Dictionary(); private readonly bool forcePowerOfTwo; private readonly bool forceSquare; private readonly bool disposeTextures; /// /// Creates a new runtime texture packer with the given settings. /// /// Whether the resulting should have a width and height that is a power of two. /// Whether the resulting should be square regardless of required size. /// Whether the original textures submitted to this texture packer should be disposed after packing. public RuntimeTexturePacker(bool forcePowerOfTwo = false, bool forceSquare = false, bool disposeTextures = false) { this.forcePowerOfTwo = forcePowerOfTwo; this.forceSquare = forceSquare; this.disposeTextures = disposeTextures; } /// /// Adds a new to this texture packer to be packed. /// The passed is invoked in and provides the caller with the resulting dictionary of texture regions on the , mapped to their x and y positions on the original . /// Note that the resulting data cannot be converted back into a , since the resulting texture regions might be scattered throughout the . /// /// The texture atlas to pack. /// The result callback which will receive the resulting texture regions. /// The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding. /// 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 is greater than 0. /// Whether completely transparent texture regions in the should be ignored. If this is true, they will not be part of the collection either. /// Thrown when trying to add a texture width a width greater than the defined max width. public void Add(UniformTextureAtlas atlas, Action> result, int padding = 0, bool padWithPixels = false, bool ignoreTransparent = false) { var addedRegions = new List(); var resultRegions = new Dictionary(); for (var x = 0; x < atlas.RegionAmountX; x++) { for (var y = 0; y < atlas.RegionAmountY; y++) { var pos = new Point(x, y); var region = atlas[pos]; if (ignoreTransparent) { if (this.IsTransparent(region)) continue; } this.Add(region, r => { resultRegions.Add(pos, r); if (resultRegions.Count >= addedRegions.Count) result.Invoke(resultRegions); }, padding, padWithPixels); addedRegions.Add(region); } } } /// /// Adds a new to this texture packer to be packed. /// The passed is invoked in and provides the caller with the resulting dictionary of texture regions on the , mapped to their name on the original . /// Note that the resulting data cannot be converted back into a , since the resulting texture regions might be scattered throughout the . /// /// The texture atlas to pack. /// The result callback which will receive the resulting texture regions. /// The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding. /// 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 is greater than 0. /// Thrown when trying to add a texture width a width greater than the defined max width. public void Add(DataTextureAtlas atlas, Action> result, int padding = 0, bool padWithPixels = false) { var atlasRegions = atlas.RegionNames.ToArray(); var resultRegions = new Dictionary(); foreach (var region in atlasRegions) { this.Add(atlas[region], r => { resultRegions.Add(region, r); if (resultRegions.Count >= atlasRegions.Length) result.Invoke(resultRegions); }, padding, padWithPixels); } } /// /// Adds a new to this texture packer to be packed. /// The passed is invoked in and provides the caller with the resulting texture region on the . /// /// The texture to pack. /// The result callback which will receive the resulting texture region. /// The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding. /// 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 is greater than 0. /// Thrown when trying to add a texture width a width greater than the defined max width. public void Add(Texture2D texture, Action result, int padding = 0, bool padWithPixels = false) { this.Add(new TextureRegion(texture), result, padding, padWithPixels); } /// /// Adds a new to this texture packer to be packed. /// The passed is invoked in and provides the caller with the resulting texture region on the . /// /// The texture region to pack. /// The result callback which will receive the resulting texture region. /// The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding. /// 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 is greater than 0. /// Thrown when trying to add a texture width a width greater than the defined max width. public void Add(TextureRegion texture, Action result, int padding = 0, bool padWithPixels = false) { this.requests.Add(new Request(texture, result, padding, padWithPixels)); } /// /// Packs all of the textures and texture regions added using into one texture, which can be retrieved using . /// All of the result callbacks that were added will also be invoked. /// This method can be called multiple times if regions are added after has already been called. When doing so, result callbacks of previous regions may be invoked again if the resulting has to be resized to accommodate newly added regions. /// /// The graphics device to use for texture generation public void Pack(GraphicsDevice device) { // set pack areas for each request based on the algo in https://codeincomplete.com/articles/bin-packing/ var stopwatch = Stopwatch.StartNew(); 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); } stopwatch.Stop(); this.LastCalculationTime = stopwatch.Elapsed; // figure out texture size and regenerate texture if necessary var width = root.Area.Width; var height = root.Area.Height; if (this.forcePowerOfTwo) { width = RuntimeTexturePacker.ToPowerOfTwo(width); height = RuntimeTexturePacker.ToPowerOfTwo(height); } if (this.forceSquare) width = height = Math.Max(width, height); // 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); } // copy texture data onto the packed texture stopwatch.Restart(); using (var data = this.PackedTexture.GetTextureData()) { foreach (var request in this.requests) this.CopyRegion(data, request); } stopwatch.Stop(); this.LastPackTime = stopwatch.Elapsed; // invoke callbacks for textures we copied 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); request.Result.Invoke(new TextureRegion(this.PackedTexture, packedArea) { Pivot = request.Texture.Pivot, Name = request.Texture.Name, Source = request.Texture }); if (this.disposeTextures) request.Texture.Texture.Dispose(); } this.dataCache.Clear(); } /// /// Resets this texture packer entirely, disposing its , clearing all previously added requests, and readying it to be re-used. /// public void Reset() { this.PackedTexture?.Dispose(); this.PackedTexture = null; this.LastCalculationTime = TimeSpan.Zero; this.LastPackTime = TimeSpan.Zero; this.requests.Clear(); this.dataCache.Clear(); } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. public void Dispose() { this.Reset(); } private void CopyRegion(TextureData destination, Request request) { var data = this.GetCachedTextureData(request.Texture.Texture); var location = request.Node.Area.Location + new Point(request.Padding, request.Padding); 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 var src = new Point((int) MathHelper.Clamp(x, 0F, request.Texture.Width - 1), (int) MathHelper.Clamp(y, 0F, request.Texture.Height - 1)); srcColor = data[request.Texture.Position + src]; } destination[location + new Point(x, y)] = srcColor; } } } 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; } private bool IsTransparent(TextureRegion region) { var data = this.GetCachedTextureData(region.Texture); 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; } private static int ToPowerOfTwo(int value) { var ret = 1; while (ret < value) ret <<= 1; return ret; } 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) }; } private class Request { public readonly TextureRegion Texture; public readonly Action Result; public readonly int Padding; public readonly bool PadWithPixels; public RequestNode Node; public Request(TextureRegion texture, Action result, int padding, bool padWithPixels) { this.Texture = texture; this.Result = result; this.Padding = padding; this.PadWithPixels = padWithPixels; } } 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); } } } }