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.
304 lines
12 KiB
C#
304 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>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;
|
|
}
|
|
}
|