Files
mrgameeng/src/MrGameEng.Atlases/AtlasBuilder.cs
T
Leonid PershinandClaude Fable 5 501d81e19f
CI / build-test (push) Failing after 1m7s
Fix engine-wide code review findings
Collisions: init bucket heads to -1 (QueryAabb hung before the first
rebuild), reset query stamps on truncated QueryAabb (later queries
silently dropped entities), inside-origin raycasts hit at fraction 0 for
circles too, exactly-touching boxes now pair like touching circles.

Graphics: render into the letterbox viewport so the picture matches
ScreenToWorld/WorldToScreen instead of stretching; Y-sort by the
transform pivot rather than the quad center; lock-free snapshot
LayerRegistry (parallel submit read it unsynchronized); validate
InitialCapacity; warn when UseRenderer2D drops options of a later scene.

Core: scenes are explicitly single-use (re-loading threw silently
duplicated systems/entities before — now it throws), Scene.RegisterUnload
for per-scene resources, a switch requested during the reveal phase
covers again instead of hard-swapping, borderless fullscreen
(HardwareModeSwitch off), InputCapture service for input-suppressing
overlays, host disposes the transition renderer and IDisposable services
on shutdown.

Input: game input reads as released while InputCapture is held; mouse
position and wheel freeze so deltas stay zero.

DevConsole: holds InputCapture while open (typing no longer drives the
camera), Revision increments only under the lock, quoted command
arguments, history capped at 256.

UI: scene Desktop skips Myra input processing while the console is open
(clicks no longer fall through), is disposed on scene unload, and Myra
init no longer depends on a process-static flag.

Audio: validate channel count/sample rate before stopping the previous
track, empty looped oggs no longer hang FillBuffers, the instance stops
when a non-looping track drains (IsPlaying was stuck true).

Atlases: metadata v2 stores per-source size+mtime snapshots, so
timestamp-preserving copies and renames invalidate correctly; loader
checks the version and disposes pages on partial load failure; shared
pages never exceed a non-POT MaxPageSize; oversized items pack first
onto exact-size pages instead of splitting an open shared page; the CLI
validates numeric options.

Assets.Generator: file names are escaped in XML docs and string
literals, members no longer collide with the enclosing class (CS0542),
and the Assets root is resolved against build_property.projectdir so
nested "Assets" directories do not shift region paths.

Pathfinding: queries throw when the grid was resized after construction;
generation stamps survive int overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:18:08 +03:00

319 lines
12 KiB
C#

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>Snapshot of one source image taken at scan time (size/mtime feed the staleness check).</summary>
private readonly record struct SourceFile(string FullPath, string Key, long Size, long ModifiedTicks);
/// <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<SourceFile>> ScanGroups(
string sourceRoot, AtlasBuildOptions options)
{
var groups = new SortedDictionary<string, List<SourceFile>>(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);
}
var info = new FileInfo(fullPath);
list.Add(new SourceFile(fullPath, key, info.Length, info.LastWriteTimeUtc.Ticks));
}
return groups;
}
private static AtlasGroupResult BuildGroup(
string name, List<SourceFile> 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, files, 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<SourceFile> 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.Version != AtlasMetadata.CurrentVersion ||
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;
}
// Источники сравниваются по точному снапшоту (ключ + размер + mtime), а не по
// «новее метаданных»: переименования и копии с сохранением времени тоже ловятся.
if (metadata.Sources.Count != files.Count)
{
return false;
}
var sourcesByKey = metadata.Sources.ToDictionary(s => s.Key, StringComparer.Ordinal);
foreach (var file in files)
{
if (!sourcesByKey.TryGetValue(file.Key, out var source) ||
source.Size != file.Size || source.Modified != file.ModifiedTicks)
{
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, List<SourceFile> files, AtlasBuildOptions options, string metadataPath)
{
var metadata = new AtlasMetadata
{
Name = name,
PageSize = options.MaxPageSize,
Padding = options.Padding,
Sources = files
.OrderBy(f => f.Key, StringComparer.Ordinal)
.Select(f => new AtlasSource { Key = f.Key, Size = f.Size, Modified = f.ModifiedTicks })
.ToList(),
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;
}
}