diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs index 370188e3c6..1ea7868e9a 100644 --- a/Content.Client/IoC/ClientContentIoC.cs +++ b/Content.Client/IoC/ClientContentIoC.cs @@ -34,6 +34,7 @@ namespace Content.Client.IoC var collection = IoCManager.Instance!; collection.Register(); + collection.Register(); collection.Register(); collection.Register(); collection.Register(); diff --git a/Content.Client/Parallax/Data/GeneratedParallaxTextureSource.cs b/Content.Client/Parallax/Data/GeneratedParallaxTextureSource.cs index 2e69a5a562..cb91645918 100644 --- a/Content.Client/Parallax/Data/GeneratedParallaxTextureSource.cs +++ b/Content.Client/Parallax/Data/GeneratedParallaxTextureSource.cs @@ -1,17 +1,9 @@ -using System.IO; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; -using Nett; -using Content.Shared.CCVar; -using Content.Client.IoC; +using Content.Client.Parallax.Managers; using Robust.Client.Graphics; using Robust.Shared.Utility; -using Robust.Shared.Configuration; -using Robust.Shared.ContentPack; -using Robust.Shared.Graphics; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; namespace Content.Client.Parallax.Data; @@ -29,116 +21,21 @@ public sealed partial class GeneratedParallaxTextureSource : IParallaxTextureSou /// /// ID for debugging, caching, and so forth. /// The empty string here is reserved for the original parallax. - /// It is advisible to provide a roughly unique ID for any unique config contents. + /// It is required to provide a unique ID for any unique config contents. /// [DataField("id")] public string Identifier { get; private set; } = "other"; - /// - /// Cached path. - /// In user directory. - /// - private ResPath ParallaxCachedImagePath => new($"/parallax_{Identifier}cache.png"); - - /// - /// Old parallax config path (for checking for parallax updates). - /// In user directory. - /// - private ResPath PreviousParallaxConfigPath => new($"/parallax_{Identifier}config_old"); - async Task IParallaxTextureSource.GenerateTexture(CancellationToken cancel) { - var parallaxConfig = GetParallaxConfig(); - if (parallaxConfig == null) - { - Logger.ErrorS("parallax", $"Parallax config not found or unreadable: {ParallaxConfigPath}"); - // The show must go on. - return Texture.Transparent; - } - - var debugParallax = IoCManager.Resolve().GetCVar(CCVars.ParallaxDebug); - var resManager = IoCManager.Resolve(); - - if (debugParallax - || !resManager.UserData.TryReadAllText(PreviousParallaxConfigPath, out var previousParallaxConfig) - || previousParallaxConfig != parallaxConfig) - { - var table = Toml.ReadString(parallaxConfig); - await UpdateCachedTexture(table, debugParallax, cancel); - - //Update the previous config - using var writer = resManager.UserData.OpenWriteText(PreviousParallaxConfigPath); - writer.Write(parallaxConfig); - } - - try - { - return GetCachedTexture(); - } - catch (Exception ex) - { - Logger.ErrorS("parallax", $"Couldn't retrieve parallax cached texture: {ex}"); - - try - { - // Also try to at least sort of fix this if we've been fooled by a config backup - resManager.UserData.Delete(PreviousParallaxConfigPath); - } - catch (Exception) - { - // The show must go on. - } - return Texture.Transparent; - } + var cache = IoCManager.Resolve(); + return await cache.Load(Identifier, ParallaxConfigPath, cancel); } - private async Task UpdateCachedTexture(TomlTable config, bool saveDebugLayers, CancellationToken cancel = default) + void IParallaxTextureSource.Unload(IDependencyCollection dependencies) { - var debugImages = saveDebugLayers ? new List>() : null; - - var sawmill = IoCManager.Resolve().GetSawmill("parallax"); - - // Generate the parallax in the thread pool. - using var newParallexImage = await Task.Run(() => - ParallaxGenerator.GenerateParallax(config, new Size(1920, 1080), sawmill, debugImages, cancel), cancel); - - // And load it in the main thread for safety reasons. - // But before spending time saving it, make sure to exit out early if it's not wanted. - cancel.ThrowIfCancellationRequested(); - var resManager = IoCManager.Resolve(); - - // Store it and CRC so further game starts don't need to regenerate it. - await using var imageStream = resManager.UserData.OpenWrite(ParallaxCachedImagePath); - await newParallexImage.SaveAsPngAsync(imageStream, cancel); - - if (saveDebugLayers) - { - for (var i = 0; i < debugImages!.Count; i++) - { - var debugImage = debugImages[i]; - await using var debugImageStream = resManager.UserData.OpenWrite(new ResPath($"/parallax_{Identifier}debug_{i}.png")); - await debugImage.SaveAsPngAsync(debugImageStream, cancel); - } - } - } - - private Texture GetCachedTexture() - { - var resManager = IoCManager.Resolve(); - using var imageStream = resManager.UserData.OpenRead(ParallaxCachedImagePath); - return Texture.LoadFromPNGStream(imageStream, "Parallax"); - } - - private string? GetParallaxConfig() - { - var resManager = IoCManager.Resolve(); - if (!resManager.TryContentFileRead(ParallaxConfigPath, out var configStream)) - { - return null; - } - - using var configReader = new StreamReader(configStream, EncodingHelpers.UTF8); - return configReader.ReadToEnd().Replace(Environment.NewLine, "\n"); + var cache = dependencies.Resolve(); + cache.Unload(Identifier); } } diff --git a/Content.Client/Parallax/Data/IParallaxTextureSource.cs b/Content.Client/Parallax/Data/IParallaxTextureSource.cs index dc514c1304..990b74a410 100644 --- a/Content.Client/Parallax/Data/IParallaxTextureSource.cs +++ b/Content.Client/Parallax/Data/IParallaxTextureSource.cs @@ -1,7 +1,6 @@ using System.Threading; using System.Threading.Tasks; using Robust.Client.Graphics; -using Robust.Shared.Graphics; namespace Content.Client.Parallax.Data { @@ -13,6 +12,13 @@ namespace Content.Client.Parallax.Data /// Note that this should be cached, but not necessarily *here*. /// Task GenerateTexture(CancellationToken cancel = default); + + /// + /// Called when the parallax texture is no longer necessary, and may be unloaded. + /// + void Unload(IDependencyCollection dependencies) + { + } } } diff --git a/Content.Client/Parallax/Managers/GeneratedParallaxCache.cs b/Content.Client/Parallax/Managers/GeneratedParallaxCache.cs new file mode 100644 index 0000000000..845f631de8 --- /dev/null +++ b/Content.Client/Parallax/Managers/GeneratedParallaxCache.cs @@ -0,0 +1,202 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Content.Client.Parallax.Data; +using Content.Shared.CCVar; +using Nett; +using Robust.Client.Graphics; +using Robust.Shared.Collections; +using Robust.Shared.Configuration; +using Robust.Shared.ContentPack; +using Robust.Shared.Utility; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Content.Client.Parallax.Managers; + +/// +/// Caches the textures generated by +/// +public sealed class GeneratedParallaxCache : IPostInjectInit +{ + [Dependency] private readonly IConfigurationManager _cfg = null!; + [Dependency] private readonly IResourceManager _res = null!; + [Dependency] private readonly ILogManager _logManager = null!; + + private readonly Dictionary _data = new(); + + private ISawmill _sawmill = null!; + + public Task Load(string id, ResPath configPath, CancellationToken cancel = default) + { + if (!_data.TryGetValue(id, out var datum)) + { + _sawmill.Verbose($"Loading new generated layer {id} with config path {configPath}"); + + var cts = new CancellationTokenSource(); + + var loadTask = LoadTask(id, configPath, cts.Token); + datum = new CacheDatum + { + CancellationSource = cts, + ConfigPath = configPath, + LoadTask = loadTask, + }; + + _data.Add(id, datum); + } + else + { + if (datum.ConfigPath != configPath) + throw new InvalidOperationException("Generated parallax layers with the same ID must have the same config path!"); + } + + datum.RefCount += 1; + + if (!datum.LoadTask.IsCompleted) + cancel.Register(() => Unload(id)); + + return datum.LoadTask; + } + + public void Unload(string id) + { + if (!_data.TryGetValue(id, out var datum)) + throw new InvalidOperationException("Layer is not cached!"); + + DebugTools.Assert(datum.RefCount >= 1); + + datum.RefCount -= 1; + if (datum.RefCount == 0) + { + _sawmill.Verbose($"Unloading generated layer {id}"); + + // If we're still loading, cancel the active load. + datum.CancellationSource.Cancel(); + + // We should probably be unloading the texture here forcibly, + // but the previous code didn't so I won't either. + _data.Remove(id); + } + } + + private async Task LoadTask(string id, ResPath configPath, CancellationToken cancel) + { + return await GenerateTexture(id, configPath, cancel); + } + + private async Task GenerateTexture(string id, ResPath configPath, CancellationToken cancel) + { + var parallaxConfig = GetParallaxConfig(configPath); + if (parallaxConfig == null) + { + _sawmill.Error($"Parallax config not found or unreadable: {configPath}"); + // The show must go on. + return Texture.Transparent; + } + + var debugParallax = _cfg.GetCVar(CCVars.ParallaxDebug); + + if (debugParallax + || !_res.UserData.TryReadAllText(PreviousConfigPath(id), out var previousParallaxConfig) + || previousParallaxConfig != parallaxConfig) + { + var table = Toml.ReadString(parallaxConfig); + await UpdateCachedTexture(id, table, debugParallax, cancel); + + //Update the previous config + using var writer = _res.UserData.OpenWriteText(PreviousConfigPath(id)); + writer.Write(parallaxConfig); + } + + try + { + return GetCachedTexture(id); + } + catch (Exception ex) + { + _sawmill.Error($"Couldn't retrieve parallax cached texture: {ex}"); + + try + { + // Also try to at least sort of fix this if we've been fooled by a config backup + _res.UserData.Delete(PreviousConfigPath(id)); + } + catch (Exception) + { + // The show must go on. + } + + return Texture.Transparent; + } + } + + private async Task UpdateCachedTexture(string id, TomlTable config, bool saveDebugLayers, CancellationToken cancel) + { + var debugImages = saveDebugLayers ? new List>() : null; + + // Generate the parallax in the thread pool. + using var newParallexImage = await Task.Run(() => + ParallaxGenerator.GenerateParallax(config, new Size(1920, 1080), _sawmill, debugImages, cancel), + cancel); + + // And load it in the main thread for safety reasons. + // But before spending time saving it, make sure to exit out early if it's not wanted. + cancel.ThrowIfCancellationRequested(); + + // Store it and CRC so further game starts don't need to regenerate it. + await using var imageStream = _res.UserData.OpenWrite(CachedImagePath(id)); + await newParallexImage.SaveAsPngAsync(imageStream, cancel); + + if (saveDebugLayers) + { + for (var i = 0; i < debugImages!.Count; i++) + { + var debugImage = debugImages[i]; + await using var debugImageStream = + _res.UserData.OpenWrite(new ResPath($"/parallax_{id}debug_{i}.png")); + await debugImage.SaveAsPngAsync(debugImageStream, cancel); + } + } + } + + private Texture GetCachedTexture(string id) + { + using var imageStream = _res.UserData.OpenRead(CachedImagePath(id)); + return Texture.LoadFromPNGStream(imageStream, $"Parallax {id}"); + } + + private string? GetParallaxConfig(ResPath configPath) + { + if (!_res.TryContentFileRead(configPath, out var configStream)) + return null; + + using var configReader = new StreamReader(configStream, EncodingHelpers.UTF8); + return configReader.ReadToEnd().Replace(Environment.NewLine, "\n"); + } + + private static ResPath CachedImagePath(string identifier) + { + return new ResPath($"/parallax_{identifier}cache.png"); + } + + private static ResPath PreviousConfigPath(string identifier) + { + return new ResPath($"/parallax_{identifier}config_old"); + } + + void IPostInjectInit.PostInject() + { + _sawmill = _logManager.GetSawmill("parallax.generated"); + } + + private sealed class CacheDatum + { + public required ResPath ConfigPath; + public required Task LoadTask; + public required CancellationTokenSource CancellationSource; + public ValueList CancelRegistrations; + + public int RefCount; + } +} diff --git a/Content.Client/Parallax/Managers/ParallaxManager.cs b/Content.Client/Parallax/Managers/ParallaxManager.cs index 83e7febe27..bc7d7d60d6 100644 --- a/Content.Client/Parallax/Managers/ParallaxManager.cs +++ b/Content.Client/Parallax/Managers/ParallaxManager.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Numerics; using System.Threading; using System.Threading.Tasks; @@ -6,7 +5,6 @@ using Content.Client.Parallax.Data; using Content.Shared.CCVar; using Robust.Shared.Prototypes; using Robust.Shared.Configuration; -using Robust.Shared.Utility; namespace Content.Client.Parallax.Managers; @@ -14,6 +12,7 @@ public sealed class ParallaxManager : IParallaxManager { [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly IConfigurationManager _configurationManager = default!; + [Dependency] private readonly IDependencyCollection _deps = null!; private ISawmill _sawmill = Logger.GetSawmill("parallax"); @@ -40,14 +39,29 @@ public sealed class ParallaxManager : IParallaxManager { if (_loadingParallaxes.TryGetValue(name, out var loading)) { + _sawmill.Debug($"Cancelling loading parallax {name}"); loading.Cancel(); _loadingParallaxes.Remove(name, out _); return; } - if (!_parallaxesLQ.ContainsKey(name)) return; - _parallaxesLQ.Remove(name); - _parallaxesHQ.Remove(name); + _sawmill.Debug($"Unloading parallax {name}"); + + if (_parallaxesLQ.Remove(name, out var layers)) + { + foreach (var layer in layers) + { + layer.Config.Texture.Unload(_deps); + } + } + + if (_parallaxesHQ.Remove(name, out layers)) + { + foreach (var layer in layers) + { + layer.Config.Texture.Unload(_deps); + } + } } public async void LoadDefaultParallax() @@ -68,6 +82,9 @@ public sealed class ParallaxManager : IParallaxManager // Begin (for real) _sawmill.Debug($"Loading parallax {name}"); + // Keep a list of layers we did successfully load, in case we have to cancel the load. + var loadedLayers = new List(); + try { var parallaxPrototype = _prototypeManager.Index(name); @@ -77,23 +94,33 @@ public sealed class ParallaxManager : IParallaxManager if (parallaxPrototype.LayersLQUseHQ) { layers = new ParallaxLayerPrepared[2][]; - layers[0] = layers[1] = await LoadParallaxLayers(parallaxPrototype.Layers, cancel); + layers[0] = layers[1] = await LoadParallaxLayers(parallaxPrototype.Layers, loadedLayers, cancel); } else { layers = await Task.WhenAll( - LoadParallaxLayers(parallaxPrototype.Layers, cancel), - LoadParallaxLayers(parallaxPrototype.LayersLQ, cancel) + LoadParallaxLayers(parallaxPrototype.Layers, loadedLayers, cancel), + LoadParallaxLayers(parallaxPrototype.LayersLQ, loadedLayers, cancel) ); } - _loadingParallaxes.Remove(name, out _); + cancel.ThrowIfCancellationRequested(); - if (token.Token.IsCancellationRequested) return; + _loadingParallaxes.Remove(name); _parallaxesLQ[name] = layers[1]; _parallaxesHQ[name] = layers[0]; + _sawmill.Verbose($"Loading parallax {name} completed"); + } + catch (OperationCanceledException) + { + _sawmill.Verbose($"Loading parallax {name} cancelled"); + + foreach (var loadedLayer in loadedLayers) + { + loadedLayer.Config.Texture.Unload(_deps); + } } catch (Exception ex) { @@ -101,25 +128,35 @@ public sealed class ParallaxManager : IParallaxManager } } - private async Task LoadParallaxLayers(List layersIn, CancellationToken cancel = default) + private async Task LoadParallaxLayers( + List layersIn, + List loadedLayers, + CancellationToken cancel = default) { // Because this is async, make sure it doesn't change (prototype reloads could muck this up) // Since the tasks aren't awaited until the end, this should be fine var tasks = new Task[layersIn.Count]; for (var i = 0; i < layersIn.Count; i++) { - tasks[i] = LoadParallaxLayer(layersIn[i], cancel); + tasks[i] = LoadParallaxLayer(layersIn[i], loadedLayers, cancel); } return await Task.WhenAll(tasks); } - private async Task LoadParallaxLayer(ParallaxLayerConfig config, CancellationToken cancel = default) + private async Task LoadParallaxLayer( + ParallaxLayerConfig config, + List loadedLayers, + CancellationToken cancel = default) { - return new ParallaxLayerPrepared() + var prepared = new ParallaxLayerPrepared() { Texture = await config.Texture.GenerateTexture(cancel), Config = config }; + + loadedLayers.Add(prepared); + + return prepared; } } diff --git a/Resources/Textures/Parallaxes/AspidParallaxBG.png.yml b/Resources/Textures/Parallaxes/AspidParallaxBG.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/AspidParallaxBG.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/AspidParallaxNeb.png.yml b/Resources/Textures/Parallaxes/AspidParallaxNeb.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/AspidParallaxNeb.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/Asteroids.png.yml b/Resources/Textures/Parallaxes/Asteroids.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/Asteroids.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/KettleParallaxBG.png.yml b/Resources/Textures/Parallaxes/KettleParallaxBG.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/KettleParallaxBG.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/KettleParallaxNeb.png.yml b/Resources/Textures/Parallaxes/KettleParallaxNeb.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/KettleParallaxNeb.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/core_planet.png.yml b/Resources/Textures/Parallaxes/core_planet.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/core_planet.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/debris_large.png.yml b/Resources/Textures/Parallaxes/debris_large.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/debris_large.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/debris_small.png.yml b/Resources/Textures/Parallaxes/debris_small.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/debris_small.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/gas_giant.png.yml b/Resources/Textures/Parallaxes/gas_giant.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/gas_giant.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/land.png.yml b/Resources/Textures/Parallaxes/land.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/land.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/layer1.png.yml b/Resources/Textures/Parallaxes/layer1.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/layer1.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/noise.png.yml b/Resources/Textures/Parallaxes/noise.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/noise.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/planet.png.yml b/Resources/Textures/Parallaxes/planet.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/planet.png.yml @@ -0,0 +1 @@ +preload: false diff --git a/Resources/Textures/Parallaxes/space_map2.png.yml b/Resources/Textures/Parallaxes/space_map2.png.yml new file mode 100644 index 0000000000..a2cfb0a1fe --- /dev/null +++ b/Resources/Textures/Parallaxes/space_map2.png.yml @@ -0,0 +1 @@ +preload: false