From 5bd90c908afd4928bca76f42f08da8cc35b99bfc Mon Sep 17 00:00:00 2001 From: beck-thompson <107373427+beck-thompson@users.noreply.github.com> Date: Sat, 10 May 2025 05:00:03 -0700 Subject: [PATCH] Optimization RSI preloading / atlas creation (#5817) * First commit * Fix multiatlas bug * Use ValueList instead * Add FFDH sorting for atlases * Minor cleanup and value lists --- .../ResourceCache.Preload.cs | 202 ++++++++++++++---- 1 file changed, 158 insertions(+), 44 deletions(-) diff --git a/Robust.Client/ResourceManagement/ResourceCache.Preload.cs b/Robust.Client/ResourceManagement/ResourceCache.Preload.cs index af5f4eaa7a..febfb97362 100644 --- a/Robust.Client/ResourceManagement/ResourceCache.Preload.cs +++ b/Robust.Client/ResourceManagement/ResourceCache.Preload.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; @@ -8,6 +9,7 @@ using Robust.Client.Graphics; using Robust.Client.Utility; using Robust.Shared; using Robust.Shared.Audio; +using Robust.Shared.Collections; using Robust.Shared.Configuration; using Robust.Shared.ContentPack; using Robust.Shared.Graphics; @@ -191,65 +193,143 @@ namespace Robust.Client.ResourceManagement // TODO allow RSIs to opt out (useful for very big & rare RSIs) // TODO combine with (non-rsi) texture atlas? - Array.Sort(atlasList, (b, a) => (b.AtlasSheet?.Height ?? 0).CompareTo(a.AtlasSheet?.Height ?? 0)); + // We now need to insert the RSIs into the atlas. This specific problem is 2BP|O|F - the items are oriented + // and cutting is free. The sorting is done by a slightly modified FFDH algorithm. The algorithm is exactly + // the same as the standard FFDH algorithm with one main difference: We create new "levels" above placed + // blocks. For example if the first block was 10x20, then the second was 10x10 units, we would create a + // 10x10 level above the second block that would be treated as a normal level. This increases the packing + // efficiency from ~85% to ~95% with very little extra computational effort. The algorithm appears to be + // ~97% effective for storing SS14s RSIs. + // + // Here are some more resources about the strip packing problem! + // - https://en.wikipedia.org/w/index.php?title=Strip_packing_problem&oldid=1263496949#First-fit_decreasing-height_(FFDH) + // - https://www.csc.liv.ac.uk/~epa/surveyhtml.html + // - https://www.dei.unipd.it/~fisch/ricop/tesi/tesi_dottorato_Lodi_1999.pdf + + // The array must be sorted from biggest to smallest first. + Array.Sort(atlasList, (b, a) => a.AtlasSheet.Height.CompareTo(b.AtlasSheet.Height)); - // Each RSI sub atlas has a different size. - // Even if we iterate through them once to estimate total area, I have NFI how to sanely estimate an optimal square-texture size. - // So fuck it, just default to letting it be as large as it needs to and crop it as needed? var maxSize = Math.Min(GL.GetInteger(GetPName.MaxTextureSize), _configurationManager.GetCVar(CVars.ResRSIAtlasSize)); - var sheet = new Image(maxSize, maxSize); - var deltaY = 0; - Vector2i offset = default; - int finalized = -1; - int atlasCount = 0; - for (int i = 0; i < atlasList.Length; i++) + // THIS IS NOT GUARANTEED TO HAVE ANY PARTICULARLY LOGICAL ORDERING. + // E.G you could have atlas 1 RSIs appear *before* you're done seeing atlas 2 RSIs. + var levels = new ValueList(); + + // List of all the image atlases. + var imageAtlases = new ValueList>(); + + // List of all the actual atlases. + var finalAtlases = new ValueList(); + + // Number of total pixels in each atlas. + var finalPixels = new ValueList(); + + // First we just find the location of all the RSIs in the atlas before actually placing them. + // This allows us to effectively determine how much space we need to allocate for the images. + var currentHeight = 0; + var currentAtlasIndex = 0; + foreach (var rsi in atlasList) { - var rsi = atlasList[i]; - if (rsi.Bad) + var insertHeight = rsi.AtlasSheet.Height; + var insertWidth = rsi.AtlasSheet.Width; + + var found = false; + for (var i = 0; i < levels.Count && !found; i++) + { + var levelPosition = levels[i].Position; + var levelWidth = levels[i].Width; + var levelHeight = levels[i].Height; + + // Check if it can fit in this level. + if (levelHeight < insertHeight || levelWidth + insertWidth > levels[i].MaxWidth) + continue; + + found = true; + + levels[i].Width += insertWidth; + rsi.AtlasOffset = levelPosition + new Vector2i(levelWidth, 0); + levels[i].RSIList.Add(rsi); + + // Creating the extra "free" space above blocks that can be used for inserting more items. + // This differs from the FFDH spec which just ignores this space. + Debug.Assert(levelHeight >= insertHeight); // Must be true because the array needs to be sorted + if (levelHeight - insertHeight == 0) + continue; + + var freeLevel = new Level + { + AtlasId = levels[i].AtlasId, + Position = levelPosition + new Vector2i(levelWidth, insertHeight), + Height = levelHeight - insertHeight, + Width = 0, + MaxWidth = insertWidth, + RSIList = [ ] + }; + + levels.Add(freeLevel); + } + + if (found) continue; - DebugTools.Assert(rsi.AtlasSheet.Width < sheet.Width); - DebugTools.Assert(rsi.AtlasSheet.Height < sheet.Height); - - if (offset.X + rsi.AtlasSheet.Width > sheet.Width) + // Ran out of space, we need to move on to the next atlas. + // This also isn't in the normal FFDH algorithm (obviously) but its close enough. + if (currentHeight + insertHeight > maxSize) { - offset.X = 0; - offset.Y += deltaY; + imageAtlases.Add(new Image(maxSize, currentHeight)); + finalPixels.Add(0); + currentHeight = 0; + currentAtlasIndex++; } - if (offset.Y + rsi.AtlasSheet.Height > sheet.Height) - { - FinalizeMetaAtlas(i-1, sheet); - sheet = new Image(maxSize, maxSize); - deltaY = 0; - offset = default; - } + rsi.AtlasOffset = new Vector2i(0, currentHeight); - deltaY = Math.Max(deltaY, rsi.AtlasSheet.Height); - var box = new UIBox2i(0, 0, rsi.AtlasSheet.Width, rsi.AtlasSheet.Height); - rsi.AtlasSheet.Blit(box, sheet, offset); - rsi.AtlasOffset = offset; - offset.X += rsi.AtlasSheet.Width; + var newLevel = new Level + { + AtlasId = currentAtlasIndex, + Position = new Vector2i(0, currentHeight), + Height = insertHeight, + Width = insertWidth, + MaxWidth = maxSize, + RSIList = [ rsi ] + }; + levels.Add(newLevel); + + currentHeight += insertHeight; } - var height = offset.Y + deltaY; - var croppedSheet = new Image(maxSize, height); - sheet.Blit(new UIBox2i(0, 0, maxSize, height), croppedSheet, default); - FinalizeMetaAtlas(atlasList.Length - 1, croppedSheet); + // This allocation takes a long time. + imageAtlases.Add(new Image(maxSize, currentHeight)); + finalPixels.Add(0); - void FinalizeMetaAtlas(int toIndex, Image sheet) + // Put all textures on the atlases + foreach (var level in levels) { - var fromIndex = finalized + 1; - var atlas = Clyde.LoadTextureFromImage(sheet, $"Meta atlas {fromIndex}-{toIndex}"); - for (int i = fromIndex; i <= toIndex; i++) + foreach (var rsi in level.RSIList) { - var rsi = atlasList[i]; - rsi.AtlasTexture = atlas; - } + var box = new UIBox2i(0, 0, rsi.AtlasSheet.Width, rsi.AtlasSheet.Height); - finalized = toIndex; - atlasCount++; + rsi.AtlasSheet.Blit(box, imageAtlases[level.AtlasId], rsi.AtlasOffset); + finalPixels[level.AtlasId] += rsi.AtlasSheet.Width * rsi.AtlasSheet.Height; + } + } + + // Finalize the atlases. + for (var i = 0; i < imageAtlases.Count; i++) + { + var atlasTexture = Clyde.LoadTextureFromImage(imageAtlases[i], $"Meta atlas {i}"); + finalAtlases.Add(atlasTexture); + + sawmill.Debug($"(Meta atlas {i}) - cropped utilization: {(float)finalPixels[i] / (maxSize * imageAtlases[i].Height):P2}, fill percentage: {(float)imageAtlases[i].Height / maxSize:P2}"); + } + + // Finally, reference the actual atlas from the RSIs. + foreach (var level in levels) + { + foreach (var rsi in level.RSIList) + { + rsi.AtlasTexture = finalAtlases[level.AtlasId]; + } } Parallel.ForEach(rsiList, data => @@ -294,7 +374,7 @@ namespace Robust.Client.ResourceManagement sawmill.Debug( "Preloaded {CountLoaded} RSIs into {CountAtlas} Atlas(es?) ({CountNotAtlas} not atlassed, {CountErrored} errored) in {LoadTime}", rsiList.Length, - atlasCount, + finalAtlases.Count, nonAtlasList.Length, errors, sw.Elapsed); @@ -305,4 +385,38 @@ namespace Robust.Client.ResourceManagement return rsi.MetaAtlas && rsi.LoadParameters == TextureLoadParameters.Default; } } + + /// + /// A "Level" to place boxes. Similar to FFDH levels, but with more parameters so we can fit in "free" levels + /// above placed boxes. + /// + internal sealed class Level + { + /// + /// Index of the atlas this is located. + /// + public required int AtlasId; + /// + /// Bottom left of the location for the RSIs. + /// + public required Vector2i Position; + /// + /// The current width of the level. + /// + /// This can (and will) be 0. Will change. + public required int Width; + /// + /// The current height of the level. + /// + /// This value should never change. + public required int Height; + /// + /// Maximum width of the level. + /// + public required int MaxWidth; + /// + /// List of all the RSIs stored in this level. RSIs are ordered from tallest to smallest per level. + /// + public required List RSIList; + } }