1
0
Fork 0
mirror of https://github.com/Ellpeck/MLEM.git synced 2024-11-24 05:38:33 +01:00

Improve the runtime texture packer algorithm (#31)

* use a random size for testing pack times to show how truly bad the current algo is

* expand overlap test to include padding and pixel padding

* fixed TestPackTimes on KNI

* also test for whether the padding intersects

* Use a binary tree algorithm for the runtime texture packer

* update changelog and bump version

* fixed fna compile errors

* save TestPackTimes textures separately
This commit is contained in:
Ell 2024-11-07 16:18:51 +01:00 committed by GitHub
parent a58470de9f
commit 6f64661817
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 185 additions and 159 deletions

View file

@ -2,7 +2,7 @@
MLEM tries to adhere to [semantic versioning](https://semver.org/). Potentially breaking changes are written in **bold**. MLEM tries to adhere to [semantic versioning](https://semver.org/). Potentially breaking changes are written in **bold**.
Jump to version: Jump to version:
- [7.2.0](#720-in-development) - [8.0.0](#800-in-development)
- [7.1.1](#711) - [7.1.1](#711)
- [7.1.0](#710) - [7.1.0](#710)
- [7.0.0](#700) - [7.0.0](#700)
@ -16,7 +16,7 @@ Jump to version:
- [5.1.0](#510) - [5.1.0](#510)
- [5.0.0](#500) - [5.0.0](#500)
## 7.2.0 (In Development) ## 8.0.0 (In Development)
### MLEM ### MLEM
Fixes Fixes
@ -35,6 +35,7 @@ Fixes
### MLEM.Data ### MLEM.Data
Improvements Improvements
- **Use a binary tree algorithm for RuntimeTexturePacker to vastly increase packing speed**
- Made fields and methods in StaticJsonConverter protected to allow extending it - Made fields and methods in StaticJsonConverter protected to allow extending it
## 7.1.1 ## 7.1.1

View file

@ -4,7 +4,6 @@ using System.Diagnostics;
using System.Linq; using System.Linq;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using MLEM.Maths;
using MLEM.Textures; using MLEM.Textures;
using static MLEM.Textures.TextureExtensions; 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. /// 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. /// The resulting texture segments are returned as <see cref="TextureRegion"/> instances.
/// </summary> /// </summary>
/// <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>
public class RuntimeTexturePacker : IDisposable { public class RuntimeTexturePacker : IDisposable {
/// <summary> /// <summary>
@ -36,31 +38,21 @@ namespace MLEM.Data {
/// <summary> /// <summary>
/// The amount of currently packed texture regions. /// The amount of currently packed texture regions.
/// </summary> /// </summary>
public int PackedTextures => this.packedTextures.Count; public int PackedTextures => this.PackedTexture != null ? this.requests.Count : 0;
private readonly List<Request> texturesToPack = new List<Request>(); private readonly List<Request> requests = new List<Request>();
private readonly List<Request> packedTextures = new List<Request>();
private readonly Dictionary<Point, Request> occupiedPositions = new Dictionary<Point, Request>();
private readonly Dictionary<Point, Point> initialPositions = new Dictionary<Point, Point>();
private readonly Dictionary<Texture2D, TextureData> dataCache = new Dictionary<Texture2D, TextureData>(); private readonly Dictionary<Texture2D, TextureData> dataCache = new Dictionary<Texture2D, TextureData>();
private readonly bool autoIncreaseMaxWidth;
private readonly bool forcePowerOfTwo; private readonly bool forcePowerOfTwo;
private readonly bool forceSquare; private readonly bool forceSquare;
private readonly bool disposeTextures; private readonly bool disposeTextures;
private int maxWidth;
/// <summary> /// <summary>
/// Creates a new runtime texture packer with the given settings. /// Creates a new runtime texture packer with the given settings.
/// </summary> /// </summary>
/// <param name="maxWidth">The maximum width that the packed texture can have. Defaults to 2048.</param>
/// <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>
/// <param name="forcePowerOfTwo">Whether the resulting <see cref="PackedTexture"/> should have a width and height that is a power of two.</param> /// <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="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> /// <param name="disposeTextures">Whether the original textures submitted to this texture packer should be disposed after packing.</param>
public RuntimeTexturePacker(int maxWidth = 2048, bool autoIncreaseMaxWidth = false, bool forcePowerOfTwo = false, bool forceSquare = false, bool disposeTextures = false) { public RuntimeTexturePacker(bool forcePowerOfTwo = false, bool forceSquare = false, bool disposeTextures = false) {
this.maxWidth = maxWidth;
this.autoIncreaseMaxWidth = autoIncreaseMaxWidth;
this.forcePowerOfTwo = forcePowerOfTwo; this.forcePowerOfTwo = forcePowerOfTwo;
this.forceSquare = forceSquare; this.forceSquare = forceSquare;
this.disposeTextures = disposeTextures; this.disposeTextures = disposeTextures;
@ -145,15 +137,7 @@ namespace MLEM.Data {
/// <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> /// <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>
/// <exception cref="InvalidOperationException">Thrown when trying to add a texture width a width greater than the defined max width.</exception> /// <exception cref="InvalidOperationException">Thrown when trying to add a texture width a width greater than the defined max width.</exception>
public void Add(TextureRegion texture, Action<TextureRegion> result, int padding = 0, bool padWithPixels = false) { public void Add(TextureRegion texture, Action<TextureRegion> result, int padding = 0, bool padWithPixels = false) {
var paddedWidth = texture.Width + 2 * padding; this.requests.Add(new Request(texture, result, padding, padWithPixels));
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));
} }
/// <summary> /// <summary>
@ -163,19 +147,32 @@ namespace MLEM.Data {
/// </summary> /// </summary>
/// <param name="device">The graphics device to use for texture generation</param> /// <param name="device">The graphics device to use for texture generation</param>
public void Pack(GraphicsDevice device) { public void Pack(GraphicsDevice device) {
// set pack areas for each request // set pack areas for each request based on the algo in https://codeincomplete.com/articles/bin-packing/
// we pack larger textures first, so that smaller textures can fit in the gaps that larger ones leave
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
foreach (var request in this.texturesToPack.OrderByDescending(t => t.Texture.Width * t.Texture.Height)) { RequestNode root = null;
request.PackedArea = this.OccupyFreeArea(request); foreach (var request in this.requests.OrderByDescending(t => Math.Max(t.Texture.Width, t.Texture.Height) + t.Padding * 2)) {
this.packedTextures.Add(request); 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(); stopwatch.Stop();
this.LastCalculationTime = stopwatch.Elapsed; this.LastCalculationTime = stopwatch.Elapsed;
// figure out texture size and regenerate texture if necessary // figure out texture size and regenerate texture if necessary
var width = this.packedTextures.Max(t => t.PackedArea.Right); var width = root.Area.Width;
var height = this.packedTextures.Max(t => t.PackedArea.Bottom); var height = root.Area.Height;
if (this.forcePowerOfTwo) { if (this.forcePowerOfTwo) {
width = RuntimeTexturePacker.ToPowerOfTwo(width); width = RuntimeTexturePacker.ToPowerOfTwo(width);
height = RuntimeTexturePacker.ToPowerOfTwo(height); height = RuntimeTexturePacker.ToPowerOfTwo(height);
@ -184,26 +181,24 @@ namespace MLEM.Data {
width = height = Math.Max(width, height); width = height = Math.Max(width, height);
// if we don't need to regenerate, we only need to add newly added regions // 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) { if (this.PackedTexture == null || this.PackedTexture.Width != width || this.PackedTexture.Height != height) {
this.PackedTexture?.Dispose(); this.PackedTexture?.Dispose();
this.PackedTexture = new Texture2D(device, width, height); 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 // copy texture data onto the packed texture
stopwatch.Restart(); stopwatch.Restart();
using (var data = this.PackedTexture.GetTextureData()) { using (var data = this.PackedTexture.GetTextureData()) {
foreach (var request in texturesToCopy) foreach (var request in this.requests)
this.CopyRegion(data, request); this.CopyRegion(data, request);
} }
stopwatch.Stop(); stopwatch.Stop();
this.LastPackTime = stopwatch.Elapsed; this.LastPackTime = stopwatch.Elapsed;
// invoke callbacks for textures we copied // invoke callbacks for textures we copied
foreach (var request in texturesToCopy) { foreach (var request in this.requests) {
var packedArea = request.PackedArea.Shrink(new Point(request.Padding, request.Padding)); 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) { request.Result.Invoke(new TextureRegion(this.PackedTexture, packedArea) {
Pivot = request.Texture.Pivot, Pivot = request.Texture.Pivot,
Name = request.Texture.Name, Name = request.Texture.Name,
@ -213,7 +208,6 @@ namespace MLEM.Data {
request.Texture.Texture.Dispose(); request.Texture.Texture.Dispose();
} }
this.texturesToPack.Clear();
this.dataCache.Clear(); this.dataCache.Clear();
} }
@ -225,10 +219,7 @@ namespace MLEM.Data {
this.PackedTexture = null; this.PackedTexture = null;
this.LastCalculationTime = TimeSpan.Zero; this.LastCalculationTime = TimeSpan.Zero;
this.LastPackTime = TimeSpan.Zero; this.LastPackTime = TimeSpan.Zero;
this.texturesToPack.Clear(); this.requests.Clear();
this.packedTextures.Clear();
this.initialPositions.Clear();
this.occupiedPositions.Clear();
this.dataCache.Clear(); this.dataCache.Clear();
} }
@ -237,53 +228,9 @@ namespace MLEM.Data {
this.Reset(); 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) { private void CopyRegion(TextureData destination, Request request) {
var data = this.GetCachedTextureData(request.Texture.Texture); 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 x = -request.Padding; x < request.Texture.Width + request.Padding; x++) {
for (var y = -request.Padding; y < request.Texture.Height + request.Padding; y++) { for (var y = -request.Padding; y < request.Texture.Height + request.Padding; y++) {
Color srcColor; Color srcColor;
@ -328,13 +275,57 @@ namespace MLEM.Data {
return ret; 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 { private class Request {
public readonly TextureRegion Texture; public readonly TextureRegion Texture;
public readonly Action<TextureRegion> Result; public readonly Action<TextureRegion> Result;
public readonly int Padding; public readonly int Padding;
public readonly bool PadWithPixels; public readonly bool PadWithPixels;
public Rectangle PackedArea; public RequestNode Node;
public Request(TextureRegion texture, Action<TextureRegion> result, int padding, bool padWithPixels) { public Request(TextureRegion texture, Action<TextureRegion> result, int padding, bool padWithPixels) {
this.Texture = texture; 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);
}
}
} }
} }

View file

@ -1,27 +1,25 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using MLEM.Data; using MLEM.Data;
using MLEM.Graphics;
using MLEM.Maths;
using MLEM.Textures; using MLEM.Textures;
using NUnit.Framework; using NUnit.Framework;
using Color = Microsoft.Xna.Framework.Color;
namespace Tests; namespace Tests;
public class TexturePackerTests : GameTestFixture { public class TexturePackerTests : GameTestFixture {
private Texture2D testTexture; private readonly List<TextureRegion> generatedTextures = [];
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);
}
[TearDown] [TearDown]
public void TearDown() { public void TearDown() {
this.testTexture?.Dispose(); foreach (var tex in this.generatedTextures)
this.disposedTestTexture?.Dispose(); tex.Texture.Dispose();
this.generatedTextures.Clear();
} }
[Test] [Test]
@ -29,69 +27,67 @@ public class TexturePackerTests : GameTestFixture {
using var packer = new RuntimeTexturePacker(); using var packer = new RuntimeTexturePacker();
for (var i = 0; i < 5; i++) { for (var i = 0; i < 5; i++) {
var width = 16 * (i + 1); 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.Width, width);
Assert.AreEqual(r.Height, 64); Assert.AreEqual(r.Height, 64);
}); });
} }
packer.Pack(this.Game.GraphicsDevice); packer.Pack(this.Game.GraphicsDevice);
Assert.AreEqual(packer.PackedTexture.Width, 16 + 32 + 48 + 64 + 80); TexturePackerTests.SaveTexture(packer);
Assert.AreEqual(packer.PackedTexture.Height, 64); Assert.AreEqual(packer.PackedTexture.Width, 128);
Assert.AreEqual(packer.PackedTexture.Height, 128);
} }
[Test] [Test]
public void TestOverlap() { public void TestOverlap([Values(0, 1, 5, 10)] int padding) {
var packed = new List<TextureRegion>(); var packed = new List<TextureRegion>();
using (var packer = new RuntimeTexturePacker(8192)) { using var packer = new RuntimeTexturePacker();
for (var i = 1; i <= 1000; i++) for (var i = 1; i <= 1000; i++)
packer.Add(new TextureRegion(this.testTexture, 0, 0, i % 239, i % 673), packed.Add); packer.Add(this.MakeTextureRegion(i % 239, i % 673), packed.Add, padding);
packer.Pack(this.Game.GraphicsDevice); packer.Pack(this.Game.GraphicsDevice);
}
TexturePackerTests.SaveTexture(packer, padding.ToString());
foreach (var r1 in packed) { foreach (var r1 in packed) {
var r1Padded = r1.Area;
r1Padded.Inflate(padding, padding);
foreach (var r2 in packed) { foreach (var r2 in packed) {
if (r1 == r2) if (r1 == r2)
continue; 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] [Test]
public void TestDisposal() { public void TestDisposal() {
using var packer = new RuntimeTexturePacker(128, disposeTextures: true); using var packer = new RuntimeTexturePacker(disposeTextures: true);
packer.Add(new TextureRegion(this.disposedTestTexture), TexturePackerTests.StubResult); var disposeLater = this.MakeTextureRegion(16, 16);
packer.Add(new TextureRegion(this.disposedTestTexture, 0, 0, 8, 8), TexturePackerTests.StubResult); packer.Add(disposeLater, TexturePackerTests.StubResult);
packer.Add(new TextureRegion(disposeLater, 0, 0, 8, 8), TexturePackerTests.StubResult);
packer.Pack(this.Game.GraphicsDevice); packer.Pack(this.Game.GraphicsDevice);
Assert.True(this.disposedTestTexture.IsDisposed); Assert.True(disposeLater.Texture.IsDisposed);
Assert.False(packer.PackedTexture.IsDisposed); Assert.False(packer.PackedTexture.IsDisposed);
} }
[Test] [Test]
public void TestBounds() { public void TestBounds() {
// test forced max width
using var packer = new RuntimeTexturePacker(128);
Assert.Throws<InvalidOperationException>(() => {
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 // test power of two forcing
using var packer3 = new RuntimeTexturePacker(128, forcePowerOfTwo: true); using var packer3 = new RuntimeTexturePacker(true);
packer3.Add(new TextureRegion(this.testTexture, 0, 0, 37, 170), TexturePackerTests.StubResult); packer3.Add(this.MakeTextureRegion(37, 170), TexturePackerTests.StubResult);
packer3.Pack(this.Game.GraphicsDevice); packer3.Pack(this.Game.GraphicsDevice);
Assert.AreEqual(64, packer3.PackedTexture.Width); Assert.AreEqual(64, packer3.PackedTexture.Width);
Assert.AreEqual(256, packer3.PackedTexture.Height); Assert.AreEqual(256, packer3.PackedTexture.Height);
// test square forcing // test square forcing
using var packer4 = new RuntimeTexturePacker(128, forceSquare: true); using var packer4 = new RuntimeTexturePacker(forceSquare: true);
packer4.Add(new TextureRegion(this.testTexture, 0, 0, 37, 170), TexturePackerTests.StubResult); packer4.Add(this.MakeTextureRegion(37, 170), TexturePackerTests.StubResult);
packer4.Pack(this.Game.GraphicsDevice); packer4.Pack(this.Game.GraphicsDevice);
Assert.AreEqual(170, packer4.PackedTexture.Width); Assert.AreEqual(170, packer4.PackedTexture.Width);
Assert.AreEqual(170, packer4.PackedTexture.Height); Assert.AreEqual(170, packer4.PackedTexture.Height);
@ -99,45 +95,66 @@ public class TexturePackerTests : GameTestFixture {
[Test] [Test]
public void TestPackMultipleTimes() { public void TestPackMultipleTimes() {
using var packer = new RuntimeTexturePacker(1024); using var packer = new RuntimeTexturePacker();
// pack the first time // pack the first time
var results = 0; var results = 0;
for (var i = 0; i < 10; i++) 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); packer.Pack(this.Game.GraphicsDevice);
Assert.AreEqual(10, results); Assert.AreEqual(10, results);
// pack without resizing // pack again
packer.Add(new TextureRegion(this.testTexture, 0, 0, 0, 0), _ => results++); packer.Add(this.MakeTextureRegion(64, 64), _ => results++);
packer.Pack(this.Game.GraphicsDevice); packer.Pack(this.Game.GraphicsDevice);
Assert.AreEqual(11, 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);
// 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);
} }
[Test] [Test]
public void TestPackTimes() { public void TestPackTimes([Values(1, 100, 1000, 5000, 10000)] int total) {
for (var total = 1; total <= 10001; total += 1000) { var random = new Random(1238492384);
using var sameSizePacker = new RuntimeTexturePacker(); using var sameSizePacker = new RuntimeTexturePacker();
using var diffSizePacker = new RuntimeTexturePacker(); using var diffSizePacker = new RuntimeTexturePacker();
for (var i = 0; i < total; i++) { for (var i = 0; i < total; i++) {
sameSizePacker.Add(new TextureRegion(this.testTexture, 0, 0, 10, 10), TexturePackerTests.StubResult); sameSizePacker.Add(this.MakeTextureRegion(10, 10), TexturePackerTests.StubResult);
diffSizePacker.Add(new TextureRegion(this.testTexture, 0, 0, 10 + i % 129, 10 * (i % 5 + 1)), TexturePackerTests.StubResult); diffSizePacker.Add(this.MakeTextureRegion(random.Next(10, 200), random.Next(10, 200)), TexturePackerTests.StubResult);
} }
sameSizePacker.Pack(this.Game.GraphicsDevice); sameSizePacker.Pack(this.Game.GraphicsDevice);
diffSizePacker.Pack(this.Game.GraphicsDevice); diffSizePacker.Pack(this.Game.GraphicsDevice);
TexturePackerTests.SaveTexture(sameSizePacker, $"SameSize{total}");
TexturePackerTests.SaveTexture(diffSizePacker, $"DiffSize{total}");
TestContext.WriteLine($""" TestContext.WriteLine($"""
{total} regions, {total} regions,
same-size {sameSizePacker.LastCalculationTime.TotalMilliseconds} calc, {sameSizePacker.LastPackTime.TotalMilliseconds} pack, {sameSizePacker.LastTotalTime.TotalMilliseconds} total, same-size {sameSizePacker.LastCalculationTime.TotalMilliseconds}ms calc, {sameSizePacker.LastPackTime.TotalMilliseconds}ms pack, {sameSizePacker.LastTotalTime.TotalMilliseconds}ms total,
diff-size {diffSizePacker.LastCalculationTime.TotalMilliseconds} calc, {diffSizePacker.LastPackTime.TotalMilliseconds} pack, {diffSizePacker.LastTotalTime.TotalMilliseconds} 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) {} private static void StubResult(TextureRegion region) {}

View file

@ -2,7 +2,7 @@
#tool dotnet:?package=docfx&version=2.75.3 #tool dotnet:?package=docfx&version=2.75.3
// this is the upcoming version, for prereleases // 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 target = Argument("target", "Default");
var gitRef = Argument("ref", "refs/heads/main"); var gitRef = Argument("ref", "refs/heads/main");
var buildNum = Argument("buildNum", ""); var buildNum = Argument("buildNum", "");
@ -40,7 +40,7 @@ Task("Test").IsDependentOn("Prepare").Does(() => {
var settings = new DotNetTestSettings { var settings = new DotNetTestSettings {
Configuration = config, Configuration = config,
Collectors = {"XPlat Code Coverage"}, Collectors = {"XPlat Code Coverage"},
Loggers = {"console;verbosity=normal"} Loggers = {"console;verbosity=normal", "nunit"}
}; };
DotNetTest("MLEM.sln", settings); DotNetTest("MLEM.sln", settings);
DotNetTest("MLEM.FNA.sln", settings); DotNetTest("MLEM.FNA.sln", settings);