var collection = IoCManager.Instance!;
collection.Register<IParallaxManager, ParallaxManager>();
+ collection.Register<GeneratedParallaxCache>();
collection.Register<IChatManager, ChatManager>();
collection.Register<ISharedChatManager, ChatManager>();
collection.Register<IClientPreferencesManager, ClientPreferencesManager>();
-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;
/// <summary>
/// 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.
/// </summary>
[DataField("id")]
public string Identifier { get; private set; } = "other";
- /// <summary>
- /// Cached path.
- /// In user directory.
- /// </summary>
- private ResPath ParallaxCachedImagePath => new($"/parallax_{Identifier}cache.png");
-
- /// <summary>
- /// Old parallax config path (for checking for parallax updates).
- /// In user directory.
- /// </summary>
- private ResPath PreviousParallaxConfigPath => new($"/parallax_{Identifier}config_old");
-
async Task<Texture> 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<IConfigurationManager>().GetCVar(CCVars.ParallaxDebug);
- var resManager = IoCManager.Resolve<IResourceManager>();
-
- 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<GeneratedParallaxCache>();
+ 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<Image<Rgba32>>() : null;
-
- var sawmill = IoCManager.Resolve<ILogManager>().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<IResourceManager>();
-
- // 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<IResourceManager>();
- using var imageStream = resManager.UserData.OpenRead(ParallaxCachedImagePath);
- return Texture.LoadFromPNGStream(imageStream, "Parallax");
- }
-
- private string? GetParallaxConfig()
- {
- var resManager = IoCManager.Resolve<IResourceManager>();
- 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<GeneratedParallaxCache>();
+ cache.Unload(Identifier);
}
}
using System.Threading;
using System.Threading.Tasks;
using Robust.Client.Graphics;
-using Robust.Shared.Graphics;
namespace Content.Client.Parallax.Data
{
/// Note that this should be cached, but not necessarily *here*.
/// </summary>
Task<Texture> GenerateTexture(CancellationToken cancel = default);
+
+ /// <summary>
+ /// Called when the parallax texture is no longer necessary, and may be unloaded.
+ /// </summary>
+ void Unload(IDependencyCollection dependencies)
+ {
+ }
}
}
--- /dev/null
+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;
+
+/// <summary>
+/// Caches the textures generated by <see cref="GeneratedParallaxTextureSource"/>
+/// </summary>
+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<string, CacheDatum> _data = new();
+
+ private ISawmill _sawmill = null!;
+
+ public Task<Texture> 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<Texture> LoadTask(string id, ResPath configPath, CancellationToken cancel)
+ {
+ return await GenerateTexture(id, configPath, cancel);
+ }
+
+ private async Task<Texture> 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<Image<Rgba32>>() : 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<Texture> LoadTask;
+ public required CancellationTokenSource CancellationSource;
+ public ValueList<CancellationTokenRegistration> CancelRegistrations;
+
+ public int RefCount;
+ }
+}
-using System.Collections.Concurrent;
using System.Numerics;
using System.Threading;
using System.Threading.Tasks;
using Content.Shared.CCVar;
using Robust.Shared.Prototypes;
using Robust.Shared.Configuration;
-using Robust.Shared.Utility;
namespace Content.Client.Parallax.Managers;
{
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IConfigurationManager _configurationManager = default!;
+ [Dependency] private readonly IDependencyCollection _deps = null!;
private ISawmill _sawmill = Logger.GetSawmill("parallax");
{
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()
// 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<ParallaxLayerPrepared>();
+
try
{
var parallaxPrototype = _prototypeManager.Index<ParallaxPrototype>(name);
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)
{
}
}
- private async Task<ParallaxLayerPrepared[]> LoadParallaxLayers(List<ParallaxLayerConfig> layersIn, CancellationToken cancel = default)
+ private async Task<ParallaxLayerPrepared[]> LoadParallaxLayers(
+ List<ParallaxLayerConfig> layersIn,
+ List<ParallaxLayerPrepared> 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<ParallaxLayerPrepared>[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<ParallaxLayerPrepared> LoadParallaxLayer(ParallaxLayerConfig config, CancellationToken cancel = default)
+ private async Task<ParallaxLayerPrepared> LoadParallaxLayer(
+ ParallaxLayerConfig config,
+ List<ParallaxLayerPrepared> loadedLayers,
+ CancellationToken cancel = default)
{
- return new ParallaxLayerPrepared()
+ var prepared = new ParallaxLayerPrepared()
{
Texture = await config.Texture.GenerateTexture(cancel),
Config = config
};
+
+ loadedLayers.Add(prepared);
+
+ return prepared;
}
}
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false
--- /dev/null
+preload: false