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;