Add MrGameEng.Atlases: texture atlas builder, runtime loader and CLI tool
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:
Leonid Pershin
2026-06-11 08:18:30 +03:00
parent d6f19b9119
commit b318d1e795
21 changed files with 1209 additions and 1 deletions
@@ -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"));
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Atlases\MrGameEng.Atlases.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,111 @@
using MrGameEng.Atlases;
using Xunit;
namespace MrGameEng.Atlases.Tests;
public class ShelfPackerTests
{
private static List<PackItem> Squares(int count, int size) =>
Enumerable.Range(0, count).Select(i => new PackItem($"item{i:D3}", size, size)).ToList();
[Fact]
public void Pack_PlacesEveryItem_WithinPageBounds()
{
var result = ShelfPacker.Pack(Squares(50, 60), maxPageSize: 256, padding: 2);
Assert.Equal(50, result.Placements.Count);
foreach (var p in result.Placements)
{
var (pageWidth, pageHeight) = result.PageSizes[p.Page];
Assert.True(p.X >= 2 && p.Y >= 2);
Assert.True(p.X + p.Width <= pageWidth);
Assert.True(p.Y + p.Height <= pageHeight);
}
}
[Fact]
public void Pack_NoTwoPlacements_Overlap()
{
var items = new List<PackItem>();
var random = new Random(42);
for (var i = 0; i < 200; i++)
{
items.Add(new PackItem($"r{i:D3}", random.Next(4, 90), random.Next(4, 90)));
}
var result = ShelfPacker.Pack(items, maxPageSize: 512, padding: 2);
var byPage = result.Placements.GroupBy(p => p.Page);
foreach (var page in byPage)
{
var list = page.ToList();
for (var i = 0; i < list.Count; i++)
{
for (var j = i + 1; j < list.Count; j++)
{
var a = list[i];
var b = list[j];
var separated =
a.X + a.Width + 2 <= b.X || b.X + b.Width + 2 <= a.X ||
a.Y + a.Height + 2 <= b.Y || b.Y + b.Height + 2 <= a.Y;
Assert.True(separated, $"{a.Key} overlaps {b.Key} (padding included)");
}
}
}
}
[Fact]
public void Pack_Overflows_ToMultiplePages()
{
// 9 квадратов 100² на страницу 256² помещаются максимум по 4.
var result = ShelfPacker.Pack(Squares(9, 100), maxPageSize: 256, padding: 2);
Assert.True(result.PageSizes.Count >= 3);
Assert.Equal(Enumerable.Range(0, result.PageSizes.Count), result.Placements.Select(p => p.Page).Distinct().Order());
}
[Fact]
public void Pack_OversizedItem_GetsDedicatedPage()
{
var items = Squares(2, 30);
items.Add(new PackItem("huge", 500, 40));
var result = ShelfPacker.Pack(items, maxPageSize: 256, padding: 2);
var huge = result.Placements.Single(p => p.Key == "huge");
Assert.Single(result.Placements, p => p.Page == huge.Page);
Assert.True(result.PageSizes[huge.Page].Width >= 504);
}
[Fact]
public void Pack_IsDeterministic_RegardlessOfInputOrder()
{
var items = Squares(30, 20).Concat(Squares(10, 50).Select(i => i with { Key = "b" + i.Key })).ToList();
var shuffled = items.AsEnumerable().Reverse().ToList();
var a = ShelfPacker.Pack(items, 128, 2);
var b = ShelfPacker.Pack(shuffled, 128, 2);
Assert.Equal(
a.Placements.OrderBy(p => p.Key, StringComparer.Ordinal),
b.Placements.OrderBy(p => p.Key, StringComparer.Ordinal));
}
[Fact]
public void Pack_TrimsPages_ToPowerOfTwo()
{
var result = ShelfPacker.Pack(Squares(1, 50), maxPageSize: 2048, padding: 2);
Assert.Equal((64, 64), result.PageSizes.Single());
}
[Theory]
[InlineData(1, 1)]
[InlineData(64, 64)]
[InlineData(65, 128)]
[InlineData(2048, 2048)]
public void NextPowerOfTwo_RoundsUp(int value, int expected)
{
Assert.Equal(expected, ShelfPacker.NextPowerOfTwo(value));
}
}