Files
mrgameeng/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs
T
Leonid Pershin b318d1e795
CI / build-test (push) Failing after 1m13s
Add MrGameEng.Atlases: texture atlas builder, runtime loader and CLI tool
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.
2026-06-11 08:18:30 +03:00

200 lines
7.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"));
}
}