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, and the registry of global frame hooks. /// /// /// /// Usage: create an instance, configure it with the With…, On… and AddFrameHook methods, /// then call . The builder's values are copied into the render when is called; /// changing the builder afterwards does not affect the running render. A builder can be reused for later renders. /// /// /// Lifecycle of a render: loads the game scene of the level. When the scene has loaded, /// a 3-second countdown runs, then capture starts and the level plays with the game clock driven by the render /// frame rate (offline, not real time). The render ends successfully seconds /// (render time) after the end condition first holds, or is cancelled by the user (ESC), or fails. In every case /// the completion callback receives a and the game returns to the level editor. /// /// /// Threading: every member must be called on the main Unity thread, except the global hook members, which are /// thread-safe (they lock an internal object). All callbacks (end condition, completion, last update, frame hooks) /// are invoked on the main Unity thread. /// /// /// Frame hooks run on each captured video frame before it is encoded, in this order: session hooks in the order /// they were added, then global hooks in registration order. /// /// [SuppressMessage("ReSharper", "UnusedType.Global")] [SuppressMessage("ReSharper", "UnusedMember.Global")] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] public class RenderSession { /// /// Handle returned by AddGlobalFrameHook. Key is the object the caller registered (delegate, material or IFrameHook) /// and is used by RemoveGlobalFrameHook; Action is the delegate the pipeline invokes. /// Dispose removes this exact registration once; later calls do nothing. /// 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); } } /// /// Session hook added to this builder. Key is the object the caller passed (delegate, material or IFrameHook) and /// is used by RemoveFrameHook; Action is the delegate copied to the render by Start. /// private sealed class SessionHookEntry { public object Key { get; } public Action Action { get; } public SessionHookEntry(object key, Action action) { Key = key; Action = action; } } /// Global hooks in registration order. Guarded by GlobalHooksLock. private static readonly List GlobalFrameHooks = new(); /// Lock for GlobalFrameHooks and _globalHooksVersion (global hooks may be changed from any thread). private static readonly object GlobalHooksLock = new(); /// /// Incremented on every change of GlobalFrameHooks. RenderManager compares it each captured frame to rebuild its /// cached hook list only after a change. Guarded by GlobalHooksLock. /// private static int _globalHooksVersion; /// Session hooks of this builder in the order they were added. private readonly List _frameHooks = new(); /// Autoplay setting copied to the render by Start (default true). 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. /// While capturing, Time.time, Time.timeAsDouble, Time.unscaledTime, /// Time.unscaledTimeAsDouble, Time.deltaTime, Time.unscaledDeltaTime, Time.frameCount /// and AudioSettings.dspTime return values of the render clock (one frame = 1 / RenderFPS seconds), /// so the game advances exactly one render frame per Unity frame regardless of how long a frame takes. /// Disabling it leaves these values to Unity. /// Time, /// /// Screen-related patch controlling screen resolution. /// While the render is active (from the countdown on), Screen.width and Screen.height return /// and , so the game creates its /// camera RenderTexture at the render resolution. /// Screen } /// Time patch setting copied to the render by Start (default true). private bool _timePatch = true; /// Screen patch setting copied to the render by Start (default true). private bool _screenPatch = true; /// Custom output file path, or null for the default output directory. private string? _outputPath; /// Notification setting copied to the render by Start (default true). private bool _notification = true; /// Custom end condition, or null to keep the default (level cleared). private Func? _endCondition; /// Combined completion callbacks, or null to keep the default (notification). private Action? _onComplete; /// Combined last-update callbacks, or null for none. private Action? _onLastUpdate; /// /// Sets whether auto mode (the game's autoplay, RDC.auto) is enabled during the render. /// With auto mode off, the level is not played automatically; the render still enables the game's no-fail /// settings (scrController.noFail, scrController.noFailInfiniteMargin). /// /// 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. /// /// /// The condition is polled on the main Unity thread once per frame from the start of the countdown until the render ends. /// When it first returns true, the render continues for seconds of /// render time and then finishes successfully; it is not required to stay true. The default condition is /// that the level is cleared (scrController.instance.state == States.Won). Calling this method again /// replaces the previous condition. Exceptions thrown by the condition are not caught. /// /// 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 directory of the path is created by when it does not exist. An existing file at the path /// is overwritten. The container format follows the file extension. With null, the file is named /// yyyy-MM-dd HH-mm-ss.<ext> (local start time, extension from ) /// in the output directory chosen in the mod settings. When the render is cancelled or fails, the unfinished file /// is deleted. /// /// 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. /// /// /// The notification is shown by the default completion callback only. When a callback is added with /// , it replaces the default callback and no notification is shown. Default is true. /// /// 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. /// /// /// Called once per render on the main Unity thread: on success after the output file has been finished, on cancel or /// failure after the unfinished file has been deleted, and before the game returns to the level editor. /// It is not called when the render stops because the game quits. Multiple calls combine the callbacks (invoked in /// the order added). The combined callback replaces the default one, which shows the desktop notification. /// An exception thrown by a callback is logged and stops the callbacks added after it. /// /// 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. /// /// /// Called on the main Unity thread every Unity frame after all other LateUpdate calls, from the moment the game scene /// of the render has loaded until the render ends (including the countdown, before capture starts). /// Multiple calls combine the callbacks. An exception thrown by a callback is logged. /// /// 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 hook is called on the main Unity thread at the end of the frame, after the game has rendered it and before /// the GPU readback for encoding is requested. Write the result into and /// swap (or use , which swaps); a hook that does not swap leaves /// the frame unchanged. An exception thrown by the hook is logged and the next hook still runs. /// Session hooks apply only to the render started by and are discarded when it ends. /// The same delegate can be added more than once; it then runs once per addition. /// /// /// 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. /// /// /// Equivalent to a hook calling : the current frame is bound as the /// material's _MainTex by Graphics.Blit. The material is referenced, not copied, so property changes /// made to it later apply to the following frames. Execution rules are the same as /// . /// Reference: https://docs.unity3d.com/ScriptReference/Graphics.Blit.html /// /// 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 . /// /// /// is invoked with the same rules as /// . Remove it with . /// /// 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. /// /// /// Removes the first entry (in the order added) whose registered object is or whose delegate /// equals it. When the same delegate was added several times, one call removes one addition. Does nothing when no /// entry matches. Affects only renders started after this call. /// /// 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. /// /// /// Removes every pass added with (any pass index). Does nothing when the material is /// null or destroyed. Affects only renders started after this call. /// /// 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. /// /// /// Removes the first entry added with this instance (reference equality). Affects only renders started after this call. /// /// 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 registration stays active for every following render (and a render already running, from its next /// captured frame) until the returned handle is disposed or the hook is removed with /// or . /// Global hooks run after the session hooks, in registration order, on the main Unity thread; an exception thrown /// by the hook is logged and the next hook still runs. This method is thread-safe. Registering the same delegate /// twice creates two registrations. /// /// /// 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. /// /// /// Same lifetime and ordering as ; the pass is applied as /// . Thread-safe. /// /// 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 . /// /// /// Same lifetime and ordering as . Thread-safe. /// /// 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. /// /// /// Removes the first registration (in registration order) whose registered object is or whose /// delegate equals it. Thread-safe; a running render stops calling the hook from its next captured frame. /// /// 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. /// /// Removes every registration made with (any pass index). Thread-safe. /// 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 . /// /// Removes the first registration made with this instance (reference equality). Thread-safe. /// 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; } } /// Removes one registration (called by its Dispose). Does nothing when it was already removed. private static void RemoveGlobalRegistration(GlobalHookRegistration registration) { lock (GlobalHooksLock) { if (GlobalFrameHooks.Remove(registration)) { _globalHooksVersion++; } } } /// /// Clears all registered global frame modification hooks. /// /// /// Removes the registrations of every mod, not only the caller's. Handles returned earlier stay valid; disposing them /// does nothing. Thread-safe. /// public static void ClearGlobalFrameHooks() { lock (GlobalHooksLock) { if (GlobalFrameHooks.Count > 0) { GlobalFrameHooks.Clear(); _globalHooksVersion++; } } } /// /// Indicates whether any global frame modification hooks are currently registered (by any mod). Thread-safe. /// public static bool HasGlobalFrameHooks { get { lock (GlobalHooksLock) { return GlobalFrameHooks.Count > 0; } } } /// /// The number of currently registered global frame modification hooks (by any mod). Thread-safe. /// public static int GlobalFrameHookCount { get { lock (GlobalHooksLock) { return GlobalFrameHooks.Count; } } } /// Change counter of the global hook list (see _globalHooksVersion). Thread-safe. internal static int GlobalHooksVersion { get { lock (GlobalHooksLock) { return _globalHooksVersion; } } } /// Appends the delegates of every global hook, in registration order, to . Thread-safe. 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. /// /// /// /// Must be called on the main Unity thread while no render is running; this method does not check for a running /// render. It copies the builder's options and hooks into the render, copies /// (later changes to the caller's object do not affect the render), creates the output directory, and starts /// loading the game scene (scnGame) with the level. It returns immediately; the render runs over the following /// frames and reports its end through the callback. /// /// /// Options that were not set keep their defaults: auto mode on, both patches on, default output path, notification /// on, end condition "level cleared", default completion callback (notification), no last-update callback. /// /// /// The configuration settings for the render session. /// The file path of the level to render (the .adofai file). /// Thrown when the output directory cannot be determined from the output path. /// /// Thrown by the directory creation when the output path has no directory part (for example a bare file name). /// public void Start(RenderConfig renderConfig, string levelPath) { // Callbacks not set on this builder keep the values RenderContext holds; RenderContext.Reset at the end of every // render restores the defaults. 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(); } }