Add MrGameEng.Atlases: texture atlas builder, runtime loader and CLI tool
CI / build-test (push) Failing after 1m13s
CI / build-test (push) Failing after 1m13s
AtlasBuilder packs a directory tree of loose images into atlas pages plus JSON metadata (deterministic shelf packing, incremental rebuilds, orphan cleanup); TextureAtlas loads them back handing out Texture2DRegions, so sprites from one page batch into a single draw call. The asset handle generator maps .atlas files to TextureAtlas and skips page images. Demonstrated in the sample (Assets/Atlases + 'atlas' console command), wrapped as tools/MrGameEng.AtlasTool for build scripts. Documented dependency exception: Atlases depends on Graphics and Assets.
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
using StbImageSharp;
|
||||
using StbImageWriteSharp;
|
||||
using Xunit;
|
||||
|
||||
namespace MrGameEng.Atlases.Tests;
|
||||
|
||||
public sealed class AtlasBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("mrge-atlas-tests-").FullName;
|
||||
|
||||
private string SourceDir => Path.Combine(_root, "Textures");
|
||||
private string OutputDir => Path.Combine(_root, "Atlases");
|
||||
|
||||
public void Dispose() => Directory.Delete(_root, recursive: true);
|
||||
|
||||
/// <summary>Writes a PNG filled with one RGBA color.</summary>
|
||||
private void WritePng(string relativePath, int width, int height, byte r, byte g, byte b, byte a = 255)
|
||||
{
|
||||
var fullPath = Path.Combine(SourceDir, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
var data = new byte[width * height * 4];
|
||||
for (var i = 0; i < data.Length; i += 4)
|
||||
{
|
||||
data[i] = r;
|
||||
data[i + 1] = g;
|
||||
data[i + 2] = b;
|
||||
data[i + 3] = a;
|
||||
}
|
||||
|
||||
using var stream = File.Create(fullPath);
|
||||
new ImageWriter().WritePng(data, width, height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream);
|
||||
}
|
||||
|
||||
private AtlasBuildOptions Options(int groupDepth = 1, bool force = false) => new()
|
||||
{
|
||||
SourceDirectory = SourceDir,
|
||||
OutputDirectory = OutputDir,
|
||||
GroupDepth = groupDepth,
|
||||
MaxPageSize = 128,
|
||||
Padding = 2,
|
||||
Force = force,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("Terrain/Surfaces/Marsh.png", 1, "Terrain", "Terrain/Surfaces/Marsh")]
|
||||
[InlineData("Terrain/Surfaces/Marsh.png", 2, "Terrain.Surfaces", "Terrain/Surfaces/Marsh")]
|
||||
[InlineData("Terrain/Surfaces/Marsh.png", 0, "Root", "Terrain/Surfaces/Marsh")]
|
||||
[InlineData("loose.png", 3, "Root", "loose")]
|
||||
public void ClassifyPath_GroupsByDepth(string path, int depth, string expectedAtlas, string expectedKey)
|
||||
{
|
||||
var (atlas, key) = AtlasBuilder.ClassifyPath(path, depth, "Root");
|
||||
|
||||
Assert.Equal(expectedAtlas, atlas);
|
||||
Assert.Equal(expectedKey, key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WritesMetadataAndPages_PixelsSurviveRoundtrip()
|
||||
{
|
||||
WritePng("Terrain/Grass.png", 16, 16, 10, 200, 30);
|
||||
WritePng("Terrain/Water.png", 16, 8, 30, 40, 250);
|
||||
|
||||
var result = AtlasBuilder.Build(Options());
|
||||
|
||||
var group = Assert.Single(result.Groups);
|
||||
Assert.Equal("Terrain", group.Name);
|
||||
Assert.Equal(2, group.RegionCount);
|
||||
Assert.False(group.Skipped);
|
||||
|
||||
var metadata = AtlasMetadata.FromJson(File.ReadAllText(Path.Combine(OutputDir, "Terrain.atlas")));
|
||||
Assert.Equal(["Terrain/Grass", "Terrain/Water"], metadata.Regions.Select(x => x.Key));
|
||||
|
||||
var grass = metadata.Regions.Single(x => x.Key == "Terrain/Grass");
|
||||
var page = metadata.Pages[grass.Page];
|
||||
using var stream = File.OpenRead(Path.Combine(OutputDir, page.File));
|
||||
var pixels = ImageResult.FromStream(stream, StbImageSharp.ColorComponents.RedGreenBlueAlpha);
|
||||
|
||||
// Центральный пиксель региона должен быть цветом исходной картинки.
|
||||
var center = ((grass.Y + 8) * pixels.Width + grass.X + 8) * 4;
|
||||
Assert.Equal((byte)10, pixels.Data[center]);
|
||||
Assert.Equal((byte)200, pixels.Data[center + 1]);
|
||||
Assert.Equal((byte)30, pixels.Data[center + 2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SecondRunWithoutChanges_SkipsGroup()
|
||||
{
|
||||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||||
|
||||
var first = AtlasBuilder.Build(Options());
|
||||
var second = AtlasBuilder.Build(Options());
|
||||
|
||||
Assert.False(Assert.Single(first.Groups).Skipped);
|
||||
Assert.True(Assert.Single(second.Groups).Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ChangedSource_Rebuilds()
|
||||
{
|
||||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||||
AtlasBuilder.Build(Options());
|
||||
|
||||
File.SetLastWriteTimeUtc(
|
||||
Path.Combine(SourceDir, "UI/button.png"), DateTime.UtcNow.AddMinutes(1));
|
||||
var result = AtlasBuilder.Build(Options());
|
||||
|
||||
Assert.False(Assert.Single(result.Groups).Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_AddedFile_RebuildsGroup()
|
||||
{
|
||||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||||
AtlasBuilder.Build(Options());
|
||||
|
||||
WritePng("UI/icon.png", 8, 8, 4, 5, 6);
|
||||
var result = AtlasBuilder.Build(Options());
|
||||
|
||||
var group = Assert.Single(result.Groups);
|
||||
Assert.False(group.Skipped);
|
||||
Assert.Equal(2, group.RegionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RemovedGroup_DeletesOrphanedAtlas()
|
||||
{
|
||||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||||
WritePng("World/rock.png", 8, 8, 7, 8, 9);
|
||||
AtlasBuilder.Build(Options());
|
||||
|
||||
Directory.Delete(Path.Combine(SourceDir, "World"), recursive: true);
|
||||
var result = AtlasBuilder.Build(Options());
|
||||
|
||||
Assert.NotEmpty(result.DeletedOrphans);
|
||||
Assert.False(File.Exists(Path.Combine(OutputDir, "World.atlas")));
|
||||
Assert.False(File.Exists(Path.Combine(OutputDir, "World.atlas.0.png")));
|
||||
Assert.True(File.Exists(Path.Combine(OutputDir, "UI.atlas")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ManyImages_SpillToMultiplePages()
|
||||
{
|
||||
for (var i = 0; i < 12; i++)
|
||||
{
|
||||
WritePng($"Things/sprite{i:D2}.png", 60, 60, (byte)i, 0, 0);
|
||||
}
|
||||
|
||||
var result = AtlasBuilder.Build(Options());
|
||||
|
||||
// 60² с padding 2 на страницу 128² помещаются по 4 — минимум 3 страницы.
|
||||
Assert.True(Assert.Single(result.Groups).PageCount >= 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metadata_JsonRoundtrip_PreservesEverything()
|
||||
{
|
||||
var metadata = new AtlasMetadata
|
||||
{
|
||||
Name = "Things.Pawn",
|
||||
PageSize = 2048,
|
||||
Padding = 2,
|
||||
Pages = [new AtlasPage { File = "Things.Pawn.atlas.0.png", Width = 256, Height = 128 }],
|
||||
Regions = [new AtlasRegion { Key = "Things/Pawn/Fox", Page = 0, X = 2, Y = 4, Width = 64, Height = 32 }],
|
||||
};
|
||||
|
||||
var parsed = AtlasMetadata.FromJson(metadata.ToJson());
|
||||
|
||||
Assert.Equal("Things.Pawn", parsed.Name);
|
||||
Assert.Equal(2048, parsed.PageSize);
|
||||
var page = Assert.Single(parsed.Pages);
|
||||
Assert.Equal(("Things.Pawn.atlas.0.png", 256, 128), (page.File, page.Width, page.Height));
|
||||
var region = Assert.Single(parsed.Regions);
|
||||
Assert.Equal(("Things/Pawn/Fox", 0, 2, 4, 64, 32),
|
||||
(region.Key, region.Page, region.X, region.Y, region.Width, region.Height));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextureAtlas_LooksUpRegions_FromMetadata()
|
||||
{
|
||||
var metadata = new AtlasMetadata
|
||||
{
|
||||
Name = "Test",
|
||||
Pages = [new AtlasPage { File = "Test.atlas.0.png", Width = 64, Height = 64 }],
|
||||
Regions =
|
||||
[
|
||||
new AtlasRegion { Key = "a/b", Page = 0, X = 2, Y = 2, Width = 10, Height = 12 },
|
||||
],
|
||||
};
|
||||
|
||||
// Texture2D == null допустим в headless-тестах (см. Texture2DRegion).
|
||||
var atlas = new TextureAtlas(metadata, new Microsoft.Xna.Framework.Graphics.Texture2D[1]);
|
||||
|
||||
var region = atlas.GetRegion("a/b");
|
||||
Assert.Equal(new Microsoft.Xna.Framework.Rectangle(2, 2, 10, 12), region.Bounds);
|
||||
Assert.True(atlas.TryGetRegion("a/b", out _));
|
||||
Assert.False(atlas.TryGetRegion("missing", out _));
|
||||
Assert.Throws<KeyNotFoundException>(() => atlas.GetRegion("missing"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user