diff --git a/MLEM.Data/RuntimeTexturePacker.cs b/MLEM.Data/RuntimeTexturePacker.cs new file mode 100644 index 0000000..d75f8d4 --- /dev/null +++ b/MLEM.Data/RuntimeTexturePacker.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using MLEM.Extensions; +using MLEM.Textures; + +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. + /// + public class RuntimeTexturePacker { + + private readonly List textures = new List(); + + /// + /// 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; + private readonly int maxWidth; + + /// + /// Creates a new runtime texture packer with the given settings + /// + /// The maximum width that the packed texture can have. Defaults to 2048. + public RuntimeTexturePacker(int maxWidth = 2048) { + this.maxWidth = maxWidth; + } + + /// + /// Adds a new texture 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 + public void Add(Texture2D texture, Action result) { + this.Add(new TextureRegion(texture), result); + } + + /// + /// 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 + /// Thrown when trying to add data to a packer that has already been packed, or when trying to add a texture width a width greater than the defined max width + public void Add(TextureRegion texture, Action result) { + if (this.PackedTexture != null) + throw new InvalidOperationException("Cannot add texture to a texture packer that is already packed"); + if (texture.Width > this.maxWidth) + throw new InvalidOperationException($"Cannot add texture with width {texture.Width} to a texture packer with max width {this.maxWidth}"); + this.textures.Add(new Request(texture, result)); + } + + /// + /// Packs all of the textures and texture regions added using into one texture. + /// The resulting texture will be stored in . + /// All of the result callbacks that were added will also be invoked. + /// + /// The graphics device to use for texture generation + /// Thrown when calling this method on a texture packer that has already been packed + public void Pack(GraphicsDevice device) { + if (this.PackedTexture != null) + throw new InvalidOperationException("Cannot pack a texture packer that is already packed"); + + // set pack areas for each request + var stopwatch = Stopwatch.StartNew(); + foreach (var request in this.textures.OrderByDescending(t => t.Texture.Width * t.Texture.Height)) { + var area = this.FindFreeArea(new Point(request.Texture.Width, request.Texture.Height)); + request.PackedArea = area; + } + stopwatch.Stop(); + this.LastCalculationTime = stopwatch.Elapsed; + + // generate texture based on required size + var width = this.textures.Max(t => t.PackedArea.Right); + var height = this.textures.Max(t => t.PackedArea.Bottom); + this.PackedTexture = new Texture2D(device, width, height); + device.Disposing += (o, a) => this.PackedTexture.Dispose(); + + // copy texture data onto the packed texture + stopwatch.Restart(); + using (var data = this.PackedTexture.GetTextureData()) { + foreach (var request in this.textures) + CopyRegion(data, request); + } + stopwatch.Stop(); + this.LastPackTime = stopwatch.Elapsed; + + // invoke callbacks + foreach (var request in this.textures) + request.Result.Invoke(new TextureRegion(this.PackedTexture, request.PackedArea)); + } + + private Rectangle FindFreeArea(Point size) { + var pos = new Point(0, 0); + var lowestY = int.MaxValue; + while (true) { + var intersected = false; + var area = new Rectangle(pos, size); + foreach (var tex in this.textures) { + if (tex.PackedArea.Intersects(area)) { + pos.X = tex.PackedArea.Right; + // when we move down, we want to move down by the smallest intersecting texture's height + if (lowestY > tex.PackedArea.Bottom) + lowestY = tex.PackedArea.Bottom; + intersected = true; + break; + } + } + if (!intersected) + return area; + if (pos.X + size.X > this.maxWidth) { + pos.X = 0; + pos.Y = lowestY; + lowestY = int.MaxValue; + } + } + } + + private static void CopyRegion(TextureExtensions.TextureData destination, Request request) { + using (var data = request.Texture.Texture.GetTextureData()) { + for (var x = 0; x < request.Texture.Width; x++) { + for (var y = 0; y < request.Texture.Height; y++) { + var dest = request.PackedArea.Location + new Point(x, y); + var src = request.Texture.Position + new Point(x, y); + destination[dest] = data[src]; + } + } + } + } + + private class Request { + + public readonly TextureRegion Texture; + public readonly Action Result; + public Rectangle PackedArea; + + public Request(TextureRegion texture, Action result) { + this.Texture = texture; + this.Result = result; + } + + } + + } +} \ No newline at end of file