Add MrGameEng.Atlases: texture atlas builder, runtime loader and CLI tool
CI / build-test (push) Failing after 1m13s
CI / build-test (push) Failing after 1m13s
AtlasBuilder packs a directory tree of loose images into atlas pages plus JSON metadata (deterministic shelf packing, incremental rebuilds, orphan cleanup); TextureAtlas loads them back handing out Texture2DRegions, so sprites from one page batch into a single draw call. The asset handle generator maps .atlas files to TextureAtlas and skips page images. Demonstrated in the sample (Assets/Atlases + 'atlas' console command), wrapped as tools/MrGameEng.AtlasTool for build scripts. Documented dependency exception: Atlases depends on Graphics and Assets.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<PackageVersion Include="FontStashSharp.MonoGame" Version="1.5.6" />
|
||||
<PackageVersion Include="NVorbis" Version="0.10.5" />
|
||||
<PackageVersion Include="Myra" Version="1.6.1" />
|
||||
<PackageVersion Include="StbImageSharp" Version="2.30.15" />
|
||||
<PackageVersion Include="StbImageWriteSharp" Version="1.16.7" />
|
||||
|
||||
<!-- Source generator -->
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
| `MrGameEng.Audio` | Звуковые эффекты и музыка |
|
||||
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` |
|
||||
| `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.<N>.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<TextureAtlas>` для файлов `.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 | Компиляция шейдеров при сборке |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -11,6 +11,7 @@
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Input\MrGameEng.Input.csproj" />
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Audio\MrGameEng.Audio.csproj" />
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Assets\MrGameEng.Assets.csproj" />
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Atlases\MrGameEng.Atlases.csproj" />
|
||||
<ProjectReference Include="..\..\src\MrGameEng.UI\MrGameEng.UI.csproj" />
|
||||
<ProjectReference Include="..\..\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" />
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Friflo.Engine.ECS;
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.Assets;
|
||||
using MrGameEng.Atlases;
|
||||
using MrGameEng.Audio;
|
||||
using MrGameEng.Core;
|
||||
using MrGameEng.DevConsole;
|
||||
@@ -36,6 +37,11 @@ public sealed class MainScene : Scene
|
||||
var shapesTexture = assets.Load(GameAssets.Textures.Shapes);
|
||||
var beep = assets.Load(GameAssets.Sounds.Beep);
|
||||
|
||||
// Текстурный атлас: Assets/Atlases собран утилитой MrGameEng.AtlasTool из Assets/Textures
|
||||
// (см. README). Регионы адресуются исходным путём без расширения и батчатся в один draw call.
|
||||
Context.UseTextureAtlases();
|
||||
var atlas = assets.Load(GameAssets.Atlases.Textures);
|
||||
|
||||
var shapeRegions = new[]
|
||||
{
|
||||
new Texture2DRegion(shapesTexture, new Rectangle(0, 0, 32, 32)),
|
||||
@@ -87,6 +93,15 @@ public sealed class MainScene : Scene
|
||||
playerSprite,
|
||||
new SpriteAnimator(blink));
|
||||
|
||||
// Пара спрайтов из атласа рядом со стартом игрока — вся пара рисуется одним draw call.
|
||||
var atlasShowcase = new[] { ("player", -80f), ("shapes", 80f) };
|
||||
foreach (var (key, offsetX) in atlasShowcase)
|
||||
{
|
||||
var sprite = new Sprite(atlas.GetRegion(key), SampleLayers.Actors);
|
||||
sprite.CenterOrigin();
|
||||
Store.CreateEntity(new Transform2D(new Vector2(offsetX, -120f)), sprite);
|
||||
}
|
||||
|
||||
var camera = Store.CreateEntity(new Camera(Vector2.Zero, zoom: 1f, bounds: WorldBounds));
|
||||
|
||||
// HUD: золотой квадрат в углу на screen-space слое — не двигается с камерой.
|
||||
@@ -131,6 +146,14 @@ public sealed class MainScene : Scene
|
||||
}
|
||||
});
|
||||
console.Register("beep", "play the beep sound", (_, _) => 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));
|
||||
|
||||
@@ -25,6 +25,7 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
|
||||
[".wav"] = "global::Microsoft.Xna.Framework.Audio.SoundEffect",
|
||||
[".ogg"] = "global::MrGameEng.Core.MusicTrack",
|
||||
[".mgfx"] = "global::Microsoft.Xna.Framework.Graphics.Effect",
|
||||
[".atlas"] = "global::MrGameEng.Atlases.TextureAtlas",
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -62,6 +63,14 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
|
||||
}
|
||||
|
||||
var relative = normalized.Substring(marker + "/Assets/".Length);
|
||||
|
||||
// Страницы атласов (Name.atlas.0.png) — внутренние файлы метаданных .atlas,
|
||||
// им собственные Texture2D-хендлы не нужны.
|
||||
if (Path.GetFileName(relative).Contains(".atlas.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(relative);
|
||||
return TypeByExtension.ContainsKey(extension) ? relative : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
using StbImageSharp;
|
||||
using StbImageWriteSharp;
|
||||
|
||||
namespace MrGameEng.Atlases;
|
||||
|
||||
/// <summary>Options for one <see cref="AtlasBuilder.Build"/> run.</summary>
|
||||
public sealed class AtlasBuildOptions
|
||||
{
|
||||
/// <summary>Directory scanned recursively for source images (png/jpg/jpeg/bmp).</summary>
|
||||
public required string SourceDirectory { get; init; }
|
||||
|
||||
/// <summary>Directory the <c>.atlas</c> metadata and page images are written to.</summary>
|
||||
public required string OutputDirectory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How many leading directories of a source-relative path form the atlas group:
|
||||
/// 0 packs everything into one atlas, 1 packs per top-level folder, and so on.
|
||||
/// </summary>
|
||||
public int GroupDepth { get; init; } = 1;
|
||||
|
||||
/// <summary>Maximum page width/height in pixels.</summary>
|
||||
public int MaxPageSize { get; init; } = 2048;
|
||||
|
||||
/// <summary>Gap in pixels between packed images and page edges (bleed protection).</summary>
|
||||
public int Padding { get; init; } = 2;
|
||||
|
||||
/// <summary>Atlas name for images that have fewer directories than <see cref="GroupDepth"/>.</summary>
|
||||
public string RootAtlasName { get; init; } = "Atlas";
|
||||
|
||||
/// <summary>Rebuild every atlas even when sources are unchanged.</summary>
|
||||
public bool Force { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Build outcome for one atlas group.</summary>
|
||||
/// <param name="Name">Atlas name (group key with '/' replaced by '.').</param>
|
||||
/// <param name="RegionCount">Number of packed source images.</param>
|
||||
/// <param name="PageCount">Number of page images written.</param>
|
||||
/// <param name="Skipped">True when the atlas was up to date and not rebuilt.</param>
|
||||
public sealed record AtlasGroupResult(string Name, int RegionCount, int PageCount, bool Skipped);
|
||||
|
||||
/// <summary>Result of an <see cref="AtlasBuilder.Build"/> run.</summary>
|
||||
/// <param name="Groups">Per-atlas outcomes, sorted by name.</param>
|
||||
/// <param name="DeletedOrphans">Output files of atlases whose source group no longer exists.</param>
|
||||
public sealed record AtlasBuildResult(IReadOnlyList<AtlasGroupResult> Groups, IReadOnlyList<string> DeletedOrphans);
|
||||
|
||||
/// <summary>
|
||||
/// Build-time utility converting a directory tree of loose images into texture atlases:
|
||||
/// page images plus an <see cref="AtlasMetadata"/> JSON per group. Pure CPU (StbImageSharp),
|
||||
/// no graphics device — intended for tools and build scripts, not for the render loop.
|
||||
/// Region keys are source-relative paths without extension, so game code addresses sprites
|
||||
/// by the same path it would have used for the loose file.
|
||||
/// </summary>
|
||||
public static class AtlasBuilder
|
||||
{
|
||||
private static readonly string[] SourceExtensions = [".png", ".jpg", ".jpeg", ".bmp"];
|
||||
|
||||
/// <summary>Builds (or incrementally refreshes) all atlases for <paramref name="options"/>.</summary>
|
||||
public static AtlasBuildResult Build(AtlasBuildOptions options)
|
||||
{
|
||||
var sourceRoot = Path.GetFullPath(options.SourceDirectory);
|
||||
if (!Directory.Exists(sourceRoot))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Atlas source directory not found: '{sourceRoot}'.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(options.OutputDirectory);
|
||||
|
||||
var groups = ScanGroups(sourceRoot, options);
|
||||
var results = new List<AtlasGroupResult>();
|
||||
foreach (var (name, files) in groups)
|
||||
{
|
||||
results.Add(BuildGroup(name, files, options));
|
||||
}
|
||||
|
||||
var orphans = DeleteOrphans(options.OutputDirectory, groups.Keys);
|
||||
return new AtlasBuildResult(results, orphans);
|
||||
}
|
||||
|
||||
/// <summary>Maps a source-relative image path to its atlas name and region key.</summary>
|
||||
internal static (string AtlasName, string Key) ClassifyPath(string relativePath, int groupDepth, string rootAtlasName)
|
||||
{
|
||||
var normalized = relativePath.Replace('\\', '/');
|
||||
var key = normalized[..normalized.LastIndexOf('.')];
|
||||
var segments = normalized.Split('/');
|
||||
var depth = Math.Min(groupDepth, segments.Length - 1);
|
||||
var name = depth == 0 ? rootAtlasName : string.Join('.', segments[..depth]);
|
||||
return (name, key);
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, List<(string FullPath, string Key)>> ScanGroups(
|
||||
string sourceRoot, AtlasBuildOptions options)
|
||||
{
|
||||
var groups = new SortedDictionary<string, List<(string, string)>>(StringComparer.Ordinal);
|
||||
var keys = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var fullPath in Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (!SourceExtensions.Contains(Path.GetExtension(fullPath), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relative = Path.GetRelativePath(sourceRoot, fullPath);
|
||||
var (atlasName, key) = ClassifyPath(relative, options.GroupDepth, options.RootAtlasName);
|
||||
if (keys.TryGetValue(key, out var existing))
|
||||
{
|
||||
throw new InvalidDataException($"Duplicate region key '{key}': '{existing}' and '{relative}'.");
|
||||
}
|
||||
|
||||
keys.Add(key, relative);
|
||||
if (!groups.TryGetValue(atlasName, out var list))
|
||||
{
|
||||
list = [];
|
||||
groups.Add(atlasName, list);
|
||||
}
|
||||
|
||||
list.Add((fullPath, key));
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static AtlasGroupResult BuildGroup(
|
||||
string name, List<(string FullPath, string Key)> files, AtlasBuildOptions options)
|
||||
{
|
||||
var metadataPath = Path.Combine(options.OutputDirectory, name + ".atlas");
|
||||
if (!options.Force && IsUpToDate(metadataPath, files, options, out var existingPages))
|
||||
{
|
||||
return new AtlasGroupResult(name, files.Count, existingPages, Skipped: true);
|
||||
}
|
||||
|
||||
// Декодирование — самая дорогая фаза, параллелим (билд-тайм, аллокации допустимы).
|
||||
var images = new ImageResult[files.Count];
|
||||
Parallel.For(0, files.Count, i =>
|
||||
{
|
||||
using var stream = File.OpenRead(files[i].FullPath);
|
||||
images[i] = ImageResult.FromStream(stream, StbImageSharp.ColorComponents.RedGreenBlueAlpha);
|
||||
});
|
||||
|
||||
var items = new PackItem[files.Count];
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
{
|
||||
items[i] = new PackItem(files[i].Key, images[i].Width, images[i].Height);
|
||||
}
|
||||
|
||||
var packed = ShelfPacker.Pack(items, options.MaxPageSize, options.Padding);
|
||||
|
||||
var pixelsByKey = new Dictionary<string, ImageResult>(files.Count, StringComparer.Ordinal);
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
{
|
||||
pixelsByKey.Add(files[i].Key, images[i]);
|
||||
}
|
||||
|
||||
WritePages(name, packed, pixelsByKey, options.OutputDirectory);
|
||||
WriteMetadata(name, packed, options, metadataPath);
|
||||
DeleteExtraPages(name, packed.PageSizes.Count, options.OutputDirectory);
|
||||
|
||||
return new AtlasGroupResult(name, files.Count, packed.PageSizes.Count, Skipped: false);
|
||||
}
|
||||
|
||||
private static bool IsUpToDate(
|
||||
string metadataPath, List<(string FullPath, string Key)> files, AtlasBuildOptions options, out int pages)
|
||||
{
|
||||
pages = 0;
|
||||
if (!File.Exists(metadataPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AtlasMetadata metadata;
|
||||
try
|
||||
{
|
||||
metadata = AtlasMetadata.FromJson(File.ReadAllText(metadataPath));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var outputDirectory = Path.GetDirectoryName(metadataPath)!;
|
||||
if (metadata.Pages.Any(page => !File.Exists(Path.Combine(outputDirectory, page.File))))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!metadata.Regions.Select(r => r.Key).Order(StringComparer.Ordinal)
|
||||
.SequenceEqual(files.Select(f => f.Key).Order(StringComparer.Ordinal)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var builtAt = File.GetLastWriteTimeUtc(metadataPath);
|
||||
if (files.Any(f => File.GetLastWriteTimeUtc(f.FullPath) > builtAt))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
pages = metadata.Pages.Count;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WritePages(
|
||||
string name, PackResult packed, Dictionary<string, ImageResult> pixelsByKey, string outputDirectory)
|
||||
{
|
||||
Parallel.For(0, packed.PageSizes.Count, page =>
|
||||
{
|
||||
var (width, height) = packed.PageSizes[page];
|
||||
var buffer = new byte[width * height * 4];
|
||||
foreach (var placement in packed.Placements)
|
||||
{
|
||||
if (placement.Page != page)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = pixelsByKey[placement.Key];
|
||||
for (var row = 0; row < source.Height; row++)
|
||||
{
|
||||
Array.Copy(
|
||||
source.Data, row * source.Width * 4,
|
||||
buffer, ((placement.Y + row) * width + placement.X) * 4,
|
||||
source.Width * 4);
|
||||
}
|
||||
}
|
||||
|
||||
using var stream = File.Create(Path.Combine(outputDirectory, PageFileName(name, page)));
|
||||
new ImageWriter().WritePng(
|
||||
buffer, width, height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream);
|
||||
});
|
||||
}
|
||||
|
||||
private static void WriteMetadata(string name, PackResult packed, AtlasBuildOptions options, string metadataPath)
|
||||
{
|
||||
var metadata = new AtlasMetadata
|
||||
{
|
||||
Name = name,
|
||||
PageSize = options.MaxPageSize,
|
||||
Padding = options.Padding,
|
||||
Pages = packed.PageSizes
|
||||
.Select((size, index) => new AtlasPage
|
||||
{
|
||||
File = PageFileName(name, index),
|
||||
Width = size.Width,
|
||||
Height = size.Height,
|
||||
})
|
||||
.ToList(),
|
||||
Regions = packed.Placements
|
||||
.OrderBy(p => p.Key, StringComparer.Ordinal)
|
||||
.Select(p => new AtlasRegion
|
||||
{
|
||||
Key = p.Key,
|
||||
Page = p.Page,
|
||||
X = p.X,
|
||||
Y = p.Y,
|
||||
Width = p.Width,
|
||||
Height = p.Height,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
File.WriteAllText(metadataPath, metadata.ToJson());
|
||||
}
|
||||
|
||||
private static string PageFileName(string atlasName, int page) => $"{atlasName}.atlas.{page}.png";
|
||||
|
||||
private static void DeleteExtraPages(string name, int pageCount, string outputDirectory)
|
||||
{
|
||||
for (var page = pageCount; ; page++)
|
||||
{
|
||||
var path = Path.Combine(outputDirectory, PageFileName(name, page));
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> DeleteOrphans(string outputDirectory, IEnumerable<string> liveAtlasNames)
|
||||
{
|
||||
var live = liveAtlasNames.ToHashSet(StringComparer.Ordinal);
|
||||
var deleted = new List<string>();
|
||||
foreach (var metadataPath in Directory.EnumerateFiles(outputDirectory, "*.atlas"))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(metadataPath);
|
||||
if (live.Contains(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
File.Delete(metadataPath);
|
||||
deleted.Add(metadataPath);
|
||||
DeleteExtraPages(name, 0, outputDirectory);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MrGameEng.Atlases;
|
||||
|
||||
/// <summary>
|
||||
/// Serializable description of one packed atlas: its page image files and the source-relative
|
||||
/// region keys with their pixel rectangles. Stored as a JSON <c>.atlas</c> file next to the pages.
|
||||
/// </summary>
|
||||
public sealed class AtlasMetadata
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
/// <summary>Format version, bumped on breaking metadata changes.</summary>
|
||||
public int Version { get; init; } = 1;
|
||||
|
||||
/// <summary>Atlas name (group key with '/' replaced by '.').</summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Maximum page size the atlas was built with (staleness check input).</summary>
|
||||
public int PageSize { get; init; }
|
||||
|
||||
/// <summary>Padding in pixels the atlas was built with (staleness check input).</summary>
|
||||
public int Padding { get; init; }
|
||||
|
||||
/// <summary>Page image files (relative to the metadata file), in page-index order.</summary>
|
||||
public List<AtlasPage> Pages { get; init; } = [];
|
||||
|
||||
/// <summary>Packed regions, sorted by key.</summary>
|
||||
public List<AtlasRegion> Regions { get; init; } = [];
|
||||
|
||||
/// <summary>Serializes this metadata to indented JSON.</summary>
|
||||
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||
|
||||
/// <summary>Parses metadata from JSON produced by <see cref="ToJson"/>.</summary>
|
||||
public static AtlasMetadata FromJson(string json) =>
|
||||
JsonSerializer.Deserialize<AtlasMetadata>(json, JsonOptions)
|
||||
?? throw new InvalidDataException("Atlas metadata JSON deserialized to null.");
|
||||
}
|
||||
|
||||
/// <summary>One page image of an atlas.</summary>
|
||||
public sealed class AtlasPage
|
||||
{
|
||||
/// <summary>Image file name, relative to the metadata file.</summary>
|
||||
public string File { get; init; } = "";
|
||||
|
||||
/// <summary>Page width in pixels.</summary>
|
||||
public int Width { get; init; }
|
||||
|
||||
/// <summary>Page height in pixels.</summary>
|
||||
public int Height { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One packed source texture inside an atlas.</summary>
|
||||
public sealed class AtlasRegion
|
||||
{
|
||||
/// <summary>
|
||||
/// Region key: the source path relative to the build source root, forward slashes,
|
||||
/// without the file extension (e.g. <c>Things/Pawn/Animal/Fox</c>).
|
||||
/// </summary>
|
||||
public string Key { get; init; } = "";
|
||||
|
||||
/// <summary>Index of the page containing this region.</summary>
|
||||
public int Page { get; init; }
|
||||
|
||||
/// <summary>X of the region in page pixels.</summary>
|
||||
public int X { get; init; }
|
||||
|
||||
/// <summary>Y of the region in page pixels.</summary>
|
||||
public int Y { get; init; }
|
||||
|
||||
/// <summary>Region width in pixels.</summary>
|
||||
[JsonPropertyName("w")]
|
||||
public int Width { get; init; }
|
||||
|
||||
/// <summary>Region height in pixels.</summary>
|
||||
[JsonPropertyName("h")]
|
||||
public int Height { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MrGameEng.Assets;
|
||||
using MrGameEng.Core;
|
||||
|
||||
namespace MrGameEng.Atlases;
|
||||
|
||||
/// <summary>Wires the atlases module into the engine.</summary>
|
||||
public static class AtlasesEngineExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the <see cref="TextureAtlas"/> loader on the <see cref="AssetManager"/>,
|
||||
/// enabling <c>assets.Load(GameAssets.…)</c> for generated <c>.atlas</c> handles.
|
||||
/// Call once at startup after <see cref="AssetsEngineExtensions.UseAssets"/>.
|
||||
/// </summary>
|
||||
public static void UseTextureAtlases(this EngineContext context)
|
||||
{
|
||||
var assets = context.Services.Get<AssetManager>();
|
||||
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GraphicsDevice, path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MrGameEng.Atlases.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="StbImageSharp" />
|
||||
<PackageReference Include="StbImageWriteSharp" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
|
||||
<ProjectReference Include="..\MrGameEng.Assets\MrGameEng.Assets.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,132 @@
|
||||
namespace MrGameEng.Atlases;
|
||||
|
||||
/// <summary>Input rectangle for the packer: an opaque key plus pixel dimensions.</summary>
|
||||
/// <param name="Key">Caller-defined identifier carried through to the placement.</param>
|
||||
/// <param name="Width">Width in pixels.</param>
|
||||
/// <param name="Height">Height in pixels.</param>
|
||||
public readonly record struct PackItem(string Key, int Width, int Height);
|
||||
|
||||
/// <summary>Where one item ended up: page index plus position in page pixels.</summary>
|
||||
/// <param name="Key">Key of the packed item.</param>
|
||||
/// <param name="Page">Index into <see cref="PackResult.PageSizes"/>.</param>
|
||||
/// <param name="X">X position in page pixels.</param>
|
||||
/// <param name="Y">Y position in page pixels.</param>
|
||||
/// <param name="Width">Item width in pixels.</param>
|
||||
/// <param name="Height">Item height in pixels.</param>
|
||||
public readonly record struct PackPlacement(string Key, int Page, int X, int Y, int Width, int Height);
|
||||
|
||||
/// <summary>Result of a packing run: placements plus the trimmed size of every page.</summary>
|
||||
/// <param name="Placements">One placement per input item.</param>
|
||||
/// <param name="PageSizes">Width/height of each page, trimmed to the next power of two covering its content.</param>
|
||||
public sealed record PackResult(IReadOnlyList<PackPlacement> Placements, IReadOnlyList<(int Width, int Height)> PageSizes);
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic shelf packer: items are sorted by height (then width, then key) and laid out
|
||||
/// in horizontal shelves; a new page starts when a shelf does not fit. Simple and fast, with
|
||||
/// good occupancy for sprite sets of similar heights. Items larger than the page size get a
|
||||
/// dedicated page of their own exact size.
|
||||
/// </summary>
|
||||
public static class ShelfPacker
|
||||
{
|
||||
/// <summary>
|
||||
/// Packs <paramref name="items"/> into pages of at most <paramref name="maxPageSize"/>²
|
||||
/// pixels keeping <paramref name="padding"/> pixels between items and page edges.
|
||||
/// </summary>
|
||||
public static PackResult Pack(IReadOnlyList<PackItem> items, int maxPageSize, int padding)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maxPageSize, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(padding);
|
||||
|
||||
var sorted = items.ToList();
|
||||
sorted.Sort(static (a, b) =>
|
||||
{
|
||||
var byHeight = b.Height.CompareTo(a.Height);
|
||||
if (byHeight != 0)
|
||||
{
|
||||
return byHeight;
|
||||
}
|
||||
|
||||
var byWidth = b.Width.CompareTo(a.Width);
|
||||
return byWidth != 0 ? byWidth : string.CompareOrdinal(a.Key, b.Key);
|
||||
});
|
||||
|
||||
var placements = new List<PackPlacement>(items.Count);
|
||||
var pageSizes = new List<(int Width, int Height)>();
|
||||
|
||||
// Открытая страница ещё не записана в pageSizes — её индекс всегда pageSizes.Count.
|
||||
var open = false;
|
||||
var x = 0;
|
||||
var y = 0;
|
||||
var shelfHeight = 0;
|
||||
var usedWidth = 0;
|
||||
var usedHeight = 0;
|
||||
|
||||
void CloseOpenPage()
|
||||
{
|
||||
if (open)
|
||||
{
|
||||
pageSizes.Add((NextPowerOfTwo(usedWidth + padding), NextPowerOfTwo(usedHeight + padding)));
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
void OpenFreshPage()
|
||||
{
|
||||
CloseOpenPage();
|
||||
open = true;
|
||||
x = padding;
|
||||
y = padding;
|
||||
shelfHeight = 0;
|
||||
usedWidth = 0;
|
||||
usedHeight = 0;
|
||||
}
|
||||
|
||||
foreach (var item in sorted)
|
||||
{
|
||||
// Слишком большой для общей страницы — отдельная страница точно под него.
|
||||
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
|
||||
{
|
||||
CloseOpenPage();
|
||||
placements.Add(new PackPlacement(item.Key, pageSizes.Count, padding, padding, item.Width, item.Height));
|
||||
pageSizes.Add((NextPowerOfTwo(item.Width + 2 * padding), NextPowerOfTwo(item.Height + 2 * padding)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!open)
|
||||
{
|
||||
OpenFreshPage();
|
||||
}
|
||||
else if (x + item.Width + padding > maxPageSize)
|
||||
{
|
||||
// Конец полки: следующая полка ниже; если не влезает по высоте — новая страница.
|
||||
y += shelfHeight + padding;
|
||||
x = padding;
|
||||
shelfHeight = 0;
|
||||
if (y + item.Height + padding > maxPageSize)
|
||||
{
|
||||
OpenFreshPage();
|
||||
}
|
||||
}
|
||||
|
||||
placements.Add(new PackPlacement(item.Key, pageSizes.Count, x, y, item.Width, item.Height));
|
||||
x += item.Width + padding;
|
||||
shelfHeight = Math.Max(shelfHeight, item.Height);
|
||||
usedWidth = Math.Max(usedWidth, x - padding);
|
||||
usedHeight = Math.Max(usedHeight, y + item.Height);
|
||||
}
|
||||
|
||||
CloseOpenPage();
|
||||
return new PackResult(placements, pageSizes);
|
||||
}
|
||||
|
||||
internal static int NextPowerOfTwo(int value)
|
||||
{
|
||||
var result = 1;
|
||||
while (result < value)
|
||||
{
|
||||
result <<= 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using MrGameEng.Graphics;
|
||||
|
||||
namespace MrGameEng.Atlases;
|
||||
|
||||
/// <summary>
|
||||
/// A loaded texture atlas: page textures plus a lookup from region key (source-relative path
|
||||
/// without extension, e.g. <c>Things/Pawn/Animal/Fox</c>) to <see cref="Texture2DRegion"/>.
|
||||
/// Sprites taken from one atlas page batch into a single draw call automatically.
|
||||
/// Owns its page textures and disposes them with the atlas.
|
||||
/// </summary>
|
||||
public sealed class TextureAtlas : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, Texture2DRegion> _regions;
|
||||
|
||||
/// <summary>Atlas name from the metadata.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>Page textures, in page-index order.</summary>
|
||||
public IReadOnlyList<Texture2D> Pages { get; }
|
||||
|
||||
/// <summary>All regions by key.</summary>
|
||||
public IReadOnlyDictionary<string, Texture2DRegion> Regions => _regions;
|
||||
|
||||
/// <summary>Creates an atlas over already-loaded page textures.</summary>
|
||||
public TextureAtlas(AtlasMetadata metadata, IReadOnlyList<Texture2D> pages)
|
||||
{
|
||||
Name = metadata.Name;
|
||||
Pages = pages;
|
||||
_regions = new Dictionary<string, Texture2DRegion>(metadata.Regions.Count, StringComparer.Ordinal);
|
||||
foreach (var region in metadata.Regions)
|
||||
{
|
||||
_regions.Add(
|
||||
region.Key,
|
||||
new Texture2DRegion(
|
||||
pages[region.Page],
|
||||
new Rectangle(region.X, region.Y, region.Width, region.Height)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the region for <paramref name="key"/>; throws when the key is unknown.</summary>
|
||||
public Texture2DRegion GetRegion(string key) =>
|
||||
_regions.TryGetValue(key, out var region)
|
||||
? region
|
||||
: throw new KeyNotFoundException($"Atlas '{Name}' has no region '{key}'.");
|
||||
|
||||
/// <summary>Returns the region for <paramref name="key"/> or false when unknown.</summary>
|
||||
public bool TryGetRegion(string key, out Texture2DRegion region) =>
|
||||
_regions.TryGetValue(key, out region!);
|
||||
|
||||
/// <summary>
|
||||
/// Loads an atlas from a <c>.atlas</c> metadata file; page images are loaded from the same
|
||||
/// directory with premultiplied alpha (matching the engine's texture loader).
|
||||
/// </summary>
|
||||
public static TextureAtlas Load(GraphicsDevice graphicsDevice, string metadataPath)
|
||||
{
|
||||
var metadata = AtlasMetadata.FromJson(File.ReadAllText(metadataPath));
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(metadataPath))!;
|
||||
var pages = new Texture2D[metadata.Pages.Count];
|
||||
for (var i = 0; i < pages.Length; i++)
|
||||
{
|
||||
using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
|
||||
pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
|
||||
}
|
||||
|
||||
return new TextureAtlas(metadata, pages);
|
||||
}
|
||||
|
||||
/// <summary>Disposes every page texture.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var page in Pages)
|
||||
{
|
||||
page?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<global::MrGameEng.Atlases.TextureAtlas> ThingsPawn = new(\"Atlases/Things.Pawn.atlas\")",
|
||||
source);
|
||||
Assert.DoesNotContain("Texture2D> ThingsPawn", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedDirectories_BecomeNestedClasses()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
using StbImageSharp;
|
||||
using StbImageWriteSharp;
|
||||
using Xunit;
|
||||
|
||||
namespace MrGameEng.Atlases.Tests;
|
||||
|
||||
public sealed class AtlasBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Directory.CreateTempSubdirectory("mrge-atlas-tests-").FullName;
|
||||
|
||||
private string SourceDir => Path.Combine(_root, "Textures");
|
||||
private string OutputDir => Path.Combine(_root, "Atlases");
|
||||
|
||||
public void Dispose() => Directory.Delete(_root, recursive: true);
|
||||
|
||||
/// <summary>Writes a PNG filled with one RGBA color.</summary>
|
||||
private void WritePng(string relativePath, int width, int height, byte r, byte g, byte b, byte a = 255)
|
||||
{
|
||||
var fullPath = Path.Combine(SourceDir, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
var data = new byte[width * height * 4];
|
||||
for (var i = 0; i < data.Length; i += 4)
|
||||
{
|
||||
data[i] = r;
|
||||
data[i + 1] = g;
|
||||
data[i + 2] = b;
|
||||
data[i + 3] = a;
|
||||
}
|
||||
|
||||
using var stream = File.Create(fullPath);
|
||||
new ImageWriter().WritePng(data, width, height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream);
|
||||
}
|
||||
|
||||
private AtlasBuildOptions Options(int groupDepth = 1, bool force = false) => new()
|
||||
{
|
||||
SourceDirectory = SourceDir,
|
||||
OutputDirectory = OutputDir,
|
||||
GroupDepth = groupDepth,
|
||||
MaxPageSize = 128,
|
||||
Padding = 2,
|
||||
Force = force,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("Terrain/Surfaces/Marsh.png", 1, "Terrain", "Terrain/Surfaces/Marsh")]
|
||||
[InlineData("Terrain/Surfaces/Marsh.png", 2, "Terrain.Surfaces", "Terrain/Surfaces/Marsh")]
|
||||
[InlineData("Terrain/Surfaces/Marsh.png", 0, "Root", "Terrain/Surfaces/Marsh")]
|
||||
[InlineData("loose.png", 3, "Root", "loose")]
|
||||
public void ClassifyPath_GroupsByDepth(string path, int depth, string expectedAtlas, string expectedKey)
|
||||
{
|
||||
var (atlas, key) = AtlasBuilder.ClassifyPath(path, depth, "Root");
|
||||
|
||||
Assert.Equal(expectedAtlas, atlas);
|
||||
Assert.Equal(expectedKey, key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WritesMetadataAndPages_PixelsSurviveRoundtrip()
|
||||
{
|
||||
WritePng("Terrain/Grass.png", 16, 16, 10, 200, 30);
|
||||
WritePng("Terrain/Water.png", 16, 8, 30, 40, 250);
|
||||
|
||||
var result = AtlasBuilder.Build(Options());
|
||||
|
||||
var group = Assert.Single(result.Groups);
|
||||
Assert.Equal("Terrain", group.Name);
|
||||
Assert.Equal(2, group.RegionCount);
|
||||
Assert.False(group.Skipped);
|
||||
|
||||
var metadata = AtlasMetadata.FromJson(File.ReadAllText(Path.Combine(OutputDir, "Terrain.atlas")));
|
||||
Assert.Equal(["Terrain/Grass", "Terrain/Water"], metadata.Regions.Select(x => x.Key));
|
||||
|
||||
var grass = metadata.Regions.Single(x => x.Key == "Terrain/Grass");
|
||||
var page = metadata.Pages[grass.Page];
|
||||
using var stream = File.OpenRead(Path.Combine(OutputDir, page.File));
|
||||
var pixels = ImageResult.FromStream(stream, StbImageSharp.ColorComponents.RedGreenBlueAlpha);
|
||||
|
||||
// Центральный пиксель региона должен быть цветом исходной картинки.
|
||||
var center = ((grass.Y + 8) * pixels.Width + grass.X + 8) * 4;
|
||||
Assert.Equal((byte)10, pixels.Data[center]);
|
||||
Assert.Equal((byte)200, pixels.Data[center + 1]);
|
||||
Assert.Equal((byte)30, pixels.Data[center + 2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_SecondRunWithoutChanges_SkipsGroup()
|
||||
{
|
||||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||||
|
||||
var first = AtlasBuilder.Build(Options());
|
||||
var second = AtlasBuilder.Build(Options());
|
||||
|
||||
Assert.False(Assert.Single(first.Groups).Skipped);
|
||||
Assert.True(Assert.Single(second.Groups).Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ChangedSource_Rebuilds()
|
||||
{
|
||||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||||
AtlasBuilder.Build(Options());
|
||||
|
||||
File.SetLastWriteTimeUtc(
|
||||
Path.Combine(SourceDir, "UI/button.png"), DateTime.UtcNow.AddMinutes(1));
|
||||
var result = AtlasBuilder.Build(Options());
|
||||
|
||||
Assert.False(Assert.Single(result.Groups).Skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_AddedFile_RebuildsGroup()
|
||||
{
|
||||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||||
AtlasBuilder.Build(Options());
|
||||
|
||||
WritePng("UI/icon.png", 8, 8, 4, 5, 6);
|
||||
var result = AtlasBuilder.Build(Options());
|
||||
|
||||
var group = Assert.Single(result.Groups);
|
||||
Assert.False(group.Skipped);
|
||||
Assert.Equal(2, group.RegionCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RemovedGroup_DeletesOrphanedAtlas()
|
||||
{
|
||||
WritePng("UI/button.png", 8, 8, 1, 2, 3);
|
||||
WritePng("World/rock.png", 8, 8, 7, 8, 9);
|
||||
AtlasBuilder.Build(Options());
|
||||
|
||||
Directory.Delete(Path.Combine(SourceDir, "World"), recursive: true);
|
||||
var result = AtlasBuilder.Build(Options());
|
||||
|
||||
Assert.NotEmpty(result.DeletedOrphans);
|
||||
Assert.False(File.Exists(Path.Combine(OutputDir, "World.atlas")));
|
||||
Assert.False(File.Exists(Path.Combine(OutputDir, "World.atlas.0.png")));
|
||||
Assert.True(File.Exists(Path.Combine(OutputDir, "UI.atlas")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ManyImages_SpillToMultiplePages()
|
||||
{
|
||||
for (var i = 0; i < 12; i++)
|
||||
{
|
||||
WritePng($"Things/sprite{i:D2}.png", 60, 60, (byte)i, 0, 0);
|
||||
}
|
||||
|
||||
var result = AtlasBuilder.Build(Options());
|
||||
|
||||
// 60² с padding 2 на страницу 128² помещаются по 4 — минимум 3 страницы.
|
||||
Assert.True(Assert.Single(result.Groups).PageCount >= 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metadata_JsonRoundtrip_PreservesEverything()
|
||||
{
|
||||
var metadata = new AtlasMetadata
|
||||
{
|
||||
Name = "Things.Pawn",
|
||||
PageSize = 2048,
|
||||
Padding = 2,
|
||||
Pages = [new AtlasPage { File = "Things.Pawn.atlas.0.png", Width = 256, Height = 128 }],
|
||||
Regions = [new AtlasRegion { Key = "Things/Pawn/Fox", Page = 0, X = 2, Y = 4, Width = 64, Height = 32 }],
|
||||
};
|
||||
|
||||
var parsed = AtlasMetadata.FromJson(metadata.ToJson());
|
||||
|
||||
Assert.Equal("Things.Pawn", parsed.Name);
|
||||
Assert.Equal(2048, parsed.PageSize);
|
||||
var page = Assert.Single(parsed.Pages);
|
||||
Assert.Equal(("Things.Pawn.atlas.0.png", 256, 128), (page.File, page.Width, page.Height));
|
||||
var region = Assert.Single(parsed.Regions);
|
||||
Assert.Equal(("Things/Pawn/Fox", 0, 2, 4, 64, 32),
|
||||
(region.Key, region.Page, region.X, region.Y, region.Width, region.Height));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextureAtlas_LooksUpRegions_FromMetadata()
|
||||
{
|
||||
var metadata = new AtlasMetadata
|
||||
{
|
||||
Name = "Test",
|
||||
Pages = [new AtlasPage { File = "Test.atlas.0.png", Width = 64, Height = 64 }],
|
||||
Regions =
|
||||
[
|
||||
new AtlasRegion { Key = "a/b", Page = 0, X = 2, Y = 2, Width = 10, Height = 12 },
|
||||
],
|
||||
};
|
||||
|
||||
// Texture2D == null допустим в headless-тестах (см. Texture2DRegion).
|
||||
var atlas = new TextureAtlas(metadata, new Microsoft.Xna.Framework.Graphics.Texture2D[1]);
|
||||
|
||||
var region = atlas.GetRegion("a/b");
|
||||
Assert.Equal(new Microsoft.Xna.Framework.Rectangle(2, 2, 10, 12), region.Bounds);
|
||||
Assert.True(atlas.TryGetRegion("a/b", out _));
|
||||
Assert.False(atlas.TryGetRegion("missing", out _));
|
||||
Assert.Throws<KeyNotFoundException>(() => atlas.GetRegion("missing"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Atlases\MrGameEng.Atlases.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,111 @@
|
||||
using MrGameEng.Atlases;
|
||||
using Xunit;
|
||||
|
||||
namespace MrGameEng.Atlases.Tests;
|
||||
|
||||
public class ShelfPackerTests
|
||||
{
|
||||
private static List<PackItem> Squares(int count, int size) =>
|
||||
Enumerable.Range(0, count).Select(i => new PackItem($"item{i:D3}", size, size)).ToList();
|
||||
|
||||
[Fact]
|
||||
public void Pack_PlacesEveryItem_WithinPageBounds()
|
||||
{
|
||||
var result = ShelfPacker.Pack(Squares(50, 60), maxPageSize: 256, padding: 2);
|
||||
|
||||
Assert.Equal(50, result.Placements.Count);
|
||||
foreach (var p in result.Placements)
|
||||
{
|
||||
var (pageWidth, pageHeight) = result.PageSizes[p.Page];
|
||||
Assert.True(p.X >= 2 && p.Y >= 2);
|
||||
Assert.True(p.X + p.Width <= pageWidth);
|
||||
Assert.True(p.Y + p.Height <= pageHeight);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pack_NoTwoPlacements_Overlap()
|
||||
{
|
||||
var items = new List<PackItem>();
|
||||
var random = new Random(42);
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
items.Add(new PackItem($"r{i:D3}", random.Next(4, 90), random.Next(4, 90)));
|
||||
}
|
||||
|
||||
var result = ShelfPacker.Pack(items, maxPageSize: 512, padding: 2);
|
||||
|
||||
var byPage = result.Placements.GroupBy(p => p.Page);
|
||||
foreach (var page in byPage)
|
||||
{
|
||||
var list = page.ToList();
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < list.Count; j++)
|
||||
{
|
||||
var a = list[i];
|
||||
var b = list[j];
|
||||
var separated =
|
||||
a.X + a.Width + 2 <= b.X || b.X + b.Width + 2 <= a.X ||
|
||||
a.Y + a.Height + 2 <= b.Y || b.Y + b.Height + 2 <= a.Y;
|
||||
Assert.True(separated, $"{a.Key} overlaps {b.Key} (padding included)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pack_Overflows_ToMultiplePages()
|
||||
{
|
||||
// 9 квадратов 100² на страницу 256² помещаются максимум по 4.
|
||||
var result = ShelfPacker.Pack(Squares(9, 100), maxPageSize: 256, padding: 2);
|
||||
|
||||
Assert.True(result.PageSizes.Count >= 3);
|
||||
Assert.Equal(Enumerable.Range(0, result.PageSizes.Count), result.Placements.Select(p => p.Page).Distinct().Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pack_OversizedItem_GetsDedicatedPage()
|
||||
{
|
||||
var items = Squares(2, 30);
|
||||
items.Add(new PackItem("huge", 500, 40));
|
||||
|
||||
var result = ShelfPacker.Pack(items, maxPageSize: 256, padding: 2);
|
||||
|
||||
var huge = result.Placements.Single(p => p.Key == "huge");
|
||||
Assert.Single(result.Placements, p => p.Page == huge.Page);
|
||||
Assert.True(result.PageSizes[huge.Page].Width >= 504);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pack_IsDeterministic_RegardlessOfInputOrder()
|
||||
{
|
||||
var items = Squares(30, 20).Concat(Squares(10, 50).Select(i => i with { Key = "b" + i.Key })).ToList();
|
||||
var shuffled = items.AsEnumerable().Reverse().ToList();
|
||||
|
||||
var a = ShelfPacker.Pack(items, 128, 2);
|
||||
var b = ShelfPacker.Pack(shuffled, 128, 2);
|
||||
|
||||
Assert.Equal(
|
||||
a.Placements.OrderBy(p => p.Key, StringComparer.Ordinal),
|
||||
b.Placements.OrderBy(p => p.Key, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pack_TrimsPages_ToPowerOfTwo()
|
||||
{
|
||||
var result = ShelfPacker.Pack(Squares(1, 50), maxPageSize: 2048, padding: 2);
|
||||
|
||||
Assert.Equal((64, 64), result.PageSizes.Single());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(64, 64)]
|
||||
[InlineData(65, 128)]
|
||||
[InlineData(2048, 2048)]
|
||||
public void NextPowerOfTwo_RoundsUp(int value, int expected)
|
||||
{
|
||||
Assert.Equal(expected, ShelfPacker.NextPowerOfTwo(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MrGameEng.Atlases\MrGameEng.Atlases.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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 <source-dir> <output-dir> [options]
|
||||
|
||||
Options:
|
||||
--group-depth <n> directories forming one atlas (0 = single atlas; default 1)
|
||||
--page-size <n> maximum page size in pixels (default 2048)
|
||||
--padding <n> gap between images in pixels (default 2)
|
||||
--root-name <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;
|
||||
Reference in New Issue
Block a user