using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using ADOFAIRenderer.Core; using ADOFAIRenderer.Data; using ADOFAIRenderer.Util; using UnityEngine; namespace ADOFAIRenderer.API; /// /// Fluent builder for configuring and starting a render session. /// [SuppressMessage("ReSharper", "UnusedType.Global")] [SuppressMessage("ReSharper", "UnusedMember.Global")] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] public class RenderSession { private sealed class GlobalHookRegistration : IDisposable { public object Key { get; } public Action Action { get; } private bool _isDisposed; public GlobalHookRegistration(object key, Action action) { Key = key; Action = action; } public void Dispose() { if (_isDisposed) return; _isDisposed = true; RemoveGlobalRegistration(this); } } private sealed class SessionHookEntry { public object Key { get; } public Action Action { get; } public SessionHookEntry(object key, Action action) { Key = key; Action = action; } } private static readonly List GlobalFrameHooks = new(); private static readonly object GlobalHooksLock = new(); private static int _globalHooksVersion; private readonly List _frameHooks = new(); private bool _auto = true; /// /// Specifies the types of patches that can be configured for a render session. /// public enum PatchType { /// /// Time-related patch controlling frame timing and clock synchronization. /// Time, /// /// Screen-related patch controlling screen resolution. /// Screen } private bool _timePatch = true; private bool _screenPatch = true; private string? _outputPath; private bool _notification = true; private Func? _endCondition; private Action? _onComplete; private Action? _onLastUpdate; /// /// Sets whether auto mode is enabled. /// /// true to enable auto mode; otherwise, false. Default is true. /// The current instance for method chaining. public RenderSession WithAuto(bool enabled = true) { _auto = enabled; return this; } /// /// Sets whether a specific patch type is enabled for the render session. /// /// The to configure. /// true to enable the specified patch; otherwise, false. Default is true. /// The current instance for method chaining. /// Thrown when an unsupported is provided. public RenderSession WithPatch(PatchType patchType, bool enabled = true) { switch (patchType) { case PatchType.Time: _timePatch = enabled; break; case PatchType.Screen: _screenPatch = enabled; break; default: throw new ArgumentOutOfRangeException(nameof(patchType), patchType, null); } return this; } /// /// Sets the custom condition function that determines when the render session should end. /// /// A delegate returning true when the session end condition is met. /// The current instance for method chaining. public RenderSession WithEndCondition(Func endCondition) { _endCondition = endCondition; return this; } /// /// Sets the custom output path for the rendered file. /// /// The file path where the output will be saved, or null to use the default capture directory. /// The current instance for method chaining. public RenderSession WithOutputPath(string? outputPath) { _outputPath = outputPath; return this; } /// /// Sets whether desktop notifications should be displayed when rendering completes. /// /// true to enable desktop notifications; otherwise, false. /// The current instance for method chaining. public RenderSession WithNotification(bool enabled) { _notification = enabled; return this; } /// /// Adds a callback action to be invoked when the render session completes or fails. /// /// The callback action that receives the resulting . /// The current instance for method chaining. public RenderSession OnComplete(Action callback) { _onComplete += callback; return this; } /// /// Adds a callback action to be invoked on the last update of each frame in the render session. /// /// The callback action to execute. /// The current instance for method chaining. public RenderSession OnLastUpdate(Action callback) { _onLastUpdate += callback; return this; } /// /// Adds a custom frame modification hook to be executed for each encoded video frame. /// The hook can modify the rendered frame using GPU ping-pong double buffering. /// /// /// Execution Frequency Note: This hook is invoked only for frames that are captured for the output video. /// When RenderFPS is higher than VideoFPS, intermediate game render frames are skipped, /// so this hook will not be called on every game frame. /// /// The callback action that receives a . /// The current instance for method chaining. /// Thrown when is null. public RenderSession AddFrameHook(Action hook) { if (hook == null) throw new ArgumentNullException(nameof(hook)); _frameHooks.Add(new SessionHookEntry(hook, hook)); return this; } /// /// Adds a material-based post-processing pass to be executed for each encoded video frame. /// Automatically performs a GPU blit from source to destination and swaps buffers. /// /// The material containing the shader to apply. /// The shader pass index to use, or -1 to execute all passes. /// The current instance for method chaining. /// Thrown when is null. public RenderSession AddFrameHook(Material material, int pass = -1) { if (material is null) throw new ArgumentNullException(nameof(material)); _frameHooks.Add(new SessionHookEntry(material, context => context.Blit(material, pass))); return this; } /// /// Adds a custom frame modification hook implementing . /// /// The hook instance to execute. /// The current instance for method chaining. /// Thrown when is null. public RenderSession AddFrameHook(IFrameHook hook) { if (hook == null) throw new ArgumentNullException(nameof(hook)); _frameHooks.Add(new SessionHookEntry(hook, hook.OnRenderFrame)); return this; } /// /// Alias for . /// /// The callback action that receives a . /// The current instance for method chaining. public RenderSession WithFrameHook(Action hook) => AddFrameHook(hook); /// /// Alias for . /// /// The material containing the shader to apply. /// The shader pass index to use, or -1 to execute all passes. /// The current instance for method chaining. public RenderSession WithFrameHook(Material material, int pass = -1) => AddFrameHook(material, pass); /// /// Alias for . /// /// The hook instance to execute. /// The current instance for method chaining. public RenderSession WithFrameHook(IFrameHook hook) => AddFrameHook(hook); /// /// Removes a previously added frame modification hook callback from this session. /// /// The hook action to remove. /// The current instance for method chaining. public RenderSession RemoveFrameHook(Action hook) { var idx = _frameHooks.FindIndex(e => ReferenceEquals(e.Key, hook) || Equals(e.Action, hook)); if (idx >= 0) _frameHooks.RemoveAt(idx); return this; } /// /// Removes a previously added material-based post-processing pass from this session. /// /// The material to remove. /// The current instance for method chaining. public RenderSession RemoveFrameHook(Material material) { if (material) { for (var i = _frameHooks.Count - 1; i >= 0; i--) { if (ReferenceEquals(_frameHooks[i].Key, material)) { _frameHooks.RemoveAt(i); } } } return this; } /// /// Removes a previously added from this session. /// /// The hook instance to remove. /// The current instance for method chaining. public RenderSession RemoveFrameHook(IFrameHook hook) { var idx = _frameHooks.FindIndex(e => ReferenceEquals(e.Key, hook)); if (idx >= 0) _frameHooks.RemoveAt(idx); return this; } /// /// Clears all frame modification hooks configured for this render session. /// /// The current instance for method chaining. public RenderSession ClearFrameHooks() { _frameHooks.Clear(); return this; } /// /// Registers a global frame modification hook that will execute on every render session. /// /// /// Execution Frequency Note: Global frame hooks are executed only for frames that are captured for the output video. /// When RenderFPS is higher than VideoFPS, intermediate game render frames are skipped, /// so this hook will not be called on every game frame. /// /// The callback action that receives a . /// An registration handle that unregisters the hook when disposed. /// Thrown when is null. public static IDisposable AddGlobalFrameHook(Action hook) { if (hook == null) throw new ArgumentNullException(nameof(hook)); var reg = new GlobalHookRegistration(hook, hook); lock (GlobalHooksLock) { GlobalFrameHooks.Add(reg); _globalHooksVersion++; } return reg; } /// /// Registers a global material-based post-processing pass that will execute on every render session. /// /// The material containing the shader to apply. /// The shader pass index to use, or -1 to execute all passes. /// An registration handle that unregisters the hook when disposed. /// Thrown when is null. public static IDisposable AddGlobalFrameHook(Material material, int pass = -1) { if (material is null) throw new ArgumentNullException(nameof(material)); var reg = new GlobalHookRegistration(material, context => context.Blit(material, pass)); lock (GlobalHooksLock) { GlobalFrameHooks.Add(reg); _globalHooksVersion++; } return reg; } /// /// Registers a global frame modification hook implementing . /// /// The hook instance to execute. /// An registration handle that unregisters the hook when disposed. /// Thrown when is null. public static IDisposable AddGlobalFrameHook(IFrameHook hook) { if (hook == null) throw new ArgumentNullException(nameof(hook)); var reg = new GlobalHookRegistration(hook, hook.OnRenderFrame); lock (GlobalHooksLock) { GlobalFrameHooks.Add(reg); _globalHooksVersion++; } return reg; } /// /// Unregisters a previously registered global frame modification hook. /// /// The hook action to remove. /// true if the hook was successfully removed; otherwise, false. public static bool RemoveGlobalFrameHook(Action hook) { lock (GlobalHooksLock) { var idx = GlobalFrameHooks.FindIndex(r => ReferenceEquals(r.Key, hook) || Equals(r.Action, hook)); if (idx >= 0) { GlobalFrameHooks.RemoveAt(idx); _globalHooksVersion++; return true; } return false; } } /// /// Unregisters a previously registered global material-based post-processing pass. /// /// The material to remove. /// true if any hooks matching were removed; otherwise, false. public static bool RemoveGlobalFrameHook(Material material) { lock (GlobalHooksLock) { var removed = false; for (var i = GlobalFrameHooks.Count - 1; i >= 0; i--) { if (ReferenceEquals(GlobalFrameHooks[i].Key, material)) { GlobalFrameHooks.RemoveAt(i); removed = true; } } if (removed) { _globalHooksVersion++; } return removed; } } /// /// Unregisters a previously registered global frame modification hook implementing . /// /// The hook instance to remove. /// true if the hook was successfully removed; otherwise, false. public static bool RemoveGlobalFrameHook(IFrameHook hook) { lock (GlobalHooksLock) { var idx = GlobalFrameHooks.FindIndex(r => ReferenceEquals(r.Key, hook)); if (idx >= 0) { GlobalFrameHooks.RemoveAt(idx); _globalHooksVersion++; return true; } return false; } } private static void RemoveGlobalRegistration(GlobalHookRegistration registration) { lock (GlobalHooksLock) { if (GlobalFrameHooks.Remove(registration)) { _globalHooksVersion++; } } } /// /// Clears all registered global frame modification hooks. /// public static void ClearGlobalFrameHooks() { lock (GlobalHooksLock) { if (GlobalFrameHooks.Count > 0) { GlobalFrameHooks.Clear(); _globalHooksVersion++; } } } /// /// Indicates whether any global frame modification hooks are currently registered. /// public static bool HasGlobalFrameHooks { get { lock (GlobalHooksLock) { return GlobalFrameHooks.Count > 0; } } } /// /// The number of currently registered global frame modification hooks. /// public static int GlobalFrameHookCount { get { lock (GlobalHooksLock) { return GlobalFrameHooks.Count; } } } internal static int GlobalHooksVersion { get { lock (GlobalHooksLock) { return _globalHooksVersion; } } } internal static void CopyGlobalFrameHooksTo(List> destination) { lock (GlobalHooksLock) { for (var i = 0; i < GlobalFrameHooks.Count; i++) { destination.Add(GlobalFrameHooks[i].Action); } } } /// /// Applies the configured flags and initiates the render session. /// /// The configuration settings for the render session. /// The file path of the level to render. /// Thrown when the output directory cannot be determined from the output path. public void Start(RenderConfig renderConfig, string levelPath) { RenderContext.Auto = _auto; RenderContext.TimePatch = _timePatch; RenderContext.ScreenPatch = _screenPatch; RenderContext.OutputPath = _outputPath; RenderContext.Notification = _notification; if (_endCondition is not null) RenderContext.EndCondition = _endCondition; if (_onComplete is not null) RenderContext.OnComplete = _onComplete; if (_onLastUpdate is not null) RenderContext.OnLastUpdate = _onLastUpdate; RenderContext.SessionFrameHooks.Clear(); if (_frameHooks.Count > 0) { foreach (var t in _frameHooks) { RenderContext.SessionFrameHooks.Add(t.Action); } } RenderManager.Instance.levelPath = levelPath; // The render uses its own copy of the configuration: values adjusted during the render // (e.g. the audio sample rate in SetupPlay) stay in this copy and do not change the caller's object. RenderManager.Instance.Configuration = renderConfig.Clone(); RenderManager.Instance.stage = RenderStage.Load; if (_outputPath is null) RenderManager.Instance.SetupCaptureDirectory(); else FileUtil.CreateDirectory(Path.GetDirectoryName(_outputPath) ?? throw new InvalidOperationException()); RenderManager.Instance.SetupGameScene(); } }