diff --git a/CHANGELOG.md b/CHANGELOG.md index 133752d..cd56cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ MLEM tries to adhere to [semantic versioning](https://semver.org/). Potentially breaking changes are written in **bold**. Jump to version: -- [7.2.0](#720-in-development) +- [8.0.0](#800-in-development) - [7.1.1](#711) - [7.1.0](#710) - [7.0.0](#700) @@ -16,7 +16,7 @@ Jump to version: - [5.1.0](#510) - [5.0.0](#500) -## 7.2.0 (In Development) +## 8.0.0 (In Development) ### MLEM Fixes @@ -35,6 +35,7 @@ Fixes ### MLEM.Data Improvements +- **Use a binary tree algorithm for RuntimeTexturePacker to vastly increase packing speed** - Made fields and methods in StaticJsonConverter protected to allow extending it ## 7.1.1 diff --git a/MLEM.Data/RuntimeTexturePacker.cs b/MLEM.Data/RuntimeTexturePacker.cs index 9d2f66f..0b147e9 100644 --- a/MLEM.Data/RuntimeTexturePacker.cs +++ b/MLEM.Data/RuntimeTexturePacker.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; -using MLEM.Maths; using MLEM.Textures; using static MLEM.Textures.TextureExtensions; @@ -14,6 +13,9 @@ namespace MLEM.Data { /// 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 { /// @@ -36,31 +38,21 @@ namespace MLEM.Data { /// /// The amount of currently packed texture regions. /// - public int PackedTextures => this.packedTextures.Count; + public int PackedTextures => this.PackedTexture != null ? this.requests.Count : 0; - private readonly List texturesToPack = new List(); - private readonly List packedTextures = new List(); - private readonly Dictionary occupiedPositions = new Dictionary(); - private readonly Dictionary initialPositions = new Dictionary(); + private readonly List requests = new List(); private readonly Dictionary dataCache = new Dictionary(); - private readonly bool autoIncreaseMaxWidth; private readonly bool forcePowerOfTwo; private readonly bool forceSquare; private readonly bool disposeTextures; - private int maxWidth; - /// /// Creates a new runtime texture packer with the given settings. /// - /// The maximum width that the packed texture can have. Defaults to 2048. - /// 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. /// 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(int maxWidth = 2048, bool autoIncreaseMaxWidth = false, bool forcePowerOfTwo = false, bool forceSquare = false, bool disposeTextures = false) { - this.maxWidth = maxWidth; - this.autoIncreaseMaxWidth = autoIncreaseMaxWidth; + public RuntimeTexturePacker(bool forcePowerOfTwo = false, bool forceSquare = false, bool disposeTextures = false) { this.forcePowerOfTwo = forcePowerOfTwo; this.forceSquare = forceSquare; this.disposeTextures = disposeTextures; @@ -145,15 +137,7 @@ namespace MLEM.Data { /// 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) { - var paddedWidth = texture.Width + 2 * padding; - if (paddedWidth > this.maxWidth) { - if (this.autoIncreaseMaxWidth) { - this.maxWidth = paddedWidth; - } else { - throw new InvalidOperationException($"Cannot add texture with width {texture.Width} to a texture packer with max width {this.maxWidth}"); - } - } - this.texturesToPack.Add(new Request(texture, result, padding, padWithPixels)); + this.requests.Add(new Request(texture, result, padding, padWithPixels)); } /// @@ -163,19 +147,32 @@ namespace MLEM.Data { /// /// The graphics device to use for texture generation public void Pack(GraphicsDevice device) { - // set pack areas for each request - // we pack larger textures first, so that smaller textures can fit in the gaps that larger ones leave + // set pack areas for each request based on the algo in https://codeincomplete.com/articles/bin-packing/ var stopwatch = Stopwatch.StartNew(); - foreach (var request in this.texturesToPack.OrderByDescending(t => t.Texture.Width * t.Texture.Height)) { - request.PackedArea = this.OccupyFreeArea(request); - this.packedTextures.Add(request); + 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 = this.packedTextures.Max(t => t.PackedArea.Right); - var height = this.packedTextures.Max(t => t.PackedArea.Bottom); + var width = root.Area.Width; + var height = root.Area.Height; if (this.forcePowerOfTwo) { width = RuntimeTexturePacker.ToPowerOfTwo(width); height = RuntimeTexturePacker.ToPowerOfTwo(height); @@ -184,26 +181,24 @@ namespace MLEM.Data { width = height = Math.Max(width, height); // if we don't need to regenerate, we only need to add newly added regions - IEnumerable 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; } // copy texture data onto the packed texture stopwatch.Restart(); using (var data = this.PackedTexture.GetTextureData()) { - foreach (var request in texturesToCopy) + 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 texturesToCopy) { - var packedArea = request.PackedArea.Shrink(new Point(request.Padding, request.Padding)); + 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, @@ -213,7 +208,6 @@ namespace MLEM.Data { request.Texture.Texture.Dispose(); } - this.texturesToPack.Clear(); this.dataCache.Clear(); } @@ -225,10 +219,7 @@ namespace MLEM.Data { this.PackedTexture = null; this.LastCalculationTime = TimeSpan.Zero; this.LastPackTime = TimeSpan.Zero; - this.texturesToPack.Clear(); - this.packedTextures.Clear(); - this.initialPositions.Clear(); - this.occupiedPositions.Clear(); + this.requests.Clear(); this.dataCache.Clear(); } @@ -237,53 +228,9 @@ namespace MLEM.Data { this.Reset(); } - private Rectangle OccupyFreeArea(Request request) { - var size = new Point(request.Texture.Width, request.Texture.Height); - size.X += request.Padding * 2; - size.Y += request.Padding * 2; - - // exit early if the texture doesn't need to find a free location - if (size.X <= 0 || size.Y <= 0) - return Rectangle.Empty; - - var pos = this.initialPositions.TryGetValue(size, out var first) ? first : Point.Zero; - var area = new Rectangle(pos.X, pos.Y, size.X, size.Y); - var lowestY = int.MaxValue; - while (true) { - // 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)); - // if no texture is occupying this space, we have found a free area - if (existing == null) { - // if this is the first position that this request fit in, no other requests of the same size will find a position before it - this.initialPositions[new Point(area.Width, area.Height)] = area.Location; - this.occupiedPositions.Add(area.Location, request); - return area; - } - - // also cache the existing texture for this position, in case we check it again in the future - this.occupiedPositions.Add(area.Location, existing); - } - - // 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; - lowestY = int.MaxValue; - } - } - } - private void CopyRegion(TextureData destination, Request request) { var data = this.GetCachedTextureData(request.Texture.Texture); - var location = request.PackedArea.Location + new Point(request.Padding, request.Padding); + 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; @@ -328,13 +275,57 @@ namespace MLEM.Data { 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 Rectangle PackedArea; + public RequestNode Node; public Request(TextureRegion texture, Action result, int padding, bool padWithPixels) { this.Texture = texture; @@ -345,5 +336,22 @@ namespace MLEM.Data { } + 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); + } + + } + } } diff --git a/Tests/TexturePackerTests.cs b/Tests/TexturePackerTests.cs index e95cea7..38f4e51 100644 --- a/Tests/TexturePackerTests.cs +++ b/Tests/TexturePackerTests.cs @@ -1,27 +1,25 @@ using System; using System.Collections.Generic; +using System.IO; using Microsoft.Xna.Framework.Graphics; using MLEM.Data; +using MLEM.Graphics; +using MLEM.Maths; using MLEM.Textures; using NUnit.Framework; +using Color = Microsoft.Xna.Framework.Color; namespace Tests; public class TexturePackerTests : GameTestFixture { - private Texture2D testTexture; - private Texture2D disposedTestTexture; - - [SetUp] - public void SetUp() { - this.testTexture = new Texture2D(this.Game.GraphicsDevice, 2048, 2048); - this.disposedTestTexture = new Texture2D(this.Game.GraphicsDevice, 16, 16); - } + private readonly List generatedTextures = []; [TearDown] public void TearDown() { - this.testTexture?.Dispose(); - this.disposedTestTexture?.Dispose(); + foreach (var tex in this.generatedTextures) + tex.Texture.Dispose(); + this.generatedTextures.Clear(); } [Test] @@ -29,69 +27,67 @@ public class TexturePackerTests : GameTestFixture { using var packer = new RuntimeTexturePacker(); for (var i = 0; i < 5; i++) { var width = 16 * (i + 1); - packer.Add(new TextureRegion(this.testTexture, 0, 0, width, 64), r => { + packer.Add(this.MakeTextureRegion(width, 64), r => { Assert.AreEqual(r.Width, width); Assert.AreEqual(r.Height, 64); }); } packer.Pack(this.Game.GraphicsDevice); - Assert.AreEqual(packer.PackedTexture.Width, 16 + 32 + 48 + 64 + 80); - Assert.AreEqual(packer.PackedTexture.Height, 64); + TexturePackerTests.SaveTexture(packer); + Assert.AreEqual(packer.PackedTexture.Width, 128); + Assert.AreEqual(packer.PackedTexture.Height, 128); } [Test] - public void TestOverlap() { + public void TestOverlap([Values(0, 1, 5, 10)] int padding) { var packed = new List(); - using (var packer = new RuntimeTexturePacker(8192)) { - for (var i = 1; i <= 1000; i++) - packer.Add(new TextureRegion(this.testTexture, 0, 0, i % 239, i % 673), packed.Add); - packer.Pack(this.Game.GraphicsDevice); - } + using var packer = new RuntimeTexturePacker(); + for (var i = 1; i <= 1000; i++) + packer.Add(this.MakeTextureRegion(i % 239, i % 673), packed.Add, padding); + packer.Pack(this.Game.GraphicsDevice); + + TexturePackerTests.SaveTexture(packer, padding.ToString()); foreach (var r1 in packed) { + var r1Padded = r1.Area; + r1Padded.Inflate(padding, padding); + foreach (var r2 in packed) { if (r1 == r2) continue; - Assert.False(r1.Area.Intersects(r2.Area)); + + Assert.False(r1.Area.Intersects(r2.Area), $"Regions {r1.Area} and {r2.Area} intersect"); + + var r2Padded = r2.Area; + r2Padded.Inflate(padding, padding); + Assert.False(r1Padded.Intersects(r2Padded), $"Padded regions {r1Padded} and {r2Padded} intersect"); } } } [Test] public void TestDisposal() { - using var packer = new RuntimeTexturePacker(128, disposeTextures: true); - packer.Add(new TextureRegion(this.disposedTestTexture), TexturePackerTests.StubResult); - packer.Add(new TextureRegion(this.disposedTestTexture, 0, 0, 8, 8), TexturePackerTests.StubResult); + using var packer = new RuntimeTexturePacker(disposeTextures: true); + var disposeLater = this.MakeTextureRegion(16, 16); + packer.Add(disposeLater, TexturePackerTests.StubResult); + packer.Add(new TextureRegion(disposeLater, 0, 0, 8, 8), TexturePackerTests.StubResult); packer.Pack(this.Game.GraphicsDevice); - Assert.True(this.disposedTestTexture.IsDisposed); + Assert.True(disposeLater.Texture.IsDisposed); Assert.False(packer.PackedTexture.IsDisposed); } [Test] public void TestBounds() { - // test forced max width - using var packer = new RuntimeTexturePacker(128); - Assert.Throws(() => { - packer.Add(new TextureRegion(this.testTexture, 0, 0, 256, 128), TexturePackerTests.StubResult); - }); - - // test auto-expanding width - using var packer2 = new RuntimeTexturePacker(128, true); - Assert.DoesNotThrow(() => { - packer2.Add(new TextureRegion(this.testTexture, 0, 0, 256, 128), TexturePackerTests.StubResult); - }); - packer2.Pack(this.Game.GraphicsDevice); - // test power of two forcing - using var packer3 = new RuntimeTexturePacker(128, forcePowerOfTwo: true); - packer3.Add(new TextureRegion(this.testTexture, 0, 0, 37, 170), TexturePackerTests.StubResult); + using var packer3 = new RuntimeTexturePacker(true); + packer3.Add(this.MakeTextureRegion(37, 170), TexturePackerTests.StubResult); packer3.Pack(this.Game.GraphicsDevice); Assert.AreEqual(64, packer3.PackedTexture.Width); Assert.AreEqual(256, packer3.PackedTexture.Height); // test square forcing - using var packer4 = new RuntimeTexturePacker(128, forceSquare: true); - packer4.Add(new TextureRegion(this.testTexture, 0, 0, 37, 170), TexturePackerTests.StubResult); + using var packer4 = new RuntimeTexturePacker(forceSquare: true); + packer4.Add(this.MakeTextureRegion(37, 170), TexturePackerTests.StubResult); packer4.Pack(this.Game.GraphicsDevice); Assert.AreEqual(170, packer4.PackedTexture.Width); Assert.AreEqual(170, packer4.PackedTexture.Height); @@ -99,45 +95,66 @@ public class TexturePackerTests : GameTestFixture { [Test] public void TestPackMultipleTimes() { - using var packer = new RuntimeTexturePacker(1024); + using var packer = new RuntimeTexturePacker(); // pack the first time var results = 0; for (var i = 0; i < 10; i++) - packer.Add(new TextureRegion(this.testTexture, 0, 0, 64, 64), _ => results++); + packer.Add(this.MakeTextureRegion(64, 64), _ => results++); packer.Pack(this.Game.GraphicsDevice); Assert.AreEqual(10, results); - // pack without resizing - packer.Add(new TextureRegion(this.testTexture, 0, 0, 0, 0), _ => results++); + // pack again + packer.Add(this.MakeTextureRegion(64, 64), _ => results++); packer.Pack(this.Game.GraphicsDevice); - Assert.AreEqual(11, results); - - // pack and force a resize - packer.Add(new TextureRegion(this.testTexture, 0, 0, 64, 64), _ => results++); - packer.Pack(this.Game.GraphicsDevice); - // all callbacks are called again, so we add 11 again, as well as the callback we just added - Assert.AreEqual(2 * 11 + 1, results); + // all callbacks are called again, so we add 10 again, as well as the callback we just added + Assert.AreEqual(10 + 10 + 1, results); } [Test] - public void TestPackTimes() { - for (var total = 1; total <= 10001; total += 1000) { - using var sameSizePacker = new RuntimeTexturePacker(); - using var diffSizePacker = new RuntimeTexturePacker(); - for (var i = 0; i < total; i++) { - sameSizePacker.Add(new TextureRegion(this.testTexture, 0, 0, 10, 10), TexturePackerTests.StubResult); - diffSizePacker.Add(new TextureRegion(this.testTexture, 0, 0, 10 + i % 129, 10 * (i % 5 + 1)), TexturePackerTests.StubResult); - } - sameSizePacker.Pack(this.Game.GraphicsDevice); - diffSizePacker.Pack(this.Game.GraphicsDevice); - - TestContext.WriteLine($""" - {total} regions, - same-size {sameSizePacker.LastCalculationTime.TotalMilliseconds} calc, {sameSizePacker.LastPackTime.TotalMilliseconds} pack, {sameSizePacker.LastTotalTime.TotalMilliseconds} total, - diff-size {diffSizePacker.LastCalculationTime.TotalMilliseconds} calc, {diffSizePacker.LastPackTime.TotalMilliseconds} pack, {diffSizePacker.LastTotalTime.TotalMilliseconds} total - """); + public void TestPackTimes([Values(1, 100, 1000, 5000, 10000)] int total) { + var random = new Random(1238492384); + using var sameSizePacker = new RuntimeTexturePacker(); + using var diffSizePacker = new RuntimeTexturePacker(); + for (var i = 0; i < total; i++) { + sameSizePacker.Add(this.MakeTextureRegion(10, 10), TexturePackerTests.StubResult); + diffSizePacker.Add(this.MakeTextureRegion(random.Next(10, 200), random.Next(10, 200)), TexturePackerTests.StubResult); } + sameSizePacker.Pack(this.Game.GraphicsDevice); + diffSizePacker.Pack(this.Game.GraphicsDevice); + + TexturePackerTests.SaveTexture(sameSizePacker, $"SameSize{total}"); + TexturePackerTests.SaveTexture(diffSizePacker, $"DiffSize{total}"); + + TestContext.WriteLine($""" + {total} regions, + same-size {sameSizePacker.LastCalculationTime.TotalMilliseconds}ms calc, {sameSizePacker.LastPackTime.TotalMilliseconds}ms pack, {sameSizePacker.LastTotalTime.TotalMilliseconds}ms total, + diff-size {diffSizePacker.LastCalculationTime.TotalMilliseconds}ms calc, {diffSizePacker.LastPackTime.TotalMilliseconds}ms pack, {diffSizePacker.LastTotalTime.TotalMilliseconds}ms total + """); + } + + private TextureRegion MakeTextureRegion(int width, int height) { + var color = ColorHelper.FromHexRgb(SingleRandom.Int(this.generatedTextures.Count)); + var texture = new Texture2D(this.Game.GraphicsDevice, Math.Max(width, 1), Math.Max(height, 1)); + using (var data = texture.GetTextureData()) { + for (var x = 0; x < texture.Width; x++) { + for (var y = 0; y < texture.Height; y++) + data[x, y] = color; + } + } + var region = new TextureRegion(texture, 0, 0, width, height); + this.generatedTextures.Add(region); + return region; + } + + private static void SaveTexture(RuntimeTexturePacker packer, string append = "") { + var caller = new System.Diagnostics.StackTrace(1).GetFrame(0).GetMethod().Name + append; + var file = Path.GetFullPath(Path.Combine(TestContext.CurrentContext.WorkDirectory, "PackedTextures", caller + ".png")); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + using (var stream = File.Create(file)) + packer.PackedTexture.SaveAsPng(stream, packer.PackedTexture.Width, packer.PackedTexture.Height); + TestContext.WriteLine($"Saving texture generated by {caller} to {file}"); + TestContext.AddTestAttachment(file); } private static void StubResult(TextureRegion region) {} diff --git a/build.cake b/build.cake index fad0686..a932083 100644 --- a/build.cake +++ b/build.cake @@ -2,7 +2,7 @@ #tool dotnet:?package=docfx&version=2.75.3 // this is the upcoming version, for prereleases -var version = Argument("version", "7.2.0"); +var version = Argument("version", "8.0.0"); var target = Argument("target", "Default"); var gitRef = Argument("ref", "refs/heads/main"); var buildNum = Argument("buildNum", ""); @@ -40,7 +40,7 @@ Task("Test").IsDependentOn("Prepare").Does(() => { var settings = new DotNetTestSettings { Configuration = config, Collectors = {"XPlat Code Coverage"}, - Loggers = {"console;verbosity=normal"} + Loggers = {"console;verbosity=normal", "nunit"} }; DotNetTest("MLEM.sln", settings); DotNetTest("MLEM.FNA.sln", settings);