SHA256
Document Render Core And API
This commit is contained in:
22 files changed
+1241
-27
No files matched your search
+69
-9
@@ -18,6 +18,21 @@ namespace ADOFAIRenderer.API;
|
|||||||
/// When <c>RenderFPS</c> exceeds <c>VideoFPS</c>, intermediate game rendering frames are skipped and the hook
|
/// When <c>RenderFPS</c> exceeds <c>VideoFPS</c>, intermediate game rendering frames are skipped and the hook
|
||||||
/// will <b>not</b> be invoked on every rendered game frame.
|
/// will <b>not</b> be invoked on every rendered game frame.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ping-pong model:</b> the renderer owns two RenderTextures with the size and format of the game frame.
|
||||||
|
/// <see cref="Source"/> is the current frame, <see cref="Destination"/> is the buffer the next pass writes.
|
||||||
|
/// A pass draws from <see cref="Source"/> into <see cref="Destination"/> and then calls <see cref="Swap"/>, which makes
|
||||||
|
/// the written buffer the new <see cref="Source"/>. <see cref="Blit(Material, int)"/>, and by default
|
||||||
|
/// <see cref="DrawToDestination"/> and <see cref="ExecuteCommandBuffer"/>, swap automatically. The texture in
|
||||||
|
/// <see cref="Current"/> after the last hook is encoded. When no hook swaps or replaces the frame, the game frame is
|
||||||
|
/// encoded unchanged. The first <see cref="Source"/> of every frame is the game's own frame texture
|
||||||
|
/// (<see cref="OriginalSource"/>); do not write into it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Threading and lifetime:</b> 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 <see cref="Source"/>,
|
||||||
|
/// <see cref="Destination"/> or the context. <c>RenderTexture.active</c> is restored after all hooks have run.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||||
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
||||||
@@ -25,12 +40,22 @@ namespace ADOFAIRenderer.API;
|
|||||||
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
|
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
|
||||||
public class FrameHookContext
|
public class FrameHookContext
|
||||||
{
|
{
|
||||||
|
/// <summary>Game frame of this capture (camera quad texture); never written by the context.</summary>
|
||||||
private RenderTexture _originalSource = null!;
|
private RenderTexture _originalSource = null!;
|
||||||
|
|
||||||
|
/// <summary>First ping-pong buffer, owned by FrameHookPipeline.</summary>
|
||||||
private RenderTexture _pingRT = null!;
|
private RenderTexture _pingRT = null!;
|
||||||
|
|
||||||
|
/// <summary>Second ping-pong buffer, owned by FrameHookPipeline.</summary>
|
||||||
private RenderTexture _pongRT = null!;
|
private RenderTexture _pongRT = null!;
|
||||||
|
|
||||||
|
/// <summary>Current frame: the original source, a ping-pong buffer, or a texture set by ReplaceCurrent.</summary>
|
||||||
private RenderTexture _currentSource = null!;
|
private RenderTexture _currentSource = null!;
|
||||||
|
|
||||||
|
/// <summary>Buffer the next pass writes: always the ping or pong buffer.</summary>
|
||||||
private RenderTexture _currentDestination = null!;
|
private RenderTexture _currentDestination = null!;
|
||||||
|
|
||||||
|
/// <summary>True after the first Swap or ReplaceCurrent of this frame.</summary>
|
||||||
private bool _hasModified;
|
private bool _hasModified;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,6 +70,7 @@ public class FrameHookContext
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The active <see cref="RenderTexture"/> representing the current state of the modified frame.
|
/// The active <see cref="RenderTexture"/> representing the current state of the modified frame.
|
||||||
|
/// Same texture as <see cref="Source"/>; after the last hook it is the frame that gets encoded.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RenderTexture Current => _currentSource;
|
public RenderTexture Current => _currentSource;
|
||||||
|
|
||||||
@@ -59,7 +85,7 @@ public class FrameHookContext
|
|||||||
public bool HasModified => _hasModified;
|
public bool HasModified => _hasModified;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long FrameIndex { get; private set; }
|
public long FrameIndex { get; private set; }
|
||||||
|
|
||||||
@@ -69,12 +95,13 @@ public class FrameHookContext
|
|||||||
public long VideoFrameIndex => FrameIndex;
|
public long VideoFrameIndex => FrameIndex;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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 <c>RenderFPS</c> above the video frame rate it advances by more than one between hook calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long RenderFrameIndex { get; private set; }
|
public long RenderFrameIndex { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The timestamp of the current frame in seconds relative to video start.
|
/// The timestamp of the current frame in seconds relative to video start (<see cref="FrameIndex"/> / <see cref="VideoFPS"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double VideoTime { get; private set; }
|
public double VideoTime { get; private set; }
|
||||||
|
|
||||||
@@ -84,30 +111,32 @@ public class FrameHookContext
|
|||||||
public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0;
|
public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The width of the frame in pixels.
|
/// The width of the frame in pixels (width of the current <see cref="Source"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Width => _currentSource.width;
|
public int Width => _currentSource.width;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The height of the frame in pixels.
|
/// The height of the frame in pixels (height of the current <see cref="Source"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Height => _currentSource.height;
|
public int Height => _currentSource.height;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The configured render frames per second.
|
/// The configured render frames per second (game frames simulated per second of render time).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double RenderFPS { get; private set; }
|
public double RenderFPS { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The output video frames per second.
|
/// The output video frames per second: the effective rate, min(configured <c>VideoFPS</c>, <see cref="RenderFPS"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double VideoFPS { get; private set; }
|
public double VideoFPS { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The number of successful modification passes completed for the current frame.
|
/// The number of modification passes completed for the current frame (calls of <see cref="Swap"/> and
|
||||||
|
/// <see cref="ReplaceCurrent"/>, including the automatic swaps of the helper methods). 0 at the start of every frame.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int PassCount { get; private set; }
|
public int PassCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Creates a context for the first frame; see <see cref="Reset"/> for the parameters.</summary>
|
||||||
internal FrameHookContext(
|
internal FrameHookContext(
|
||||||
RenderTexture originalSource,
|
RenderTexture originalSource,
|
||||||
RenderTexture pingRT,
|
RenderTexture pingRT,
|
||||||
@@ -122,6 +151,10 @@ public class FrameHookContext
|
|||||||
Reset(originalSource, pingRT, pongRT, firstDestination, frameIndex, renderFrameIndex, videoTime, renderFps, videoFps);
|
Reset(originalSource, pingRT, pongRT, firstDestination, frameIndex, renderFrameIndex, videoTime, renderFps, videoFps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prepares the context for a new frame: Source = <paramref name="originalSource"/>, Destination =
|
||||||
|
/// <paramref name="firstDestination"/> (ping or pong), not modified, PassCount 0, and the frame metadata.
|
||||||
|
/// </summary>
|
||||||
internal void Reset(
|
internal void Reset(
|
||||||
RenderTexture originalSource,
|
RenderTexture originalSource,
|
||||||
RenderTexture pingRT,
|
RenderTexture pingRT,
|
||||||
@@ -152,6 +185,11 @@ public class FrameHookContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Blits from <see cref="Source"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
|
/// Blits from <see cref="Source"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Uses <c>Graphics.Blit</c>, which binds <see cref="Source"/> as the material's <c>_MainTex</c> and changes
|
||||||
|
/// <c>RenderTexture.active</c>.
|
||||||
|
/// Reference: https://docs.unity3d.com/ScriptReference/Graphics.Blit.html
|
||||||
|
/// </remarks>
|
||||||
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
|
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
|
||||||
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||||
public void Blit(Material? material = null, int pass = -1)
|
public void Blit(Material? material = null, int pass = -1)
|
||||||
@@ -174,6 +212,10 @@ public class FrameHookContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Blits from a custom source <see cref="Texture"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
|
/// Blits from a custom source <see cref="Texture"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The current <see cref="Source"/> is not read; the result replaces the frame. <paramref name="customSource"/> is
|
||||||
|
/// stretched to the size of <see cref="Destination"/>.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="customSource">The custom source texture to blit from.</param>
|
/// <param name="customSource">The custom source texture to blit from.</param>
|
||||||
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
|
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
|
||||||
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||||
@@ -200,6 +242,7 @@ public class FrameHookContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Copies the contents of <see cref="Source"/> into <see cref="Destination"/> without swapping buffers.
|
/// Copies the contents of <see cref="Source"/> into <see cref="Destination"/> without swapping buffers.
|
||||||
/// Call this before drawing custom overlays into <see cref="Destination"/> if you wish to preserve the background.
|
/// Call this before drawing custom overlays into <see cref="Destination"/> if you wish to preserve the background.
|
||||||
|
/// Does not count as a modification; call <see cref="Swap"/> after drawing to make the result the current frame.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Copy()
|
public void Copy()
|
||||||
{
|
{
|
||||||
@@ -207,7 +250,8 @@ public class FrameHookContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clears <see cref="Destination"/> with the specified color.
|
/// Clears <see cref="Destination"/> with the specified color (color only; no depth buffer is cleared).
|
||||||
|
/// Does not swap; the previous <c>RenderTexture.active</c> is restored.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="clearColor">The color to clear with, or <c>null</c> for <see cref="Color.clear"/>.</param>
|
/// <param name="clearColor">The color to clear with, or <c>null</c> for <see cref="Color.clear"/>.</param>
|
||||||
public void ClearDestination(Color? clearColor = null)
|
public void ClearDestination(Color? clearColor = null)
|
||||||
@@ -261,6 +305,11 @@ public class FrameHookContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes a <see cref="CommandBuffer"/> against the GPU and optionally swaps buffers.
|
/// Executes a <see cref="CommandBuffer"/> against the GPU and optionally swaps buffers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The context does not set a render target for the command buffer: the commands must render into
|
||||||
|
/// <see cref="Destination"/> themselves (for example with <c>SetRenderTarget</c> or <c>Blit(Source, Destination)</c>),
|
||||||
|
/// so build or update the buffer inside the hook with the current <see cref="Source"/> and <see cref="Destination"/>.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="commandBuffer">The command buffer to execute.</param>
|
/// <param name="commandBuffer">The command buffer to execute.</param>
|
||||||
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after execution.</param>
|
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after execution.</param>
|
||||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="commandBuffer"/> is null.</exception>
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="commandBuffer"/> is null.</exception>
|
||||||
@@ -278,6 +327,11 @@ public class FrameHookContext
|
|||||||
/// Swaps the active <see cref="Source"/> and <see cref="Destination"/> ping-pong buffers.
|
/// Swaps the active <see cref="Source"/> and <see cref="Destination"/> ping-pong buffers.
|
||||||
/// Call this after manually writing or rendering into <see cref="Destination"/> so that subsequent hooks or the video encoder read the updated frame.
|
/// Call this after manually writing or rendering into <see cref="Destination"/> so that subsequent hooks or the video encoder read the updated frame.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// After the call, <see cref="Source"/> is the buffer that was <see cref="Destination"/>, and <see cref="Destination"/>
|
||||||
|
/// 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 <see cref="ReplaceCurrent"/> is dropped by a swap.
|
||||||
|
/// </remarks>
|
||||||
public void Swap()
|
public void Swap()
|
||||||
{
|
{
|
||||||
_hasModified = true;
|
_hasModified = true;
|
||||||
@@ -291,6 +345,12 @@ public class FrameHookContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Replaces the current modified frame texture with a custom <see cref="RenderTexture"/>.
|
/// Replaces the current modified frame texture with a custom <see cref="RenderTexture"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The texture becomes <see cref="Source"/> / <see cref="Current"/>; <see cref="Destination"/> 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.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="customTexture">The custom texture to set as the active frame.</param>
|
/// <param name="customTexture">The custom texture to set as the active frame.</param>
|
||||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customTexture"/> is null.</exception>
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customTexture"/> is null.</exception>
|
||||||
public void ReplaceCurrent(RenderTexture customTexture)
|
public void ReplaceCurrent(RenderTexture customTexture)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ namespace ADOFAIRenderer.API;
|
|||||||
/// <b>Execution Frequency Note:</b> Frame hooks are only invoked for frames that are actually encoded into the final video (<c>VideoFPS</c>).
|
/// <b>Execution Frequency Note:</b> Frame hooks are only invoked for frames that are actually encoded into the final video (<c>VideoFPS</c>).
|
||||||
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped and this hook
|
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped and this hook
|
||||||
/// will <b>not</b> be called on every game frame.
|
/// will <b>not</b> be called on every game frame.
|
||||||
|
/// Register an implementation per render with <see cref="RenderSession.AddFrameHook(IFrameHook)"/> or for every render
|
||||||
|
/// with <see cref="RenderSession.AddGlobalFrameHook(IFrameHook)"/>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||||
public interface IFrameHook
|
public interface IFrameHook
|
||||||
@@ -18,6 +20,10 @@ public interface IFrameHook
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This method is not called on every rendered game frame if <c>RenderFPS</c> exceeds <c>VideoFPS</c>.
|
/// This method is not called on every rendered game frame if <c>RenderFPS</c> exceeds <c>VideoFPS</c>.
|
||||||
|
/// It is called on the main Unity thread at the end of the frame. Draw from <see cref="FrameHookContext.Source"/>
|
||||||
|
/// into <see cref="FrameHookContext.Destination"/> and swap (see <see cref="FrameHookContext"/>); 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.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="context">The frame hook context providing access to ping-pong textures and frame metadata.</param>
|
/// <param name="context">The frame hook context providing access to ping-pong textures and frame metadata.</param>
|
||||||
void OnRenderFrame(FrameHookContext context);
|
void OnRenderFrame(FrameHookContext context);
|
||||||
|
|||||||
+14
-3
@@ -7,6 +7,9 @@ namespace ADOFAIRenderer.API;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the result and metadata of a completed or canceled render session.
|
/// Represents the result and metadata of a completed or canceled render session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Passed to the completion callback registered with <see cref="RenderSession.OnComplete"/>. Immutable.
|
||||||
|
/// </remarks>
|
||||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||||
public class RenderResult
|
public class RenderResult
|
||||||
@@ -17,7 +20,10 @@ public class RenderResult
|
|||||||
public bool Success { get; }
|
public bool Success { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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 <see cref="RenderSession.WithOutputPath"/> 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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string OutputPath { get; }
|
public string OutputPath { get; }
|
||||||
|
|
||||||
@@ -27,12 +33,13 @@ public class RenderResult
|
|||||||
public long TotalFrames { get; }
|
public long TotalFrames { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The calculated video duration in seconds (based on TotalFrames and FPS).
|
/// The calculated video duration in seconds: <see cref="TotalFrames"/> divided by the effective video frame rate
|
||||||
|
/// (min of the configured VideoFPS and RenderFPS); 0 when the frame rate is not positive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double VideoDuration { get; }
|
public double VideoDuration { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan RenderDuration { get; }
|
public TimeSpan RenderDuration { get; }
|
||||||
|
|
||||||
@@ -43,6 +50,8 @@ public class RenderResult
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Error message or reason if rendering failed or was canceled; <c>null</c> on success.
|
/// Error message or reason if rendering failed or was canceled; <c>null</c> on success.
|
||||||
|
/// A cancelled render has "Rendering was cancelled by user."; a failed render has "Rendering failed: " followed by
|
||||||
|
/// the reason. Use <see cref="Success"/> to test the outcome; the texts are meant for display.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? ErrorMessage { get; }
|
public string? ErrorMessage { get; }
|
||||||
|
|
||||||
@@ -82,6 +91,7 @@ public class RenderResult
|
|||||||
/// <param name="fps">The effective frames per second used to compute video duration.</param>
|
/// <param name="fps">The effective frames per second used to compute video duration.</param>
|
||||||
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
|
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
|
||||||
/// <returns>A new <see cref="RenderResult"/> representing a successful render session.</returns>
|
/// <returns>A new <see cref="RenderResult"/> representing a successful render session.</returns>
|
||||||
|
/// <remarks>The file size is read from disk; it is 0 when the file does not exist or cannot be read.</remarks>
|
||||||
internal static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration)
|
internal static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration)
|
||||||
{
|
{
|
||||||
var fileSizeBytes = 0L;
|
var fileSizeBytes = 0L;
|
||||||
@@ -103,6 +113,7 @@ public class RenderResult
|
|||||||
/// <param name="fps">The effective frames per second used to compute video duration.</param>
|
/// <param name="fps">The effective frames per second used to compute video duration.</param>
|
||||||
/// <param name="renderDuration">Total wall-clock time spent rendering prior to termination.</param>
|
/// <param name="renderDuration">Total wall-clock time spent rendering prior to termination.</param>
|
||||||
/// <returns>A new <see cref="RenderResult"/> representing a failed or canceled render session.</returns>
|
/// <returns>A new <see cref="RenderResult"/> representing a failed or canceled render session.</returns>
|
||||||
|
/// <remarks>OutputPath is empty and FileSizeBytes is 0. A null message is replaced by a generic one.</remarks>
|
||||||
internal static RenderResult CreateFailure(string? errorMessage, long totalFrames, double fps, TimeSpan renderDuration)
|
internal static RenderResult CreateFailure(string? errorMessage, long totalFrames, double fps, TimeSpan renderDuration)
|
||||||
{
|
{
|
||||||
var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
|
var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
|
||||||
|
|||||||
+180
-5
@@ -10,13 +10,41 @@ using UnityEngine;
|
|||||||
namespace ADOFAIRenderer.API;
|
namespace ADOFAIRenderer.API;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Usage:</b> create an instance, configure it with the <c>With…</c>, <c>On…</c> and <c>AddFrameHook</c> methods,
|
||||||
|
/// then call <see cref="Start"/>. The builder's values are copied into the render when <see cref="Start"/> is called;
|
||||||
|
/// changing the builder afterwards does not affect the running render. A builder can be reused for later renders.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Lifecycle of a render:</b> <see cref="Start"/> 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 <see cref="RenderConfig.DelayAfterEnd"/> 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 <see cref="RenderResult"/> and the game returns to the level editor.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Threading:</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Frame hooks</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||||
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
||||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||||
public class RenderSession
|
public class RenderSession
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private sealed class GlobalHookRegistration : IDisposable
|
private sealed class GlobalHookRegistration : IDisposable
|
||||||
{
|
{
|
||||||
public object Key { get; }
|
public object Key { get; }
|
||||||
@@ -37,6 +65,10 @@ public class RenderSession
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private sealed class SessionHookEntry
|
private sealed class SessionHookEntry
|
||||||
{
|
{
|
||||||
public object Key { get; }
|
public object Key { get; }
|
||||||
@@ -49,11 +81,22 @@ public class RenderSession
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Global hooks in registration order. Guarded by GlobalHooksLock.</summary>
|
||||||
private static readonly List<GlobalHookRegistration> GlobalFrameHooks = new();
|
private static readonly List<GlobalHookRegistration> GlobalFrameHooks = new();
|
||||||
|
|
||||||
|
/// <summary>Lock for GlobalFrameHooks and _globalHooksVersion (global hooks may be changed from any thread).</summary>
|
||||||
private static readonly object GlobalHooksLock = new();
|
private static readonly object GlobalHooksLock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static int _globalHooksVersion;
|
private static int _globalHooksVersion;
|
||||||
|
|
||||||
|
/// <summary>Session hooks of this builder in the order they were added.</summary>
|
||||||
private readonly List<SessionHookEntry> _frameHooks = new();
|
private readonly List<SessionHookEntry> _frameHooks = new();
|
||||||
|
|
||||||
|
/// <summary>Autoplay setting copied to the render by Start (default true).</summary>
|
||||||
private bool _auto = true;
|
private bool _auto = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -63,27 +106,48 @@ public class RenderSession
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Time-related patch controlling frame timing and clock synchronization.
|
/// Time-related patch controlling frame timing and clock synchronization.
|
||||||
|
/// While capturing, <c>Time.time</c>, <c>Time.timeAsDouble</c>, <c>Time.unscaledTime</c>,
|
||||||
|
/// <c>Time.unscaledTimeAsDouble</c>, <c>Time.deltaTime</c>, <c>Time.unscaledDeltaTime</c>, <c>Time.frameCount</c>
|
||||||
|
/// and <c>AudioSettings.dspTime</c> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Time,
|
Time,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Screen-related patch controlling screen resolution.
|
/// Screen-related patch controlling screen resolution.
|
||||||
|
/// While the render is active (from the countdown on), <c>Screen.width</c> and <c>Screen.height</c> return
|
||||||
|
/// <see cref="RenderConfig.RenderWidth"/> and <see cref="RenderConfig.RenderHeight"/>, so the game creates its
|
||||||
|
/// camera RenderTexture at the render resolution.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Screen
|
Screen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Time patch setting copied to the render by Start (default true).</summary>
|
||||||
private bool _timePatch = true;
|
private bool _timePatch = true;
|
||||||
|
|
||||||
|
/// <summary>Screen patch setting copied to the render by Start (default true).</summary>
|
||||||
private bool _screenPatch = true;
|
private bool _screenPatch = true;
|
||||||
|
|
||||||
|
/// <summary>Custom output file path, or null for the default output directory.</summary>
|
||||||
private string? _outputPath;
|
private string? _outputPath;
|
||||||
|
|
||||||
|
/// <summary>Notification setting copied to the render by Start (default true).</summary>
|
||||||
private bool _notification = true;
|
private bool _notification = true;
|
||||||
|
|
||||||
|
/// <summary>Custom end condition, or null to keep the default (level cleared).</summary>
|
||||||
private Func<bool>? _endCondition;
|
private Func<bool>? _endCondition;
|
||||||
|
|
||||||
|
/// <summary>Combined completion callbacks, or null to keep the default (notification).</summary>
|
||||||
private Action<RenderResult>? _onComplete;
|
private Action<RenderResult>? _onComplete;
|
||||||
|
|
||||||
|
/// <summary>Combined last-update callbacks, or null for none.</summary>
|
||||||
private Action? _onLastUpdate;
|
private Action? _onLastUpdate;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets whether auto mode is enabled.
|
/// Sets whether auto mode (the game's autoplay, <c>RDC.auto</c>) 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 (<c>scrController.noFail</c>, <c>scrController.noFailInfiniteMargin</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="enabled"><c>true</c> to enable auto mode; otherwise, <c>false</c>. Default is <c>true</c>.</param>
|
/// <param name="enabled"><c>true</c> to enable auto mode; otherwise, <c>false</c>. Default is <c>true</c>.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
@@ -115,6 +179,13 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the custom condition function that determines when the render session should end.
|
/// Sets the custom condition function that determines when the render session should end.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 <c>true</c>, the render continues for <see cref="RenderConfig.DelayAfterEnd"/> seconds of
|
||||||
|
/// render time and then finishes successfully; it is not required to stay <c>true</c>. The default condition is
|
||||||
|
/// that the level is cleared (<c>scrController.instance.state == States.Won</c>). Calling this method again
|
||||||
|
/// replaces the previous condition. Exceptions thrown by the condition are not caught.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="endCondition">A delegate returning <c>true</c> when the session end condition is met.</param>
|
/// <param name="endCondition">A delegate returning <c>true</c> when the session end condition is met.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession WithEndCondition(Func<bool> endCondition)
|
public RenderSession WithEndCondition(Func<bool> endCondition)
|
||||||
@@ -126,6 +197,13 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the custom output path for the rendered file.
|
/// Sets the custom output path for the rendered file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The directory of the path is created by <see cref="Start"/> when it does not exist. An existing file at the path
|
||||||
|
/// is overwritten. The container format follows the file extension. With <c>null</c>, the file is named
|
||||||
|
/// <c>yyyy-MM-dd HH-mm-ss.<ext></c> (local start time, extension from <see cref="AdvancedConfig.FileExtension"/>)
|
||||||
|
/// in the output directory chosen in the mod settings. When the render is cancelled or fails, the unfinished file
|
||||||
|
/// is deleted.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="outputPath">The file path where the output will be saved, or <c>null</c> to use the default capture directory.</param>
|
/// <param name="outputPath">The file path where the output will be saved, or <c>null</c> to use the default capture directory.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession WithOutputPath(string? outputPath)
|
public RenderSession WithOutputPath(string? outputPath)
|
||||||
@@ -137,6 +215,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets whether desktop notifications should be displayed when rendering completes.
|
/// Sets whether desktop notifications should be displayed when rendering completes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The notification is shown by the default completion callback only. When a callback is added with
|
||||||
|
/// <see cref="OnComplete"/>, it replaces the default callback and no notification is shown. Default is <c>true</c>.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="enabled"><c>true</c> to enable desktop notifications; otherwise, <c>false</c>.</param>
|
/// <param name="enabled"><c>true</c> to enable desktop notifications; otherwise, <c>false</c>.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession WithNotification(bool enabled)
|
public RenderSession WithNotification(bool enabled)
|
||||||
@@ -148,6 +230,13 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a callback action to be invoked when the render session completes or fails.
|
/// Adds a callback action to be invoked when the render session completes or fails.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="callback">The callback action that receives the resulting <see cref="RenderResult"/>.</param>
|
/// <param name="callback">The callback action that receives the resulting <see cref="RenderResult"/>.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession OnComplete(Action<RenderResult> callback)
|
public RenderSession OnComplete(Action<RenderResult> callback)
|
||||||
@@ -159,6 +248,11 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a callback action to be invoked on the last update of each frame in the render session.
|
/// Adds a callback action to be invoked on the last update of each frame in the render session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Called on the main thread every Unity frame after all other <c>LateUpdate</c> 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.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="callback">The callback action to execute.</param>
|
/// <param name="callback">The callback action to execute.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession OnLastUpdate(Action callback)
|
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.
|
/// The hook can modify the rendered frame using GPU ping-pong double buffering.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// <b>Execution Frequency Note:</b> This hook is invoked only for frames that are captured for the output video.
|
/// <b>Execution Frequency Note:</b> This hook is invoked only for frames that are captured for the output video.
|
||||||
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
|
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
|
||||||
/// so this hook will <b>not</b> be called on every game frame.
|
/// so this hook will <b>not</b> be called on every game frame.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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 <see cref="FrameHookContext.Destination"/> and
|
||||||
|
/// swap (or use <see cref="FrameHookContext.Blit(Material, int)"/>, 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 <see cref="Start"/> and are discarded when it ends.
|
||||||
|
/// The same delegate can be added more than once; it then runs once per addition.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
|
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
@@ -190,6 +294,13 @@ public class RenderSession
|
|||||||
/// Adds a material-based post-processing pass to be executed for each encoded video frame.
|
/// 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.
|
/// Automatically performs a GPU blit from source to destination and swaps buffers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Equivalent to a hook calling <see cref="FrameHookContext.Blit(Material, int)"/>: the current frame is bound as the
|
||||||
|
/// material's <c>_MainTex</c> by <c>Graphics.Blit</c>. The material is referenced, not copied, so property changes
|
||||||
|
/// made to it later apply to the following frames. Execution rules are the same as
|
||||||
|
/// <see cref="AddFrameHook(Action{FrameHookContext})"/>.
|
||||||
|
/// Reference: https://docs.unity3d.com/ScriptReference/Graphics.Blit.html
|
||||||
|
/// </remarks>
|
||||||
/// <param name="material">The material containing the shader to apply.</param>
|
/// <param name="material">The material containing the shader to apply.</param>
|
||||||
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
@@ -204,6 +315,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a custom frame modification hook implementing <see cref="IFrameHook"/>.
|
/// Adds a custom frame modification hook implementing <see cref="IFrameHook"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="IFrameHook.OnRenderFrame"/> is invoked with the same rules as
|
||||||
|
/// <see cref="AddFrameHook(Action{FrameHookContext})"/>. Remove it with <see cref="RemoveFrameHook(IFrameHook)"/>.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="hook">The hook instance to execute.</param>
|
/// <param name="hook">The hook instance to execute.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
|
||||||
@@ -239,6 +354,11 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a previously added frame modification hook callback from this session.
|
/// Removes a previously added frame modification hook callback from this session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Removes the first entry (in the order added) whose registered object is <paramref name="hook"/> 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.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="hook">The hook action to remove.</param>
|
/// <param name="hook">The hook action to remove.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession RemoveFrameHook(Action<FrameHookContext> hook)
|
public RenderSession RemoveFrameHook(Action<FrameHookContext> hook)
|
||||||
@@ -251,6 +371,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a previously added material-based post-processing pass from this session.
|
/// Removes a previously added material-based post-processing pass from this session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Removes every pass added with <paramref name="material"/> (any pass index). Does nothing when the material is
|
||||||
|
/// null or destroyed. Affects only renders started after this call.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="material">The material to remove.</param>
|
/// <param name="material">The material to remove.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession RemoveFrameHook(Material material)
|
public RenderSession RemoveFrameHook(Material material)
|
||||||
@@ -271,6 +395,9 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a previously added <see cref="IFrameHook"/> from this session.
|
/// Removes a previously added <see cref="IFrameHook"/> from this session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Removes the first entry added with this instance (reference equality). Affects only renders started after this call.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="hook">The hook instance to remove.</param>
|
/// <param name="hook">The hook instance to remove.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession RemoveFrameHook(IFrameHook hook)
|
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.
|
/// Registers a global frame modification hook that will execute on every render session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// <b>Execution Frequency Note:</b> Global frame hooks are executed only for frames that are captured for the output video.
|
/// <b>Execution Frequency Note:</b> Global frame hooks are executed only for frames that are captured for the output video.
|
||||||
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
|
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
|
||||||
/// so this hook will <b>not</b> be called on every game frame.
|
/// so this hook will <b>not</b> be called on every game frame.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="RemoveGlobalFrameHook(Action{FrameHookContext})"/> or <see cref="ClearGlobalFrameHooks"/>.
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
|
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
|
||||||
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
||||||
@@ -316,6 +453,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a global material-based post-processing pass that will execute on every render session.
|
/// Registers a global material-based post-processing pass that will execute on every render session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Same lifetime and ordering as <see cref="AddGlobalFrameHook(Action{FrameHookContext})"/>; the pass is applied as
|
||||||
|
/// <see cref="FrameHookContext.Blit(Material, int)"/>. Thread-safe.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="material">The material containing the shader to apply.</param>
|
/// <param name="material">The material containing the shader to apply.</param>
|
||||||
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||||
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
||||||
@@ -335,6 +476,9 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a global frame modification hook implementing <see cref="IFrameHook"/>.
|
/// Registers a global frame modification hook implementing <see cref="IFrameHook"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Same lifetime and ordering as <see cref="AddGlobalFrameHook(Action{FrameHookContext})"/>. Thread-safe.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="hook">The hook instance to execute.</param>
|
/// <param name="hook">The hook instance to execute.</param>
|
||||||
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
||||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
|
||||||
@@ -353,6 +497,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unregisters a previously registered global frame modification hook.
|
/// Unregisters a previously registered global frame modification hook.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Removes the first registration (in registration order) whose registered object is <paramref name="hook"/> or whose
|
||||||
|
/// delegate equals it. Thread-safe; a running render stops calling the hook from its next captured frame.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="hook">The hook action to remove.</param>
|
/// <param name="hook">The hook action to remove.</param>
|
||||||
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
|
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
|
||||||
public static bool RemoveGlobalFrameHook(Action<FrameHookContext> hook)
|
public static bool RemoveGlobalFrameHook(Action<FrameHookContext> hook)
|
||||||
@@ -373,6 +521,7 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unregisters a previously registered global material-based post-processing pass.
|
/// Unregisters a previously registered global material-based post-processing pass.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>Removes every registration made with <paramref name="material"/> (any pass index). Thread-safe.</remarks>
|
||||||
/// <param name="material">The material to remove.</param>
|
/// <param name="material">The material to remove.</param>
|
||||||
/// <returns><c>true</c> if any hooks matching <paramref name="material"/> were removed; otherwise, <c>false</c>.</returns>
|
/// <returns><c>true</c> if any hooks matching <paramref name="material"/> were removed; otherwise, <c>false</c>.</returns>
|
||||||
public static bool RemoveGlobalFrameHook(Material material)
|
public static bool RemoveGlobalFrameHook(Material material)
|
||||||
@@ -399,6 +548,7 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unregisters a previously registered global frame modification hook implementing <see cref="IFrameHook"/>.
|
/// Unregisters a previously registered global frame modification hook implementing <see cref="IFrameHook"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>Removes the first registration made with this instance (reference equality). Thread-safe.</remarks>
|
||||||
/// <param name="hook">The hook instance to remove.</param>
|
/// <param name="hook">The hook instance to remove.</param>
|
||||||
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
|
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
|
||||||
public static bool RemoveGlobalFrameHook(IFrameHook hook)
|
public static bool RemoveGlobalFrameHook(IFrameHook hook)
|
||||||
@@ -416,6 +566,7 @@ public class RenderSession
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes one registration (called by its Dispose). Does nothing when it was already removed.</summary>
|
||||||
private static void RemoveGlobalRegistration(GlobalHookRegistration registration)
|
private static void RemoveGlobalRegistration(GlobalHookRegistration registration)
|
||||||
{
|
{
|
||||||
lock (GlobalHooksLock)
|
lock (GlobalHooksLock)
|
||||||
@@ -430,6 +581,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clears all registered global frame modification hooks.
|
/// Clears all registered global frame modification hooks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Removes the registrations of every mod, not only the caller's. Handles returned earlier stay valid; disposing them
|
||||||
|
/// does nothing. Thread-safe.
|
||||||
|
/// </remarks>
|
||||||
public static void ClearGlobalFrameHooks()
|
public static void ClearGlobalFrameHooks()
|
||||||
{
|
{
|
||||||
lock (GlobalHooksLock)
|
lock (GlobalHooksLock)
|
||||||
@@ -443,7 +598,7 @@ public class RenderSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool HasGlobalFrameHooks
|
public static bool HasGlobalFrameHooks
|
||||||
{
|
{
|
||||||
@@ -457,7 +612,7 @@ public class RenderSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The number of currently registered global frame modification hooks.
|
/// The number of currently registered global frame modification hooks (by any mod). Thread-safe.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static int GlobalFrameHookCount
|
public static int GlobalFrameHookCount
|
||||||
{
|
{
|
||||||
@@ -470,6 +625,7 @@ public class RenderSession
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Change counter of the global hook list (see _globalHooksVersion). Thread-safe.</summary>
|
||||||
internal static int GlobalHooksVersion
|
internal static int GlobalHooksVersion
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -481,6 +637,7 @@ public class RenderSession
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends the delegates of every global hook, in registration order, to <paramref name="destination"/>. Thread-safe.</summary>
|
||||||
internal static void CopyGlobalFrameHooksTo(List<Action<FrameHookContext>> destination)
|
internal static void CopyGlobalFrameHooksTo(List<Action<FrameHookContext>> destination)
|
||||||
{
|
{
|
||||||
lock (GlobalHooksLock)
|
lock (GlobalHooksLock)
|
||||||
@@ -495,11 +652,29 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies the configured flags and initiates the render session.
|
/// Applies the configured flags and initiates the render session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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 <paramref name="renderConfig"/>
|
||||||
|
/// (later changes to the caller's object do not affect the render), creates the output directory, and starts
|
||||||
|
/// loading the game scene (<c>scnGame</c>) with the level. It returns immediately; the render runs over the following
|
||||||
|
/// frames and reports its end through the <see cref="OnComplete"/> callback.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
/// <param name="renderConfig">The configuration settings for the render session.</param>
|
/// <param name="renderConfig">The configuration settings for the render session.</param>
|
||||||
/// <param name="levelPath">The file path of the level to render.</param>
|
/// <param name="levelPath">The file path of the level to render (the .adofai file).</param>
|
||||||
/// <exception cref="InvalidOperationException">Thrown when the output directory cannot be determined from the output path.</exception>
|
/// <exception cref="InvalidOperationException">Thrown when the output directory cannot be determined from the output path.</exception>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// Thrown by the directory creation when the output path has no directory part (for example a bare file name).
|
||||||
|
/// </exception>
|
||||||
public void Start(RenderConfig renderConfig, string levelPath)
|
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.Auto = _auto;
|
||||||
RenderContext.TimePatch = _timePatch;
|
RenderContext.TimePatch = _timePatch;
|
||||||
RenderContext.ScreenPatch = _screenPatch;
|
RenderContext.ScreenPatch = _screenPatch;
|
||||||
|
|||||||
@@ -6,29 +6,59 @@ using Newtonsoft.Json;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Core.Data;
|
namespace ADOFAIRenderer.Core.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mod-wide settings that are not part of a render preset, stored as JSON in
|
||||||
|
/// <game root>/UserData/ADOFAIRenderer/global_settings.json (see <see cref="GlobalSettings"/>).
|
||||||
|
/// </summary>
|
||||||
internal class GlobalConfig
|
internal class GlobalConfig
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Output directory chosen in the render configuration window; null or empty selects
|
||||||
|
/// GlobalSettings.DefaultOutputDirectory. Ignored while the directory does not exist (see EffectiveOutputDirectory).
|
||||||
|
/// </summary>
|
||||||
public string? OutputDirectory { get; set; }
|
public string? OutputDirectory { get; set; }
|
||||||
|
/// <summary>Name of the preset last selected or saved in the render configuration window; selected again when it opens.</summary>
|
||||||
public string? LastPresetName { get; set; }
|
public string? LastPresetName { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// True once PresetManager.EnsureDefaultPresetsCreated has written the built-in preset files. Built-in presets the user
|
||||||
|
/// deleted afterwards are not written again.
|
||||||
|
/// </summary>
|
||||||
public bool DefaultPresetsInitialized { get; set; }
|
public bool DefaultPresetsInitialized { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads and saves <see cref="GlobalConfig"/>. 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.
|
||||||
|
/// </summary>
|
||||||
internal static class GlobalSettings
|
internal static class GlobalSettings
|
||||||
{
|
{
|
||||||
|
/// <summary>MelonLoader's game root directory, or the application base directory when it is empty.</summary>
|
||||||
private static string GetGameRootDir()
|
private static string GetGameRootDir()
|
||||||
{
|
{
|
||||||
var root = MelonEnvironment.GameRootDirectory;
|
var root = MelonEnvironment.GameRootDirectory;
|
||||||
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
|
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary><game root>/UserData/ADOFAIRenderer/global_settings.json.</summary>
|
||||||
private static string SettingsFilePath =>
|
private static string SettingsFilePath =>
|
||||||
Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json");
|
Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settings in memory. Changes are kept in memory until <see cref="Save"/> is called; <see cref="Load"/> replaces the
|
||||||
|
/// object.
|
||||||
|
/// </summary>
|
||||||
public static GlobalConfig Current { get; private set; } = new();
|
public static GlobalConfig Current { get; private set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Output directory used when none is configured: <game root>/Render.</summary>
|
||||||
public static string DefaultOutputDirectory =>
|
public static string DefaultOutputDirectory =>
|
||||||
Path.Combine(GetGameRootDir(), "Render");
|
Path.Combine(GetGameRootDir(), "Render");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Directory where renders without an explicit output path are written: Current.OutputDirectory when it is set and
|
||||||
|
/// exists, otherwise <see cref="DefaultOutputDirectory"/>. The default directory is not created here
|
||||||
|
/// (RenderManager.SetupCaptureDirectory creates it).
|
||||||
|
/// </summary>
|
||||||
public static string EffectiveOutputDirectory
|
public static string EffectiveOutputDirectory
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -47,6 +77,10 @@ internal static class GlobalSettings
|
|||||||
Load();
|
Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces <see cref="Current"/> 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).
|
||||||
|
/// </summary>
|
||||||
public static void Load()
|
public static void Load()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -66,6 +100,10 @@ internal static class GlobalSettings
|
|||||||
Current = new GlobalConfig();
|
Current = new GlobalConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes <see cref="Current"/> as indented JSON to the settings file, creating the directory when needed.
|
||||||
|
/// Errors are logged as warnings.
|
||||||
|
/// </summary>
|
||||||
public static void Save()
|
public static void Save()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -4,14 +4,30 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Core;
|
namespace ADOFAIRenderer.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// </summary>
|
||||||
[DefaultExecutionOrder(int.MinValue)]
|
[DefaultExecutionOrder(int.MinValue)]
|
||||||
internal class FirstUpdateHook : MonoBehaviour
|
internal class FirstUpdateHook : MonoBehaviour
|
||||||
{
|
{
|
||||||
|
/// <summary>True once the FFmpeg libraries were found in UserLibs (after the download UI, if it was needed).</summary>
|
||||||
private bool _checkedFFmpeg;
|
private bool _checkedFFmpeg;
|
||||||
|
|
||||||
|
/// <summary>True once the NVIDIA driver version check ran (it runs once per game session).</summary>
|
||||||
private bool _checkedNvidiaDriver;
|
private bool _checkedNvidiaDriver;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void Update()
|
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 (!_checkedFFmpeg)
|
||||||
{
|
{
|
||||||
if (FFmpegDownloadUI.Instance) return;
|
if (FFmpegDownloadUI.Instance) return;
|
||||||
@@ -24,6 +40,7 @@ internal class FirstUpdateHook : MonoBehaviour
|
|||||||
_checkedFFmpeg = true;
|
_checkedFFmpeg = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. NVIDIA driver version: waits until no other warning dialog is open, then runs once.
|
||||||
if (!_checkedNvidiaDriver)
|
if (!_checkedNvidiaDriver)
|
||||||
{
|
{
|
||||||
if (CommonWarningDialogUI.Instance) return;
|
if (CommonWarningDialogUI.Instance) return;
|
||||||
@@ -32,12 +49,19 @@ internal class FirstUpdateHook : MonoBehaviour
|
|||||||
CheckNvidiaDriverWarning();
|
CheckNvidiaDriverWarning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Render frame counter (only counts while a render is in the OnScene stage or later).
|
||||||
if (RenderManager.Instance)
|
if (RenderManager.Instance)
|
||||||
{
|
{
|
||||||
RenderManager.Instance.OnPreUpdate();
|
RenderManager.Instance.OnPreUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void CheckNvidiaDriverWarning()
|
private void CheckNvidiaDriverWarning()
|
||||||
{
|
{
|
||||||
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
|
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
|
||||||
|
|||||||
@@ -6,16 +6,58 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Core;
|
namespace ADOFAIRenderer.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
internal class FrameHookPipeline : IDisposable
|
internal class FrameHookPipeline : IDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>First ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
|
||||||
private RenderTexture? _pingRT;
|
private RenderTexture? _pingRT;
|
||||||
|
|
||||||
|
/// <summary>Second ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
|
||||||
private RenderTexture? _pongRT;
|
private RenderTexture? _pongRT;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private RenderTexture? _lastOutputRT;
|
private RenderTexture? _lastOutputRT;
|
||||||
|
|
||||||
|
/// <summary>Context object passed to the hooks; reset and reused for every frame (no allocation per frame).</summary>
|
||||||
private FrameHookContext? _reusableContext;
|
private FrameHookContext? _reusableContext;
|
||||||
|
|
||||||
|
/// <summary>Width of the current buffers in pixels (0 when no buffers exist).</summary>
|
||||||
private int _width;
|
private int _width;
|
||||||
|
|
||||||
|
/// <summary>Height of the current buffers in pixels (0 when no buffers exist).</summary>
|
||||||
private int _height;
|
private int _height;
|
||||||
|
|
||||||
|
/// <summary>Format of the source texture the current buffers were created for.</summary>
|
||||||
private RenderTextureFormat _format;
|
private RenderTextureFormat _format;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs <paramref name="hooks"/> in order on <paramref name="sourceTexture"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sourceTexture">Captured game frame. Hooks do not write into it (they write the ping-pong buffers).</param>
|
||||||
|
/// <param name="frameIndex">Video frame index (0-based) of this frame.</param>
|
||||||
|
/// <param name="renderFrameIndex">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="videoTime">Time of this video frame in seconds from the start of the video.</param>
|
||||||
|
/// <param name="renderFps">Render frame rate (game frames per second).</param>
|
||||||
|
/// <param name="videoFps">Effective video frame rate (min(VideoFPS, RenderFPS)).</param>
|
||||||
|
/// <param name="hooks">Hooks to run, in order. Session hooks come before global hooks.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// <paramref name="sourceTexture"/> 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).
|
||||||
|
/// </returns>
|
||||||
public RenderTexture? Process(
|
public RenderTexture? Process(
|
||||||
RenderTexture sourceTexture,
|
RenderTexture sourceTexture,
|
||||||
long frameIndex,
|
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;
|
var result = _reusableContext.Current;
|
||||||
_lastOutputRT = _reusableContext.HasModified ? result : null;
|
_lastOutputRT = _reusableContext.HasModified ? result : null;
|
||||||
return result;
|
return result;
|
||||||
@@ -91,6 +135,12 @@ internal class FrameHookPipeline : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void EnsureBuffers(RenderTexture sourceTexture)
|
private void EnsureBuffers(RenderTexture sourceTexture)
|
||||||
{
|
{
|
||||||
var width = sourceTexture.width;
|
var width = sourceTexture.width;
|
||||||
@@ -160,6 +210,9 @@ internal class FrameHookPipeline : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases and destroys both buffers and drops the reusable context. The next Process call creates them again.
|
||||||
|
/// </summary>
|
||||||
public void ReleaseBuffers()
|
public void ReleaseBuffers()
|
||||||
{
|
{
|
||||||
if (_pingRT)
|
if (_pingRT)
|
||||||
@@ -182,6 +235,7 @@ internal class FrameHookPipeline : IDisposable
|
|||||||
_reusableContext = null;
|
_reusableContext = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Same as <see cref="ReleaseBuffers"/>. Main thread (Object.Destroy is used).</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
ReleaseBuffers();
|
ReleaseBuffers();
|
||||||
|
|||||||
@@ -5,10 +5,22 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Core;
|
namespace ADOFAIRenderer.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// </summary>
|
||||||
[DefaultExecutionOrder(int.MaxValue)]
|
[DefaultExecutionOrder(int.MaxValue)]
|
||||||
internal class PostRenderHook : MonoBehaviour
|
internal class PostRenderHook : MonoBehaviour
|
||||||
{
|
{
|
||||||
|
/// <summary>Private field scrCamera.camQuadMesh: the MeshRenderer of the quad that displays the camera RenderTexture.</summary>
|
||||||
private FieldInfo? _camQuadMeshField;
|
private FieldInfo? _camQuadMeshField;
|
||||||
|
|
||||||
|
/// <summary>Cached value of camQuadMesh for the current scrCamera instance. Looked up again while it is null or destroyed.</summary>
|
||||||
private MeshRenderer? _camQuadMeshRenderer;
|
private MeshRenderer? _camQuadMeshRenderer;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
@@ -16,6 +28,10 @@ internal class PostRenderHook : MonoBehaviour
|
|||||||
_camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh");
|
_camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void LateUpdate()
|
private void LateUpdate()
|
||||||
{
|
{
|
||||||
if (RenderManager.Instance)
|
if (RenderManager.Instance)
|
||||||
@@ -24,6 +40,11 @@ internal class PostRenderHook : MonoBehaviour
|
|||||||
PostFrameAsync().Forget();
|
PostFrameAsync().Forget();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private async UniTaskVoid PostFrameAsync()
|
private async UniTaskVoid PostFrameAsync()
|
||||||
{
|
{
|
||||||
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
|
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ using Newtonsoft.Json.Linq;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Core;
|
namespace ADOFAIRenderer.Core;
|
||||||
|
|
||||||
|
/// <summary>A named render configuration, stored as one JSON file in the presets directory.</summary>
|
||||||
public class RenderPreset
|
public class RenderPreset
|
||||||
{
|
{
|
||||||
|
/// <summary>Display name of the preset. A file without a name uses the file name (without extension).</summary>
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Render configuration of the preset.</summary>
|
||||||
public RenderConfig Config { get; set; } = null!;
|
public RenderConfig Config { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Returns a copy with a copied configuration.</summary>
|
||||||
public RenderPreset Clone()
|
public RenderPreset Clone()
|
||||||
{
|
{
|
||||||
return new RenderPreset
|
return new RenderPreset
|
||||||
@@ -26,8 +31,18 @@ public class RenderPreset
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static class PresetManager
|
public static class PresetManager
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static readonly List<RenderPreset> DefaultPresets = new()
|
private static readonly List<RenderPreset> DefaultPresets = new()
|
||||||
{
|
{
|
||||||
new RenderPreset
|
new RenderPreset
|
||||||
@@ -96,6 +111,10 @@ public static class PresetManager
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <root>/UserData/ADOFAIRenderer/Presets, where root is MelonLoader's game root directory, or the application
|
||||||
|
/// base directory when that is unavailable.
|
||||||
|
/// </summary>
|
||||||
private static string GetPresetsDirectory()
|
private static string GetPresetsDirectory()
|
||||||
{
|
{
|
||||||
string? root = null;
|
string? root = null;
|
||||||
@@ -125,6 +144,10 @@ public static class PresetManager
|
|||||||
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static void EnsureDefaultPresetsCreated()
|
public static void EnsureDefaultPresetsCreated()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -164,6 +187,12 @@ public static class PresetManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves a copy of <paramref name="config"/> as preset <paramref name="name"/>, overwriting the file of the same
|
||||||
|
/// safe file name (see GetSafeFileName).
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentException">The name is empty or white space.</exception>
|
||||||
|
/// <exception cref="IOException">The directory or file cannot be written (not caught).</exception>
|
||||||
public static void SavePreset(string name, RenderConfig config)
|
public static void SavePreset(string name, RenderConfig config)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
@@ -195,6 +224,13 @@ public static class PresetManager
|
|||||||
File.WriteAllText(filePath, json);
|
File.WriteAllText(filePath, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static List<RenderPreset> GetPresets()
|
public static List<RenderPreset> GetPresets()
|
||||||
{
|
{
|
||||||
EnsureDefaultPresetsCreated();
|
EnsureDefaultPresetsCreated();
|
||||||
@@ -332,6 +368,10 @@ public static class PresetManager
|
|||||||
return presets;
|
return presets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renames key <paramref name="keyBefore"/> to <paramref name="targetKey"/>: a non-null old value overwrites the target
|
||||||
|
/// key, and the old key is always removed.
|
||||||
|
/// </summary>
|
||||||
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
|
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
|
||||||
{
|
{
|
||||||
var legacyToken = configObj[keyBefore];
|
var legacyToken = configObj[keyBefore];
|
||||||
@@ -341,6 +381,12 @@ public static class PresetManager
|
|||||||
configObj.Remove(keyBefore);
|
configObj.Remove(keyBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes the file of preset <paramref name="name"/>: 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="IOException">The file with the safe file name exists but cannot be deleted (not caught).</exception>
|
||||||
public static void DeletePreset(string name)
|
public static void DeletePreset(string name)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name)) return;
|
if (string.IsNullOrWhiteSpace(name)) return;
|
||||||
|
|||||||
+379
-10
@@ -25,24 +25,61 @@ using ThreadPriority = System.Threading.ThreadPriority;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Core;
|
namespace ADOFAIRenderer.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Progress of the current render (RenderManager.stage). Values are ordered: checks use comparisons such as
|
||||||
|
/// stage >= OnScene.
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>None</c>: no render. Set at start-up and whenever a render ends, is cancelled or fails (ResetRenderState).</item>
|
||||||
|
/// <item><c>Load</c>: RenderSession.Start was called; the game scene (scnGame) is being loaded.</item>
|
||||||
|
/// <item><c>OnScene</c>: 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.</item>
|
||||||
|
/// <item><c>Render</c>: 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.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
internal enum RenderStage
|
internal enum RenderStage
|
||||||
{
|
{
|
||||||
None, Load, OnScene, Render
|
None, Load, OnScene, Render
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Options of the current render, copied from a <see cref="RenderSession"/> by RenderSession.Start and read by
|
||||||
|
/// RenderManager and the Harmony patches. <see cref="Reset"/> restores the defaults when the render environment is
|
||||||
|
/// restored (RenderManager.RestoreRenderEnvironment). Main thread only.
|
||||||
|
/// </summary>
|
||||||
internal static class RenderContext
|
internal static class RenderContext
|
||||||
{
|
{
|
||||||
|
/// <summary>Value assigned to RDC.auto (the game's autoplay flag) when the game scene of the render has loaded.</summary>
|
||||||
public static bool Auto = true;
|
public static bool Auto = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static bool TimePatch = true;
|
public static bool TimePatch = true;
|
||||||
|
|
||||||
|
/// <summary>Enables the Screen.width / Screen.height patches (report the render resolution) while the render is active.</summary>
|
||||||
public static bool ScreenPatch = true;
|
public static bool ScreenPatch = true;
|
||||||
|
|
||||||
|
/// <summary>Output file path of the render, or null for a time-stamped file in GlobalSettings.EffectiveOutputDirectory.</summary>
|
||||||
public static string? OutputPath;
|
public static string? OutputPath;
|
||||||
|
|
||||||
|
/// <summary>Whether the default OnComplete callback shows an OS notification after a successful render.</summary>
|
||||||
public static bool Notification = true;
|
public static bool Notification = true;
|
||||||
|
|
||||||
|
/// <summary>Frame hooks of the current session, run before the global hooks (see RenderSession.AddFrameHook).</summary>
|
||||||
public static readonly List<Action<FrameHookContext>> SessionFrameHooks = new();
|
public static readonly List<Action<FrameHookContext>> SessionFrameHooks = new();
|
||||||
|
|
||||||
|
/// <summary>Default end condition: the level is cleared (scrController state Won).</summary>
|
||||||
private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won;
|
private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static Func<bool> EndCondition = EndConditionDefault;
|
public static Func<bool> EndCondition = EndConditionDefault;
|
||||||
|
|
||||||
|
/// <summary>Default completion callback: shows an OS notification for a successful render when Notification is true.</summary>
|
||||||
private static readonly Action<RenderResult> OnCompleteDefault = result =>
|
private static readonly Action<RenderResult> OnCompleteDefault = result =>
|
||||||
{
|
{
|
||||||
if (result.Success && Notification)
|
if (result.Success && Notification)
|
||||||
@@ -54,11 +91,23 @@ internal static class RenderContext
|
|||||||
OSNotification.Show("Rendering Complete!", $"{fileString} saved successfully.", renderFolder);
|
OSNotification.Show("Rendering Complete!", $"{fileString} saved successfully.", renderFolder);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
public static Action<RenderResult> OnComplete = OnCompleteDefault;
|
public static Action<RenderResult> OnComplete = OnCompleteDefault;
|
||||||
|
|
||||||
private static readonly Action OnLastUpdateDefault = () => { };
|
private static readonly Action OnLastUpdateDefault = () => { };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
public static Action OnLastUpdate = OnLastUpdateDefault;
|
public static Action OnLastUpdate = OnLastUpdateDefault;
|
||||||
|
|
||||||
|
/// <summary>Restores every option to its default and clears the session frame hooks.</summary>
|
||||||
public static void Reset()
|
public static void Reset()
|
||||||
{
|
{
|
||||||
Auto = true;
|
Auto = true;
|
||||||
@@ -74,11 +123,34 @@ internal static class RenderContext
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
internal class RenderManager : MonoBehaviour
|
internal class RenderManager : MonoBehaviour
|
||||||
{
|
{
|
||||||
// Singleton Accessors
|
/// <summary>The single instance, set in Awake. Unity null checks (<c>if (Instance)</c>) are used before access.</summary>
|
||||||
public static RenderManager Instance { get; private set; } = null!;
|
public static RenderManager Instance { get; private set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>True from the OnScene stage (scene set up, countdown running) until the render ends.</summary>
|
||||||
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
|
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
|
||||||
|
|
||||||
|
/// <summary>True while frames are captured and encoded (Render stage). The time patches apply only then.</summary>
|
||||||
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
|
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -104,26 +176,66 @@ internal class RenderManager : MonoBehaviour
|
|||||||
public int QueueFrameLimit { get; private set; } = MaxQueuedFrames;
|
public int QueueFrameLimit { get; private set; } = MaxQueuedFrames;
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public long framesSent;
|
public long framesSent;
|
||||||
|
|
||||||
|
/// <summary>Wall-clock time of the render from the end of the countdown (SetupPlay) to BuildResult.</summary>
|
||||||
private readonly Stopwatch _renderStopwatch = new();
|
private readonly Stopwatch _renderStopwatch = new();
|
||||||
|
|
||||||
// Core Render Configuration and Path
|
// Core Render Configuration and Path
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public int countdown;
|
public int countdown;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public long frameCount;
|
public long frameCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set by SetupGameScene; scrPressToStartPatch consumes it (sets it back to false) to skip the press-to-start
|
||||||
|
/// screen once. Cleared by StopFFmpeg.
|
||||||
|
/// </summary>
|
||||||
public bool skipPressToStart;
|
public bool skipPressToStart;
|
||||||
|
|
||||||
|
/// <summary>Path of the level file to render (ADOBase.levelPath of the editor, or the path given to RenderSession.Start).</summary>
|
||||||
public string levelPath = "";
|
public string levelPath = "";
|
||||||
|
|
||||||
|
/// <summary>Serializes changes of the abort state (_isRenderCancelled, _renderFailed, _failureReason).</summary>
|
||||||
private readonly object _abortStateLock = new();
|
private readonly object _abortStateLock = new();
|
||||||
|
|
||||||
|
/// <summary>The render was cancelled (ESC, or a non-successful stop). Written under _abortStateLock, read by any thread.</summary>
|
||||||
private volatile bool _isRenderCancelled;
|
private volatile bool _isRenderCancelled;
|
||||||
|
|
||||||
|
/// <summary>The render failed (MarkFailed). Written under _abortStateLock, read by any thread.</summary>
|
||||||
private volatile bool _renderFailed;
|
private volatile bool _renderFailed;
|
||||||
|
|
||||||
|
/// <summary>Reason given to the first MarkFailed call of this render; null when the render has not failed.</summary>
|
||||||
private volatile string? _failureReason;
|
private volatile string? _failureReason;
|
||||||
|
|
||||||
|
/// <summary>True when the render was cancelled. Thread-safe read.</summary>
|
||||||
// ReSharper disable once MemberCanBePrivate.Global
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
public bool IsRenderCancelled => _isRenderCancelled;
|
public bool IsRenderCancelled => _isRenderCancelled;
|
||||||
|
|
||||||
|
/// <summary>True when the render failed. Thread-safe read.</summary>
|
||||||
public bool IsRenderFailed => _renderFailed;
|
public bool IsRenderFailed => _renderFailed;
|
||||||
|
|
||||||
// Cancelled and failed are mutually exclusive; this is true for either (render must stop).
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
|
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
|
||||||
|
|
||||||
|
/// <summary>Marks the render as cancelled unless it has already failed (a failure keeps its reason).</summary>
|
||||||
private void MarkCancelled()
|
private void MarkCancelled()
|
||||||
{
|
{
|
||||||
lock (_abortStateLock)
|
lock (_abortStateLock)
|
||||||
@@ -132,7 +244,10 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The first failure reason wins; ignored once the render is already cancelled or failed.
|
/// <summary>
|
||||||
|
/// Marks the render as failed with <paramref name="reason"/> (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.
|
||||||
|
/// </summary>
|
||||||
private void MarkFailed(string reason)
|
private void MarkFailed(string reason)
|
||||||
{
|
{
|
||||||
lock (_abortStateLock)
|
lock (_abortStateLock)
|
||||||
@@ -143,6 +258,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Clears cancelled, failed and the failure reason. Called when a new encoder starts (StartFFmpegThread).</summary>
|
||||||
private void ClearAbortState()
|
private void ClearAbortState()
|
||||||
{
|
{
|
||||||
lock (_abortStateLock)
|
lock (_abortStateLock)
|
||||||
@@ -153,30 +269,70 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Error message of the RenderResult of an aborted render (failure reason, or the cancel message).</summary>
|
||||||
private string BuildAbortMessage() => IsRenderFailed
|
private string BuildAbortMessage() => IsRenderFailed
|
||||||
? $"Rendering failed: {_failureReason ?? "unknown error"}"
|
? $"Rendering failed: {_failureReason ?? "unknown error"}"
|
||||||
: "Rendering was cancelled by user.";
|
: "Rendering was cancelled by user.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public RenderConfig Configuration = null!;
|
public RenderConfig Configuration = null!;
|
||||||
|
|
||||||
// Render Sessions State (OOP Instance Fields)
|
// Render Sessions State (OOP Instance Fields)
|
||||||
|
|
||||||
|
/// <summary>Current stage of the render. Written on the main thread; read by the render thread in TryDequeueNextFrame.</summary>
|
||||||
public RenderStage stage = RenderStage.None;
|
public RenderStage stage = RenderStage.None;
|
||||||
|
|
||||||
|
/// <summary>Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS.</summary>
|
||||||
public double timeBase;
|
public double timeBase;
|
||||||
|
|
||||||
|
/// <summary>Time.unscaledTimeAsDouble when capture started. The time patch returns unscaledTimeBase + frameCount / RenderFPS.</summary>
|
||||||
public double unscaledTimeBase;
|
public double unscaledTimeBase;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AudioSettings.dspTime when capture started (seconds). The dspTime patch returns dspTimeBase + frameCount / RenderFPS;
|
||||||
|
/// audio command times are stored relative to it (scheduled dspTime - dspTimeBase).
|
||||||
|
/// </summary>
|
||||||
public double dspTimeBase;
|
public double dspTimeBase;
|
||||||
|
|
||||||
|
/// <summary>Time.frameCount when capture started. The frameCount patch returns frameCountBase + frameCount.</summary>
|
||||||
public int frameCountBase;
|
public int frameCountBase;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public OfflineAudioMixer? AudioMixer { get; private set; }
|
public OfflineAudioMixer? AudioMixer { get; private set; }
|
||||||
|
|
||||||
/// <summary>Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands.</summary>
|
/// <summary>Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands.</summary>
|
||||||
public double AudioTime => frameCount / Configuration.RenderFPS;
|
public double AudioTime => frameCount / Configuration.RenderFPS;
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private AudioSyncCalculator? SyncCalculator { get; set; }
|
private AudioSyncCalculator? SyncCalculator { get; set; }
|
||||||
|
|
||||||
|
/// <summary>GPU capture of frames (color conversion and AsyncGPUReadback). Created in SetupPlay after the encoder started.</summary>
|
||||||
private FrameReader? _frameReader;
|
private FrameReader? _frameReader;
|
||||||
|
|
||||||
|
/// <summary>Frame hook runner of this render. Created in SetupPlay, disposed by ResetFrameHookState.</summary>
|
||||||
private FrameHookPipeline? _frameHookPipeline;
|
private FrameHookPipeline? _frameHookPipeline;
|
||||||
|
|
||||||
|
/// <summary>Session hooks followed by global hooks, rebuilt when the global hook version changes.</summary>
|
||||||
private readonly List<Action<FrameHookContext>> _activeHooksCache = new();
|
private readonly List<Action<FrameHookContext>> _activeHooksCache = new();
|
||||||
|
|
||||||
|
/// <summary>RenderSession.GlobalHooksVersion the cache was built for; -1 forces a rebuild.</summary>
|
||||||
private int _cachedGlobalHooksVersion = -1;
|
private int _cachedGlobalHooksVersion = -1;
|
||||||
|
|
||||||
|
/// <summary>True when _activeHooksCache is not empty.</summary>
|
||||||
private bool _hasActiveHooksCached;
|
private bool _hasActiveHooksCached;
|
||||||
|
|
||||||
|
/// <summary>True when at least one session or global frame hook is registered. Main thread.</summary>
|
||||||
public bool HasActiveFrameHooks
|
public bool HasActiveFrameHooks
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -186,6 +342,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void EnsureActiveHooksUpToDate()
|
private void EnsureActiveHooksUpToDate()
|
||||||
{
|
{
|
||||||
var globalVersion = RenderSession.GlobalHooksVersion;
|
var globalVersion = RenderSession.GlobalHooksVersion;
|
||||||
@@ -201,12 +362,14 @@ internal class RenderManager : MonoBehaviour
|
|||||||
_hasActiveHooksCached = _activeHooksCache.Count > 0;
|
_hasActiveHooksCached = _activeHooksCache.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the cached active hook list (session hooks first, then global hooks). The list is reused.</summary>
|
||||||
private List<Action<FrameHookContext>> GetActiveFrameHooks()
|
private List<Action<FrameHookContext>> GetActiveFrameHooks()
|
||||||
{
|
{
|
||||||
EnsureActiveHooksUpToDate();
|
EnsureActiveHooksUpToDate();
|
||||||
return _activeHooksCache;
|
return _activeHooksCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Disposes the frame hook pipeline (ping-pong buffers) and clears the hook cache.</summary>
|
||||||
private void ResetFrameHookState()
|
private void ResetFrameHookState()
|
||||||
{
|
{
|
||||||
_frameHookPipeline?.Dispose();
|
_frameHookPipeline?.Dispose();
|
||||||
@@ -216,46 +379,132 @@ internal class RenderManager : MonoBehaviour
|
|||||||
_hasActiveHooksCached = false;
|
_hasActiveHooksCached = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public long frameCountForVideo;
|
public long frameCountForVideo;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public long lastVideoFrameIndex = -1;
|
public long lastVideoFrameIndex = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
|
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
|
||||||
public long lastRequestedVideoFrameIndex = -1;
|
public long lastRequestedVideoFrameIndex = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Number of GPU readbacks that failed and were replaced by a black frame (shown by the debug monitor).
|
||||||
|
/// Incremented in the readback completion callback.
|
||||||
|
/// </summary>
|
||||||
public int readbackFailureCount;
|
public int readbackFailureCount;
|
||||||
|
|
||||||
|
/// <summary>Number of frames on which the main thread had to wait for the render thread (throttle loop entries).</summary>
|
||||||
public int throttleCount;
|
public int throttleCount;
|
||||||
|
|
||||||
|
/// <summary>True while the main thread is inside the throttle wait of OnPostFrame.</summary>
|
||||||
public bool isThrottling;
|
public bool isThrottling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
internal struct CapturedFrame
|
internal struct CapturedFrame
|
||||||
{
|
{
|
||||||
|
/// <summary>Start of the native frame data (ReadbackState.NativeBuffer); zero for a managed frame.</summary>
|
||||||
public IntPtr NativePointer;
|
public IntPtr NativePointer;
|
||||||
|
|
||||||
|
/// <summary>Number of valid bytes of the frame data.</summary>
|
||||||
public int Length;
|
public int Length;
|
||||||
|
|
||||||
|
/// <summary>Managed frame data rented from ArrayPool<byte>.Shared (may be longer than Length); null for a native frame.</summary>
|
||||||
public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame)
|
public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame)
|
||||||
|
|
||||||
|
/// <summary>Readback state owning NativePointer; returned to the FrameReader pool after the frame is encoded or dropped.</summary>
|
||||||
public ReadbackRequestState? ReadbackState; // Associated state to recycle
|
public ReadbackRequestState? ReadbackState; // Associated state to recycle
|
||||||
|
|
||||||
|
/// <summary>True when the GPU converted the frame to the encoder's YUV format; false for RGBA data.</summary>
|
||||||
public bool IsColorConverted;
|
public bool IsColorConverted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thread and Queue State
|
// Thread and Queue State
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
internal SortedList<long, CapturedFrame> ImageQueue = new();
|
internal SortedList<long, CapturedFrame> ImageQueue = new();
|
||||||
|
|
||||||
|
/// <summary>Set when a frame is added to ImageQueue or the end is requested; the render thread waits on it.</summary>
|
||||||
internal AutoResetEvent FrameAvailableEvent = new(false);
|
internal AutoResetEvent FrameAvailableEvent = new(false);
|
||||||
// Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.
|
|
||||||
|
/// <summary>Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.</summary>
|
||||||
internal AutoResetEvent FrameDequeuedEvent = new(false);
|
internal AutoResetEvent FrameDequeuedEvent = new(false);
|
||||||
|
|
||||||
|
/// <summary>The render thread of the current render (background, AboveNormal priority); null before the first render.</summary>
|
||||||
public Thread? RenderThread;
|
public Thread? RenderThread;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public long passCount;
|
public long passCount;
|
||||||
|
|
||||||
|
/// <summary>Set by StopFFmpeg: no more frames will be captured; the render thread drains the queue and stops.</summary>
|
||||||
public volatile bool endRequested;
|
public volatile bool endRequested;
|
||||||
|
|
||||||
|
/// <summary>Set by the render thread after the output file was finished and the encoder released without an abort.</summary>
|
||||||
public volatile bool endSuccessful;
|
public volatile bool endSuccessful;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// frameCount at which EndCondition first returned true; -1 until then. The render ends DelayAfterEnd seconds of
|
||||||
|
/// render time later.
|
||||||
|
/// </summary>
|
||||||
public long endFrame = -1;
|
public long endFrame = -1;
|
||||||
|
|
||||||
// Private Instance Fields
|
// Private Instance Fields
|
||||||
|
|
||||||
|
/// <summary>Encoder of the current render; null when none runs. Cleared when the render thread releases it.</summary>
|
||||||
private FFmpegNativeEncoder? _activeVideoFFmpeg;
|
private FFmpegNativeEncoder? _activeVideoFFmpeg;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private readonly List<FFmpegNativeEncoder> _allVideoFFmpegInstances = new();
|
private readonly List<FFmpegNativeEncoder> _allVideoFFmpegInstances = new();
|
||||||
|
|
||||||
|
/// <summary>The user's input offset (scrConductor.currentPreset.inputOffset), set to 0 during the render and restored afterwards.</summary>
|
||||||
private int _originalInputOffset;
|
private int _originalInputOffset;
|
||||||
|
|
||||||
|
/// <summary>Debug monitor overlay object, created when the encoder starts and destroyed when the render ends.</summary>
|
||||||
private GameObject? _debugMonitorObj;
|
private GameObject? _debugMonitorObj;
|
||||||
|
|
||||||
|
/// <summary>ESC handler overlay object (cancels the render), created when the scene is set up and destroyed by StopFFmpeg.</summary>
|
||||||
private GameObject? _escapeHandlerObj;
|
private GameObject? _escapeHandlerObj;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
internal string? CurrentOutPath;
|
internal string? CurrentOutPath;
|
||||||
|
|
||||||
// Output file of the current render that is not finished yet.
|
// 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).
|
// 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).
|
// Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile).
|
||||||
private string? _incompleteOutputPath;
|
private string? _incompleteOutputPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private bool _renderSetup;
|
private bool _renderSetup;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
@@ -268,6 +517,10 @@ internal class RenderManager : MonoBehaviour
|
|||||||
CleanUpActiveRender();
|
CleanUpActiveRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the stage to None. <paramref name="success"/> true clears the cancelled flag; false marks the render as
|
||||||
|
/// cancelled (unless it already failed). Called at every end path before StopFFmpeg.
|
||||||
|
/// </summary>
|
||||||
public void ResetRenderState(bool success)
|
public void ResetRenderState(bool success)
|
||||||
{
|
{
|
||||||
if (success) _isRenderCancelled = false;
|
if (success) _isRenderCancelled = false;
|
||||||
@@ -275,6 +528,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
stage = RenderStage.None;
|
stage = RenderStage.None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public void CleanUpActiveRender()
|
public void CleanUpActiveRender()
|
||||||
{
|
{
|
||||||
if (stage != RenderStage.None)
|
if (stage != RenderStage.None)
|
||||||
@@ -346,8 +604,16 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
|
// scrPressToStart reads lm.hideDifficultyUI to decide whether the difficulty UI is shown (scrPressToStart.cs).
|
||||||
if (_renderSetup && ADOBase.lm)
|
if (_renderSetup && ADOBase.lm)
|
||||||
{
|
{
|
||||||
ADOBase.lm.hideDifficultyUI = true;
|
ADOBase.lm.hideDifficultyUI = true;
|
||||||
@@ -356,18 +622,24 @@ internal class RenderManager : MonoBehaviour
|
|||||||
if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading)
|
if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading)
|
||||||
{
|
{
|
||||||
_renderSetup = false;
|
_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;
|
scrCamera.instance.forceRTCam = true;
|
||||||
|
|
||||||
if (!scrCamera.instance.gameObject.GetComponent<PostRenderHook>())
|
if (!scrCamera.instance.gameObject.GetComponent<PostRenderHook>())
|
||||||
scrCamera.instance.gameObject.AddComponent<PostRenderHook>();
|
scrCamera.instance.gameObject.AddComponent<PostRenderHook>();
|
||||||
|
|
||||||
|
// 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;
|
_originalInputOffset = scrConductor.currentPreset.inputOffset;
|
||||||
scrConductor.currentPreset.inputOffset = 0;
|
scrConductor.currentPreset.inputOffset = 0;
|
||||||
|
|
||||||
|
// Autoplay; disableCongratsMessage clears the "congratulations" text shown on the win screen (scrController.cs).
|
||||||
RDC.auto = RenderContext.Auto;
|
RDC.auto = RenderContext.Auto;
|
||||||
AccessTools.Field(typeof(scrController), "disableCongratsMessage")
|
AccessTools.Field(typeof(scrController), "disableCongratsMessage")
|
||||||
.SetValue(scrController.instance, true);
|
.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)
|
if (scrController.instance)
|
||||||
{
|
{
|
||||||
scrController.instance.noFail = true;
|
scrController.instance.noFail = true;
|
||||||
@@ -384,6 +656,9 @@ internal class RenderManager : MonoBehaviour
|
|||||||
DontDestroyOnLoad(_escapeHandlerObj);
|
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);
|
Time.captureDeltaTime = (float)(1 / Configuration.RenderFPS);
|
||||||
scrController.instance!.StartCoroutine(SetupPlay());
|
scrController.instance!.StartCoroutine(SetupPlay());
|
||||||
}
|
}
|
||||||
@@ -404,6 +679,8 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
else if (RenderContext.EndCondition())
|
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)
|
if (endFrame == -1)
|
||||||
{
|
{
|
||||||
endFrame = frameCount;
|
endFrame = frameCount;
|
||||||
@@ -417,6 +694,9 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by FirstUpdateHook before every other Update of the frame. Advances frameCount by one from the OnScene stage.
|
||||||
|
/// </summary>
|
||||||
public void OnPreUpdate()
|
public void OnPreUpdate()
|
||||||
{
|
{
|
||||||
if (Instance.stage < RenderStage.OnScene) return;
|
if (Instance.stage < RenderStage.OnScene) return;
|
||||||
@@ -428,6 +708,10 @@ internal class RenderManager : MonoBehaviour
|
|||||||
private List<scrFloor>? _holdFloorsSource;
|
private List<scrFloor>? _holdFloorsSource;
|
||||||
private int _holdFloorsSourceCount = -1;
|
private int _holdFloorsSourceCount = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public void OnLastUpdate()
|
public void OnLastUpdate()
|
||||||
{
|
{
|
||||||
if (Instance.stage < RenderStage.Load) return;
|
if (Instance.stage < RenderStage.Load) return;
|
||||||
@@ -479,6 +763,15 @@ internal class RenderManager : MonoBehaviour
|
|||||||
return (int)Math.Min(MaxQueuedFrames, Math.Max(4, MaxQueuedBytes / frameBytes));
|
return (int)Math.Min(MaxQueuedFrames, Math.Max(4, MaxQueuedBytes / frameBytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="texture">Frame shown by the camera quad; null skips throttling and capture.</param>
|
||||||
// ReSharper disable once Unity.IncorrectMethodSignature
|
// ReSharper disable once Unity.IncorrectMethodSignature
|
||||||
public void OnPostFrame(RenderTexture? texture)
|
public void OnPostFrame(RenderTexture? texture)
|
||||||
{
|
{
|
||||||
@@ -532,10 +825,13 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
var currentFrame = frameCountForVideo++;
|
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 renderFPS = Configuration.RenderFPS;
|
||||||
var videoFps = Configuration.VideoFPS;
|
var videoFps = Configuration.VideoFPS;
|
||||||
var effectiveVideoFps = Math.Min(videoFps, renderFPS);
|
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);
|
var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS);
|
||||||
|
|
||||||
if (videoFrameIndex > lastVideoFrameIndex)
|
if (videoFrameIndex > lastVideoFrameIndex)
|
||||||
@@ -562,6 +858,13 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static void StartRender()
|
public static void StartRender()
|
||||||
{
|
{
|
||||||
var targetMethod = typeof(scnEditor).GetMethod("TryApplicationQuit",
|
var targetMethod = typeof(scnEditor).GetMethod("TryApplicationQuit",
|
||||||
@@ -585,12 +888,18 @@ internal class RenderManager : MonoBehaviour
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates the default output directory (GlobalSettings.EffectiveOutputDirectory) when it does not exist.</summary>
|
||||||
public void SetupCaptureDirectory()
|
public void SetupCaptureDirectory()
|
||||||
{
|
{
|
||||||
var renderDir = GlobalSettings.EffectiveOutputDirectory;
|
var renderDir = GlobalSettings.EffectiveOutputDirectory;
|
||||||
FileUtil.CreateDirectory(renderDir);
|
FileUtil.CreateDirectory(renderDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public void SetupGameScene()
|
public void SetupGameScene()
|
||||||
{
|
{
|
||||||
GCS.customLevelPaths = new[] { levelPath };
|
GCS.customLevelPaths = new[] { levelPath };
|
||||||
@@ -605,8 +914,18 @@ internal class RenderManager : MonoBehaviour
|
|||||||
_renderSetup = true;
|
_renderSetup = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private IEnumerator SetupPlay()
|
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 (Configuration.AudioConfig.RenderAudio)
|
||||||
{
|
{
|
||||||
if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate))
|
if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate))
|
||||||
@@ -624,6 +943,8 @@ internal class RenderManager : MonoBehaviour
|
|||||||
frameCount = 0; framesSent = 0;
|
frameCount = 0; framesSent = 0;
|
||||||
_renderStopwatch.Restart();
|
_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;
|
timeBase = Time.timeAsDouble;
|
||||||
unscaledTimeBase = Time.unscaledTimeAsDouble;
|
unscaledTimeBase = Time.unscaledTimeAsDouble;
|
||||||
dspTimeBase = AudioSettings.dspTime;
|
dspTimeBase = AudioSettings.dspTime;
|
||||||
@@ -635,6 +956,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
_frameHookPipeline = new FrameHookPipeline();
|
_frameHookPipeline = new FrameHookPipeline();
|
||||||
_cachedGlobalHooksVersion = -1;
|
_cachedGlobalHooksVersion = -1;
|
||||||
EnsureActiveHooksUpToDate();
|
EnsureActiveHooksUpToDate();
|
||||||
|
// The frame reader converts on the GPU to the pixel format of the encoder that was actually opened.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
StartFFmpegThread();
|
StartFFmpegThread();
|
||||||
@@ -663,6 +985,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
Instance.stage = RenderStage.Render;
|
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.paused = scrController.instance.audioPaused = false;
|
||||||
scrController.instance.levelWasSkipped = true;
|
scrController.instance.levelWasSkipped = true;
|
||||||
|
|
||||||
@@ -681,10 +1004,18 @@ internal class RenderManager : MonoBehaviour
|
|||||||
AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip, AudioMixer);
|
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.clip = null;
|
||||||
scrConductor.instance.song.time = -1000;
|
scrConductor.instance.song.time = -1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="Exception">The encoder could not be created or started; it is disposed before the exception is rethrown.</exception>
|
||||||
private void StartFFmpegThread()
|
private void StartFFmpegThread()
|
||||||
{
|
{
|
||||||
if (!_debugMonitorObj)
|
if (!_debugMonitorObj)
|
||||||
@@ -770,6 +1101,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Background thread: it does not keep the process alive when the game quits.
|
||||||
var encoder = videoFFmpeg;
|
var encoder = videoFFmpeg;
|
||||||
RenderThread = new Thread(() => RunRenderThread(encoder))
|
RenderThread = new Thread(() => RunRenderThread(encoder))
|
||||||
{
|
{
|
||||||
@@ -858,8 +1190,10 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount.
|
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount. Render thread.
|
||||||
/// Before the Render stage, passCount jumps to the first queued key (the first captured frame index is not 0).
|
/// 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.
|
/// Returns false when the queue is empty or the next frame has not arrived yet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool TryDequeueNextFrame(out CapturedFrame frame)
|
private bool TryDequeueNextFrame(out CapturedFrame frame)
|
||||||
@@ -886,7 +1220,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends one captured frame to the encoder and releases its buffer (managed: ArrayPool, native: readback state).
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame)
|
private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame)
|
||||||
{
|
{
|
||||||
@@ -914,8 +1248,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <exception cref="OperationCanceledException">The mixer was cancelled (render aborted) while waiting.</exception>
|
||||||
private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg)
|
private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg)
|
||||||
{
|
{
|
||||||
if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return;
|
if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return;
|
||||||
@@ -945,6 +1282,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex);
|
return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes the encoder from the cleanup list and clears _activeVideoFFmpeg. Called by the render thread when it finishes.</summary>
|
||||||
private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg)
|
private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg)
|
||||||
{
|
{
|
||||||
lock (_allVideoFFmpegInstances)
|
lock (_allVideoFFmpegInstances)
|
||||||
@@ -954,6 +1292,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
_activeVideoFFmpeg = null;
|
_activeVideoFFmpeg = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public void CancelRender()
|
public void CancelRender()
|
||||||
{
|
{
|
||||||
ResetRenderState(success: false);
|
ResetRenderState(success: false);
|
||||||
@@ -962,6 +1305,15 @@ internal class RenderManager : MonoBehaviour
|
|||||||
EndRender();
|
EndRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requests the end of encoding. Main thread.
|
||||||
|
/// <paramref name="success"/> true: completes the audio commands and starts StopRenderCoroutine, which waits for
|
||||||
|
/// the render thread to send the remaining frames and finish the file.
|
||||||
|
/// <paramref name="success"/> 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).
|
||||||
|
/// </summary>
|
||||||
private void StopFFmpeg(bool success)
|
private void StopFFmpeg(bool success)
|
||||||
{
|
{
|
||||||
endRequested = true;
|
endRequested = true;
|
||||||
@@ -993,7 +1345,8 @@ internal class RenderManager : MonoBehaviour
|
|||||||
RenderThread.Join(2000);
|
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)
|
if (_activeVideoFFmpeg != null)
|
||||||
{
|
{
|
||||||
_activeVideoFFmpeg.Dispose();
|
_activeVideoFFmpeg.Dispose();
|
||||||
@@ -1055,6 +1408,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private IEnumerator StopRenderCoroutine()
|
private IEnumerator StopRenderCoroutine()
|
||||||
{
|
{
|
||||||
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
|
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
|
||||||
@@ -1071,6 +1429,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
EndRender();
|
EndRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Restores the render environment and loads the level editor (scnEditor).</summary>
|
||||||
private void EndRender()
|
private void EndRender()
|
||||||
{
|
{
|
||||||
RestoreRenderEnvironment();
|
RestoreRenderEnvironment();
|
||||||
@@ -1079,7 +1438,11 @@ internal class RenderManager : MonoBehaviour
|
|||||||
Cursor.visible = true;
|
Cursor.visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene.
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void RestoreRenderEnvironment()
|
private void RestoreRenderEnvironment()
|
||||||
{
|
{
|
||||||
Time.timeScale = 1f;
|
Time.timeScale = 1f;
|
||||||
@@ -1122,6 +1485,10 @@ internal class RenderManager : MonoBehaviour
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private RenderResult BuildResult(bool success, string? errorMessage = null)
|
private RenderResult BuildResult(bool success, string? errorMessage = null)
|
||||||
{
|
{
|
||||||
_renderStopwatch.Stop();
|
_renderStopwatch.Stop();
|
||||||
@@ -1133,6 +1500,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
: RenderResult.CreateFailure(errorMessage, sent, effectiveFps, _renderStopwatch.Elapsed);
|
: RenderResult.CreateFailure(errorMessage, sent, effectiveFps, _renderStopwatch.Elapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Invokes RenderContext.OnComplete; an exception from the callback is logged.</summary>
|
||||||
private static void TriggerOnComplete(RenderResult result)
|
private static void TriggerOnComplete(RenderResult result)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -1145,6 +1513,7 @@ internal class RenderManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Invokes RenderContext.OnLastUpdate; an exception from the callback is logged.</summary>
|
||||||
private static void TriggerOnLastUpdate()
|
private static void TriggerOnLastUpdate()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -4,14 +4,22 @@ using FFmpeg.AutoGen;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Data;
|
namespace ADOFAIRenderer.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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: <c>H264</c> (H.264 / AVC), <c>H265</c> (H.265 / HEVC), <c>VP9</c>, <c>AV1</c>, <c>VVC</c> (H.266).
|
||||||
|
/// The audio codec paired with each value is given by CodecUtil.AudioCodecMatch.
|
||||||
|
/// </summary>
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public enum Codec
|
public enum Codec
|
||||||
{
|
{
|
||||||
H264, H265, VP9, AV1, VVC
|
H264, H265, VP9, AV1, VVC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Audio codec choice and supported sample rates per video codec.</summary>
|
||||||
internal static class CodecUtil
|
internal static class CodecUtil
|
||||||
{
|
{
|
||||||
|
/// <summary>Audio codec encoded together with each video codec (AAC for H.264/H.265, Opus for the others).</summary>
|
||||||
public static readonly Dictionary<Codec, AVCodecID> AudioCodecMatch = new()
|
public static readonly Dictionary<Codec, AVCodecID> AudioCodecMatch = new()
|
||||||
{
|
{
|
||||||
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
|
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
|
||||||
@@ -21,6 +29,10 @@ internal static class CodecUtil
|
|||||||
[Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS
|
[Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static readonly Dictionary<AVCodecID, List<int>> AvailableSampleRate = new()
|
public static readonly Dictionary<AVCodecID, List<int>> AvailableSampleRate = new()
|
||||||
{
|
{
|
||||||
[AVCodecID.AV_CODEC_ID_AAC] = new List<int> { 44100, 48000, 96000 },
|
[AVCodecID.AV_CODEC_ID_AAC] = new List<int> { 44100, 48000, 96000 },
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ namespace ADOFAIRenderer.Data;
|
|||||||
/// Converts Unity <see cref="Color"/> and nullable <see cref="Color"/> to and from hex string (#RRGGBBAA).
|
/// Converts Unity <see cref="Color"/> and nullable <see cref="Color"/> to and from hex string (#RRGGBBAA).
|
||||||
/// Pure C# implementation to completely avoid Unity internal native ECalls (such as Color.linear or ColorUtility.TryParseHtmlString).
|
/// Pure C# implementation to completely avoid Unity internal native ECalls (such as Color.linear or ColorUtility.TryParseHtmlString).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
public class ColorJsonConverter : JsonConverter
|
public class ColorJsonConverter : JsonConverter
|
||||||
{
|
{
|
||||||
|
/// <summary>True for <see cref="Color"/> and <see cref="Nullable{Color}"/>.</summary>
|
||||||
public override bool CanConvert(Type objectType)
|
public override bool CanConvert(Type objectType)
|
||||||
{
|
{
|
||||||
return objectType == typeof(Color) || objectType == typeof(Color?);
|
return objectType == typeof(Color) || objectType == typeof(Color?);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the color as "#RRGGBBAA" (see <see cref="ToHex"/>), or a JSON null for a null value.</summary>
|
||||||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||||
{
|
{
|
||||||
if (value == null)
|
if (value == null)
|
||||||
@@ -29,6 +36,7 @@ public class ColorJsonConverter : JsonConverter
|
|||||||
writer.WriteValue(ToHex(color));
|
writer.WriteValue(ToHex(color));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads a color in one of the accepted forms (see the class remarks).</summary>
|
||||||
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
||||||
{
|
{
|
||||||
bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType;
|
bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType;
|
||||||
@@ -62,6 +70,10 @@ public class ColorJsonConverter : JsonConverter
|
|||||||
return isNullable ? null : (object)default(Color);
|
return isNullable ? null : (object)default(Color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
public static string ToHex(Color color)
|
public static string ToHex(Color color)
|
||||||
{
|
{
|
||||||
byte r = (byte)Math.Clamp((int)Math.Round(color.r * 255f), 0, 255);
|
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}";
|
return $"#{r:X2}{g:X2}{b:X2}{a:X2}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses "RRGGBB" or "RRGGBBAA" (optional leading '#', surrounding white space ignored, case-insensitive).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hex">Text to parse.</param>
|
||||||
|
/// <param name="color">Parsed color (components / 255; alpha 1 for the 6-digit form), or default when parsing fails.</param>
|
||||||
|
/// <returns>True when the text was parsed.</returns>
|
||||||
public static bool TryParseHex(string? hex, out Color color)
|
public static bool TryParseHex(string? hex, out Color color)
|
||||||
{
|
{
|
||||||
color = default;
|
color = default;
|
||||||
|
|||||||
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Data;
|
namespace ADOFAIRenderer.Data;
|
||||||
|
|
||||||
|
/// <summary>Video bitrate control mode (see VideoEncodingConfig and FFmpegNativeEncoder.ApplyQualitySettings).</summary>
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public enum RateControlMode
|
public enum RateControlMode
|
||||||
{
|
{
|
||||||
|
/// <summary>Constant bitrate at VideoEncodingConfig.Bitrate (kbps).</summary>
|
||||||
CBR,
|
CBR,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Variable bitrate averaging VideoEncodingConfig.Bitrate (kbps), peaking at MaxBitrate (kbps) when it is larger,
|
||||||
|
/// otherwise at 1.5 × Bitrate.
|
||||||
|
/// </summary>
|
||||||
VBR,
|
VBR,
|
||||||
|
|
||||||
|
/// <summary>Constant quality with VideoEncodingConfig.QP (lower is better; range depends on the encoder).</summary>
|
||||||
CQP
|
CQP
|
||||||
}
|
}
|
||||||
@@ -4,22 +4,50 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Data;
|
namespace ADOFAIRenderer.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settings of one render: resolution, frame rates, encoding and advanced options. Serialized to JSON in the preset
|
||||||
|
/// files (PresetManager). <see cref="ADOFAIRenderer.API.RenderSession.Start"/> renders with a copy (<see cref="Clone"/>).
|
||||||
|
/// </summary>
|
||||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||||
public class RenderConfig
|
public class RenderConfig
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Game frames simulated per second of render time (the game clock advances 1 / RenderFPS seconds per frame).
|
||||||
|
/// Values above <see cref="VideoFPS"/> render extra frames between video frames, which are not captured
|
||||||
|
/// (the game state then advances in smaller time steps).
|
||||||
|
/// </summary>
|
||||||
public double RenderFPS { get; set; }
|
public double RenderFPS { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frame rate of the output video. The effective rate is min(VideoFPS, RenderFPS).
|
||||||
|
/// </summary>
|
||||||
public double VideoFPS { get; set; }
|
public double VideoFPS { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Width of the rendered frames and the output video in pixels (also reported by the Screen.width patch).</summary>
|
||||||
public int RenderWidth { get; set; }
|
public int RenderWidth { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Height of the rendered frames and the output video in pixels (also reported by the Screen.height patch).</summary>
|
||||||
public int RenderHeight { get; set; }
|
public int RenderHeight { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Video encoder settings.</summary>
|
||||||
public VideoEncodingConfig VideoConfig { get; set; } = new();
|
public VideoEncodingConfig VideoConfig { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Audio rendering and encoder settings.</summary>
|
||||||
public AudioEncodingConfig AudioConfig { get; set; } = new();
|
public AudioEncodingConfig AudioConfig { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Advanced options (random seed, spectrum processing, tile hiding, container, planet colors).</summary>
|
||||||
public AdvancedConfig AdvancedConfig { get; set; } = new();
|
public AdvancedConfig AdvancedConfig { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Seconds of render time recorded after the end condition first holds (default 15).</summary>
|
||||||
public double DelayAfterEnd { get; set; } = 15;
|
public double DelayAfterEnd { get; set; } = 15;
|
||||||
|
|
||||||
|
/// <summary>Whether the judgement texts (hit accuracy texts) are shown during the render (scrHitTextManagerPatch). Default false.</summary>
|
||||||
public bool ShowJudgementText { get; set; } = false;
|
public bool ShowJudgementText { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies every value of <paramref name="source"/> into this object; the nested configs are copied value by value
|
||||||
|
/// into the existing nested objects. Does nothing when <paramref name="source"/> is null.
|
||||||
|
/// </summary>
|
||||||
public void CopyFrom(RenderConfig? source)
|
public void CopyFrom(RenderConfig? source)
|
||||||
{
|
{
|
||||||
if (source == null) return;
|
if (source == null) return;
|
||||||
@@ -35,6 +63,7 @@ public class RenderConfig
|
|||||||
AdvancedConfig.CopyFrom(source.AdvancedConfig);
|
AdvancedConfig.CopyFrom(source.AdvancedConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a deep copy (nested configs are new objects).</summary>
|
||||||
public RenderConfig Clone()
|
public RenderConfig Clone()
|
||||||
{
|
{
|
||||||
var clone = new RenderConfig();
|
var clone = new RenderConfig();
|
||||||
@@ -43,23 +72,48 @@ public class RenderConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Advanced render options.</summary>
|
||||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||||
public class AdvancedConfig
|
public class AdvancedConfig
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Seed for UnityEngine.Random set before the game scene loads, so random visual effects repeat between renders;
|
||||||
|
/// null leaves the random state unchanged.
|
||||||
|
/// </summary>
|
||||||
public int? RandomSeed { get; set; } = null;
|
public int? RandomSeed { get; set; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public bool ProcessAudio { get; set; } = true;
|
public bool ProcessAudio { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public bool NonTile { get; set; } = false;
|
public bool NonTile { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>File extension of the default output file name, without the dot (e.g. "mkv", "mp4"); selects the container.</summary>
|
||||||
public string FileExtension { get; set; } = "mkv";
|
public string FileExtension { get; set; } = "mkv";
|
||||||
|
|
||||||
|
/// <summary>Color of the fire (red) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".</summary>
|
||||||
[JsonConverter(typeof(ColorJsonConverter))]
|
[JsonConverter(typeof(ColorJsonConverter))]
|
||||||
public Color? firePlanetColor = null;
|
public Color? firePlanetColor = null;
|
||||||
|
|
||||||
|
/// <summary>Color of the ice (blue) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".</summary>
|
||||||
[JsonConverter(typeof(ColorJsonConverter))]
|
[JsonConverter(typeof(ColorJsonConverter))]
|
||||||
public Color? icePlanetColor = null;
|
public Color? icePlanetColor = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Color of the third (wind, planetGreen) planet during the render (PlanetarySystemPatch); null keeps the game's color.
|
||||||
|
/// Serialized as "#RRGGBBAA".
|
||||||
|
/// </summary>
|
||||||
[JsonConverter(typeof(ColorJsonConverter))]
|
[JsonConverter(typeof(ColorJsonConverter))]
|
||||||
public Color? windPlanetColor = null;
|
public Color? windPlanetColor = null;
|
||||||
|
|
||||||
|
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
|
||||||
public void CopyFrom(AdvancedConfig? source)
|
public void CopyFrom(AdvancedConfig? source)
|
||||||
{
|
{
|
||||||
if (source == null) return;
|
if (source == null) return;
|
||||||
@@ -72,6 +126,7 @@ public class AdvancedConfig
|
|||||||
windPlanetColor = source.windPlanetColor;
|
windPlanetColor = source.windPlanetColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a copy of this object.</summary>
|
||||||
public AdvancedConfig Clone()
|
public AdvancedConfig Clone()
|
||||||
{
|
{
|
||||||
var clone = new AdvancedConfig();
|
var clone = new AdvancedConfig();
|
||||||
@@ -80,18 +135,35 @@ public class AdvancedConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Video encoder settings (passed to FFmpegNativeEncoder.EncoderSettings).</summary>
|
||||||
public class VideoEncodingConfig
|
public class VideoEncodingConfig
|
||||||
{
|
{
|
||||||
|
/// <summary>Video codec (default H.264). Also selects the audio codec (CodecUtil.AudioCodecMatch).</summary>
|
||||||
public Codec Codec { get; set; } = Codec.H264;
|
public Codec Codec { get; set; } = Codec.H264;
|
||||||
|
|
||||||
|
/// <summary>Bitrate control mode (default CBR).</summary>
|
||||||
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
|
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
|
||||||
|
|
||||||
|
/// <summary>Target video bitrate in kbps for CBR and VBR (default 30000 = 30 Mbps).</summary>
|
||||||
public int Bitrate { get; set; } = 30000;
|
public int Bitrate { get; set; } = 30000;
|
||||||
|
|
||||||
|
/// <summary>Peak video bitrate in kbps for VBR, used when larger than Bitrate (otherwise 1.5 × Bitrate). Default 50000.</summary>
|
||||||
public int MaxBitrate { get; set; } = 50000;
|
public int MaxBitrate { get; set; } = 50000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quality value for CQP: lower is higher quality. Clamped to the range of the selected encoder; a negative value
|
||||||
|
/// selects 18. Default 18.
|
||||||
|
/// </summary>
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public int QP { get; set; } = 18;
|
public int QP { get; set; } = 18;
|
||||||
|
|
||||||
|
/// <summary>Keyframe interval in seconds (GOP length = round(effective video FPS × interval) frames, at least 1). Default 2.</summary>
|
||||||
public double KeyframeInterval { get; set; } = 2.0;
|
public double KeyframeInterval { get; set; } = 2.0;
|
||||||
|
|
||||||
|
/// <summary>When true, only software (CPU) encoders are tried; hardware encoders are skipped.</summary>
|
||||||
public bool ForceSoftwareEncoder { get; set; }
|
public bool ForceSoftwareEncoder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
|
||||||
public void CopyFrom(VideoEncodingConfig? source)
|
public void CopyFrom(VideoEncodingConfig? source)
|
||||||
{
|
{
|
||||||
if (source == null) return;
|
if (source == null) return;
|
||||||
@@ -104,6 +176,7 @@ public class VideoEncodingConfig
|
|||||||
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
|
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a copy of this object.</summary>
|
||||||
public VideoEncodingConfig Clone()
|
public VideoEncodingConfig Clone()
|
||||||
{
|
{
|
||||||
var clone = new VideoEncodingConfig();
|
var clone = new VideoEncodingConfig();
|
||||||
@@ -112,32 +185,44 @@ public class VideoEncodingConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audio rendering and encoder settings. Non-positive values of the numeric settings read back as their defaults
|
||||||
|
/// (48000 Hz, 2 channels, 256000 bps).
|
||||||
|
/// </summary>
|
||||||
public class AudioEncodingConfig
|
public class AudioEncodingConfig
|
||||||
{
|
{
|
||||||
private int _audioSampleRate = 48000;
|
private int _audioSampleRate = 48000;
|
||||||
private int _audioChannels = 2;
|
private int _audioChannels = 2;
|
||||||
private int _audioBitrate = 256000;
|
private int _audioBitrate = 256000;
|
||||||
|
|
||||||
|
/// <summary>When true, the song and sound effects are mixed offline and encoded as an audio stream. Default true.</summary>
|
||||||
public bool RenderAudio { get; set; } = true;
|
public bool RenderAudio { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public int AudioSampleRate
|
public int AudioSampleRate
|
||||||
{
|
{
|
||||||
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
|
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
|
||||||
set => _audioSampleRate = value <= 0 ? 48000 : value;
|
set => _audioSampleRate = value <= 0 ? 48000 : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of output audio channels (default 2).</summary>
|
||||||
public int AudioChannels
|
public int AudioChannels
|
||||||
{
|
{
|
||||||
get => _audioChannels <= 0 ? 2 : _audioChannels;
|
get => _audioChannels <= 0 ? 2 : _audioChannels;
|
||||||
set => _audioChannels = value <= 0 ? 2 : value;
|
set => _audioChannels = value <= 0 ? 2 : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Audio bitrate in bits per second (default 256000 = 256 kbps). Unit differs from the video bitrate (kbps).</summary>
|
||||||
public int AudioBitrate
|
public int AudioBitrate
|
||||||
{
|
{
|
||||||
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
|
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
|
||||||
set => _audioBitrate = value <= 0 ? 256000 : value;
|
set => _audioBitrate = value <= 0 ? 256000 : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
|
||||||
public void CopyFrom(AudioEncodingConfig? source)
|
public void CopyFrom(AudioEncodingConfig? source)
|
||||||
{
|
{
|
||||||
if (source == null) return;
|
if (source == null) return;
|
||||||
@@ -147,6 +232,7 @@ public class AudioEncodingConfig
|
|||||||
AudioBitrate = source.AudioBitrate;
|
AudioBitrate = source.AudioBitrate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a copy of this object.</summary>
|
||||||
public AudioEncodingConfig Clone()
|
public AudioEncodingConfig Clone()
|
||||||
{
|
{
|
||||||
var clone = new AudioEncodingConfig();
|
var clone = new AudioEncodingConfig();
|
||||||
|
|||||||
@@ -14,12 +14,30 @@ using Object = UnityEngine.Object;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer;
|
namespace ADOFAIRenderer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
internal class RenderMod : MelonMod
|
internal class RenderMod : MelonMod
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
// ReSharper disable once MemberCanBePrivate.Global
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
public static bool IsFeatureDisabled = false;
|
public static bool IsFeatureDisabled = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public static DateTime ExpirationTime = new(2026, 11, 21, 0, 0, 0, DateTimeKind.Utc);
|
public static DateTime ExpirationTime = new(2026, 11, 21, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public override void OnInitializeMelon()
|
public override void OnInitializeMelon()
|
||||||
{
|
{
|
||||||
GlobalSettings.Load();
|
GlobalSettings.Load();
|
||||||
@@ -39,6 +57,10 @@ internal class RenderMod : MelonMod
|
|||||||
ModAssets.Load();
|
ModAssets.Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by MelonLoader when the game quits. Aborts an active render (encoders disposed, render thread joined,
|
||||||
|
/// unfinished output file deleted) through RenderManager.CleanUpActiveRender.
|
||||||
|
/// </summary>
|
||||||
public override void OnApplicationQuit()
|
public override void OnApplicationQuit()
|
||||||
{
|
{
|
||||||
if (RenderManager.Instance == null) return;
|
if (RenderManager.Instance == null) return;
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util;
|
namespace ADOFAIRenderer.Util;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
internal static class BundleLoader
|
internal static class BundleLoader
|
||||||
{
|
{
|
||||||
|
/// <summary>Resource folder of the running platform: "Win64" on Windows, "OSX" on macOS, "Linux" otherwise.</summary>
|
||||||
private static readonly string BundlePlatform;
|
private static readonly string BundlePlatform;
|
||||||
|
/// <summary>The mod assembly that contains the bundle resources.</summary>
|
||||||
private static readonly Assembly? ExecutingAssembly;
|
private static readonly Assembly? ExecutingAssembly;
|
||||||
|
|
||||||
static BundleLoader()
|
static BundleLoader()
|
||||||
@@ -22,6 +29,16 @@ internal static class BundleLoader
|
|||||||
ExecutingAssembly = Assembly.GetExecutingAssembly();
|
ExecutingAssembly = Assembly.GetExecutingAssembly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resourcePath">Bundle name inside the platform folder, for example "fonts" or "shaders".</param>
|
||||||
|
/// <returns>The loaded bundle, or null when Unity fails to load it.</returns>
|
||||||
|
/// <exception cref="MissingManifestResourceException">The resource is not embedded in the DLL.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">The executing assembly is not available.</exception>
|
||||||
public static AssetBundle GetBundle(string resourcePath)
|
public static AssetBundle GetBundle(string resourcePath)
|
||||||
{
|
{
|
||||||
if (ExecutingAssembly != null)
|
if (ExecutingAssembly != null)
|
||||||
|
|||||||
@@ -2,8 +2,15 @@
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util;
|
namespace ADOFAIRenderer.Util;
|
||||||
|
|
||||||
|
/// <summary>File system helpers.</summary>
|
||||||
internal static class FileUtil
|
internal static class FileUtil
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates <paramref name="path"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Directory path, absolute or relative to the current directory.</param>
|
||||||
public static void CreateDirectory(string path)
|
public static void CreateDirectory(string path)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
|
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
|
||||||
|
|||||||
@@ -13,25 +13,61 @@ using UnityEngine.Rendering;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
namespace ADOFAIRenderer.Util
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// <para>
|
||||||
|
/// Each request uses a <see cref="ReadbackRequestState"/> 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 <see cref="RecycleState"/>) or the frame was dropped.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Threads: requests and readback completions run on the main thread; <see cref="RecycleState"/> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
internal class FrameReader : IDisposable
|
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 Height = Shader.PropertyToID("Height");
|
||||||
private static readonly int Width = Shader.PropertyToID("Width");
|
private static readonly int Width = Shader.PropertyToID("Width");
|
||||||
private static readonly int DestinationBuffer = Shader.PropertyToID("DestinationBuffer");
|
private static readonly int DestinationBuffer = Shader.PropertyToID("DestinationBuffer");
|
||||||
private static readonly int SourceTexture = Shader.PropertyToID("SourceTexture");
|
private static readonly int SourceTexture = Shader.PropertyToID("SourceTexture");
|
||||||
|
/// <summary>Number of readback states created by InitializePool.</summary>
|
||||||
private const int InitialBufferSize = 16;
|
private const int InitialBufferSize = 16;
|
||||||
|
|
||||||
|
/// <summary>States that are free for a new request. Guarded by _poolLock.</summary>
|
||||||
private readonly Stack<ReadbackRequestState> _statePool = new();
|
private readonly Stack<ReadbackRequestState> _statePool = new();
|
||||||
|
/// <summary>Every state created by this reader (free, in flight or holding a queued frame), for Dispose. Guarded by _poolLock.</summary>
|
||||||
private readonly List<ReadbackRequestState> _allocatedStates = new();
|
private readonly List<ReadbackRequestState> _allocatedStates = new();
|
||||||
|
/// <summary>Lock for _statePool, _allocatedStates, _isInitialized, _isDisposed and ReadbackRequestState.IsInFlight.</summary>
|
||||||
private readonly object _poolLock = new();
|
private readonly object _poolLock = new();
|
||||||
|
/// <summary>True after InitializePool; false again after Dispose (RecycleState then disposes returned states).</summary>
|
||||||
private bool _isInitialized;
|
private bool _isInitialized;
|
||||||
|
/// <summary>Set by Dispose. Completions and recycles that arrive afterwards dispose their state.</summary>
|
||||||
private volatile bool _isDisposed;
|
private volatile bool _isDisposed;
|
||||||
|
|
||||||
|
/// <summary>Color conversion compute shader from the asset bundle (ModAssets.ColorConversionShader); null when not loaded.</summary>
|
||||||
private readonly ComputeShader? _computeShader;
|
private readonly ComputeShader? _computeShader;
|
||||||
|
/// <summary>Kernel index for the target pixel format; -1 when the format has no kernel or the kernel was not found.</summary>
|
||||||
private readonly int _kernelIndex = -1;
|
private readonly int _kernelIndex = -1;
|
||||||
|
/// <summary>True when frames are converted to YUV on the GPU (shader and kernel available).</summary>
|
||||||
private readonly bool _useGPUConversion;
|
private readonly bool _useGPUConversion;
|
||||||
|
/// <summary>Pixel format of the read-back data: the target format with GPU conversion, AV_PIX_FMT_RGBA otherwise.</summary>
|
||||||
private readonly AVPixelFormat _expectedFormat;
|
private readonly AVPixelFormat _expectedFormat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selects the conversion kernel for <paramref name="targetFormat"/> and fills the state pool.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="targetFormat">
|
||||||
|
/// Pixel format of the encoder input (FFmpegNativeEncoder.SelectedPixelFormat). NV12 and YUV420P use GPU conversion;
|
||||||
|
/// any other format falls back to RGBA readback.
|
||||||
|
/// </param>
|
||||||
public FrameReader(AVPixelFormat targetFormat)
|
public FrameReader(AVPixelFormat targetFormat)
|
||||||
{
|
{
|
||||||
_computeShader = ModAssets.ColorConversionShader;
|
_computeShader = ModAssets.ColorConversionShader;
|
||||||
@@ -57,6 +93,7 @@ namespace ADOFAIRenderer.Util
|
|||||||
InitializePool();
|
InitializePool();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates InitialBufferSize states once. Their buffers are allocated on first use (ReadbackRequestState.Setup).</summary>
|
||||||
private void InitializePool()
|
private void InitializePool()
|
||||||
{
|
{
|
||||||
lock (_poolLock)
|
lock (_poolLock)
|
||||||
@@ -83,6 +120,15 @@ namespace ADOFAIRenderer.Util
|
|||||||
return _useGPUConversion ? pixels * 3 / 2 : pixels * 4;
|
return _useGPUConversion ? pixels * 3 / 2 : pixels * 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requests the capture of <paramref name="texture"/> as video frame <paramref name="currentFrame"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="texture">Frame to capture (game frame or the output of the frame hooks).</param>
|
||||||
|
/// <param name="currentFrame">Video frame index; the key of the frame in ImageQueue.</param>
|
||||||
public void RequestFrameCapture(Texture texture, long currentFrame)
|
public void RequestFrameCapture(Texture texture, long currentFrame)
|
||||||
{
|
{
|
||||||
if (!RenderManager.IsCapturingActive) return;
|
if (!RenderManager.IsCapturingActive) return;
|
||||||
@@ -113,6 +159,8 @@ namespace ADOFAIRenderer.Util
|
|||||||
_computeShader.SetInt(Width, texture.width);
|
_computeShader.SetInt(Width, texture.width);
|
||||||
_computeShader.SetInt(Height, texture.height);
|
_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
|
var threadGroupsX = _expectedFormat == AVPixelFormat.AV_PIX_FMT_NV12
|
||||||
? (texture.width + 31) / 32
|
? (texture.width + 31) / 32
|
||||||
: (texture.width + 63) / 64;
|
: (texture.width + 63) / 64;
|
||||||
@@ -120,6 +168,8 @@ namespace ADOFAIRenderer.Util
|
|||||||
|
|
||||||
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
|
_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(
|
AsyncGPUReadback.RequestIntoNativeArray(
|
||||||
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
|
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
|
||||||
);
|
);
|
||||||
@@ -136,6 +186,17 @@ namespace ADOFAIRenderer.Util
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completion of a readback request (through ReadbackRequestState.CachedCallback).
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Reader disposed: the state is disposed.</item>
|
||||||
|
/// <item>Render aborted: the state is returned to the pool and nothing is queued.</item>
|
||||||
|
/// <item>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.</item>
|
||||||
|
/// <item>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.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
internal void OnReadbackComplete(ReadbackRequestState state, AsyncGPUReadbackRequest request)
|
internal void OnReadbackComplete(ReadbackRequestState state, AsyncGPUReadbackRequest request)
|
||||||
{
|
{
|
||||||
var frame = state.FrameIndex;
|
var frame = state.FrameIndex;
|
||||||
@@ -227,6 +288,10 @@ namespace ADOFAIRenderer.Util
|
|||||||
return frame;
|
return frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
internal void RecycleState(ReadbackRequestState state)
|
internal void RecycleState(ReadbackRequestState state)
|
||||||
{
|
{
|
||||||
lock (_poolLock)
|
lock (_poolLock)
|
||||||
@@ -242,6 +307,10 @@ namespace ADOFAIRenderer.Util
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the frame to RenderManager.ImageQueue under <paramref name="frame"/> (replacing a frame with the same index)
|
||||||
|
/// and wakes the render thread.
|
||||||
|
/// </summary>
|
||||||
private static void PushToQueue(long frame, RenderManager.CapturedFrame capturedFrame)
|
private static void PushToQueue(long frame, RenderManager.CapturedFrame capturedFrame)
|
||||||
{
|
{
|
||||||
if (!RenderManager.Instance) return;
|
if (!RenderManager.Instance) return;
|
||||||
@@ -252,6 +321,11 @@ namespace ADOFAIRenderer.Util
|
|||||||
RenderManager.Instance.FrameAvailableEvent.Set();
|
RenderManager.Instance.FrameAvailableEvent.Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
lock (_poolLock)
|
lock (_poolLock)
|
||||||
@@ -269,20 +343,38 @@ namespace ADOFAIRenderer.Util
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One reusable capture slot of <see cref="FrameReader"/>: 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.
|
||||||
|
/// </summary>
|
||||||
internal class ReadbackRequestState : IDisposable
|
internal class ReadbackRequestState : IDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>Reader that receives the completion and owns the pool.</summary>
|
||||||
private readonly FrameReader _owner;
|
private readonly FrameReader _owner;
|
||||||
|
/// <summary>Video frame index of the current request; -1 while the state is free.</summary>
|
||||||
public long FrameIndex { get; private set; }
|
public long FrameIndex { get; private set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public NativeArray<byte> NativeBuffer;
|
public NativeArray<byte> NativeBuffer;
|
||||||
|
/// <summary>True between the request and its completion. Written under FrameReader._poolLock.</summary>
|
||||||
public bool IsInFlight { get; internal set; }
|
public bool IsInFlight { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Raw ComputeBuffer (4-byte elements) the conversion kernel writes; null without GPU conversion.</summary>
|
||||||
public ComputeBuffer? GPUBuffer;
|
public ComputeBuffer? GPUBuffer;
|
||||||
|
/// <summary>True when the current frame is converted to YUV on the GPU.</summary>
|
||||||
public bool IsGPUConversion { get; private set; }
|
public bool IsGPUConversion { get; private set; }
|
||||||
|
/// <summary>Width of the current frame in pixels.</summary>
|
||||||
public int Width { get; private set; }
|
public int Width { get; private set; }
|
||||||
|
/// <summary>Height of the current frame in pixels.</summary>
|
||||||
public int Height { get; private set; }
|
public int Height { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Completion delegate created once per state, so a request does not allocate a new delegate.</summary>
|
||||||
public readonly Action<AsyncGPUReadbackRequest> CachedCallback;
|
public readonly Action<AsyncGPUReadbackRequest> CachedCallback;
|
||||||
|
|
||||||
|
/// <summary>Creates a free state without buffers.</summary>
|
||||||
public ReadbackRequestState(FrameReader owner)
|
public ReadbackRequestState(FrameReader owner)
|
||||||
{
|
{
|
||||||
_owner = owner;
|
_owner = owner;
|
||||||
@@ -290,6 +382,17 @@ namespace ADOFAIRenderer.Util
|
|||||||
FrameIndex = -1;
|
FrameIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <paramref name="bufferSize"/>.
|
||||||
|
/// The ComputeBuffer holds the same number of bytes as 4-byte elements (byte count / 4, rounded down).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="frameIndex">Video frame index of the request.</param>
|
||||||
|
/// <param name="bufferSize">Readback size in bytes for RGBA readback (width x height x 4).</param>
|
||||||
|
/// <param name="isGPUConversion">Whether the kernel converts the frame to YUV.</param>
|
||||||
|
/// <param name="pixelFormat">Pixel format of the read-back data (not used by this method).</param>
|
||||||
|
/// <param name="width">Frame width in pixels.</param>
|
||||||
|
/// <param name="height">Frame height in pixels.</param>
|
||||||
public void Setup(long frameIndex, int bufferSize, bool isGPUConversion, AVPixelFormat pixelFormat, int width, int height)
|
public void Setup(long frameIndex, int bufferSize, bool isGPUConversion, AVPixelFormat pixelFormat, int width, int height)
|
||||||
{
|
{
|
||||||
FrameIndex = frameIndex;
|
FrameIndex = frameIndex;
|
||||||
@@ -320,16 +423,19 @@ namespace ADOFAIRenderer.Util
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Readback completion; forwards to FrameReader.OnReadbackComplete.</summary>
|
||||||
private void OnComplete(AsyncGPUReadbackRequest request)
|
private void OnComplete(AsyncGPUReadbackRequest request)
|
||||||
{
|
{
|
||||||
_owner.OnReadbackComplete(this, request);
|
_owner.OnReadbackComplete(this, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Marks the state as free (FrameIndex -1). The buffers are kept for the next request.</summary>
|
||||||
public void Recycle()
|
public void Recycle()
|
||||||
{
|
{
|
||||||
FrameIndex = -1;
|
FrameIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the native buffer and the ComputeBuffer.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (NativeBuffer.IsCreated) NativeBuffer.Dispose();
|
if (NativeBuffer.IsCreated) NativeBuffer.Dispose();
|
||||||
|
|||||||
@@ -10,27 +10,48 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util;
|
namespace ADOFAIRenderer.Util;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GPU vendor, used to try the hardware encoders of the active GPU first (VideoEncoder.Vendor). <c>Unknown</c> is
|
||||||
|
/// also the vendor of the software encoders.
|
||||||
|
/// </summary>
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
internal enum GPUVendor
|
internal enum GPUVendor
|
||||||
{
|
{
|
||||||
Unknown, NVIDIA, AMD, Intel, Apple
|
Unknown, NVIDIA, AMD, Intel, Apple
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
internal static class GPUUtil
|
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 <number>" with optional SUPER / Ti / Ti SUPER, memory size, "Laptop GPU" and a
|
||||||
|
// "/PCIe/SSE2" suffix.
|
||||||
private const string NvidiaPattern =
|
private const string NvidiaPattern =
|
||||||
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
|
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
|
||||||
|
|
||||||
|
// Intel: "Intel(R) UHD/HD Graphics <number>" or "Intel(R) Arc(TM) <A-C><number> Graphics", with an optional
|
||||||
|
// parenthesized suffix.
|
||||||
private const string IntelPattern =
|
private const string IntelPattern =
|
||||||
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+|Arc\(TM\) [A-C][0-9]+ Graphics)(?> \([^)]+\))?$";
|
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+|Arc\(TM\) [A-C][0-9]+ Graphics)(?> \([^)]+\))?$";
|
||||||
|
|
||||||
|
// AMD: "AMD Radeon RX <number>" with an optional " XT".
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
private const string AMDPattern =
|
private const string AMDPattern =
|
||||||
@"^AMD Radeon RX [0-9]+(?> XT)?$";
|
@"^AMD Radeon RX [0-9]+(?> XT)?$";
|
||||||
|
|
||||||
|
/// <summary>Vendor found by the first GetActiveGPUVendor call.</summary>
|
||||||
private static GPUVendor _cachedVendor = GPUVendor.Unknown;
|
private static GPUVendor _cachedVendor = GPUVendor.Unknown;
|
||||||
|
/// <summary>True once _cachedVendor is set.</summary>
|
||||||
private static bool _hasCachedVendor;
|
private static bool _hasCachedVendor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private static GPUVendor GetActiveGPUVendor()
|
private static GPUVendor GetActiveGPUVendor()
|
||||||
{
|
{
|
||||||
if (_hasCachedVendor) return _cachedVendor;
|
if (_hasCachedVendor) return _cachedVendor;
|
||||||
@@ -72,9 +93,21 @@ internal static class GPUUtil
|
|||||||
return _cachedVendor;
|
return _cachedVendor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of the first GetNvidiaDriverVersion call (null when not available).</summary>
|
||||||
private static double? _cachedNvidiaVersion;
|
private static double? _cachedNvidiaVersion;
|
||||||
|
/// <summary>True once _cachedNvidiaVersion is set.</summary>
|
||||||
private static bool _hasCachedNvidiaVersion;
|
private static bool _hasCachedNvidiaVersion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
public static double? GetNvidiaDriverVersion()
|
public static double? GetNvidiaDriverVersion()
|
||||||
{
|
{
|
||||||
if (_hasCachedNvidiaVersion) return _cachedNvidiaVersion;
|
if (_hasCachedNvidiaVersion) return _cachedNvidiaVersion;
|
||||||
@@ -103,6 +136,9 @@ internal static class GPUUtil
|
|||||||
|
|
||||||
// 2. Fallback to Windows Registry (only on Windows NT platforms via RegistryUtil)
|
// 2. Fallback to Windows Registry (only on Windows NT platforms via RegistryUtil)
|
||||||
if (System.Environment.OSVersion.Platform != System.PlatformID.Win32NT) return null;
|
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}";
|
const string classKey = @"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}";
|
||||||
var subkeys = RegistryUtil.GetHKLMSubKeyNames(classKey);
|
var subkeys = RegistryUtil.GetHKLMSubKeyNames(classKey);
|
||||||
if (subkeys == null) return null;
|
if (subkeys == null) return null;
|
||||||
@@ -130,6 +166,12 @@ internal static class GPUUtil
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encoders for <paramref name="codec"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="codec">Video codec.</param>
|
||||||
|
/// <param name="forceSoftware">When true, only the software encoders of the codec are returned.</param>
|
||||||
public static List<VideoEncoder> GetPrioritizedEncoders(Codec codec, bool forceSoftware = false)
|
public static List<VideoEncoder> GetPrioritizedEncoders(Codec codec, bool forceSoftware = false)
|
||||||
{
|
{
|
||||||
var encoders = VideoEncoder.All.Where(e => e.Codec == codec).ToList();
|
var encoders = VideoEncoder.All.Where(e => e.Codec == codec).ToList();
|
||||||
|
|||||||
@@ -4,10 +4,22 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
namespace ADOFAIRenderer.Util
|
||||||
{
|
{
|
||||||
|
/// <summary>Particle system helpers.</summary>
|
||||||
internal static class ParticleUtil
|
internal static class ParticleUtil
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Renderer found for each particle system. Weak keys: an entry is dropped when its ParticleSystem object is collected.
|
||||||
|
/// Main thread.
|
||||||
|
/// </summary>
|
||||||
private static readonly ConditionalWeakTable<ParticleSystem, ParticleSystemRenderer> Cache = new();
|
private static readonly ConditionalWeakTable<ParticleSystem, ParticleSystemRenderer> Cache = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the ParticleSystemRenderer that belongs to <paramref name="ps"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ps">Particle system; null or destroyed returns null.</param>
|
||||||
|
/// <returns>The renderer, or null when the GameObject has none.</returns>
|
||||||
public static ParticleSystemRenderer? GetRenderer(this ParticleSystem ps)
|
public static ParticleSystemRenderer? GetRenderer(this ParticleSystem ps)
|
||||||
{
|
{
|
||||||
if (!ps) return null;
|
if (!ps) return null;
|
||||||
|
|||||||
@@ -2,8 +2,19 @@ using System;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
namespace ADOFAIRenderer.Util
|
||||||
{
|
{
|
||||||
|
/// <summary>Conversion of floating-point rates (frame rates) to integer fractions.</summary>
|
||||||
internal static class RationalUtil
|
internal static class RationalUtil
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Best rational approximation Numerator / Denominator of <paramref name="value"/> by continued fraction expansion:
|
||||||
|
/// the last convergent whose numerator fits in int and whose denominator does not exceed
|
||||||
|
/// <paramref name="maxDenominator"/>. 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).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">Value to convert, for example a frame rate in frames per second.</param>
|
||||||
|
/// <param name="maxDenominator">Largest allowed denominator (default 100000000).</param>
|
||||||
|
/// <returns>Numerator and denominator (positive for magnitudes up to int.MaxValue).</returns>
|
||||||
public static (int Numerator, int Denominator) Double2MaxPrecisionInt(double value,
|
public static (int Numerator, int Denominator) Double2MaxPrecisionInt(double value,
|
||||||
int maxDenominator = 100000000)
|
int maxDenominator = 100000000)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,9 +9,25 @@ using FFmpeg.AutoGen;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util;
|
namespace ADOFAIRenderer.Util;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
internal class VideoBGPlayer : MonoBehaviour
|
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 YTex = Shader.PropertyToID("_YTex");
|
||||||
private static readonly int UVTex = Shader.PropertyToID("_UVTex");
|
private static readonly int UVTex = Shader.PropertyToID("_UVTex");
|
||||||
private static readonly int Result = Shader.PropertyToID("_Result");
|
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 ColorRange = Shader.PropertyToID("ColorRange");
|
||||||
private static readonly int ColorMatrix = Shader.PropertyToID("ColorMatrix");
|
private static readonly int ColorMatrix = Shader.PropertyToID("ColorMatrix");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conversion path of the current video: <c>None</c> = CPU RGBA texture, <c>YUV420P</c> = separate Y, U, V planes,
|
||||||
|
/// <c>NV12</c> = Y plane and interleaved UV plane.
|
||||||
|
/// </summary>
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
private enum GPUMode
|
private enum GPUMode
|
||||||
{
|
{
|
||||||
@@ -30,6 +50,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
NV12
|
NV12
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Decoder of the current video; null when not initialized or after CleanUp.</summary>
|
||||||
private FFmpegNativeDecoder? _decoder;
|
private FFmpegNativeDecoder? _decoder;
|
||||||
|
|
||||||
// CPU fallback rendering
|
// CPU fallback rendering
|
||||||
@@ -47,16 +68,34 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
private RenderTexture? _renderTexture;
|
private RenderTexture? _renderTexture;
|
||||||
private int _kernelIndex = -1;
|
private int _kernelIndex = -1;
|
||||||
|
|
||||||
|
/// <summary>Presentation time in seconds of the frame currently shown (video time, 0 = start of the file).</summary>
|
||||||
private double _currentFrameTimestamp;
|
private double _currentFrameTimestamp;
|
||||||
|
/// <summary>Video time in seconds from which the next frame is decoded (current timestamp + 1 / FPS).</summary>
|
||||||
private double _nextFrameTimestamp;
|
private double _nextFrameTimestamp;
|
||||||
|
/// <summary>Conversion path selected by Init.</summary>
|
||||||
private GPUMode _gpuMode = GPUMode.None;
|
private GPUMode _gpuMode = GPUMode.None;
|
||||||
|
/// <summary>Target RenderTexture of the native VideoPlayer; null when the video is shown on the background quad.</summary>
|
||||||
private RenderTexture? _targetTexture;
|
private RenderTexture? _targetTexture;
|
||||||
|
/// <summary>Whether the level loops the video (miscSettings "loopVideo").</summary>
|
||||||
private bool _isLooping;
|
private bool _isLooping;
|
||||||
|
/// <summary>True after a successful Init; set to false by CleanUp or a playback error (playback then stops).</summary>
|
||||||
private bool _isInitialized;
|
private bool _isInitialized;
|
||||||
|
|
||||||
|
/// <summary>Background quad created by SetupBackgroundQuad when there is no target texture; hidden until the first frame.</summary>
|
||||||
private GameObject? _quad;
|
private GameObject? _quad;
|
||||||
|
/// <summary>Material of the background quad; its main texture is the converted frame.</summary>
|
||||||
private Material? _material;
|
private Material? _material;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases any previous video and opens <paramref name="videoPath"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="videoPath">Full path of the video file.</param>
|
||||||
|
/// <param name="targetTexture">RenderTexture the native VideoPlayer renders into, or null to draw on a background quad.</param>
|
||||||
|
/// <param name="loop">Whether the video loops.</param>
|
||||||
|
/// <param name="targetCamera">Camera the background quad is attached to when <paramref name="targetTexture"/> is null.</param>
|
||||||
|
/// <param name="videoBGGo">VideoPlayer object; its layer is given to the background quad.</param>
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public void Init(string videoPath, RenderTexture? targetTexture, bool loop, Camera? targetCamera, GameObject videoBGGo)
|
public void Init(string videoPath, RenderTexture? targetTexture, bool loop, Camera? targetCamera, GameObject videoBGGo)
|
||||||
{
|
{
|
||||||
@@ -169,12 +208,19 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets clamp wrapping and bilinear filtering.</summary>
|
||||||
private static void ConfigureTexture(Texture2D tex)
|
private static void ConfigureTexture(Texture2D tex)
|
||||||
{
|
{
|
||||||
tex.wrapMode = TextureWrapMode.Clamp;
|
tex.wrapMode = TextureWrapMode.Clamp;
|
||||||
tex.filterMode = FilterMode.Bilinear;
|
tex.filterMode = FilterMode.Bilinear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
private void LateUpdate()
|
private void LateUpdate()
|
||||||
{
|
{
|
||||||
if (!_isInitialized || _decoder == null) return;
|
if (!_isInitialized || _decoder == null) return;
|
||||||
@@ -182,6 +228,9 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
var cond = scrConductor.instance;
|
var cond = scrConductor.instance;
|
||||||
if (!cond || !cond.hasSongStarted) return;
|
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 pitch = cond.song.pitch;
|
||||||
var separateCountdownOffset = cond.separateCountdownTime ?
|
var separateCountdownOffset = cond.separateCountdownTime ?
|
||||||
cond.crotchetAtStart * cond.adjustedCountdownTicks : 0.0;
|
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)
|
if (ADOBase.customLevel)
|
||||||
{
|
{
|
||||||
ADOBase.customLevel.ShowTutorialBackground(false);
|
ADOBase.customLevel.ShowTutorialBackground(false);
|
||||||
@@ -222,6 +272,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
|
|
||||||
try
|
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)
|
if (targetVideoTime < _currentFrameTimestamp || targetVideoTime - _currentFrameTimestamp > 1.0)
|
||||||
{
|
{
|
||||||
_decoder.SeekToTime(targetVideoTime);
|
_decoder.SeekToTime(targetVideoTime);
|
||||||
@@ -298,6 +349,10 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
private void RenderToTarget()
|
private void RenderToTarget()
|
||||||
{
|
{
|
||||||
if (_decoder == null) return;
|
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(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||||
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
_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 threadGroupsX = (w + 7) / 8;
|
||||||
int threadGroupsY = (h + 7) / 8;
|
int threadGroupsY = (h + 7) / 8;
|
||||||
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
|
_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(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
||||||
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
|
_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 threadGroupsX = (w + 7) / 8;
|
||||||
int threadGroupsY = (h + 7) / 8;
|
int threadGroupsY = (h + 7) / 8;
|
||||||
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
|
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
|
||||||
@@ -394,11 +451,16 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the decoder and GPU resources when the component is destroyed.</summary>
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
CleanUp();
|
CleanUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
public void CleanUp()
|
public void CleanUp()
|
||||||
{
|
{
|
||||||
_isInitialized = false;
|
_isInitialized = false;
|
||||||
@@ -446,6 +508,12 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a quad as a child of <paramref name="targetCamera"/>, 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 <paramref name="videoBGGo"/>. 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.
|
||||||
|
/// </summary>
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo)
|
private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user