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"]; /// Snapshot of one source image taken at scan time (size/mtime feed the staleness check). private readonly record struct SourceFile(string FullPath, string Key, long Size, long ModifiedTicks); /// 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); } 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 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, 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 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 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 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 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; } }