diff --git a/CLAUDE.md b/CLAUDE.md
index 74e2305..56809b2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,11 +19,13 @@ docs/ architecture, conventions, roadmap (Russian)
Engine modules: `Core` (game loop, ECS world, scenes, time), `Graphics` (custom batched
renderer, camera, sprites), `Input`, `Audio`, `Assets` (runtime loading, no content
pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles),
+`Atlases` (texture-atlas builder + runtime loader; CLI wrapper in `tools/MrGameEng.AtlasTool`),
`UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`), `DevConsole`
(in-game console capturing `Core.Log`; `scene.UseDevConsole()` last in OnLoad).
Dependency rule: every module may depend only on `Core`; `Core` depends only on
MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer.
-Documented exception: Myra renders with its own SpriteBatch internally.
+Documented exceptions: Myra renders with its own SpriteBatch internally; `Atlases`
+depends on `Graphics` (Texture2DRegion) and `Assets` (loader registration).
## Commands
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 35caff2..a118171 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,6 +7,8 @@
+
+
diff --git a/MrGameEng.sln b/MrGameEng.sln
index f3f4e1d..2cb56c4 100644
--- a/MrGameEng.sln
+++ b/MrGameEng.sln
@@ -37,6 +37,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.DevConsole", "src
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.DevConsole.Tests", "tests\MrGameEng.DevConsole.Tests\MrGameEng.DevConsole.Tests.csproj", "{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Atlases", "src\MrGameEng.Atlases\MrGameEng.Atlases.csproj", "{B5980FD4-43DF-41B3-97BB-B93D89761FB9}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.AtlasTool", "tools\MrGameEng.AtlasTool\MrGameEng.AtlasTool.csproj", "{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Atlases.Tests", "tests\MrGameEng.Atlases.Tests\MrGameEng.Atlases.Tests.csproj", "{1951D50B-122A-45B5-9356-F186A3CBC974}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -215,6 +223,42 @@ Global
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x64.Build.0 = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x86.ActiveCfg = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x86.Build.0 = Release|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x64.Build.0 = Debug|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x86.Build.0 = Debug|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x64.ActiveCfg = Release|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x64.Build.0 = Release|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x86.ActiveCfg = Release|Any CPU
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x86.Build.0 = Release|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|x64.Build.0 = Debug|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|x86.Build.0 = Debug|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|x64.ActiveCfg = Release|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|x64.Build.0 = Release|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|x86.ActiveCfg = Release|Any CPU
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|x86.Build.0 = Release|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x64.Build.0 = Debug|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x86.Build.0 = Debug|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x64.ActiveCfg = Release|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x64.Build.0 = Release|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x86.ActiveCfg = Release|Any CPU
+ {1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -234,5 +278,8 @@ Global
{17EB97D5-DCF8-47DF-B810-DA45AE314170} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {B5980FD4-43DF-41B3-97BB-B93D89761FB9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {C9782A2A-1D37-4EAF-A628-AB6F399DE9F9} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
+ {1951D50B-122A-45B5-9356-F186A3CBC974} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
diff --git a/docs/architecture.md b/docs/architecture.md
index 3dc1df4..ffa34d3 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -33,6 +33,7 @@
| `MrGameEng.Audio` | Звуковые эффекты и музыка |
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef` |
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов |
+| `MrGameEng.Atlases` | Текстурные атласы: офлайн-сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`); CLI — `tools/MrGameEng.AtlasTool` |
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг |
| `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение |
@@ -50,6 +51,10 @@ MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS
Модули зависят **только от `Core`** и никогда друг от друга. `Core` зависит только
от MonoGame и Friflo. Если двум модулям нужен общий тип — он переезжает в `Core`.
+Документированное исключение: `MrGameEng.Atlases` зависит от `Graphics`
+(выдаёт `Texture2DRegion`) и от `Assets` (регистрирует загрузчик в `AssetManager`) —
+атлас по своей природе склейка этих двух областей.
+
`MrGameEng.Assets.Generator` — особый случай: это анализатор (netstandard2.0),
он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит
от других модулей движка.
@@ -125,6 +130,39 @@ public static partial class GameAssets
Обращение к ресурсу по строковому пути в коде игры — запрещено соглашением;
строки существуют только внутри сгенерированного кода.
+## Текстурные атласы
+
+`MrGameEng.Atlases` превращает дерево отдельных картинок в атласы и грузит их в рантайме.
+Спрайты с регионами одной страницы атласа батчер сливает в один draw call.
+
+### Сборка (билд-тайм, без GPU)
+
+`AtlasBuilder.Build(AtlasBuildOptions)` — чистый CPU (StbImageSharp/StbImageWriteSharp):
+
+- Источник сканируется рекурсивно (png/jpg/jpeg/bmp); картинки группируются в атласы
+ по первым `GroupDepth` папкам относительного пути (0 — один атлас на всё).
+- Упаковка — детерминированный shelf-packer (`ShelfPacker`): сортировка по высоте,
+ полки, страницы до `MaxPageSize`² (по умолчанию 2048), зазор `Padding` (2 px),
+ размер страницы подрезается до степени двойки; негабаритные картинки получают
+ отдельную страницу под себя.
+- Выход: страницы `<Имя>.atlas..png` + метаданные `<Имя>.atlas` (JSON: страницы,
+ регионы с ключами и прямоугольниками). Ключ региона — путь от корня источника без
+ расширения (`Things/Pawn/Animal/Fox`).
+- Инкрементальность: группа пересобирается только если изменились исходники, состав
+ файлов или параметры сборки; атласы исчезнувших групп удаляются из выходной папки.
+
+CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <источник> <выход>
+[--group-depth N] [--page-size N] [--padding N] [--root-name Имя] [--force]`.
+
+### Загрузка (рантайм)
+
+- `context.UseTextureAtlases()` регистрирует загрузчик `TextureAtlas` в `AssetManager`;
+ кодогенератор выдаёт хендлы `AssetRef` для файлов `.atlas`
+ (страницы `*.atlas.N.png` собственных Texture2D-хендлов не получают).
+- `TextureAtlas` владеет страницами (premultiplied alpha, как все текстуры движка)
+ и отдаёт регионы: `atlas.GetRegion("Things/Pawn/Animal/Fox")` → `Texture2DRegion`,
+ готовый для `Sprite`.
+
## Рендеринг
`SpriteBatch` в движке **не используется** — в `MrGameEng.Graphics` свой батчер,
@@ -243,4 +281,6 @@ docs/ документация (русский)
| FontStashSharp.MonoGame | 1.5.6 | Шрифты (ttf) в рантайме |
| NVorbis | 0.10.5 | Декодирование ogg |
| Myra | 1.6.1 | Игровой UI |
+| StbImageSharp | 2.30.15 | Декодирование картинок при сборке атласов |
+| StbImageWriteSharp | 1.16.7 | Запись PNG-страниц атласов |
| dotnet-mgfxc (dotnet tool) | 3.8.4.1 | Компиляция шейдеров при сборке |
diff --git a/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas b/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas
new file mode 100644
index 0000000..0ae5c9b
--- /dev/null
+++ b/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas
@@ -0,0 +1,31 @@
+{
+ "version": 1,
+ "name": "Textures",
+ "pageSize": 2048,
+ "padding": 2,
+ "pages": [
+ {
+ "file": "Textures.atlas.0.png",
+ "width": 256,
+ "height": 128
+ }
+ ],
+ "regions": [
+ {
+ "key": "player",
+ "page": 0,
+ "x": 68,
+ "y": 2,
+ "w": 64,
+ "h": 32
+ },
+ {
+ "key": "shapes",
+ "page": 0,
+ "x": 2,
+ "y": 2,
+ "w": 64,
+ "h": 64
+ }
+ ]
+}
\ No newline at end of file
diff --git a/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas.0.png b/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas.0.png
new file mode 100644
index 0000000..6e8632d
Binary files /dev/null and b/samples/MrGameEng.Sample/Assets/Atlases/Textures.atlas.0.png differ
diff --git a/samples/MrGameEng.Sample/MrGameEng.Sample.csproj b/samples/MrGameEng.Sample/MrGameEng.Sample.csproj
index 35b622c..82fd157 100644
--- a/samples/MrGameEng.Sample/MrGameEng.Sample.csproj
+++ b/samples/MrGameEng.Sample/MrGameEng.Sample.csproj
@@ -11,6 +11,7 @@
+
audio.Play(beep));
+ console.Register("atlas", "list texture atlas regions", (c, _) =>
+ {
+ c.WriteLine($"atlas '{atlas.Name}': {atlas.Pages.Count} page(s), {atlas.Regions.Count} region(s)");
+ foreach (var (key, region) in atlas.Regions.OrderBy(r => r.Key, StringComparer.Ordinal))
+ {
+ c.WriteLine($" {key}: {region.Bounds.Width}x{region.Bounds.Height} at ({region.Bounds.X},{region.Bounds.Y})");
+ }
+ });
UpdateSystems.Add(new PlayerControlSystem(player, actions, audio, beep, console));
UpdateSystems.Add(new BounceSystem(WorldBounds));
diff --git a/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs b/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs
index 6a9f27e..6cfc7ba 100644
--- a/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs
+++ b/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs
@@ -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",
};
///
@@ -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;
}
diff --git a/src/MrGameEng.Atlases/AtlasBuilder.cs b/src/MrGameEng.Atlases/AtlasBuilder.cs
new file mode 100644
index 0000000..89880e2
--- /dev/null
+++ b/src/MrGameEng.Atlases/AtlasBuilder.cs
@@ -0,0 +1,303 @@
+using StbImageSharp;
+using StbImageWriteSharp;
+
+namespace MrGameEng.Atlases;
+
+/// Options for one run.
+public sealed class AtlasBuildOptions
+{
+ /// Directory scanned recursively for source images (png/jpg/jpeg/bmp).
+ public required string SourceDirectory { get; init; }
+
+ /// Directory the .atlas metadata and page images are written to.
+ public required string OutputDirectory { get; init; }
+
+ ///
+ /// 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.
+ ///
+ public int GroupDepth { get; init; } = 1;
+
+ /// Maximum page width/height in pixels.
+ public int MaxPageSize { get; init; } = 2048;
+
+ /// Gap in pixels between packed images and page edges (bleed protection).
+ public int Padding { get; init; } = 2;
+
+ /// Atlas name for images that have fewer directories than .
+ public string RootAtlasName { get; init; } = "Atlas";
+
+ /// Rebuild every atlas even when sources are unchanged.
+ public bool Force { get; init; }
+}
+
+/// Build outcome for one atlas group.
+/// Atlas name (group key with '/' replaced by '.').
+/// Number of packed source images.
+/// Number of page images written.
+/// True when the atlas was up to date and not rebuilt.
+public sealed record AtlasGroupResult(string Name, int RegionCount, int PageCount, bool Skipped);
+
+/// Result of an run.
+/// Per-atlas outcomes, sorted by name.
+/// Output files of atlases whose source group no longer exists.
+public sealed record AtlasBuildResult(IReadOnlyList Groups, IReadOnlyList DeletedOrphans);
+
+///
+/// Build-time utility converting a directory tree of loose images into texture atlases:
+/// page images plus an 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.
+///
+public static class AtlasBuilder
+{
+ private static readonly string[] SourceExtensions = [".png", ".jpg", ".jpeg", ".bmp"];
+
+ /// Builds (or incrementally refreshes) all atlases for .
+ 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();
+ foreach (var (name, files) in groups)
+ {
+ results.Add(BuildGroup(name, files, options));
+ }
+
+ var orphans = DeleteOrphans(options.OutputDirectory, groups.Keys);
+ return new AtlasBuildResult(results, orphans);
+ }
+
+ /// Maps a source-relative image path to its atlas name and region key.
+ 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> ScanGroups(
+ string sourceRoot, AtlasBuildOptions options)
+ {
+ var groups = new SortedDictionary>(StringComparer.Ordinal);
+ var keys = new Dictionary(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(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 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 DeleteOrphans(string outputDirectory, IEnumerable liveAtlasNames)
+ {
+ var live = liveAtlasNames.ToHashSet(StringComparer.Ordinal);
+ var deleted = new List();
+ 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;
+ }
+}
diff --git a/src/MrGameEng.Atlases/AtlasMetadata.cs b/src/MrGameEng.Atlases/AtlasMetadata.cs
new file mode 100644
index 0000000..171eeaa
--- /dev/null
+++ b/src/MrGameEng.Atlases/AtlasMetadata.cs
@@ -0,0 +1,83 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MrGameEng.Atlases;
+
+///
+/// Serializable description of one packed atlas: its page image files and the source-relative
+/// region keys with their pixel rectangles. Stored as a JSON .atlas file next to the pages.
+///
+public sealed class AtlasMetadata
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+
+ /// Format version, bumped on breaking metadata changes.
+ public int Version { get; init; } = 1;
+
+ /// Atlas name (group key with '/' replaced by '.').
+ public string Name { get; init; } = "";
+
+ /// Maximum page size the atlas was built with (staleness check input).
+ public int PageSize { get; init; }
+
+ /// Padding in pixels the atlas was built with (staleness check input).
+ public int Padding { get; init; }
+
+ /// Page image files (relative to the metadata file), in page-index order.
+ public List Pages { get; init; } = [];
+
+ /// Packed regions, sorted by key.
+ public List Regions { get; init; } = [];
+
+ /// Serializes this metadata to indented JSON.
+ public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
+
+ /// Parses metadata from JSON produced by .
+ public static AtlasMetadata FromJson(string json) =>
+ JsonSerializer.Deserialize(json, JsonOptions)
+ ?? throw new InvalidDataException("Atlas metadata JSON deserialized to null.");
+}
+
+/// One page image of an atlas.
+public sealed class AtlasPage
+{
+ /// Image file name, relative to the metadata file.
+ public string File { get; init; } = "";
+
+ /// Page width in pixels.
+ public int Width { get; init; }
+
+ /// Page height in pixels.
+ public int Height { get; init; }
+}
+
+/// One packed source texture inside an atlas.
+public sealed class AtlasRegion
+{
+ ///
+ /// Region key: the source path relative to the build source root, forward slashes,
+ /// without the file extension (e.g. Things/Pawn/Animal/Fox).
+ ///
+ public string Key { get; init; } = "";
+
+ /// Index of the page containing this region.
+ public int Page { get; init; }
+
+ /// X of the region in page pixels.
+ public int X { get; init; }
+
+ /// Y of the region in page pixels.
+ public int Y { get; init; }
+
+ /// Region width in pixels.
+ [JsonPropertyName("w")]
+ public int Width { get; init; }
+
+ /// Region height in pixels.
+ [JsonPropertyName("h")]
+ public int Height { get; init; }
+}
diff --git a/src/MrGameEng.Atlases/AtlasesEngineExtensions.cs b/src/MrGameEng.Atlases/AtlasesEngineExtensions.cs
new file mode 100644
index 0000000..1d3214b
--- /dev/null
+++ b/src/MrGameEng.Atlases/AtlasesEngineExtensions.cs
@@ -0,0 +1,19 @@
+using MrGameEng.Assets;
+using MrGameEng.Core;
+
+namespace MrGameEng.Atlases;
+
+/// Wires the atlases module into the engine.
+public static class AtlasesEngineExtensions
+{
+ ///
+ /// Registers the loader on the ,
+ /// enabling assets.Load(GameAssets.…) for generated .atlas handles.
+ /// Call once at startup after .
+ ///
+ public static void UseTextureAtlases(this EngineContext context)
+ {
+ var assets = context.Services.Get();
+ assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GraphicsDevice, path));
+ }
+}
diff --git a/src/MrGameEng.Atlases/MrGameEng.Atlases.csproj b/src/MrGameEng.Atlases/MrGameEng.Atlases.csproj
new file mode 100644
index 0000000..3a75bc7
--- /dev/null
+++ b/src/MrGameEng.Atlases/MrGameEng.Atlases.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/MrGameEng.Atlases/ShelfPacker.cs b/src/MrGameEng.Atlases/ShelfPacker.cs
new file mode 100644
index 0000000..b926be6
--- /dev/null
+++ b/src/MrGameEng.Atlases/ShelfPacker.cs
@@ -0,0 +1,132 @@
+namespace MrGameEng.Atlases;
+
+/// Input rectangle for the packer: an opaque key plus pixel dimensions.
+/// Caller-defined identifier carried through to the placement.
+/// Width in pixels.
+/// Height in pixels.
+public readonly record struct PackItem(string Key, int Width, int Height);
+
+/// Where one item ended up: page index plus position in page pixels.
+/// Key of the packed item.
+/// Index into .
+/// X position in page pixels.
+/// Y position in page pixels.
+/// Item width in pixels.
+/// Item height in pixels.
+public readonly record struct PackPlacement(string Key, int Page, int X, int Y, int Width, int Height);
+
+/// Result of a packing run: placements plus the trimmed size of every page.
+/// One placement per input item.
+/// Width/height of each page, trimmed to the next power of two covering its content.
+public sealed record PackResult(IReadOnlyList Placements, IReadOnlyList<(int Width, int Height)> PageSizes);
+
+///
+/// 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.
+///
+public static class ShelfPacker
+{
+ ///
+ /// Packs into pages of at most ²
+ /// pixels keeping pixels between items and page edges.
+ ///
+ public static PackResult Pack(IReadOnlyList 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(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;
+ }
+}
diff --git a/src/MrGameEng.Atlases/TextureAtlas.cs b/src/MrGameEng.Atlases/TextureAtlas.cs
new file mode 100644
index 0000000..3748203
--- /dev/null
+++ b/src/MrGameEng.Atlases/TextureAtlas.cs
@@ -0,0 +1,78 @@
+using Microsoft.Xna.Framework;
+using Microsoft.Xna.Framework.Graphics;
+using MrGameEng.Graphics;
+
+namespace MrGameEng.Atlases;
+
+///
+/// A loaded texture atlas: page textures plus a lookup from region key (source-relative path
+/// without extension, e.g. Things/Pawn/Animal/Fox) to .
+/// Sprites taken from one atlas page batch into a single draw call automatically.
+/// Owns its page textures and disposes them with the atlas.
+///
+public sealed class TextureAtlas : IDisposable
+{
+ private readonly Dictionary _regions;
+
+ /// Atlas name from the metadata.
+ public string Name { get; }
+
+ /// Page textures, in page-index order.
+ public IReadOnlyList Pages { get; }
+
+ /// All regions by key.
+ public IReadOnlyDictionary Regions => _regions;
+
+ /// Creates an atlas over already-loaded page textures.
+ public TextureAtlas(AtlasMetadata metadata, IReadOnlyList pages)
+ {
+ Name = metadata.Name;
+ Pages = pages;
+ _regions = new Dictionary(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)));
+ }
+ }
+
+ /// Returns the region for ; throws when the key is unknown.
+ public Texture2DRegion GetRegion(string key) =>
+ _regions.TryGetValue(key, out var region)
+ ? region
+ : throw new KeyNotFoundException($"Atlas '{Name}' has no region '{key}'.");
+
+ /// Returns the region for or false when unknown.
+ public bool TryGetRegion(string key, out Texture2DRegion region) =>
+ _regions.TryGetValue(key, out region!);
+
+ ///
+ /// Loads an atlas from a .atlas metadata file; page images are loaded from the same
+ /// directory with premultiplied alpha (matching the engine's texture loader).
+ ///
+ 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);
+ }
+
+ /// Disposes every page texture.
+ public void Dispose()
+ {
+ foreach (var page in Pages)
+ {
+ page?.Dispose();
+ }
+ }
+}
diff --git a/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs b/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs
index 1227fb0..58cedaa 100644
--- a/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs
+++ b/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs
@@ -86,6 +86,22 @@ public class AssetHandlesGeneratorTests
Assert.DoesNotContain("Image", source);
}
+ [Fact]
+ public void AtlasFiles_GetTextureAtlasHandles_AndPagesAreExcluded()
+ {
+ var source = RunGenerator(
+ [
+ @"D:\game\Assets\Atlases\Things.Pawn.atlas",
+ @"D:\game\Assets\Atlases\Things.Pawn.atlas.0.png",
+ @"D:\game\Assets\Atlases\Things.Pawn.atlas.1.png",
+ ]);
+
+ Assert.Contains(
+ "AssetRef ThingsPawn = new(\"Atlases/Things.Pawn.atlas\")",
+ source);
+ Assert.DoesNotContain("Texture2D> ThingsPawn", source);
+ }
+
[Fact]
public void NestedDirectories_BecomeNestedClasses()
{
diff --git a/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs b/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs
new file mode 100644
index 0000000..3582864
--- /dev/null
+++ b/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs
@@ -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);
+
+ /// Writes a PNG filled with one RGBA color.
+ 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(() => atlas.GetRegion("missing"));
+ }
+}
diff --git a/tests/MrGameEng.Atlases.Tests/MrGameEng.Atlases.Tests.csproj b/tests/MrGameEng.Atlases.Tests/MrGameEng.Atlases.Tests.csproj
new file mode 100644
index 0000000..7592188
--- /dev/null
+++ b/tests/MrGameEng.Atlases.Tests/MrGameEng.Atlases.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ Exe
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/MrGameEng.Atlases.Tests/ShelfPackerTests.cs b/tests/MrGameEng.Atlases.Tests/ShelfPackerTests.cs
new file mode 100644
index 0000000..fb09533
--- /dev/null
+++ b/tests/MrGameEng.Atlases.Tests/ShelfPackerTests.cs
@@ -0,0 +1,111 @@
+using MrGameEng.Atlases;
+using Xunit;
+
+namespace MrGameEng.Atlases.Tests;
+
+public class ShelfPackerTests
+{
+ private static List 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();
+ 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));
+ }
+}
diff --git a/tools/MrGameEng.AtlasTool/MrGameEng.AtlasTool.csproj b/tools/MrGameEng.AtlasTool/MrGameEng.AtlasTool.csproj
new file mode 100644
index 0000000..956a704
--- /dev/null
+++ b/tools/MrGameEng.AtlasTool/MrGameEng.AtlasTool.csproj
@@ -0,0 +1,13 @@
+
+
+
+ Exe
+ net8.0
+ false
+
+
+
+
+
+
+
diff --git a/tools/MrGameEng.AtlasTool/Program.cs b/tools/MrGameEng.AtlasTool/Program.cs
new file mode 100644
index 0000000..2eb88b3
--- /dev/null
+++ b/tools/MrGameEng.AtlasTool/Program.cs
@@ -0,0 +1,58 @@
+using System.Diagnostics;
+using MrGameEng.Atlases;
+
+if (args.Length < 2 || args.Contains("--help") || args.Contains("-h"))
+{
+ Console.WriteLine(
+ """
+ MrGameEng.AtlasTool — packs a directory tree of images into texture atlases.
+
+ Usage: MrGameEng.AtlasTool [options]
+
+ Options:
+ --group-depth directories forming one atlas (0 = single atlas; default 1)
+ --page-size maximum page size in pixels (default 2048)
+ --padding gap between images in pixels (default 2)
+ --root-name atlas name for files above group depth (default "Atlas")
+ --force rebuild even when sources are unchanged
+ """);
+ return args.Length < 2 && !args.Contains("--help") && !args.Contains("-h") ? 1 : 0;
+}
+
+int Option(string name, int fallback)
+{
+ var index = Array.IndexOf(args, name);
+ return index >= 0 && index + 1 < args.Length ? int.Parse(args[index + 1]) : fallback;
+}
+
+var rootNameIndex = Array.IndexOf(args, "--root-name");
+var options = new AtlasBuildOptions
+{
+ SourceDirectory = args[0],
+ OutputDirectory = args[1],
+ GroupDepth = Option("--group-depth", 1),
+ MaxPageSize = Option("--page-size", 2048),
+ Padding = Option("--padding", 2),
+ RootAtlasName = rootNameIndex >= 0 && rootNameIndex + 1 < args.Length ? args[rootNameIndex + 1] : "Atlas",
+ Force = args.Contains("--force"),
+};
+
+var stopwatch = Stopwatch.StartNew();
+var result = AtlasBuilder.Build(options);
+stopwatch.Stop();
+
+foreach (var group in result.Groups)
+{
+ Console.WriteLine(group.Skipped
+ ? $" {group.Name}: up to date ({group.RegionCount} regions, {group.PageCount} pages)"
+ : $" {group.Name}: {group.RegionCount} regions -> {group.PageCount} pages");
+}
+
+foreach (var orphan in result.DeletedOrphans)
+{
+ Console.WriteLine($" deleted orphan {orphan}");
+}
+
+var built = result.Groups.Count(g => !g.Skipped);
+Console.WriteLine($"Done: {built} atlases built, {result.Groups.Count - built} up to date, {stopwatch.Elapsed.TotalSeconds:F1}s.");
+return 0;