diff --git a/API/FrameHookContext.cs b/API/FrameHookContext.cs index cec727f..ac34e82 100644 --- a/API/FrameHookContext.cs +++ b/API/FrameHookContext.cs @@ -18,6 +18,21 @@ namespace ADOFAIRenderer.API; /// When RenderFPS exceeds VideoFPS, intermediate game rendering frames are skipped and the hook /// will not be invoked on every rendered game frame. /// +/// +/// Ping-pong model: the renderer owns two RenderTextures with the size and format of the game frame. +/// is the current frame, is the buffer the next pass writes. +/// A pass draws from into and then calls , which makes +/// the written buffer the new . , and by default +/// and , swap automatically. The texture in +/// after the last hook is encoded. When no hook swaps or replaces the frame, the game frame is +/// encoded unchanged. The first of every frame is the game's own frame texture +/// (); do not write into it. +/// +/// +/// Threading and lifetime: hooks run on the main Unity thread at the end of the frame, after the game has +/// rendered. The textures are valid only during the hook call: do not keep references to , +/// or the context. RenderTexture.active is restored after all hooks have run. +/// /// [SuppressMessage("ReSharper", "UnusedType.Global")] [SuppressMessage("ReSharper", "UnusedMember.Global")] @@ -25,12 +40,22 @@ namespace ADOFAIRenderer.API; [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] public class FrameHookContext { + /// Game frame of this capture (camera quad texture); never written by the context. private RenderTexture _originalSource = null!; + + /// First ping-pong buffer, owned by FrameHookPipeline. private RenderTexture _pingRT = null!; + + /// Second ping-pong buffer, owned by FrameHookPipeline. private RenderTexture _pongRT = null!; + /// Current frame: the original source, a ping-pong buffer, or a texture set by ReplaceCurrent. private RenderTexture _currentSource = null!; + + /// Buffer the next pass writes: always the ping or pong buffer. private RenderTexture _currentDestination = null!; + + /// True after the first Swap or ReplaceCurrent of this frame. private bool _hasModified; /// @@ -45,6 +70,7 @@ public class FrameHookContext /// /// The active representing the current state of the modified frame. + /// Same texture as ; after the last hook it is the frame that gets encoded. /// public RenderTexture Current => _currentSource; @@ -59,7 +85,7 @@ public class FrameHookContext public bool HasModified => _hasModified; /// - /// The sequential 0-based index of the video frame currently being encoded. + /// The sequential 0-based index of the video frame currently being captured (its position in the output video). /// public long FrameIndex { get; private set; } @@ -69,12 +95,13 @@ public class FrameHookContext public long VideoFrameIndex => FrameIndex; /// - /// The sequential 0-based index of the game render frame. + /// The sequential 0-based index of the game render frame, counted from the start of capture. + /// With RenderFPS above the video frame rate it advances by more than one between hook calls. /// public long RenderFrameIndex { get; private set; } /// - /// The timestamp of the current frame in seconds relative to video start. + /// The timestamp of the current frame in seconds relative to video start ( / ). /// public double VideoTime { get; private set; } @@ -84,30 +111,32 @@ public class FrameHookContext public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0; /// - /// The width of the frame in pixels. + /// The width of the frame in pixels (width of the current ). /// public int Width => _currentSource.width; /// - /// The height of the frame in pixels. + /// The height of the frame in pixels (height of the current ). /// public int Height => _currentSource.height; /// - /// The configured render frames per second. + /// The configured render frames per second (game frames simulated per second of render time). /// public double RenderFPS { get; private set; } /// - /// The output video frames per second. + /// The output video frames per second: the effective rate, min(configured VideoFPS, ). /// public double VideoFPS { get; private set; } /// - /// The number of successful modification passes completed for the current frame. + /// The number of modification passes completed for the current frame (calls of and + /// , including the automatic swaps of the helper methods). 0 at the start of every frame. /// public int PassCount { get; private set; } + /// Creates a context for the first frame; see for the parameters. internal FrameHookContext( RenderTexture originalSource, RenderTexture pingRT, @@ -122,6 +151,10 @@ public class FrameHookContext Reset(originalSource, pingRT, pongRT, firstDestination, frameIndex, renderFrameIndex, videoTime, renderFps, videoFps); } + /// + /// Prepares the context for a new frame: Source = , Destination = + /// (ping or pong), not modified, PassCount 0, and the frame metadata. + /// internal void Reset( RenderTexture originalSource, RenderTexture pingRT, @@ -152,6 +185,11 @@ public class FrameHookContext /// /// Blits from to using the specified and automatically swaps ping-pong buffers. /// + /// + /// Uses Graphics.Blit, which binds as the material's _MainTex and changes + /// RenderTexture.active. + /// Reference: https://docs.unity3d.com/ScriptReference/Graphics.Blit.html + /// /// The material containing the shader to apply, or null for a standard copy. /// The shader pass index to use, or -1 to execute all passes. public void Blit(Material? material = null, int pass = -1) @@ -174,6 +212,10 @@ public class FrameHookContext /// /// Blits from a custom source to using the specified and automatically swaps ping-pong buffers. /// + /// + /// The current is not read; the result replaces the frame. is + /// stretched to the size of . + /// /// The custom source texture to blit from. /// The material containing the shader to apply, or null for a standard copy. /// The shader pass index to use, or -1 to execute all passes. @@ -200,6 +242,7 @@ public class FrameHookContext /// /// Copies the contents of into without swapping buffers. /// Call this before drawing custom overlays into if you wish to preserve the background. + /// Does not count as a modification; call after drawing to make the result the current frame. /// public void Copy() { @@ -207,7 +250,8 @@ public class FrameHookContext } /// - /// Clears with the specified color. + /// Clears with the specified color (color only; no depth buffer is cleared). + /// Does not swap; the previous RenderTexture.active is restored. /// /// The color to clear with, or null for . public void ClearDestination(Color? clearColor = null) @@ -261,6 +305,11 @@ public class FrameHookContext /// /// Executes a against the GPU and optionally swaps buffers. /// + /// + /// The context does not set a render target for the command buffer: the commands must render into + /// themselves (for example with SetRenderTarget or Blit(Source, Destination)), + /// so build or update the buffer inside the hook with the current and . + /// /// The command buffer to execute. /// If true (default), automatically calls after execution. /// Thrown when is null. @@ -278,6 +327,11 @@ public class FrameHookContext /// Swaps the active and ping-pong buffers. /// Call this after manually writing or rendering into so that subsequent hooks or the video encoder read the updated frame. /// + /// + /// After the call, is the buffer that was , and + /// is the other ping-pong buffer (its content is left over from an earlier pass or frame; the next pass overwrites it). + /// A texture set by is dropped by a swap. + /// public void Swap() { _hasModified = true; @@ -291,6 +345,12 @@ public class FrameHookContext /// /// Replaces the current modified frame texture with a custom . /// + /// + /// The texture becomes / ; is unchanged. + /// The texture is read back for encoding when it is still current after the last hook, so it must stay alive and + /// unchanged until the end of the hook chain, and it should have the frame's width, height and a format the capture + /// can read: the capture sizes its buffers from the texture, and the encoder expects the render resolution. + /// /// The custom texture to set as the active frame. /// Thrown when is null. public void ReplaceCurrent(RenderTexture customTexture) diff --git a/API/IFrameHook.cs b/API/IFrameHook.cs index 179547f..e3459a7 100644 --- a/API/IFrameHook.cs +++ b/API/IFrameHook.cs @@ -9,6 +9,8 @@ namespace ADOFAIRenderer.API; /// Execution Frequency Note: Frame hooks are only invoked for frames that are actually encoded into the final video (VideoFPS). /// When RenderFPS is higher than VideoFPS, intermediate game render frames are skipped and this hook /// will not be called on every game frame. +/// Register an implementation per render with or for every render +/// with . /// [SuppressMessage("ReSharper", "UnusedType.Global")] public interface IFrameHook @@ -18,6 +20,10 @@ public interface IFrameHook /// /// /// This method is not called on every rendered game frame if RenderFPS exceeds VideoFPS. + /// It is called on the main Unity thread at the end of the frame. Draw from + /// into and swap (see ); returning without + /// a swap leaves the frame unchanged. Do not keep the context or its textures after returning. + /// An exception thrown here is logged and the next hook still runs. /// /// The frame hook context providing access to ping-pong textures and frame metadata. void OnRenderFrame(FrameHookContext context); diff --git a/API/RenderResult.cs b/API/RenderResult.cs index d1ade45..2dbece4 100644 --- a/API/RenderResult.cs +++ b/API/RenderResult.cs @@ -7,6 +7,9 @@ namespace ADOFAIRenderer.API; /// /// Represents the result and metadata of a completed or canceled render session. /// +/// +/// Passed to the completion callback registered with . Immutable. +/// [SuppressMessage("ReSharper", "UnusedType.Global")] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] public class RenderResult @@ -17,7 +20,10 @@ public class RenderResult public bool Success { get; } /// - /// The absolute output path of the rendered video file. Empty string if rendering failed or output was not created. + /// The output path of the rendered video file: the path given to as passed + /// (relative if it was relative), or the path of the time-stamped file in the output directory chosen in the mod + /// settings (by default <game root>/Render). + /// Empty string if rendering failed or was cancelled (the unfinished file is deleted). /// public string OutputPath { get; } @@ -27,12 +33,13 @@ public class RenderResult public long TotalFrames { get; } /// - /// The calculated video duration in seconds (based on TotalFrames and FPS). + /// The calculated video duration in seconds: divided by the effective video frame rate + /// (min of the configured VideoFPS and RenderFPS); 0 when the frame rate is not positive. /// public double VideoDuration { get; } /// - /// Total wall-clock time spent rendering. + /// Total wall-clock time spent rendering, from the end of the countdown (start of capture) to the end of the render. /// public TimeSpan RenderDuration { get; } @@ -43,6 +50,8 @@ public class RenderResult /// /// Error message or reason if rendering failed or was canceled; null on success. + /// A cancelled render has "Rendering was cancelled by user."; a failed render has "Rendering failed: " followed by + /// the reason. Use to test the outcome; the texts are meant for display. /// public string? ErrorMessage { get; } @@ -82,6 +91,7 @@ public class RenderResult /// The effective frames per second used to compute video duration. /// Total wall-clock time spent rendering. /// A new representing a successful render session. + /// The file size is read from disk; it is 0 when the file does not exist or cannot be read. internal static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration) { var fileSizeBytes = 0L; @@ -103,6 +113,7 @@ public class RenderResult /// The effective frames per second used to compute video duration. /// Total wall-clock time spent rendering prior to termination. /// A new representing a failed or canceled render session. + /// OutputPath is empty and FileSizeBytes is 0. A null message is replaced by a generic one. internal static RenderResult CreateFailure(string? errorMessage, long totalFrames, double fps, TimeSpan renderDuration) { var videoDuration = fps > 0 ? totalFrames / fps : 0.0; diff --git a/API/RenderSession.cs b/API/RenderSession.cs index 56ad5da..07bfa87 100644 --- a/API/RenderSession.cs +++ b/API/RenderSession.cs @@ -10,13 +10,41 @@ using UnityEngine; namespace ADOFAIRenderer.API; /// -/// Fluent builder for configuring and starting a render session. +/// 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; } @@ -37,6 +65,10 @@ public class RenderSession } } + /// + /// 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; } @@ -49,11 +81,22 @@ public class RenderSession } } + /// 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; /// @@ -63,27 +106,48 @@ public class RenderSession { /// /// 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 is enabled. + /// 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. @@ -115,6 +179,13 @@ public class RenderSession /// /// Sets the custom condition function that determines when the render session should end. /// + /// + /// The condition is polled on the main 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) @@ -126,6 +197,13 @@ public class RenderSession /// /// 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) @@ -137,6 +215,10 @@ public class RenderSession /// /// 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) @@ -148,6 +230,13 @@ public class RenderSession /// /// Adds a callback action to be invoked when the render session completes or fails. /// + /// + /// Called once per render on the main 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) @@ -159,6 +248,11 @@ public class RenderSession /// /// Adds a callback action to be invoked on the last update of each frame in the render session. /// + /// + /// Called on the main 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) @@ -172,9 +266,19 @@ public class RenderSession /// 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. @@ -190,6 +294,13 @@ public class RenderSession /// 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. @@ -204,6 +315,10 @@ public class RenderSession /// /// 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. @@ -239,6 +354,11 @@ public class RenderSession /// /// 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) @@ -251,6 +371,10 @@ public class RenderSession /// /// 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) @@ -271,6 +395,9 @@ public class RenderSession /// /// 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) @@ -294,9 +421,19 @@ public class RenderSession /// 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. @@ -316,6 +453,10 @@ public class RenderSession /// /// 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. @@ -335,6 +476,9 @@ public class RenderSession /// /// 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. @@ -353,6 +497,10 @@ public class RenderSession /// /// 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) @@ -373,6 +521,7 @@ public class RenderSession /// /// 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) @@ -399,6 +548,7 @@ public class RenderSession /// /// 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) @@ -416,6 +566,7 @@ public class RenderSession } } + /// Removes one registration (called by its Dispose). Does nothing when it was already removed. private static void RemoveGlobalRegistration(GlobalHookRegistration registration) { lock (GlobalHooksLock) @@ -430,6 +581,10 @@ public class RenderSession /// /// 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) @@ -443,7 +598,7 @@ public class RenderSession } /// - /// Indicates whether any global frame modification hooks are currently registered. + /// Indicates whether any global frame modification hooks are currently registered (by any mod). Thread-safe. /// public static bool HasGlobalFrameHooks { @@ -457,7 +612,7 @@ public class RenderSession } /// - /// The number of currently registered global frame modification hooks. + /// The number of currently registered global frame modification hooks (by any mod). Thread-safe. /// public static int GlobalFrameHookCount { @@ -470,6 +625,7 @@ public class RenderSession } } + /// Change counter of the global hook list (see _globalHooksVersion). Thread-safe. internal static int GlobalHooksVersion { get @@ -481,6 +637,7 @@ public class RenderSession } } + /// Appends the delegates of every global hook, in registration order, to . Thread-safe. internal static void CopyGlobalFrameHooksTo(List> destination) { lock (GlobalHooksLock) @@ -495,11 +652,29 @@ public class RenderSession /// /// 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 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; diff --git a/Core/Data/GlobalSettings.cs b/Core/Data/GlobalSettings.cs index 398f88e..f4bb28a 100644 --- a/Core/Data/GlobalSettings.cs +++ b/Core/Data/GlobalSettings.cs @@ -6,29 +6,59 @@ using Newtonsoft.Json; namespace ADOFAIRenderer.Core.Data; +/// +/// Mod-wide settings that are not part of a render preset, stored as JSON in +/// <game root>/UserData/ADOFAIRenderer/global_settings.json (see ). +/// internal class GlobalConfig { + /// + /// Output directory chosen in the render configuration window; null or empty selects + /// GlobalSettings.DefaultOutputDirectory. Ignored while the directory does not exist (see EffectiveOutputDirectory). + /// public string? OutputDirectory { get; set; } + /// Name of the preset last selected or saved in the render configuration window; selected again when it opens. public string? LastPresetName { get; set; } + /// + /// True once PresetManager.EnsureDefaultPresetsCreated has written the built-in preset files. Built-in presets the user + /// deleted afterwards are not written again. + /// public bool DefaultPresetsInitialized { get; set; } } +/// +/// Loads and saves . The file is read once by the static constructor and again by +/// RenderMod.OnInitializeMelon (Load), and written by Save. Read and write errors are logged as warnings and never +/// thrown; a file that cannot be read yields the default settings. Main thread. +/// internal static class GlobalSettings { + /// MelonLoader's game root directory, or the application base directory when it is empty. private static string GetGameRootDir() { var root = MelonEnvironment.GameRootDirectory; return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root; } + /// <game root>/UserData/ADOFAIRenderer/global_settings.json. private static string SettingsFilePath => Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json"); + /// + /// Settings in memory. Changes are kept in memory until is called; replaces the + /// object. + /// public static GlobalConfig Current { get; private set; } = new(); + /// Output directory used when none is configured: <game root>/Render. public static string DefaultOutputDirectory => Path.Combine(GetGameRootDir(), "Render"); + /// + /// Directory where renders without an explicit output path are written: Current.OutputDirectory when it is set and + /// exists, otherwise . The default directory is not created here + /// (RenderManager.SetupCaptureDirectory creates it). + /// public static string EffectiveOutputDirectory { get @@ -47,6 +77,10 @@ internal static class GlobalSettings Load(); } + /// + /// Replaces with the content of the settings file. A missing, empty or unreadable file gives the + /// default settings (an unreadable file is logged as a warning). + /// public static void Load() { try @@ -66,6 +100,10 @@ internal static class GlobalSettings Current = new GlobalConfig(); } + /// + /// Writes as indented JSON to the settings file, creating the directory when needed. + /// Errors are logged as warnings. + /// public static void Save() { try diff --git a/Core/FirstUpdateHook.cs b/Core/FirstUpdateHook.cs index 1f1debb..185e518 100644 --- a/Core/FirstUpdateHook.cs +++ b/Core/FirstUpdateHook.cs @@ -4,14 +4,30 @@ using UnityEngine; namespace ADOFAIRenderer.Core; +/// +/// MonoBehaviour that runs before every other script's Update (execution order int.MinValue; scripts run from the +/// lowest order to the highest). Lives on a DontDestroyOnLoad object created by RenderMod. +/// It performs the start-up checks (FFmpeg libraries present, NVIDIA driver version) and then advances the render +/// frame counter through RenderManager.OnPreUpdate once per Unity frame, so every other script of that frame sees +/// the same render time. +/// Reference: https://docs.unity3d.com/ScriptReference/DefaultExecutionOrder.html +/// [DefaultExecutionOrder(int.MinValue)] internal class FirstUpdateHook : MonoBehaviour { + /// True once the FFmpeg libraries were found in UserLibs (after the download UI, if it was needed). private bool _checkedFFmpeg; + + /// True once the NVIDIA driver version check ran (it runs once per game session). private bool _checkedNvidiaDriver; + /// + /// Main thread, every frame. The checks are done in order and each one blocks the following steps while its dialog + /// is open: the frame counter is not advanced until both checks are done. + /// private void Update() { + // 1. FFmpeg libraries: while the download UI is open, wait. When the libraries are missing, open the download UI. if (!_checkedFFmpeg) { if (FFmpegDownloadUI.Instance) return; @@ -24,6 +40,7 @@ internal class FirstUpdateHook : MonoBehaviour _checkedFFmpeg = true; } + // 2. NVIDIA driver version: waits until no other warning dialog is open, then runs once. if (!_checkedNvidiaDriver) { if (CommonWarningDialogUI.Instance) return; @@ -32,12 +49,19 @@ internal class FirstUpdateHook : MonoBehaviour CheckNvidiaDriverWarning(); } + // 3. Render frame counter (only counts while a render is in the OnScene stage or later). if (RenderManager.Instance) { RenderManager.Instance.OnPreUpdate(); } } + /// + /// Shows a warning dialog when the active GPU is an NVIDIA GPU with a driver version below 610.00. + /// No dialog is shown when the GPU is not NVIDIA or the version cannot be determined (GPUUtil returns null). + /// Buttons: open the NVIDIA driver download page and quit the game, disable the render feature for this session + /// (RenderMod.IsFeatureDisabled), or continue. + /// private void CheckNvidiaDriverWarning() { var driverVer = Util.GPUUtil.GetNvidiaDriverVersion(); diff --git a/Core/FrameHookPipeline.cs b/Core/FrameHookPipeline.cs index cf59da4..d25d9f4 100644 --- a/Core/FrameHookPipeline.cs +++ b/Core/FrameHookPipeline.cs @@ -6,16 +6,58 @@ using UnityEngine; namespace ADOFAIRenderer.Core; +/// +/// Runs the frame hooks (session and global, see RenderSession) on a captured frame before it is read back. +/// Owns two RenderTextures (ping and pong) with the size and format of the source frame; each hook pass reads the +/// current frame and writes the other buffer (see FrameHookContext). One instance per render, created in +/// RenderManager.SetupPlay and disposed when the render ends. Main thread only. +/// internal class FrameHookPipeline : IDisposable { + /// First ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers. private RenderTexture? _pingRT; + + /// Second ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers. private RenderTexture? _pongRT; + + /// + /// Buffer returned as the output of the previous Process call (ping or pong), or null when the previous frame was + /// not modified by any hook. Its readback may still be in flight, so the next frame starts writing the other buffer. + /// private RenderTexture? _lastOutputRT; + + /// Context object passed to the hooks; reset and reused for every frame (no allocation per frame). private FrameHookContext? _reusableContext; + + /// Width of the current buffers in pixels (0 when no buffers exist). private int _width; + + /// Height of the current buffers in pixels (0 when no buffers exist). private int _height; + + /// Format of the source texture the current buffers were created for. private RenderTextureFormat _format; + /// + /// Runs in order on and returns the resulting texture. + /// An exception thrown by a hook is logged and the remaining hooks still run. + /// RenderTexture.active is restored after the hooks. + /// + /// Captured game frame. Hooks do not write into it (they write the ping-pong buffers). + /// Video frame index (0-based) of this frame. + /// + /// Render frame index (0-based, RenderManager.frameCountForVideo): counts every game frame since capture started, + /// including game frames that were not captured because they fall in an already captured video frame. + /// + /// Time of this video frame in seconds from the start of the video. + /// Render frame rate (game frames per second). + /// Effective video frame rate (min(VideoFPS, RenderFPS)). + /// Hooks to run, in order. Session hooks come before global hooks. + /// + /// when there are no hooks, the source is not created, the buffers could not be + /// created, or no hook modified the frame; otherwise the texture holding the final frame (a ping-pong buffer, or a + /// texture a hook passed to FrameHookContext.ReplaceCurrent). + /// public RenderTexture? Process( RenderTexture sourceTexture, long frameIndex, @@ -81,6 +123,8 @@ internal class FrameHookPipeline : IDisposable } } + // Current is the source when nothing was modified. A texture set by ReplaceCurrent is stored in + // _lastOutputRT too; it then matches neither buffer and the next frame starts with ping. var result = _reusableContext.Current; _lastOutputRT = _reusableContext.HasModified ? result : null; return result; @@ -91,6 +135,12 @@ internal class FrameHookPipeline : IDisposable } } + /// + /// Creates the ping and pong buffers when they do not exist, were released, or the source size or format changed. + /// The buffers copy the source descriptor without depth, MSAA and mipmaps, and enable random write (UAV) when the + /// GPU supports compute shaders and random write for that format, so compute shaders can write them. + /// When creating them from the descriptor throws, ARGB32 buffers of the same size are created. + /// private void EnsureBuffers(RenderTexture sourceTexture) { var width = sourceTexture.width; @@ -160,6 +210,9 @@ internal class FrameHookPipeline : IDisposable } } + /// + /// Releases and destroys both buffers and drops the reusable context. The next Process call creates them again. + /// public void ReleaseBuffers() { if (_pingRT) @@ -182,6 +235,7 @@ internal class FrameHookPipeline : IDisposable _reusableContext = null; } + /// Same as . Main thread (Object.Destroy is used). public void Dispose() { ReleaseBuffers(); diff --git a/Core/PostRenderHook.cs b/Core/PostRenderHook.cs index e8e49a9..b6ac16c 100644 --- a/Core/PostRenderHook.cs +++ b/Core/PostRenderHook.cs @@ -5,10 +5,22 @@ using UnityEngine; namespace ADOFAIRenderer.Core; +/// +/// Per-frame hook attached to the scrCamera GameObject by RenderManager when the game scene of a render has loaded, +/// and destroyed again when a render is cancelled or fails. +/// Execution order int.MaxValue: its LateUpdate runs after every other script's LateUpdate of the frame. +/// Each frame it calls RenderManager.OnLastUpdate (LateUpdate) and, after the frame has been rendered +/// (end of frame), RenderManager.OnPostFrame with the texture shown on scrCamera's camera quad, which is the frame +/// that gets captured. +/// Reference: https://docs.unity3d.com/ScriptReference/DefaultExecutionOrder.html +/// [DefaultExecutionOrder(int.MaxValue)] internal class PostRenderHook : MonoBehaviour { + /// Private field scrCamera.camQuadMesh: the MeshRenderer of the quad that displays the camera RenderTexture. private FieldInfo? _camQuadMeshField; + + /// Cached value of camQuadMesh for the current scrCamera instance. Looked up again while it is null or destroyed. private MeshRenderer? _camQuadMeshRenderer; private void Awake() @@ -16,6 +28,10 @@ internal class PostRenderHook : MonoBehaviour _camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh"); } + /// + /// Main thread, once per frame after all other LateUpdate calls. Runs the last-update work of RenderManager + /// (OnLastUpdate callbacks of the session) and schedules the end-of-frame capture. + /// private void LateUpdate() { if (RenderManager.Instance) @@ -24,6 +40,11 @@ internal class PostRenderHook : MonoBehaviour PostFrameAsync().Forget(); } + /// + /// Waits until the end of the current frame (after the cameras have rendered) and passes the camera quad's main + /// texture to RenderManager.OnPostFrame. The wait is cancelled when this component is destroyed. + /// The texture is null when the quad's material has no RenderTexture; OnPostFrame then only updates the audio. + /// private async UniTaskVoid PostFrameAsync() { await UniTask.WaitForEndOfFrame(this, destroyCancellationToken); diff --git a/Core/PresetManager.cs b/Core/PresetManager.cs index eaa7e6a..ba933ce 100644 --- a/Core/PresetManager.cs +++ b/Core/PresetManager.cs @@ -11,11 +11,16 @@ using Newtonsoft.Json.Linq; namespace ADOFAIRenderer.Core; +/// A named render configuration, stored as one JSON file in the presets directory. public class RenderPreset { + /// Display name of the preset. A file without a name uses the file name (without extension). public string Name { get; set; } = string.Empty; + + /// Render configuration of the preset. public RenderConfig Config { get; set; } = null!; + /// Returns a copy with a copied configuration. public RenderPreset Clone() { return new RenderPreset @@ -26,8 +31,18 @@ public class RenderPreset } } +/// +/// Loads, saves and deletes render presets. Presets are JSON files (one per preset, file name derived from the preset +/// name) in <game root>/UserData/ADOFAIRenderer/Presets. The built-in presets are written once, on first use +/// (GlobalConfig.DefaultPresetsInitialized), so the user can edit or delete them. Main thread. +/// public static class PresetManager { + /// + /// Built-in presets (H.264 CBR, 48 kHz stereo AAC at 256 kbps, mkv): 1080p, 1440p and 2160p at 60 fps video. + /// "(4x)" presets render 240 game frames per second (4 per video frame); "(1x)" renders 60. + /// Video bitrates are in kbps. + /// private static readonly List DefaultPresets = new() { new RenderPreset @@ -96,6 +111,10 @@ public static class PresetManager } }; + /// + /// <root>/UserData/ADOFAIRenderer/Presets, where root is MelonLoader's game root directory, or the application + /// base directory when that is unavailable. + /// private static string GetPresetsDirectory() { string? root = null; @@ -125,6 +144,10 @@ public static class PresetManager return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_')); } + /// + /// Creates the presets directory and, once per installation (GlobalConfig.DefaultPresetsInitialized), writes the + /// built-in presets whose files do not exist yet, then saves the flag. Errors are logged, not thrown. + /// public static void EnsureDefaultPresetsCreated() { try @@ -164,6 +187,12 @@ public static class PresetManager } } + /// + /// Saves a copy of as preset , overwriting the file of the same + /// safe file name (see GetSafeFileName). + /// + /// The name is empty or white space. + /// The directory or file cannot be written (not caught). public static void SavePreset(string name, RenderConfig config) { if (string.IsNullOrWhiteSpace(name)) @@ -195,6 +224,13 @@ public static class PresetManager File.WriteAllText(filePath, json); } + /// + /// Loads every *.json preset file (after creating the built-in presets on first use). Files written by older + /// versions are migrated in memory (old key names and flat properties moved into the nested configs); the files are + /// not rewritten. A file that cannot be read is logged and skipped. When two files have the same preset name + /// (case-insensitive), the first one in directory order is used. Returns copies of the built-in presets when the + /// directory does not exist. + /// public static List GetPresets() { EnsureDefaultPresetsCreated(); @@ -332,6 +368,10 @@ public static class PresetManager return presets; } + /// + /// Renames key to : a non-null old value overwrites the target + /// key, and the old key is always removed. + /// private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey) { var legacyToken = configObj[keyBefore]; @@ -341,6 +381,12 @@ public static class PresetManager configObj.Remove(keyBefore); } + /// + /// Deletes the file of preset : first the file with the safe file name; otherwise the first + /// file whose base name or stored "Name" matches (case-insensitive). Does nothing for an empty name or when no file + /// matches. + /// + /// The file with the safe file name exists but cannot be deleted (not caught). public static void DeletePreset(string name) { if (string.IsNullOrWhiteSpace(name)) return; diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index bb41f58..dbad697 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -25,24 +25,61 @@ using ThreadPriority = System.Threading.ThreadPriority; namespace ADOFAIRenderer.Core; +/// +/// Progress of the current render (RenderManager.stage). Values are ordered: checks use comparisons such as +/// stage >= OnScene. +/// +/// None: no render. Set at start-up and whenever a render ends, is cancelled or fails (ResetRenderState). +/// Load: RenderSession.Start was called; the game scene (scnGame) is being loaded. +/// OnScene: the game scene has loaded (RenderManager.Update set up the scene): the frame counter runs, the +/// 3-second countdown is shown, input is ignored. Nothing is captured yet. RenderManager.IsActive is true from this stage. +/// Render: the encoder and render thread run and frames are captured (set by SetupPlay after the countdown). +/// RenderManager.IsCapturingActive is true; the time patches replace Unity's clock with the render clock. +/// +/// internal enum RenderStage { None, Load, OnScene, Render } +/// +/// Options of the current render, copied from a by RenderSession.Start and read by +/// RenderManager and the Harmony patches. restores the defaults when the render environment is +/// restored (RenderManager.RestoreRenderEnvironment). Main thread only. +/// internal static class RenderContext { + /// Value assigned to RDC.auto (the game's autoplay flag) when the game scene of the render has loaded. public static bool Auto = true; + + /// + /// Enables the time patches (Time.time / unscaledTime / deltaTime / frameCount, AudioSettings.dspTime) while capturing, + /// which drive the game by the render clock (frameCount / RenderFPS) instead of real time. + /// public static bool TimePatch = true; + + /// Enables the Screen.width / Screen.height patches (report the render resolution) while the render is active. public static bool ScreenPatch = true; + + /// Output file path of the render, or null for a time-stamped file in GlobalSettings.EffectiveOutputDirectory. public static string? OutputPath; + /// Whether the default OnComplete callback shows an OS notification after a successful render. public static bool Notification = true; + + /// Frame hooks of the current session, run before the global hooks (see RenderSession.AddFrameHook). public static readonly List> SessionFrameHooks = new(); + /// Default end condition: the level is cleared (scrController state Won). private static readonly Func EndConditionDefault = () => scrController.instance.state == States.Won; + + /// + /// Polled by RenderManager.Update every frame from the OnScene stage. When it first returns true, the render + /// continues for RenderConfig.DelayAfterEnd seconds of render time and then ends successfully. + /// public static Func EndCondition = EndConditionDefault; + /// Default completion callback: shows an OS notification for a successful render when Notification is true. private static readonly Action OnCompleteDefault = result => { if (result.Success && Notification) @@ -54,11 +91,23 @@ internal static class RenderContext OSNotification.Show("Rendering Complete!", $"{fileString} saved successfully.", renderFolder); } }; + /// + /// Called once on the main thread when the render finishes (success) or is cancelled or fails. + /// A session callback replaces the default one, so no notification is shown when a session sets OnComplete. + /// Exceptions are caught and logged (RenderManager.TriggerOnComplete). + /// public static Action OnComplete = OnCompleteDefault; private static readonly Action OnLastUpdateDefault = () => { }; + + /// + /// Called on the main thread every frame after all other LateUpdate calls, through PostRenderHook (attached when the + /// game scene of the render has loaded) while the stage is Load or later. + /// Exceptions are caught and logged (RenderManager.TriggerOnLastUpdate). + /// public static Action OnLastUpdate = OnLastUpdateDefault; + /// Restores every option to its default and clears the session frame hooks. public static void Reset() { Auto = true; @@ -74,11 +123,34 @@ internal static class RenderContext } } +/// +/// Drives a render from scene setup to the finished video file. Singleton MonoBehaviour on a DontDestroyOnLoad object +/// created by RenderMod. +/// +/// Flow: RenderSession.Start (stage Load) → SetupGameScene loads scnGame → Update sets up the loaded scene +/// (stage OnScene, 3-second countdown) → SetupPlay starts the encoder and the render thread (stage Render) → +/// every frame OnPreUpdate advances frameCount, OnPostFrame queues the audio commands and requests the GPU capture of +/// the frame (FrameReader, AsyncGPUReadback) → readback completions add frames to ImageQueue → the render thread +/// (RunRenderThread) encodes them in frame order together with the audio of each frame. +/// +/// End: success (EndCondition plus DelayAfterEnd) → StopFFmpeg(true) → StopRenderCoroutine waits for the render +/// thread to finish the file → EndRender. Cancel (ESC) or failure → StopFFmpeg(false) → EndRender, which deletes the +/// unfinished file. Encoder initialization failure → StopFFmpeg(false) → RestoreRenderEnvironment and an error dialog. +/// +/// Threads: the main Unity thread runs every public method unless noted otherwise. The render thread reads +/// ImageQueue (lock on ImageQueue), passCount (render thread only while it runs), endRequested / endSuccessful +/// (volatile), the abort state (volatile, written under _abortStateLock) and lastRequestedVideoFrameIndex +/// (Volatile.Read/Write). FrameAvailableEvent wakes the render thread, FrameDequeuedEvent wakes the throttle loop. +/// internal class RenderManager : MonoBehaviour { - // Singleton Accessors + /// The single instance, set in Awake. Unity null checks (if (Instance)) are used before access. public static RenderManager Instance { get; private set; } = null!; + + /// True from the OnScene stage (scene set up, countdown running) until the render ends. public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene; + + /// True while frames are captured and encoded (Render stage). The time patches apply only then. public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render; /// @@ -104,26 +176,66 @@ internal class RenderManager : MonoBehaviour public int QueueFrameLimit { get; private set; } = MaxQueuedFrames; // Statistics + + /// + /// Number of video frames sent to the encoder in this render. Incremented by the render thread (Interlocked), + /// read with Interlocked.Read by the main thread (result, debug monitor). Reset in SetupPlay. + /// public long framesSent; + + /// Wall-clock time of the render from the end of the countdown (SetupPlay) to BuildResult. private readonly Stopwatch _renderStopwatch = new(); // Core Render Configuration and Path + + /// + /// Seconds left in the countdown before capture starts (3 when the scene is set up, decremented once per real-time + /// second by SetupPlay). Shown by scrShowIfDebugPatch and read by scrPlayerManagerPatch. + /// public int countdown; + + /// + /// Render frame counter: incremented once per Unity frame by OnPreUpdate from the OnScene stage and reset to 0 when + /// capture starts (SetupPlay). The render clock is frameCount / RenderFPS seconds (AudioTime, time patches). + /// Main thread only. + /// public long frameCount; + + /// + /// Set by SetupGameScene; scrPressToStartPatch consumes it (sets it back to false) to skip the press-to-start + /// screen once. Cleared by StopFFmpeg. + /// public bool skipPressToStart; + + /// Path of the level file to render (ADOBase.levelPath of the editor, or the path given to RenderSession.Start). public string levelPath = ""; + + /// Serializes changes of the abort state (_isRenderCancelled, _renderFailed, _failureReason). private readonly object _abortStateLock = new(); + + /// The render was cancelled (ESC, or a non-successful stop). Written under _abortStateLock, read by any thread. private volatile bool _isRenderCancelled; + + /// The render failed (MarkFailed). Written under _abortStateLock, read by any thread. private volatile bool _renderFailed; + + /// Reason given to the first MarkFailed call of this render; null when the render has not failed. private volatile string? _failureReason; + /// True when the render was cancelled. Thread-safe read. // ReSharper disable once MemberCanBePrivate.Global public bool IsRenderCancelled => _isRenderCancelled; + + /// True when the render failed. Thread-safe read. public bool IsRenderFailed => _renderFailed; - // Cancelled and failed are mutually exclusive; this is true for either (render must stop). + /// + /// True when the render was cancelled or failed (the two are mutually exclusive) and must stop. + /// Read by the render thread before each frame and by readback completions. Thread-safe read. + /// public bool IsRenderAborted => _isRenderCancelled || _renderFailed; + /// Marks the render as cancelled unless it has already failed (a failure keeps its reason). private void MarkCancelled() { lock (_abortStateLock) @@ -132,7 +244,10 @@ internal class RenderManager : MonoBehaviour } } - // The first failure reason wins; ignored once the render is already cancelled or failed. + /// + /// Marks the render as failed with (shown in the result message as "Rendering failed: reason"). + /// The first failure reason wins; ignored once the render is already cancelled or failed. Callable from any thread. + /// private void MarkFailed(string reason) { lock (_abortStateLock) @@ -143,6 +258,7 @@ internal class RenderManager : MonoBehaviour } } + /// Clears cancelled, failed and the failure reason. Called when a new encoder starts (StartFFmpegThread). private void ClearAbortState() { lock (_abortStateLock) @@ -153,30 +269,70 @@ internal class RenderManager : MonoBehaviour } } + /// Error message of the RenderResult of an aborted render (failure reason, or the cancel message). private string BuildAbortMessage() => IsRenderFailed ? $"Rendering failed: {_failureReason ?? "unknown error"}" : "Rendering was cancelled by user."; + + /// + /// Configuration of the current render: a copy made by RenderSession.Start, so values adjusted during the render + /// (the audio sample rate in SetupPlay) do not change the caller's object. Read by the main thread, the render + /// thread (audio settings) and the patches. Null until the first render starts. + /// public RenderConfig Configuration = null!; // Render Sessions State (OOP Instance Fields) + + /// Current stage of the render. Written on the main thread; read by the render thread in TryDequeueNextFrame. public RenderStage stage = RenderStage.None; + + /// Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS. public double timeBase; + + /// Time.unscaledTimeAsDouble when capture started. The time patch returns unscaledTimeBase + frameCount / RenderFPS. public double unscaledTimeBase; + + /// + /// AudioSettings.dspTime when capture started (seconds). The dspTime patch returns dspTimeBase + frameCount / RenderFPS; + /// audio command times are stored relative to it (scheduled dspTime - dspTimeBase). + /// public double dspTimeBase; + + /// Time.frameCount when capture started. The frameCount patch returns frameCountBase + frameCount. public int frameCountBase; + /// + /// Offline mixer of the song and sound effects of this render; null when audio is not rendered or no render runs. + /// Created in SetupPlay. Commands are added on the main thread (OnPostFrame, audio patches), mixing runs on the + /// render thread (WriteAudioForFrame); the mixer synchronizes the two internally. + /// public OfflineAudioMixer? AudioMixer { get; private set; } /// Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands. public double AudioTime => frameCount / Configuration.RenderFPS; + /// + /// Number of audio samples (per channel) to write after each video frame, so that the audio stays aligned with + /// the video frame times at the effective video frame rate. Created in SetupPlay when audio is rendered; + /// used by the render thread only. + /// private AudioSyncCalculator? SyncCalculator { get; set; } + /// GPU capture of frames (color conversion and AsyncGPUReadback). Created in SetupPlay after the encoder started. private FrameReader? _frameReader; + + /// Frame hook runner of this render. Created in SetupPlay, disposed by ResetFrameHookState. private FrameHookPipeline? _frameHookPipeline; + + /// Session hooks followed by global hooks, rebuilt when the global hook version changes. private readonly List> _activeHooksCache = new(); + + /// RenderSession.GlobalHooksVersion the cache was built for; -1 forces a rebuild. private int _cachedGlobalHooksVersion = -1; + + /// True when _activeHooksCache is not empty. private bool _hasActiveHooksCached; + /// True when at least one session or global frame hook is registered. Main thread. public bool HasActiveFrameHooks { get @@ -186,6 +342,11 @@ internal class RenderManager : MonoBehaviour } } + /// + /// Rebuilds the active hook list when the global hook registrations changed since the last build. + /// Session hooks are only read on a rebuild; SetupPlay forces one (version -1) at the start of capture, so changes to + /// RenderContext.SessionFrameHooks after that are picked up only together with a global hook change. + /// private void EnsureActiveHooksUpToDate() { var globalVersion = RenderSession.GlobalHooksVersion; @@ -201,12 +362,14 @@ internal class RenderManager : MonoBehaviour _hasActiveHooksCached = _activeHooksCache.Count > 0; } + /// Returns the cached active hook list (session hooks first, then global hooks). The list is reused. private List> GetActiveFrameHooks() { EnsureActiveHooksUpToDate(); return _activeHooksCache; } + /// Disposes the frame hook pipeline (ping-pong buffers) and clears the hook cache. private void ResetFrameHookState() { _frameHookPipeline?.Dispose(); @@ -216,46 +379,132 @@ internal class RenderManager : MonoBehaviour _hasActiveHooksCached = false; } + /// + /// Number of game frames offered for capture since capture started (OnPostFrame with a texture, after throttling). + /// The value before the increment is the render frame index of the current frame. Main thread only. + /// public long frameCountForVideo; + + /// + /// Video frame index of the last captured frame (-1 before the first). A game frame is captured only when its video + /// frame index (render frame index × effective video FPS / RenderFPS, rounded down) is greater. Main thread only. + /// public long lastVideoFrameIndex = -1; + + /// + /// Video frame index of the last capture request (-1 before the first). Written on the main thread, read by the + /// render thread to know when every requested frame has been sent after endRequested. + /// // long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread) public long lastRequestedVideoFrameIndex = -1; + + /// + /// Number of GPU readbacks that failed and were replaced by a black frame (shown by the debug monitor). + /// Incremented in the readback completion callback. + /// public int readbackFailureCount; + + /// Number of frames on which the main thread had to wait for the render thread (throttle loop entries). public int throttleCount; + + /// True while the main thread is inside the throttle wait of OnPostFrame. public bool isThrottling; + /// + /// One frame waiting in ImageQueue: either a native readback buffer (owned by ReadbackState until it is recycled) + /// or a managed buffer rented from ArrayPool<byte>.Shared (black frame for a failed readback). + /// The data is YUV 4:2:0 in the encoder's pixel format when IsColorConverted, RGBA otherwise. + /// internal struct CapturedFrame { + /// Start of the native frame data (ReadbackState.NativeBuffer); zero for a managed frame. public IntPtr NativePointer; + + /// Number of valid bytes of the frame data. public int Length; + + /// Managed frame data rented from ArrayPool<byte>.Shared (may be longer than Length); null for a native frame. public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame) + + /// Readback state owning NativePointer; returned to the FrameReader pool after the frame is encoded or dropped. public ReadbackRequestState? ReadbackState; // Associated state to recycle + + /// True when the GPU converted the frame to the encoder's YUV format; false for RGBA data. public bool IsColorConverted; } // Thread and Queue State + + /// + /// Captured frames waiting for the render thread, keyed by video frame index. Readbacks may complete in any order; + /// the render thread takes them in index order (passCount). Every access locks the list itself. + /// Frames are added by readback completions (main thread) and removed by the render thread, or released by the + /// cleanup in StopFFmpeg / RestoreRenderEnvironment. + /// internal SortedList ImageQueue = new(); + + /// Set when a frame is added to ImageQueue or the end is requested; the render thread waits on it. internal AutoResetEvent FrameAvailableEvent = new(false); - // Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it. + + /// Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it. internal AutoResetEvent FrameDequeuedEvent = new(false); + + /// The render thread of the current render (background, AboveNormal priority); null before the first render. public Thread? RenderThread; + + /// + /// Video frame index the render thread sends next. Reset by StartFFmpegThread before the thread starts; afterwards + /// read and written only by the render thread, under the ImageQueue lock. + /// public long passCount; + + /// Set by StopFFmpeg: no more frames will be captured; the render thread drains the queue and stops. public volatile bool endRequested; + + /// Set by the render thread after the output file was finished and the encoder released without an abort. public volatile bool endSuccessful; + + /// + /// frameCount at which EndCondition first returned true; -1 until then. The render ends DelayAfterEnd seconds of + /// render time later. + /// public long endFrame = -1; // Private Instance Fields + + /// Encoder of the current render; null when none runs. Cleared when the render thread releases it. private FFmpegNativeEncoder? _activeVideoFFmpeg; + + /// + /// Every encoder created and not yet released, disposed by CleanUpActiveRender on exit. Locked on the list itself + /// (the render thread removes its encoder when it finishes). + /// private readonly List _allVideoFFmpegInstances = new(); + + /// The user's input offset (scrConductor.currentPreset.inputOffset), set to 0 during the render and restored afterwards. private int _originalInputOffset; + + /// Debug monitor overlay object, created when the encoder starts and destroyed when the render ends. private GameObject? _debugMonitorObj; + + /// ESC handler overlay object (cancels the render), created when the scene is set up and destroyed by StopFFmpeg. private GameObject? _escapeHandlerObj; + + /// + /// Output file path of the current or last render. Set when the encoder starts; set to null when an aborted render's + /// file was deleted. + /// internal string? CurrentOutPath; // Output file of the current render that is not finished yet. // Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End). // Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile). private string? _incompleteOutputPath; + + /// + /// True from SetupGameScene until Update has set up the loaded game scene (or the render stops). While true, Update + /// hides the difficulty UI and waits for scnGame to finish loading. + /// private bool _renderSetup; private void Awake() @@ -268,6 +517,10 @@ internal class RenderManager : MonoBehaviour CleanUpActiveRender(); } + /// + /// Sets the stage to None. true clears the cancelled flag; false marks the render as + /// cancelled (unless it already failed). Called at every end path before StopFFmpeg. + /// public void ResetRenderState(bool success) { if (success) _isRenderCancelled = false; @@ -275,6 +528,11 @@ internal class RenderManager : MonoBehaviour stage = RenderStage.None; } + /// + /// Emergency cleanup when the game quits or this object is destroyed: marks a running render as failed, releases + /// the audio, the frame hooks and every encoder, waits up to 200 ms for the render thread, and deletes the + /// unfinished output file. Does not restore the game scene or settings. Main thread. + /// public void CleanUpActiveRender() { if (stage != RenderStage.None) @@ -346,8 +604,16 @@ internal class RenderManager : MonoBehaviour } } + /// + /// Main thread, every frame. + /// 1. While the game scene of a render loads: hides the difficulty UI of the press-to-start screen. + /// 2. Once scnGame has finished loading: sets up the scene for rendering and starts SetupPlay (stage OnScene). + /// 3. From the OnScene stage: fails the render when the render thread died, the level failed or the controller is + /// gone; ends it successfully DelayAfterEnd seconds (render time) after EndCondition first returned true. + /// private void Update() { + // scrPressToStart reads lm.hideDifficultyUI to decide whether the difficulty UI is shown (scrPressToStart.cs). if (_renderSetup && ADOBase.lm) { ADOBase.lm.hideDifficultyUI = true; @@ -356,18 +622,24 @@ internal class RenderManager : MonoBehaviour if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading) { _renderSetup = false; + // scrCamera.Awake calls SetupRTCam(enable: true) when forceRTCam is set (scrCamera.cs), so the flag applies to + // the next scrCamera.Awake. scnGame also calls SetupRTCam(enable: true) when it starts playing (scnGame.cs). scrCamera.instance.forceRTCam = true; if (!scrCamera.instance.gameObject.GetComponent()) scrCamera.instance.gameObject.AddComponent(); + // The input offset shifts the song position (calibration_i = inputOffset / 1000 in scrConductor); + // the render runs with 0 so audio and visuals follow the level timing exactly. Restored by RestoreRenderEnvironment. _originalInputOffset = scrConductor.currentPreset.inputOffset; scrConductor.currentPreset.inputOffset = 0; + // Autoplay; disableCongratsMessage clears the "congratulations" text shown on the win screen (scrController.cs). RDC.auto = RenderContext.Auto; AccessTools.Field(typeof(scrController), "disableCongratsMessage") .SetValue(scrController.instance, true); + // No-fail with an infinite hit margin, so a missed hit does not fail the level during the render. if (scrController.instance) { scrController.instance.noFail = true; @@ -384,6 +656,9 @@ internal class RenderManager : MonoBehaviour DontDestroyOnLoad(_escapeHandlerObj); } + // While non-zero, Unity advances Time.time by exactly this interval per frame regardless of real time, + // so the game simulation steps 1 / RenderFPS seconds per frame. Reset to 0 by RestoreRenderEnvironment. + // Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html Time.captureDeltaTime = (float)(1 / Configuration.RenderFPS); scrController.instance!.StartCoroutine(SetupPlay()); } @@ -404,6 +679,8 @@ internal class RenderManager : MonoBehaviour } else if (RenderContext.EndCondition()) { + // The first frame the end condition holds starts the DelayAfterEnd period; afterwards the end condition is + // still polled every frame, but endFrame is kept. if (endFrame == -1) { endFrame = frameCount; @@ -417,6 +694,9 @@ internal class RenderManager : MonoBehaviour } } + /// + /// Called by FirstUpdateHook before every other Update of the frame. Advances frameCount by one from the OnScene stage. + /// public void OnPreUpdate() { if (Instance.stage < RenderStage.OnScene) return; @@ -428,6 +708,10 @@ internal class RenderManager : MonoBehaviour private List? _holdFloorsSource; private int _holdFloorsSourceCount = -1; + /// + /// Called by PostRenderHook after every other LateUpdate of the frame, from the Load stage. + /// Hides the hold meshes when AdvancedConfig.NonTile is set, then runs RenderContext.OnLastUpdate. + /// public void OnLastUpdate() { if (Instance.stage < RenderStage.Load) return; @@ -479,6 +763,15 @@ internal class RenderManager : MonoBehaviour return (int)Math.Min(MaxQueuedFrames, Math.Max(4, MaxQueuedBytes / frameBytes)); } + /// + /// Called by PostRenderHook at the end of every frame (after rendering) with the frame texture. In the Render stage: + /// 1. Audio: updates the song voice, commits the audio commands of this frame, and deactivates the effect + /// AudioSources that were mapped to mixer voices. + /// 2. Throttle: blocks while ImageQueue holds more than QueueFrameLimit frames (render thread behind). + /// 3. Capture: when this game frame starts a new video frame, runs the frame hooks and requests the GPU readback. + /// With RenderFPS above the effective video FPS, only some game frames are captured. + /// + /// Frame shown by the camera quad; null skips throttling and capture. // ReSharper disable once Unity.IncorrectMethodSignature public void OnPostFrame(RenderTexture? texture) { @@ -532,10 +825,13 @@ internal class RenderManager : MonoBehaviour var currentFrame = frameCountForVideo++; + // The video cannot have more frames per second than the game renders: effective video FPS = min(VideoFPS, RenderFPS). var renderFPS = Configuration.RenderFPS; var videoFps = Configuration.VideoFPS; var effectiveVideoFps = Math.Min(videoFps, renderFPS); + // Video frame that contains this game frame's time (currentFrame / renderFPS seconds), rounded down. + // Only the first game frame of each video frame is captured. var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS); if (videoFrameIndex > lastVideoFrameIndex) @@ -562,6 +858,13 @@ internal class RenderManager : MonoBehaviour } } + /// + /// Handler of the render button in the level editor (added by scnEditorPatch). + /// When the level has no file path, scnEditor.TryQuitToMenu is called and no render starts. + /// Otherwise, after the editor's unsaved-changes prompt, removes scnEditor.TryApplicationQuit from + /// Application.wantsToQuit (it would block quitting with an unsaved-changes prompt; scnEditor registers it again + /// when the editor starts) and opens the render configuration window. Main thread. + /// public static void StartRender() { var targetMethod = typeof(scnEditor).GetMethod("TryApplicationQuit", @@ -585,12 +888,18 @@ internal class RenderManager : MonoBehaviour }); } + /// Creates the default output directory (GlobalSettings.EffectiveOutputDirectory) when it does not exist. public void SetupCaptureDirectory() { var renderDir = GlobalSettings.EffectiveOutputDirectory; FileUtil.CreateDirectory(renderDir); } + /// + /// Loads scnGame with levelPath as the only custom level (checkpoint 0, speed trial multiplier 1) and arms the + /// press-to-start skip and the scene setup in Update. Seeds UnityEngine.Random when AdvancedConfig.RandomSeed is set, + /// so random effects repeat between renders. Called by RenderSession.Start. + /// public void SetupGameScene() { GCS.customLevelPaths = new[] { levelPath }; @@ -605,8 +914,18 @@ internal class RenderManager : MonoBehaviour _renderSetup = true; } + /// + /// Coroutine started on scrController when the game scene is set up (stage OnScene). + /// Waits the countdown (real-time seconds), sets the time bases, starts the encoder, the render thread and the + /// frame reader, switches to the Render stage, starts the level without the press-to-start wait, and creates the + /// offline audio mixer and spectrum generator. Stops without starting when the render was aborted during the + /// countdown. On an encoder initialization error, the render fails, the environment is restored and an error + /// dialog returns to the editor. + /// private IEnumerator SetupPlay() { + // The audio sample rate must be one the audio codec paired with the video codec supports + // (CodecUtil.AudioCodecMatch, CodecUtil.AvailableSampleRate); otherwise 48000 Hz is used. if (Configuration.AudioConfig.RenderAudio) { if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate)) @@ -624,6 +943,8 @@ internal class RenderManager : MonoBehaviour frameCount = 0; framesSent = 0; _renderStopwatch.Restart(); + // Clock values at the start of capture. The time patches are not active yet (stage OnScene), so these are + // Unity's own values; from the Render stage the patched clocks continue from them at frameCount / RenderFPS. timeBase = Time.timeAsDouble; unscaledTimeBase = Time.unscaledTimeAsDouble; dspTimeBase = AudioSettings.dspTime; @@ -635,6 +956,7 @@ internal class RenderManager : MonoBehaviour _frameHookPipeline = new FrameHookPipeline(); _cachedGlobalHooksVersion = -1; EnsureActiveHooksUpToDate(); + // The frame reader converts on the GPU to the pixel format of the encoder that was actually opened. try { StartFFmpegThread(); @@ -663,6 +985,7 @@ internal class RenderManager : MonoBehaviour } Instance.stage = RenderStage.Render; + // levelWasSkipped ends the press-to-start wait loop of scrController (scrController.cs, WaitForStartCo). scrController.instance.paused = scrController.instance.audioPaused = false; scrController.instance.levelWasSkipped = true; @@ -681,10 +1004,18 @@ internal class RenderManager : MonoBehaviour AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip, AudioMixer); } + // The song is played by the offline mixer only: the clip is removed from the game's song AudioSource so Unity + // does not play it. scrConductor.instance.song.clip = null; scrConductor.instance.song.time = -1000; } + /// + /// Resets the queue and render thread state, opens the encoder on the output file (RenderContext.OutputPath, or + /// "yyyy-MM-dd HH-mm-ss.ext" in the output directory with the configured extension), and starts the render thread. + /// The output path is registered as incomplete before the encoder opens the file. + /// + /// The encoder could not be created or started; it is disposed before the exception is rethrown. private void StartFFmpegThread() { if (!_debugMonitorObj) @@ -770,6 +1101,7 @@ internal class RenderManager : MonoBehaviour throw; } + // Background thread: it does not keep the process alive when the game quits. var encoder = videoFFmpeg; RenderThread = new Thread(() => RunRenderThread(encoder)) { @@ -858,8 +1190,10 @@ internal class RenderManager : MonoBehaviour } /// - /// Removes the frame whose key equals passCount from ImageQueue and advances passCount. - /// Before the Render stage, passCount jumps to the first queued key (the first captured frame index is not 0). + /// Removes the frame whose key equals passCount from ImageQueue and advances passCount. Render thread. + /// While the stage is below Render, passCount jumps forward to the first queued key when that key is greater: + /// frames are only captured in the Render stage, so this happens after the stage was set back to None at the end + /// of the render while the thread drains the queue, and a frame whose readback has not arrived yet is skipped. /// Returns false when the queue is empty or the next frame has not arrived yet. /// private bool TryDequeueNextFrame(out CapturedFrame frame) @@ -886,7 +1220,7 @@ internal class RenderManager : MonoBehaviour /// /// Sends one captured frame to the encoder and releases its buffer (managed: ArrayPool, native: readback state). - /// A frame without data is skipped. + /// A frame without data is skipped (not counted in framesSent). Render thread. /// private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame) { @@ -914,8 +1248,11 @@ internal class RenderManager : MonoBehaviour /// /// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder. - /// Does nothing when audio rendering is off. + /// The sample count per frame comes from SyncCalculator (sample rate / effective video FPS, with the remainder + /// carried to later frames). Mixing waits until the main thread has committed the audio commands up to that time. + /// Does nothing when audio rendering is off. Render thread. /// + /// The mixer was cancelled (render aborted) while waiting. private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg) { if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return; @@ -945,6 +1282,7 @@ internal class RenderManager : MonoBehaviour return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex); } + /// Removes the encoder from the cleanup list and clears _activeVideoFFmpeg. Called by the render thread when it finishes. private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg) { lock (_allVideoFFmpegInstances) @@ -954,6 +1292,11 @@ internal class RenderManager : MonoBehaviour _activeVideoFFmpeg = null; } + /// + /// Stops the render without finishing the file (cancelled, or failed when MarkFailed was called first), deletes + /// the unfinished file, reports the result through OnComplete, restores the environment and returns to the editor. + /// Called by the ESC handler and on failures. Main thread. + /// public void CancelRender() { ResetRenderState(success: false); @@ -962,6 +1305,15 @@ internal class RenderManager : MonoBehaviour EndRender(); } + /// + /// Requests the end of encoding. Main thread. + /// true: completes the audio commands and starts StopRenderCoroutine, which waits for + /// the render thread to send the remaining frames and finish the file. + /// false: marks the render as cancelled (unless failed), cancels the audio waits, waits up + /// to 2 s for the render thread, disposes the encoder, releases the queued frames, the frame reader, the hooks and + /// the debug monitor, restores the camera and no-fail flags, deletes the unfinished file and calls OnComplete with + /// a failure result. Does not restore time, input offset or the scene (EndRender / RestoreRenderEnvironment do). + /// private void StopFFmpeg(bool success) { endRequested = true; @@ -993,7 +1345,8 @@ internal class RenderManager : MonoBehaviour RenderThread.Join(2000); } - // 2. After ensuring thread termination, safely release FFmpeg native encoder resources + // 2. Release the encoder. The render thread normally released it already (_activeVideoFFmpeg is then null). + // When the thread is still running after the 2 s wait, the encoder is disposed while that thread may still use it. if (_activeVideoFFmpeg != null) { _activeVideoFFmpeg.Dispose(); @@ -1055,6 +1408,11 @@ internal class RenderManager : MonoBehaviour } } + /// + /// Successful end: waits (one check per frame) until the render thread reports endSuccessful or has died. + /// On success calls OnComplete with a success result and EndRender; when the thread died without finishing + /// the file, the render fails and is cancelled (file deleted). + /// private IEnumerator StopRenderCoroutine() { yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true }); @@ -1071,6 +1429,7 @@ internal class RenderManager : MonoBehaviour EndRender(); } + /// Restores the render environment and loads the level editor (scnEditor). private void EndRender() { RestoreRenderEnvironment(); @@ -1079,7 +1438,11 @@ internal class RenderManager : MonoBehaviour Cursor.visible = true; } - // Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene. + /// + /// Undoes what the render setup changed (time scale and capture delta time, input offset, autoplay, custom level + /// paths, queued frames, frame reader, frame hooks, audio mixer and spectrum, RenderContext options), without + /// leaving the scene. + /// private void RestoreRenderEnvironment() { Time.timeScale = 1f; @@ -1122,6 +1485,10 @@ internal class RenderManager : MonoBehaviour } + /// + /// Stops the render stopwatch and creates the result: frames sent, video duration (frames / effective video FPS), + /// wall-clock render time, and the output path and file size on success. + /// private RenderResult BuildResult(bool success, string? errorMessage = null) { _renderStopwatch.Stop(); @@ -1133,6 +1500,7 @@ internal class RenderManager : MonoBehaviour : RenderResult.CreateFailure(errorMessage, sent, effectiveFps, _renderStopwatch.Elapsed); } + /// Invokes RenderContext.OnComplete; an exception from the callback is logged. private static void TriggerOnComplete(RenderResult result) { try @@ -1145,6 +1513,7 @@ internal class RenderManager : MonoBehaviour } } + /// Invokes RenderContext.OnLastUpdate; an exception from the callback is logged. private static void TriggerOnLastUpdate() { try diff --git a/Data/Codec.cs b/Data/Codec.cs index d9ee105..346785d 100644 --- a/Data/Codec.cs +++ b/Data/Codec.cs @@ -4,14 +4,22 @@ using FFmpeg.AutoGen; namespace ADOFAIRenderer.Data; +/// +/// Video codec of the output. The encoder implementation (hardware or software) is chosen at run time from the +/// encoders available for the codec (VideoEncoder, GPUUtil.GetPrioritizedEncoders). +/// Values: H264 (H.264 / AVC), H265 (H.265 / HEVC), VP9, AV1, VVC (H.266). +/// The audio codec paired with each value is given by CodecUtil.AudioCodecMatch. +/// [SuppressMessage("ReSharper", "InconsistentNaming")] public enum Codec { H264, H265, VP9, AV1, VVC } +/// Audio codec choice and supported sample rates per video codec. internal static class CodecUtil { + /// Audio codec encoded together with each video codec (AAC for H.264/H.265, Opus for the others). public static readonly Dictionary AudioCodecMatch = new() { [Codec.H264] = AVCodecID.AV_CODEC_ID_AAC, @@ -21,6 +29,10 @@ internal static class CodecUtil [Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS }; + /// + /// Output sample rates (Hz) the mod allows per audio codec. RenderManager.SetupPlay replaces a configured rate that + /// is not in the list with 48000 Hz. + /// public static readonly Dictionary> AvailableSampleRate = new() { [AVCodecID.AV_CODEC_ID_AAC] = new List { 44100, 48000, 96000 }, diff --git a/Data/ColorJsonConverter.cs b/Data/ColorJsonConverter.cs index 374ff85..28c501d 100644 --- a/Data/ColorJsonConverter.cs +++ b/Data/ColorJsonConverter.cs @@ -10,13 +10,20 @@ namespace ADOFAIRenderer.Data; /// Converts Unity and nullable to and from hex string (#RRGGBBAA). /// Pure C# implementation to completely avoid Unity internal native ECalls (such as Color.linear or ColorUtility.TryParseHtmlString). /// +/// +/// Reading accepts "#RRGGBB" (alpha 1), "#RRGGBBAA" (the '#' is optional), an object {"r","g","b","a"} with 0–1 floats +/// (missing r/g/b = 0, missing a = 1), or null. Invalid or unsupported input reads as null for Color? and as +/// default(Color) (all components 0) for Color; no exception is thrown for an unparsable string. +/// public class ColorJsonConverter : JsonConverter { + /// True for and . public override bool CanConvert(Type objectType) { return objectType == typeof(Color) || objectType == typeof(Color?); } + /// Writes the color as "#RRGGBBAA" (see ), or a JSON null for a null value. public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { if (value == null) @@ -29,6 +36,7 @@ public class ColorJsonConverter : JsonConverter writer.WriteValue(ToHex(color)); } + /// Reads a color in one of the accepted forms (see the class remarks). public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType; @@ -62,6 +70,10 @@ public class ColorJsonConverter : JsonConverter return isNullable ? null : (object)default(Color); } + /// + /// Formats the color as "#RRGGBBAA" with uppercase hex digits. Each component is scaled to 0–255, rounded and clamped. + /// The components are written as stored (no gamma conversion). + /// public static string ToHex(Color color) { byte r = (byte)Math.Clamp((int)Math.Round(color.r * 255f), 0, 255); @@ -71,6 +83,12 @@ public class ColorJsonConverter : JsonConverter return $"#{r:X2}{g:X2}{b:X2}{a:X2}"; } + /// + /// Parses "RRGGBB" or "RRGGBBAA" (optional leading '#', surrounding white space ignored, case-insensitive). + /// + /// Text to parse. + /// Parsed color (components / 255; alpha 1 for the 6-digit form), or default when parsing fails. + /// True when the text was parsed. public static bool TryParseHex(string? hex, out Color color) { color = default; diff --git a/Data/RateControlMode.cs b/Data/RateControlMode.cs index 5e646fe..d4ea7cf 100644 --- a/Data/RateControlMode.cs +++ b/Data/RateControlMode.cs @@ -2,10 +2,19 @@ namespace ADOFAIRenderer.Data; +/// Video bitrate control mode (see VideoEncodingConfig and FFmpegNativeEncoder.ApplyQualitySettings). [SuppressMessage("ReSharper", "InconsistentNaming")] public enum RateControlMode { + /// Constant bitrate at VideoEncodingConfig.Bitrate (kbps). CBR, + + /// + /// Variable bitrate averaging VideoEncodingConfig.Bitrate (kbps), peaking at MaxBitrate (kbps) when it is larger, + /// otherwise at 1.5 × Bitrate. + /// VBR, + + /// Constant quality with VideoEncodingConfig.QP (lower is better; range depends on the encoder). CQP } \ No newline at end of file diff --git a/Data/RenderConfig.cs b/Data/RenderConfig.cs index 832db43..397c5ea 100644 --- a/Data/RenderConfig.cs +++ b/Data/RenderConfig.cs @@ -4,22 +4,50 @@ using UnityEngine; namespace ADOFAIRenderer.Data; +/// +/// Settings of one render: resolution, frame rates, encoding and advanced options. Serialized to JSON in the preset +/// files (PresetManager). renders with a copy (). +/// [SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] public class RenderConfig { + /// + /// Game frames simulated per second of render time (the game clock advances 1 / RenderFPS seconds per frame). + /// Values above render extra frames between video frames, which are not captured + /// (the game state then advances in smaller time steps). + /// public double RenderFPS { get; set; } + + /// + /// Frame rate of the output video. The effective rate is min(VideoFPS, RenderFPS). + /// public double VideoFPS { get; set; } + /// Width of the rendered frames and the output video in pixels (also reported by the Screen.width patch). public int RenderWidth { get; set; } + + /// Height of the rendered frames and the output video in pixels (also reported by the Screen.height patch). public int RenderHeight { get; set; } + /// Video encoder settings. public VideoEncodingConfig VideoConfig { get; set; } = new(); + + /// Audio rendering and encoder settings. public AudioEncodingConfig AudioConfig { get; set; } = new(); + + /// Advanced options (random seed, spectrum processing, tile hiding, container, planet colors). public AdvancedConfig AdvancedConfig { get; set; } = new(); + /// Seconds of render time recorded after the end condition first holds (default 15). public double DelayAfterEnd { get; set; } = 15; + + /// Whether the judgement texts (hit accuracy texts) are shown during the render (scrHitTextManagerPatch). Default false. public bool ShowJudgementText { get; set; } = false; + /// + /// Copies every value of into this object; the nested configs are copied value by value + /// into the existing nested objects. Does nothing when is null. + /// public void CopyFrom(RenderConfig? source) { if (source == null) return; @@ -35,6 +63,7 @@ public class RenderConfig AdvancedConfig.CopyFrom(source.AdvancedConfig); } + /// Returns a deep copy (nested configs are new objects). public RenderConfig Clone() { var clone = new RenderConfig(); @@ -43,23 +72,48 @@ public class RenderConfig } } +/// Advanced render options. [SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] public class AdvancedConfig { + /// + /// Seed for UnityEngine.Random set before the game scene loads, so random visual effects repeat between renders; + /// null leaves the random state unchanged. + /// public int? RandomSeed { get; set; } = null; + + /// + /// When true, AudioSource.GetSpectrumData is answered from the offline song audio at the render time + /// (AudioSpectrumGenerator), so audio-reactive visuals follow the rendered audio. Default true. + /// public bool ProcessAudio { get; set; } = true; + + /// + /// When true, the level's floor tiles (including hold meshes and the portal particles of the level's floors) and the + /// planets are hidden during the render, so only the background and decorations remain (scnGamePatch, scrFloorPatch, + /// RenderManager.OnLastUpdate). Default false. + /// public bool NonTile { get; set; } = false; + + /// File extension of the default output file name, without the dot (e.g. "mkv", "mp4"); selects the container. public string FileExtension { get; set; } = "mkv"; + /// Color of the fire (red) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA". [JsonConverter(typeof(ColorJsonConverter))] public Color? firePlanetColor = null; + /// Color of the ice (blue) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA". [JsonConverter(typeof(ColorJsonConverter))] public Color? icePlanetColor = null; + /// + /// Color of the third (wind, planetGreen) planet during the render (PlanetarySystemPatch); null keeps the game's color. + /// Serialized as "#RRGGBBAA". + /// [JsonConverter(typeof(ColorJsonConverter))] public Color? windPlanetColor = null; + /// Copies every value of ; does nothing when it is null. public void CopyFrom(AdvancedConfig? source) { if (source == null) return; @@ -72,6 +126,7 @@ public class AdvancedConfig windPlanetColor = source.windPlanetColor; } + /// Returns a copy of this object. public AdvancedConfig Clone() { var clone = new AdvancedConfig(); @@ -80,18 +135,35 @@ public class AdvancedConfig } } +/// Video encoder settings (passed to FFmpegNativeEncoder.EncoderSettings). public class VideoEncodingConfig { + /// Video codec (default H.264). Also selects the audio codec (CodecUtil.AudioCodecMatch). public Codec Codec { get; set; } = Codec.H264; + + /// Bitrate control mode (default CBR). public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR; + /// Target video bitrate in kbps for CBR and VBR (default 30000 = 30 Mbps). public int Bitrate { get; set; } = 30000; + + /// Peak video bitrate in kbps for VBR, used when larger than Bitrate (otherwise 1.5 × Bitrate). Default 50000. public int MaxBitrate { get; set; } = 50000; + + /// + /// Quality value for CQP: lower is higher quality. Clamped to the range of the selected encoder; a negative value + /// selects 18. Default 18. + /// // ReSharper disable once InconsistentNaming public int QP { get; set; } = 18; + + /// Keyframe interval in seconds (GOP length = round(effective video FPS × interval) frames, at least 1). Default 2. public double KeyframeInterval { get; set; } = 2.0; + + /// When true, only software (CPU) encoders are tried; hardware encoders are skipped. public bool ForceSoftwareEncoder { get; set; } + /// Copies every value of ; does nothing when it is null. public void CopyFrom(VideoEncodingConfig? source) { if (source == null) return; @@ -104,6 +176,7 @@ public class VideoEncodingConfig ForceSoftwareEncoder = source.ForceSoftwareEncoder; } + /// Returns a copy of this object. public VideoEncodingConfig Clone() { var clone = new VideoEncodingConfig(); @@ -112,32 +185,44 @@ public class VideoEncodingConfig } } +/// +/// Audio rendering and encoder settings. Non-positive values of the numeric settings read back as their defaults +/// (48000 Hz, 2 channels, 256000 bps). +/// public class AudioEncodingConfig { private int _audioSampleRate = 48000; private int _audioChannels = 2; private int _audioBitrate = 256000; + /// When true, the song and sound effects are mixed offline and encoded as an audio stream. Default true. public bool RenderAudio { get; set; } = true; + /// + /// Output sample rate in Hz (default 48000). A rate the audio codec does not support in the mod + /// (CodecUtil.AvailableSampleRate) is replaced with 48000 when the render starts. + /// public int AudioSampleRate { get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate; set => _audioSampleRate = value <= 0 ? 48000 : value; } + /// Number of output audio channels (default 2). public int AudioChannels { get => _audioChannels <= 0 ? 2 : _audioChannels; set => _audioChannels = value <= 0 ? 2 : value; } + /// Audio bitrate in bits per second (default 256000 = 256 kbps). Unit differs from the video bitrate (kbps). public int AudioBitrate { get => _audioBitrate <= 0 ? 256000 : _audioBitrate; set => _audioBitrate = value <= 0 ? 256000 : value; } + /// Copies every value of ; does nothing when it is null. public void CopyFrom(AudioEncodingConfig? source) { if (source == null) return; @@ -147,6 +232,7 @@ public class AudioEncodingConfig AudioBitrate = source.AudioBitrate; } + /// Returns a copy of this object. public AudioEncodingConfig Clone() { var clone = new AudioEncodingConfig(); diff --git a/RenderMod.cs b/RenderMod.cs index 5a6020b..9d8f0e7 100644 --- a/RenderMod.cs +++ b/RenderMod.cs @@ -14,12 +14,30 @@ using Object = UnityEngine.Object; namespace ADOFAIRenderer; +/// +/// MelonLoader entry point of the mod. Loads the settings and localization, points FFmpeg.AutoGen at the native +/// FFmpeg libraries, and creates the persistent manager objects (RenderManager, FirstUpdateHook, RendererUIManager). +/// internal class RenderMod : MelonMod { + /// + /// When true, the render button is not added to the level editor (scnEditorPatch checks it in scnEditor.Start). + /// Set by the user from the NVIDIA driver warning dialog (FirstUpdateHook). Main thread only. + /// // ReSharper disable once MemberCanBePrivate.Global public static bool IsFeatureDisabled = false; + + /// + /// Expiration time of this build (UTC). After it, the render configuration window shows an "expired" note next to + /// the version text. It does not block rendering. + /// public static DateTime ExpirationTime = new(2026, 11, 21, 0, 0, 0, DateTimeKind.Utc); + /// + /// Called once by MelonLoader after the mod assembly is loaded. + /// The native FFmpeg libraries are loaded from MelonLoader's UserLibs directory. + /// The three manager GameObjects are marked DontDestroyOnLoad so they survive scene changes. + /// public override void OnInitializeMelon() { GlobalSettings.Load(); @@ -39,6 +57,10 @@ internal class RenderMod : MelonMod ModAssets.Load(); } + /// + /// Called by MelonLoader when the game quits. Aborts an active render (encoders disposed, render thread joined, + /// unfinished output file deleted) through RenderManager.CleanUpActiveRender. + /// public override void OnApplicationQuit() { if (RenderManager.Instance == null) return; diff --git a/Util/BundleLoader.cs b/Util/BundleLoader.cs index 668e65c..3b02bfb 100644 --- a/Util/BundleLoader.cs +++ b/Util/BundleLoader.cs @@ -6,9 +6,16 @@ using UnityEngine; namespace ADOFAIRenderer.Util; +/// +/// Loads the Unity asset bundles (fonts, shaders) embedded in the mod DLL as manifest resources. One bundle set is +/// embedded per platform (Bundle/Win64, Bundle/OSX, Bundle/Linux). +/// Main thread (AssetBundle loading). +/// internal static class BundleLoader { + /// Resource folder of the running platform: "Win64" on Windows, "OSX" on macOS, "Linux" otherwise. private static readonly string BundlePlatform; + /// The mod assembly that contains the bundle resources. private static readonly Assembly? ExecutingAssembly; static BundleLoader() @@ -22,6 +29,16 @@ internal static class BundleLoader ExecutingAssembly = Assembly.GetExecutingAssembly(); } + /// + /// Loads the bundle stored as manifest resource "ADOFAIRenderer.Bundle.<platform>.<resourcePath>" with + /// AssetBundle.LoadFromStream. The resource stream is disposed when this method returns. The caller owns the returned + /// bundle. + /// Reference: https://docs.unity3d.com/ScriptReference/AssetBundle.LoadFromStream.html + /// + /// Bundle name inside the platform folder, for example "fonts" or "shaders". + /// The loaded bundle, or null when Unity fails to load it. + /// The resource is not embedded in the DLL. + /// The executing assembly is not available. public static AssetBundle GetBundle(string resourcePath) { if (ExecutingAssembly != null) diff --git a/Util/FileUtil.cs b/Util/FileUtil.cs index cca31c6..8ac3a19 100644 --- a/Util/FileUtil.cs +++ b/Util/FileUtil.cs @@ -2,8 +2,15 @@ namespace ADOFAIRenderer.Util; +/// File system helpers. internal static class FileUtil { + /// + /// Creates and its missing parent directories when it does not exist. + /// Exceptions of Directory.CreateDirectory are not caught (for example ArgumentException for an empty path, + /// IOException or UnauthorizedAccessException when the directory cannot be created). + /// + /// Directory path, absolute or relative to the current directory. public static void CreateDirectory(string path) { if (!Directory.Exists(path)) Directory.CreateDirectory(path); diff --git a/Util/FrameReader.cs b/Util/FrameReader.cs index 50508d8..6c05f1c 100644 --- a/Util/FrameReader.cs +++ b/Util/FrameReader.cs @@ -13,25 +13,61 @@ using UnityEngine.Rendering; namespace ADOFAIRenderer.Util { + /// + /// GPU capture of rendered frames for the encoder. For each requested frame it optionally converts the RGBA texture to + /// the encoder's YUV 4:2:0 layout with a compute shader (ColorConversion.compute from the mod's asset bundle, kernels + /// CSMain_NV12 / CSMain_YUV420P), then reads the result back asynchronously (AsyncGPUReadback.RequestIntoNativeArray) + /// into a persistent native buffer and adds it to RenderManager.ImageQueue under its video frame index. + /// When the shader or the kernel for the pixel format is unavailable, the RGBA texture is read back unchanged and the + /// encoder converts it (IsColorConverted = false). + /// + /// Each request uses a from a pool (16 created up front, more allocated when the pool + /// is empty). A state stays out of the pool from the request until the render thread has sent its frame + /// (RenderManager.SendVideoFrame, then ) or the frame was dropped. + /// + /// + /// Threads: requests and readback completions run on the main thread; is also called by + /// the render thread. The pool and the disposed flag are guarded by _poolLock. One instance per render, created in + /// RenderManager.SetupPlay with the pixel format of the opened encoder. + /// + /// internal class FrameReader : IDisposable { + // Property IDs of the compute shader parameters: source texture, output buffer and frame size in pixels. private static readonly int Height = Shader.PropertyToID("Height"); private static readonly int Width = Shader.PropertyToID("Width"); private static readonly int DestinationBuffer = Shader.PropertyToID("DestinationBuffer"); private static readonly int SourceTexture = Shader.PropertyToID("SourceTexture"); + /// Number of readback states created by InitializePool. private const int InitialBufferSize = 16; + /// States that are free for a new request. Guarded by _poolLock. private readonly Stack _statePool = new(); + /// Every state created by this reader (free, in flight or holding a queued frame), for Dispose. Guarded by _poolLock. private readonly List _allocatedStates = new(); + /// Lock for _statePool, _allocatedStates, _isInitialized, _isDisposed and ReadbackRequestState.IsInFlight. private readonly object _poolLock = new(); + /// True after InitializePool; false again after Dispose (RecycleState then disposes returned states). private bool _isInitialized; + /// Set by Dispose. Completions and recycles that arrive afterwards dispose their state. private volatile bool _isDisposed; + /// Color conversion compute shader from the asset bundle (ModAssets.ColorConversionShader); null when not loaded. private readonly ComputeShader? _computeShader; + /// Kernel index for the target pixel format; -1 when the format has no kernel or the kernel was not found. private readonly int _kernelIndex = -1; + /// True when frames are converted to YUV on the GPU (shader and kernel available). private readonly bool _useGPUConversion; + /// Pixel format of the read-back data: the target format with GPU conversion, AV_PIX_FMT_RGBA otherwise. private readonly AVPixelFormat _expectedFormat; + /// + /// Selects the conversion kernel for and fills the state pool. + /// + /// + /// Pixel format of the encoder input (FFmpegNativeEncoder.SelectedPixelFormat). NV12 and YUV420P use GPU conversion; + /// any other format falls back to RGBA readback. + /// public FrameReader(AVPixelFormat targetFormat) { _computeShader = ModAssets.ColorConversionShader; @@ -57,6 +93,7 @@ namespace ADOFAIRenderer.Util InitializePool(); } + /// Creates InitialBufferSize states once. Their buffers are allocated on first use (ReadbackRequestState.Setup). private void InitializePool() { lock (_poolLock) @@ -83,6 +120,15 @@ namespace ADOFAIRenderer.Util return _useGPUConversion ? pixels * 3 / 2 : pixels * 4; } + /// + /// Requests the capture of as video frame . Does nothing + /// outside the Render stage. Main thread. + /// With GPU conversion, the compute kernel writes the YUV data of the texture into the state's ComputeBuffer and that + /// buffer is read back; otherwise the texture itself is read back as RGBA. The buffers are sized from the texture + /// dimensions of this call. The result arrives in OnReadbackComplete. + /// + /// Frame to capture (game frame or the output of the frame hooks). + /// Video frame index; the key of the frame in ImageQueue. public void RequestFrameCapture(Texture texture, long currentFrame) { if (!RenderManager.IsCapturingActive) return; @@ -113,6 +159,8 @@ namespace ADOFAIRenderer.Util _computeShader.SetInt(Width, texture.width); _computeShader.SetInt(Height, texture.height); + // Thread group counts cover the frame in tiles of 32 x 16 pixels (NV12) or 64 x 16 pixels (YUV420P), rounded up. + // The tile size is the area one thread group of the ColorConversion.compute kernels processes. var threadGroupsX = _expectedFormat == AVPixelFormat.AV_PIX_FMT_NV12 ? (texture.width + 31) / 32 : (texture.width + 63) / 64; @@ -120,6 +168,8 @@ namespace ADOFAIRenderer.Util _computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1); + // The native buffer must not be disposed until the request completes; Dispose skips states that are in flight. + // Reference: https://docs.unity3d.com/ScriptReference/Rendering.AsyncGPUReadback.RequestIntoNativeArray.html AsyncGPUReadback.RequestIntoNativeArray( ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback ); @@ -136,6 +186,17 @@ namespace ADOFAIRenderer.Util } } + /// + /// Completion of a readback request (through ReadbackRequestState.CachedCallback). + /// + /// Reader disposed: the state is disposed. + /// Render aborted: the state is returned to the pool and nothing is queued. + /// Readback error: a black frame (managed buffer) is queued under the same index, so the render thread does not + /// wait forever for the missing index, and the state is returned to the pool. + /// Success: the state's native buffer is queued without copying; the state stays out of the pool until the + /// render thread has encoded the frame. + /// + /// internal void OnReadbackComplete(ReadbackRequestState state, AsyncGPUReadbackRequest request) { var frame = state.FrameIndex; @@ -227,6 +288,10 @@ namespace ADOFAIRenderer.Util return frame; } + /// + /// Returns a state to the pool after its frame was sent or dropped, or disposes it when the reader is disposed. + /// Called by the render thread (after sending) and by the main thread (abort, errors, queue cleanup). + /// internal void RecycleState(ReadbackRequestState state) { lock (_poolLock) @@ -242,6 +307,10 @@ namespace ADOFAIRenderer.Util } } + /// + /// Adds the frame to RenderManager.ImageQueue under (replacing a frame with the same index) + /// and wakes the render thread. + /// private static void PushToQueue(long frame, RenderManager.CapturedFrame capturedFrame) { if (!RenderManager.Instance) return; @@ -252,6 +321,11 @@ namespace ADOFAIRenderer.Util RenderManager.Instance.FrameAvailableEvent.Set(); } + /// + /// Disposes every state that is not in flight and clears the pool. States in flight are disposed when their + /// readback completes. States still referenced by frames in ImageQueue are disposed too, so callers clear the queue + /// first (RenderManager.StopFFmpeg / RestoreRenderEnvironment). Main thread. + /// public void Dispose() { lock (_poolLock) @@ -269,20 +343,38 @@ namespace ADOFAIRenderer.Util } } + /// + /// One reusable capture slot of : the persistent native buffer the readback writes into, the + /// ComputeBuffer the conversion kernel writes (GPU conversion only), and the metadata of the frame it currently holds. + /// The buffers are kept between frames and reallocated only when the frame size or conversion mode changes. + /// internal class ReadbackRequestState : IDisposable { + /// Reader that receives the completion and owns the pool. private readonly FrameReader _owner; + /// Video frame index of the current request; -1 while the state is free. public long FrameIndex { get; private set; } + /// + /// Persistent native buffer receiving the readback: Width x Height x 3 / 2 bytes of YUV 4:2:0 with GPU conversion, + /// Width x Height x 4 bytes of RGBA otherwise. Read by the render thread through CapturedFrame.NativePointer. + /// public NativeArray NativeBuffer; + /// True between the request and its completion. Written under FrameReader._poolLock. public bool IsInFlight { get; internal set; } + /// Raw ComputeBuffer (4-byte elements) the conversion kernel writes; null without GPU conversion. public ComputeBuffer? GPUBuffer; + /// True when the current frame is converted to YUV on the GPU. public bool IsGPUConversion { get; private set; } + /// Width of the current frame in pixels. public int Width { get; private set; } + /// Height of the current frame in pixels. public int Height { get; private set; } + /// Completion delegate created once per state, so a request does not allocate a new delegate. public readonly Action CachedCallback; + /// Creates a free state without buffers. public ReadbackRequestState(FrameReader owner) { _owner = owner; @@ -290,6 +382,17 @@ namespace ADOFAIRenderer.Util FrameIndex = -1; } + /// + /// Prepares the state for a request: stores the frame metadata and (re)allocates the buffers when their size differs. + /// The native buffer holds width x height x 3 / 2 bytes with GPU conversion, otherwise . + /// The ComputeBuffer holds the same number of bytes as 4-byte elements (byte count / 4, rounded down). + /// + /// Video frame index of the request. + /// Readback size in bytes for RGBA readback (width x height x 4). + /// Whether the kernel converts the frame to YUV. + /// Pixel format of the read-back data (not used by this method). + /// Frame width in pixels. + /// Frame height in pixels. public void Setup(long frameIndex, int bufferSize, bool isGPUConversion, AVPixelFormat pixelFormat, int width, int height) { FrameIndex = frameIndex; @@ -320,16 +423,19 @@ namespace ADOFAIRenderer.Util } } + /// Readback completion; forwards to FrameReader.OnReadbackComplete. private void OnComplete(AsyncGPUReadbackRequest request) { _owner.OnReadbackComplete(this, request); } + /// Marks the state as free (FrameIndex -1). The buffers are kept for the next request. public void Recycle() { FrameIndex = -1; } + /// Releases the native buffer and the ComputeBuffer. public void Dispose() { if (NativeBuffer.IsCreated) NativeBuffer.Dispose(); diff --git a/Util/GPUUtil.cs b/Util/GPUUtil.cs index 586dcba..bdf4221 100644 --- a/Util/GPUUtil.cs +++ b/Util/GPUUtil.cs @@ -10,27 +10,48 @@ using UnityEngine; namespace ADOFAIRenderer.Util; +/// +/// GPU vendor, used to try the hardware encoders of the active GPU first (VideoEncoder.Vendor). Unknown is +/// also the vendor of the software encoders. +/// [SuppressMessage("ReSharper", "InconsistentNaming")] internal enum GPUVendor { Unknown, NVIDIA, AMD, Intel, Apple } +/// +/// Detection of the active GPU vendor and the NVIDIA driver version, and the encoder try order derived from the vendor. +/// Results are cached for the game session. Main thread (SystemInfo is read on first use). +/// internal static class GPUUtil { + // Device name patterns (SystemInfo.graphicsDeviceName) of the GPU families recognized per vendor. A name that matches + // none of them gives GPUVendor.Unknown (Apple on macOS), which only affects the encoder try order. + // NVIDIA: "NVIDIA GeForce RTX/GTX " with optional SUPER / Ti / Ti SUPER, memory size, "Laptop GPU" and a + // "/PCIe/SSE2" suffix. private const string NvidiaPattern = @"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$"; + // Intel: "Intel(R) UHD/HD Graphics " or "Intel(R) Arc(TM) Graphics", with an optional + // parenthesized suffix. private const string IntelPattern = @"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+|Arc\(TM\) [A-C][0-9]+ Graphics)(?> \([^)]+\))?$"; + // AMD: "AMD Radeon RX " with an optional " XT". // ReSharper disable once InconsistentNaming private const string AMDPattern = @"^AMD Radeon RX [0-9]+(?> XT)?$"; + /// Vendor found by the first GetActiveGPUVendor call. private static GPUVendor _cachedVendor = GPUVendor.Unknown; + /// True once _cachedVendor is set. private static bool _hasCachedVendor; + /// + /// Vendor of the GPU Unity renders with: the device name is matched against the NVIDIA, Intel and AMD patterns in + /// that order; on macOS an unmatched name gives Apple; otherwise Unknown. Logs the device name once. + /// private static GPUVendor GetActiveGPUVendor() { if (_hasCachedVendor) return _cachedVendor; @@ -72,9 +93,21 @@ internal static class GPUUtil return _cachedVendor; } + /// Result of the first GetNvidiaDriverVersion call (null when not available). private static double? _cachedNvidiaVersion; + /// True once _cachedNvidiaVersion is set. private static bool _hasCachedNvidiaVersion; + /// + /// NVIDIA driver version in NVIDIA's numbering (for example 561.09), or null when the active GPU is not detected as + /// NVIDIA or no version can be found. The result is cached. + /// + /// The version is derived from a four-part Windows driver version a.b.c.d as ((c mod 10) x 10000 + d) / 100, + /// for example 31.0.15.6109 gives 561.09. It is read from SystemInfo.graphicsDeviceVersion when that string contains a + /// four-part number, otherwise (Windows only) from the DriverVersion value of the first display adapter entry whose + /// ProviderName contains "NVIDIA" under the display adapter class key of the registry. + /// + /// public static double? GetNvidiaDriverVersion() { if (_hasCachedNvidiaVersion) return _cachedNvidiaVersion; @@ -103,6 +136,9 @@ internal static class GPUUtil // 2. Fallback to Windows Registry (only on Windows NT platforms via RegistryUtil) if (System.Environment.OSVersion.Platform != System.PlatformID.Win32NT) return null; + // Device class key of display adapters (class GUID {4d36e968-e325-11ce-bfc1-08002be10318}); each numbered subkey is + // one installed adapter driver. + // Reference: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/system-defined-device-setup-classes-available-to-vendors const string classKey = @"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}"; var subkeys = RegistryUtil.GetHKLMSubKeyNames(classKey); if (subkeys == null) return null; @@ -130,6 +166,12 @@ internal static class GPUUtil return null; } + /// + /// Encoders for in the order they are tried: the encoders of the active GPU vendor first, + /// then all others in VideoEncoder.All order (hardware encoders of other vendors and the software encoders). + /// + /// Video codec. + /// When true, only the software encoders of the codec are returned. public static List GetPrioritizedEncoders(Codec codec, bool forceSoftware = false) { var encoders = VideoEncoder.All.Where(e => e.Codec == codec).ToList(); diff --git a/Util/ParticleUtil.cs b/Util/ParticleUtil.cs index 9c16058..021f63e 100644 --- a/Util/ParticleUtil.cs +++ b/Util/ParticleUtil.cs @@ -4,10 +4,22 @@ using UnityEngine; namespace ADOFAIRenderer.Util { + /// Particle system helpers. internal static class ParticleUtil { + /// + /// Renderer found for each particle system. Weak keys: an entry is dropped when its ParticleSystem object is collected. + /// Main thread. + /// private static readonly ConditionalWeakTable Cache = new(); + /// + /// Returns the ParticleSystemRenderer that belongs to . When a GameObject has several particle + /// systems and renderers, the renderer with the same component index as the particle system is used; otherwise the + /// first renderer of the GameObject. The result is cached per particle system. Main thread. + /// + /// Particle system; null or destroyed returns null. + /// The renderer, or null when the GameObject has none. public static ParticleSystemRenderer? GetRenderer(this ParticleSystem ps) { if (!ps) return null; diff --git a/Util/RationalUtil.cs b/Util/RationalUtil.cs index dcfba11..09081da 100644 --- a/Util/RationalUtil.cs +++ b/Util/RationalUtil.cs @@ -2,8 +2,19 @@ using System; namespace ADOFAIRenderer.Util { + /// Conversion of floating-point rates (frame rates) to integer fractions. internal static class RationalUtil { + /// + /// Best rational approximation Numerator / Denominator of by continued fraction expansion: + /// the last convergent whose numerator fits in int and whose denominator does not exceed + /// . Exact for values with a short expansion (for example 60 gives 60/1). The sign is + /// carried by the numerator; 0 gives 0/1. A magnitude above int.MaxValue has no valid convergent and gives 1/0. + /// Thread-safe (pure function). + /// + /// Value to convert, for example a frame rate in frames per second. + /// Largest allowed denominator (default 100000000). + /// Numerator and denominator (positive for magnitudes up to int.MaxValue). public static (int Numerator, int Denominator) Double2MaxPrecisionInt(double value, int maxDenominator = 100000000) { diff --git a/Util/VideoBGPlayer.cs b/Util/VideoBGPlayer.cs index b9ac9d1..e860172 100644 --- a/Util/VideoBGPlayer.cs +++ b/Util/VideoBGPlayer.cs @@ -9,9 +9,25 @@ using FFmpeg.AutoGen; namespace ADOFAIRenderer.Util; +/// +/// Plays a custom level's background video with FFmpeg (FFmpegNativeDecoder) in place of Unity's VideoPlayer while a +/// render is active, so the video frame shown always matches the render clock. Attached to the scnGame.videoBG object +/// by scnGamePatch.ReplaceVideo, which disables the native VideoPlayer; removed again by that patch when no render is +/// active. +/// +/// Every LateUpdate it computes the video time from the conductor (the same formula scrVfxPlus uses to start and +/// position the native video), decodes frames up to that time and draws the newest one either into the VideoPlayer's +/// target RenderTexture or onto a quad at the far plane of the background camera. +/// Decoded YUV frames are converted to RGBA on the GPU (ColorConversion.compute, kernels CSMain_NV12ToRGBA / +/// CSMain_YUV420PToRGBA); other pixel formats, or a missing shader, use the decoder's RGBA output (CPU conversion). +/// Main thread only. +/// +/// // ReSharper disable once InconsistentNaming internal class VideoBGPlayer : MonoBehaviour { + // Property IDs of the compute shader parameters: input planes, output texture, frame size in pixels, + // ColorRange (1 = full range, 0 = limited range) and ColorMatrix (FFmpegNativeDecoder.ColorMatrix). private static readonly int YTex = Shader.PropertyToID("_YTex"); private static readonly int UVTex = Shader.PropertyToID("_UVTex"); private static readonly int Result = Shader.PropertyToID("_Result"); @@ -22,6 +38,10 @@ internal class VideoBGPlayer : MonoBehaviour private static readonly int ColorRange = Shader.PropertyToID("ColorRange"); private static readonly int ColorMatrix = Shader.PropertyToID("ColorMatrix"); + /// + /// Conversion path of the current video: None = CPU RGBA texture, YUV420P = separate Y, U, V planes, + /// NV12 = Y plane and interleaved UV plane. + /// [SuppressMessage("ReSharper", "InconsistentNaming")] private enum GPUMode { @@ -30,6 +50,7 @@ internal class VideoBGPlayer : MonoBehaviour NV12 } + /// Decoder of the current video; null when not initialized or after CleanUp. private FFmpegNativeDecoder? _decoder; // CPU fallback rendering @@ -47,16 +68,34 @@ internal class VideoBGPlayer : MonoBehaviour private RenderTexture? _renderTexture; private int _kernelIndex = -1; + /// Presentation time in seconds of the frame currently shown (video time, 0 = start of the file). private double _currentFrameTimestamp; + /// Video time in seconds from which the next frame is decoded (current timestamp + 1 / FPS). private double _nextFrameTimestamp; + /// Conversion path selected by Init. private GPUMode _gpuMode = GPUMode.None; + /// Target RenderTexture of the native VideoPlayer; null when the video is shown on the background quad. private RenderTexture? _targetTexture; + /// Whether the level loops the video (miscSettings "loopVideo"). private bool _isLooping; + /// True after a successful Init; set to false by CleanUp or a playback error (playback then stops). private bool _isInitialized; + /// Background quad created by SetupBackgroundQuad when there is no target texture; hidden until the first frame. private GameObject? _quad; + /// Material of the background quad; its main texture is the converted frame. private Material? _material; + /// + /// Releases any previous video and opens . Allocates the plane textures for the GPU path + /// of the decoder's pixel format (chroma planes are (width + 1) / 2 x (height + 1) / 2), or an RGBA texture for the + /// CPU path. Errors are logged; the player then stays uninitialized and shows nothing. + /// + /// Full path of the video file. + /// RenderTexture the native VideoPlayer renders into, or null to draw on a background quad. + /// Whether the video loops. + /// Camera the background quad is attached to when is null. + /// VideoPlayer object; its layer is given to the background quad. // ReSharper disable once InconsistentNaming public void Init(string videoPath, RenderTexture? targetTexture, bool loop, Camera? targetCamera, GameObject videoBGGo) { @@ -169,12 +208,19 @@ internal class VideoBGPlayer : MonoBehaviour } } + /// Sets clamp wrapping and bilinear filtering. private static void ConfigureTexture(Texture2D tex) { tex.wrapMode = TextureWrapMode.Clamp; tex.filterMode = FilterMode.Bilinear; } + /// + /// Main thread, every frame after the song has started. Computes the video time, seeks when it moved backwards or more + /// than 1 s ahead of the shown frame, decodes frames until the next frame lies after that time, and draws the last + /// decoded frame. Before the video starts (negative time) the target texture is cleared to black and the quad hidden. + /// When decoding fails at the end of the file, a looping video seeks to 0; otherwise the last frame stays. + /// private void LateUpdate() { if (!_isInitialized || _decoder == null) return; @@ -182,6 +228,9 @@ internal class VideoBGPlayer : MonoBehaviour var cond = scrConductor.instance; if (!cond || !cond.hasSongStarted) return; + // Song position in seconds of song time, as scrConductor computes songposition_minusi from dspTime with the input + // offset calibration_i = 0 (the render sets the input offset to 0), minus the separate countdown length. + // scrVfxPlus starts the native video at songposition_minusi - countdown + vidOffset (scrVfxPlus.cs). var pitch = cond.song.pitch; var separateCountdownOffset = cond.separateCountdownTime ? cond.crotchetAtStart * cond.adjustedCountdownTicks : 0.0; @@ -215,6 +264,7 @@ internal class VideoBGPlayer : MonoBehaviour } + // scrVfxPlus hides the tutorial (editor) background when the native video starts (scrVfxPlus.cs). if (ADOBase.customLevel) { ADOBase.customLevel.ShowTutorialBackground(false); @@ -222,6 +272,7 @@ internal class VideoBGPlayer : MonoBehaviour try { + // A seek is cheaper than decoding many frames when the time jumped forward by more than 1 s. if (targetVideoTime < _currentFrameTimestamp || targetVideoTime - _currentFrameTimestamp > 1.0) { _decoder.SeekToTime(targetVideoTime); @@ -298,6 +349,10 @@ internal class VideoBGPlayer : MonoBehaviour } } + /// + /// Uploads the decoded planes, converts them to RGBA with the compute shader (or uploads the RGBA texture), and shows + /// the result: blitted into the target texture, or set as the quad's main texture (the quad is activated). + /// private void RenderToTarget() { if (_decoder == null) return; @@ -318,6 +373,7 @@ internal class VideoBGPlayer : MonoBehaviour _computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0); _computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix); + // One thread group per 8 x 8 output pixels, rounded up (group size expected by the ColorConversion.compute kernels). int threadGroupsX = (w + 7) / 8; int threadGroupsY = (h + 7) / 8; _computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1); @@ -353,6 +409,7 @@ internal class VideoBGPlayer : MonoBehaviour _computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0); _computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix); + // One thread group per 8 x 8 output pixels, rounded up (group size expected by the ColorConversion.compute kernels). int threadGroupsX = (w + 7) / 8; int threadGroupsY = (h + 7) / 8; _computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1); @@ -394,11 +451,16 @@ internal class VideoBGPlayer : MonoBehaviour } } + /// Releases the decoder and GPU resources when the component is destroyed. private void OnDestroy() { CleanUp(); } + /// + /// Disposes the decoder and destroys the textures, the RenderTexture, the background quad and its material. + /// The player is uninitialized afterwards. Called by Init, on errors and on destroy. + /// public void CleanUp() { _isInitialized = false; @@ -446,6 +508,12 @@ internal class VideoBGPlayer : MonoBehaviour } } + /// + /// Creates a quad as a child of , 0.5 units in front of its far clip plane, scaled to + /// fill the view (orthographic size or field of view), on the layer of . Its shader is + /// taken from scrCamera's camera quad material when available, otherwise the first of Sprites/Default, Unlit/Texture + /// and Standard that exists. The quad starts inactive. + /// // ReSharper disable once InconsistentNaming private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo) {