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
@@ -25,6 +25,7 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
[".wav"] = "global::Microsoft.Xna.Framework.Audio.SoundEffect",
[".ogg"] = "global::MrGameEng.Core.MusicTrack",
[".mgfx"] = "global::Microsoft.Xna.Framework.Graphics.Effect",
[".atlas"] = "global::MrGameEng.Atlases.TextureAtlas",
};
/// <inheritdoc />
@@ -62,6 +63,14 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
}
var relative = normalized.Substring(marker + "/Assets/".Length);
// Страницы атласов (Name.atlas.0.png) — внутренние файлы метаданных .atlas,
// им собственные Texture2D-хендлы не нужны.
if (Path.GetFileName(relative).Contains(".atlas.", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var extension = Path.GetExtension(relative);
return TypeByExtension.ContainsKey(extension) ? relative : null;
}
+303
View File
@@ -0,0 +1,303 @@
using StbImageSharp;
using StbImageWriteSharp;
namespace MrGameEng.Atlases;
/// <summary>Options for one <see cref="AtlasBuilder.Build"/> run.</summary>
public sealed class AtlasBuildOptions
{
/// <summary>Directory scanned recursively for source images (png/jpg/jpeg/bmp).</summary>
public required string SourceDirectory { get; init; }
/// <summary>Directory the <c>.atlas</c> metadata and page images are written to.</summary>
public required string OutputDirectory { get; init; }
/// <summary>
/// How many leading directories of a source-relative path form the atlas group:
/// 0 packs everything into one atlas, 1 packs per top-level folder, and so on.
/// </summary>
public int GroupDepth { get; init; } = 1;
/// <summary>Maximum page width/height in pixels.</summary>
public int MaxPageSize { get; init; } = 2048;
/// <summary>Gap in pixels between packed images and page edges (bleed protection).</summary>
public int Padding { get; init; } = 2;
/// <summary>Atlas name for images that have fewer directories than <see cref="GroupDepth"/>.</summary>
public string RootAtlasName { get; init; } = "Atlas";
/// <summary>Rebuild every atlas even when sources are unchanged.</summary>
public bool Force { get; init; }
}
/// <summary>Build outcome for one atlas group.</summary>
/// <param name="Name">Atlas name (group key with '/' replaced by '.').</param>
/// <param name="RegionCount">Number of packed source images.</param>
/// <param name="PageCount">Number of page images written.</param>
/// <param name="Skipped">True when the atlas was up to date and not rebuilt.</param>
public sealed record AtlasGroupResult(string Name, int RegionCount, int PageCount, bool Skipped);
/// <summary>Result of an <see cref="AtlasBuilder.Build"/> run.</summary>
/// <param name="Groups">Per-atlas outcomes, sorted by name.</param>
/// <param name="DeletedOrphans">Output files of atlases whose source group no longer exists.</param>
public sealed record AtlasBuildResult(IReadOnlyList<AtlasGroupResult> Groups, IReadOnlyList<string> DeletedOrphans);
/// <summary>
/// Build-time utility converting a directory tree of loose images into texture atlases:
/// page images plus an <see cref="AtlasMetadata"/> JSON per group. Pure CPU (StbImageSharp),
/// no graphics device — intended for tools and build scripts, not for the render loop.
/// Region keys are source-relative paths without extension, so game code addresses sprites
/// by the same path it would have used for the loose file.
/// </summary>
public static class AtlasBuilder
{
private static readonly string[] SourceExtensions = [".png", ".jpg", ".jpeg", ".bmp"];
/// <summary>Builds (or incrementally refreshes) all atlases for <paramref name="options"/>.</summary>
public static AtlasBuildResult Build(AtlasBuildOptions options)
{
var sourceRoot = Path.GetFullPath(options.SourceDirectory);
if (!Directory.Exists(sourceRoot))
{
throw new DirectoryNotFoundException($"Atlas source directory not found: '{sourceRoot}'.");
}
Directory.CreateDirectory(options.OutputDirectory);
var groups = ScanGroups(sourceRoot, options);
var results = new List<AtlasGroupResult>();
foreach (var (name, files) in groups)
{
results.Add(BuildGroup(name, files, options));
}
var orphans = DeleteOrphans(options.OutputDirectory, groups.Keys);
return new AtlasBuildResult(results, orphans);
}
/// <summary>Maps a source-relative image path to its atlas name and region key.</summary>
internal static (string AtlasName, string Key) ClassifyPath(string relativePath, int groupDepth, string rootAtlasName)
{
var normalized = relativePath.Replace('\\', '/');
var key = normalized[..normalized.LastIndexOf('.')];
var segments = normalized.Split('/');
var depth = Math.Min(groupDepth, segments.Length - 1);
var name = depth == 0 ? rootAtlasName : string.Join('.', segments[..depth]);
return (name, key);
}
private static SortedDictionary<string, List<(string FullPath, string Key)>> ScanGroups(
string sourceRoot, AtlasBuildOptions options)
{
var groups = new SortedDictionary<string, List<(string, string)>>(StringComparer.Ordinal);
var keys = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var fullPath in Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories))
{
if (!SourceExtensions.Contains(Path.GetExtension(fullPath), StringComparer.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(sourceRoot, fullPath);
var (atlasName, key) = ClassifyPath(relative, options.GroupDepth, options.RootAtlasName);
if (keys.TryGetValue(key, out var existing))
{
throw new InvalidDataException($"Duplicate region key '{key}': '{existing}' and '{relative}'.");
}
keys.Add(key, relative);
if (!groups.TryGetValue(atlasName, out var list))
{
list = [];
groups.Add(atlasName, list);
}
list.Add((fullPath, key));
}
return groups;
}
private static AtlasGroupResult BuildGroup(
string name, List<(string FullPath, string Key)> files, AtlasBuildOptions options)
{
var metadataPath = Path.Combine(options.OutputDirectory, name + ".atlas");
if (!options.Force && IsUpToDate(metadataPath, files, options, out var existingPages))
{
return new AtlasGroupResult(name, files.Count, existingPages, Skipped: true);
}
// Декодирование — самая дорогая фаза, параллелим (билд-тайм, аллокации допустимы).
var images = new ImageResult[files.Count];
Parallel.For(0, files.Count, i =>
{
using var stream = File.OpenRead(files[i].FullPath);
images[i] = ImageResult.FromStream(stream, StbImageSharp.ColorComponents.RedGreenBlueAlpha);
});
var items = new PackItem[files.Count];
for (var i = 0; i < files.Count; i++)
{
items[i] = new PackItem(files[i].Key, images[i].Width, images[i].Height);
}
var packed = ShelfPacker.Pack(items, options.MaxPageSize, options.Padding);
var pixelsByKey = new Dictionary<string, ImageResult>(files.Count, StringComparer.Ordinal);
for (var i = 0; i < files.Count; i++)
{
pixelsByKey.Add(files[i].Key, images[i]);
}
WritePages(name, packed, pixelsByKey, options.OutputDirectory);
WriteMetadata(name, packed, options, metadataPath);
DeleteExtraPages(name, packed.PageSizes.Count, options.OutputDirectory);
return new AtlasGroupResult(name, files.Count, packed.PageSizes.Count, Skipped: false);
}
private static bool IsUpToDate(
string metadataPath, List<(string FullPath, string Key)> files, AtlasBuildOptions options, out int pages)
{
pages = 0;
if (!File.Exists(metadataPath))
{
return false;
}
AtlasMetadata metadata;
try
{
metadata = AtlasMetadata.FromJson(File.ReadAllText(metadataPath));
}
catch (Exception)
{
return false;
}
if (metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding)
{
return false;
}
var outputDirectory = Path.GetDirectoryName(metadataPath)!;
if (metadata.Pages.Any(page => !File.Exists(Path.Combine(outputDirectory, page.File))))
{
return false;
}
if (!metadata.Regions.Select(r => r.Key).Order(StringComparer.Ordinal)
.SequenceEqual(files.Select(f => f.Key).Order(StringComparer.Ordinal)))
{
return false;
}
var builtAt = File.GetLastWriteTimeUtc(metadataPath);
if (files.Any(f => File.GetLastWriteTimeUtc(f.FullPath) > builtAt))
{
return false;
}
pages = metadata.Pages.Count;
return true;
}
private static void WritePages(
string name, PackResult packed, Dictionary<string, ImageResult> pixelsByKey, string outputDirectory)
{
Parallel.For(0, packed.PageSizes.Count, page =>
{
var (width, height) = packed.PageSizes[page];
var buffer = new byte[width * height * 4];
foreach (var placement in packed.Placements)
{
if (placement.Page != page)
{
continue;
}
var source = pixelsByKey[placement.Key];
for (var row = 0; row < source.Height; row++)
{
Array.Copy(
source.Data, row * source.Width * 4,
buffer, ((placement.Y + row) * width + placement.X) * 4,
source.Width * 4);
}
}
using var stream = File.Create(Path.Combine(outputDirectory, PageFileName(name, page)));
new ImageWriter().WritePng(
buffer, width, height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream);
});
}
private static void WriteMetadata(string name, PackResult packed, AtlasBuildOptions options, string metadataPath)
{
var metadata = new AtlasMetadata
{
Name = name,
PageSize = options.MaxPageSize,
Padding = options.Padding,
Pages = packed.PageSizes
.Select((size, index) => new AtlasPage
{
File = PageFileName(name, index),
Width = size.Width,
Height = size.Height,
})
.ToList(),
Regions = packed.Placements
.OrderBy(p => p.Key, StringComparer.Ordinal)
.Select(p => new AtlasRegion
{
Key = p.Key,
Page = p.Page,
X = p.X,
Y = p.Y,
Width = p.Width,
Height = p.Height,
})
.ToList(),
};
File.WriteAllText(metadataPath, metadata.ToJson());
}
private static string PageFileName(string atlasName, int page) => $"{atlasName}.atlas.{page}.png";
private static void DeleteExtraPages(string name, int pageCount, string outputDirectory)
{
for (var page = pageCount; ; page++)
{
var path = Path.Combine(outputDirectory, PageFileName(name, page));
if (!File.Exists(path))
{
return;
}
File.Delete(path);
}
}
private static List<string> DeleteOrphans(string outputDirectory, IEnumerable<string> liveAtlasNames)
{
var live = liveAtlasNames.ToHashSet(StringComparer.Ordinal);
var deleted = new List<string>();
foreach (var metadataPath in Directory.EnumerateFiles(outputDirectory, "*.atlas"))
{
var name = Path.GetFileNameWithoutExtension(metadataPath);
if (live.Contains(name))
{
continue;
}
File.Delete(metadataPath);
deleted.Add(metadataPath);
DeleteExtraPages(name, 0, outputDirectory);
}
return deleted;
}
}
+83
View File
@@ -0,0 +1,83 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MrGameEng.Atlases;
/// <summary>
/// Serializable description of one packed atlas: its page image files and the source-relative
/// region keys with their pixel rectangles. Stored as a JSON <c>.atlas</c> file next to the pages.
/// </summary>
public sealed class AtlasMetadata
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>Format version, bumped on breaking metadata changes.</summary>
public int Version { get; init; } = 1;
/// <summary>Atlas name (group key with '/' replaced by '.').</summary>
public string Name { get; init; } = "";
/// <summary>Maximum page size the atlas was built with (staleness check input).</summary>
public int PageSize { get; init; }
/// <summary>Padding in pixels the atlas was built with (staleness check input).</summary>
public int Padding { get; init; }
/// <summary>Page image files (relative to the metadata file), in page-index order.</summary>
public List<AtlasPage> Pages { get; init; } = [];
/// <summary>Packed regions, sorted by key.</summary>
public List<AtlasRegion> Regions { get; init; } = [];
/// <summary>Serializes this metadata to indented JSON.</summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
/// <summary>Parses metadata from JSON produced by <see cref="ToJson"/>.</summary>
public static AtlasMetadata FromJson(string json) =>
JsonSerializer.Deserialize<AtlasMetadata>(json, JsonOptions)
?? throw new InvalidDataException("Atlas metadata JSON deserialized to null.");
}
/// <summary>One page image of an atlas.</summary>
public sealed class AtlasPage
{
/// <summary>Image file name, relative to the metadata file.</summary>
public string File { get; init; } = "";
/// <summary>Page width in pixels.</summary>
public int Width { get; init; }
/// <summary>Page height in pixels.</summary>
public int Height { get; init; }
}
/// <summary>One packed source texture inside an atlas.</summary>
public sealed class AtlasRegion
{
/// <summary>
/// Region key: the source path relative to the build source root, forward slashes,
/// without the file extension (e.g. <c>Things/Pawn/Animal/Fox</c>).
/// </summary>
public string Key { get; init; } = "";
/// <summary>Index of the page containing this region.</summary>
public int Page { get; init; }
/// <summary>X of the region in page pixels.</summary>
public int X { get; init; }
/// <summary>Y of the region in page pixels.</summary>
public int Y { get; init; }
/// <summary>Region width in pixels.</summary>
[JsonPropertyName("w")]
public int Width { get; init; }
/// <summary>Region height in pixels.</summary>
[JsonPropertyName("h")]
public int Height { get; init; }
}
@@ -0,0 +1,19 @@
using MrGameEng.Assets;
using MrGameEng.Core;
namespace MrGameEng.Atlases;
/// <summary>Wires the atlases module into the engine.</summary>
public static class AtlasesEngineExtensions
{
/// <summary>
/// Registers the <see cref="TextureAtlas"/> loader on the <see cref="AssetManager"/>,
/// enabling <c>assets.Load(GameAssets.…)</c> for generated <c>.atlas</c> handles.
/// Call once at startup after <see cref="AssetsEngineExtensions.UseAssets"/>.
/// </summary>
public static void UseTextureAtlases(this EngineContext context)
{
var assets = context.Services.Get<AssetManager>();
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GraphicsDevice, path));
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Atlases.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="StbImageSharp" />
<PackageReference Include="StbImageWriteSharp" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
<ProjectReference Include="..\MrGameEng.Assets\MrGameEng.Assets.csproj" />
</ItemGroup>
</Project>
+132
View File
@@ -0,0 +1,132 @@
namespace MrGameEng.Atlases;
/// <summary>Input rectangle for the packer: an opaque key plus pixel dimensions.</summary>
/// <param name="Key">Caller-defined identifier carried through to the placement.</param>
/// <param name="Width">Width in pixels.</param>
/// <param name="Height">Height in pixels.</param>
public readonly record struct PackItem(string Key, int Width, int Height);
/// <summary>Where one item ended up: page index plus position in page pixels.</summary>
/// <param name="Key">Key of the packed item.</param>
/// <param name="Page">Index into <see cref="PackResult.PageSizes"/>.</param>
/// <param name="X">X position in page pixels.</param>
/// <param name="Y">Y position in page pixels.</param>
/// <param name="Width">Item width in pixels.</param>
/// <param name="Height">Item height in pixels.</param>
public readonly record struct PackPlacement(string Key, int Page, int X, int Y, int Width, int Height);
/// <summary>Result of a packing run: placements plus the trimmed size of every page.</summary>
/// <param name="Placements">One placement per input item.</param>
/// <param name="PageSizes">Width/height of each page, trimmed to the next power of two covering its content.</param>
public sealed record PackResult(IReadOnlyList<PackPlacement> Placements, IReadOnlyList<(int Width, int Height)> PageSizes);
/// <summary>
/// Deterministic shelf packer: items are sorted by height (then width, then key) and laid out
/// in horizontal shelves; a new page starts when a shelf does not fit. Simple and fast, with
/// good occupancy for sprite sets of similar heights. Items larger than the page size get a
/// dedicated page of their own exact size.
/// </summary>
public static class ShelfPacker
{
/// <summary>
/// Packs <paramref name="items"/> into pages of at most <paramref name="maxPageSize"/>²
/// pixels keeping <paramref name="padding"/> pixels between items and page edges.
/// </summary>
public static PackResult Pack(IReadOnlyList<PackItem> items, int maxPageSize, int padding)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maxPageSize, 1);
ArgumentOutOfRangeException.ThrowIfNegative(padding);
var sorted = items.ToList();
sorted.Sort(static (a, b) =>
{
var byHeight = b.Height.CompareTo(a.Height);
if (byHeight != 0)
{
return byHeight;
}
var byWidth = b.Width.CompareTo(a.Width);
return byWidth != 0 ? byWidth : string.CompareOrdinal(a.Key, b.Key);
});
var placements = new List<PackPlacement>(items.Count);
var pageSizes = new List<(int Width, int Height)>();
// Открытая страница ещё не записана в pageSizes — её индекс всегда pageSizes.Count.
var open = false;
var x = 0;
var y = 0;
var shelfHeight = 0;
var usedWidth = 0;
var usedHeight = 0;
void CloseOpenPage()
{
if (open)
{
pageSizes.Add((NextPowerOfTwo(usedWidth + padding), NextPowerOfTwo(usedHeight + padding)));
open = false;
}
}
void OpenFreshPage()
{
CloseOpenPage();
open = true;
x = padding;
y = padding;
shelfHeight = 0;
usedWidth = 0;
usedHeight = 0;
}
foreach (var item in sorted)
{
// Слишком большой для общей страницы — отдельная страница точно под него.
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
{
CloseOpenPage();
placements.Add(new PackPlacement(item.Key, pageSizes.Count, padding, padding, item.Width, item.Height));
pageSizes.Add((NextPowerOfTwo(item.Width + 2 * padding), NextPowerOfTwo(item.Height + 2 * padding)));
continue;
}
if (!open)
{
OpenFreshPage();
}
else if (x + item.Width + padding > maxPageSize)
{
// Конец полки: следующая полка ниже; если не влезает по высоте — новая страница.
y += shelfHeight + padding;
x = padding;
shelfHeight = 0;
if (y + item.Height + padding > maxPageSize)
{
OpenFreshPage();
}
}
placements.Add(new PackPlacement(item.Key, pageSizes.Count, x, y, item.Width, item.Height));
x += item.Width + padding;
shelfHeight = Math.Max(shelfHeight, item.Height);
usedWidth = Math.Max(usedWidth, x - padding);
usedHeight = Math.Max(usedHeight, y + item.Height);
}
CloseOpenPage();
return new PackResult(placements, pageSizes);
}
internal static int NextPowerOfTwo(int value)
{
var result = 1;
while (result < value)
{
result <<= 1;
}
return result;
}
}
+78
View File
@@ -0,0 +1,78 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Graphics;
namespace MrGameEng.Atlases;
/// <summary>
/// A loaded texture atlas: page textures plus a lookup from region key (source-relative path
/// without extension, e.g. <c>Things/Pawn/Animal/Fox</c>) to <see cref="Texture2DRegion"/>.
/// Sprites taken from one atlas page batch into a single draw call automatically.
/// Owns its page textures and disposes them with the atlas.
/// </summary>
public sealed class TextureAtlas : IDisposable
{
private readonly Dictionary<string, Texture2DRegion> _regions;
/// <summary>Atlas name from the metadata.</summary>
public string Name { get; }
/// <summary>Page textures, in page-index order.</summary>
public IReadOnlyList<Texture2D> Pages { get; }
/// <summary>All regions by key.</summary>
public IReadOnlyDictionary<string, Texture2DRegion> Regions => _regions;
/// <summary>Creates an atlas over already-loaded page textures.</summary>
public TextureAtlas(AtlasMetadata metadata, IReadOnlyList<Texture2D> pages)
{
Name = metadata.Name;
Pages = pages;
_regions = new Dictionary<string, Texture2DRegion>(metadata.Regions.Count, StringComparer.Ordinal);
foreach (var region in metadata.Regions)
{
_regions.Add(
region.Key,
new Texture2DRegion(
pages[region.Page],
new Rectangle(region.X, region.Y, region.Width, region.Height)));
}
}
/// <summary>Returns the region for <paramref name="key"/>; throws when the key is unknown.</summary>
public Texture2DRegion GetRegion(string key) =>
_regions.TryGetValue(key, out var region)
? region
: throw new KeyNotFoundException($"Atlas '{Name}' has no region '{key}'.");
/// <summary>Returns the region for <paramref name="key"/> or false when unknown.</summary>
public bool TryGetRegion(string key, out Texture2DRegion region) =>
_regions.TryGetValue(key, out region!);
/// <summary>
/// Loads an atlas from a <c>.atlas</c> metadata file; page images are loaded from the same
/// directory with premultiplied alpha (matching the engine's texture loader).
/// </summary>
public static TextureAtlas Load(GraphicsDevice graphicsDevice, string metadataPath)
{
var metadata = AtlasMetadata.FromJson(File.ReadAllText(metadataPath));
var directory = Path.GetDirectoryName(Path.GetFullPath(metadataPath))!;
var pages = new Texture2D[metadata.Pages.Count];
for (var i = 0; i < pages.Length; i++)
{
using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
}
return new TextureAtlas(metadata, pages);
}
/// <summary>Disposes every page texture.</summary>
public void Dispose()
{
foreach (var page in Pages)
{
page?.Dispose();
}
}
}