Document Render Core And API

This commit is contained in:
copecone committed 2026-10-15 16:17:13 +09:00
1 parent 354c42fdd9
commit 8deb64fd4d
22 files changed
+1241 -27

No files matched your search

+38
View File
@@ -6,29 +6,59 @@ using Newtonsoft.Json;
namespace ADOFAIRenderer.Core.Data;
/// <summary>
/// Mod-wide settings that are not part of a render preset, stored as JSON in
/// &lt;game root&gt;/UserData/ADOFAIRenderer/global_settings.json (see <see cref="GlobalSettings"/>).
/// </summary>
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; }
/// <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; }
/// <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; }
}
/// <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
{
/// <summary>MelonLoader's game root directory, or the application base directory when it is empty.</summary>
private static string GetGameRootDir()
{
var root = MelonEnvironment.GameRootDirectory;
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
}
/// <summary>&lt;game root&gt;/UserData/ADOFAIRenderer/global_settings.json.</summary>
private static string SettingsFilePath =>
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();
/// <summary>Output directory used when none is configured: &lt;game root&gt;/Render.</summary>
public static string DefaultOutputDirectory =>
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
{
get
@@ -47,6 +77,10 @@ internal static class GlobalSettings
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()
{
try
@@ -66,6 +100,10 @@ internal static class GlobalSettings
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()
{
try
+24
View File
@@ -4,14 +4,30 @@ using UnityEngine;
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)]
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;
/// <summary>True once the NVIDIA driver version check ran (it runs once per game session).</summary>
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()
{
// 1. FFmpeg libraries: while the download UI is open, wait. When the libraries are missing, open the download UI.
if (!_checkedFFmpeg)
{
if (FFmpegDownloadUI.Instance) return;
@@ -24,6 +40,7 @@ internal class FirstUpdateHook : MonoBehaviour
_checkedFFmpeg = true;
}
// 2. NVIDIA driver version: waits until no other warning dialog is open, then runs once.
if (!_checkedNvidiaDriver)
{
if (CommonWarningDialogUI.Instance) return;
@@ -32,12 +49,19 @@ internal class FirstUpdateHook : MonoBehaviour
CheckNvidiaDriverWarning();
}
// 3. Render frame counter (only counts while a render is in the OnScene stage or later).
if (RenderManager.Instance)
{
RenderManager.Instance.OnPreUpdate();
}
}
/// <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()
{
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
+54
View File
@@ -6,16 +6,58 @@ using UnityEngine;
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
{
/// <summary>First ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
private RenderTexture? _pingRT;
/// <summary>Second ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
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;
/// <summary>Context object passed to the hooks; reset and reused for every frame (no allocation per frame).</summary>
private FrameHookContext? _reusableContext;
/// <summary>Width of the current buffers in pixels (0 when no buffers exist).</summary>
private int _width;
/// <summary>Height of the current buffers in pixels (0 when no buffers exist).</summary>
private int _height;
/// <summary>Format of the source texture the current buffers were created for.</summary>
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(
RenderTexture sourceTexture,
long frameIndex,
@@ -81,6 +123,8 @@ internal class FrameHookPipeline : IDisposable
}
}
// Current is the source when nothing was modified. A texture set by ReplaceCurrent is stored in
// _lastOutputRT too; it then matches neither buffer and the next frame starts with ping.
var result = _reusableContext.Current;
_lastOutputRT = _reusableContext.HasModified ? result : null;
return result;
@@ -91,6 +135,12 @@ internal class FrameHookPipeline : IDisposable
}
}
/// <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)
{
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()
{
if (_pingRT)
@@ -182,6 +235,7 @@ internal class FrameHookPipeline : IDisposable
_reusableContext = null;
}
/// <summary>Same as <see cref="ReleaseBuffers"/>. Main thread (Object.Destroy is used).</summary>
public void Dispose()
{
ReleaseBuffers();
+21
View File
@@ -5,10 +5,22 @@ using UnityEngine;
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)]
internal class PostRenderHook : MonoBehaviour
{
/// <summary>Private field scrCamera.camQuadMesh: the MeshRenderer of the quad that displays the camera RenderTexture.</summary>
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 void Awake()
@@ -16,6 +28,10 @@ internal class PostRenderHook : MonoBehaviour
_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()
{
if (RenderManager.Instance)
@@ -24,6 +40,11 @@ internal class PostRenderHook : MonoBehaviour
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()
{
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
+46
View File
@@ -11,11 +11,16 @@ using Newtonsoft.Json.Linq;
namespace ADOFAIRenderer.Core;
/// <summary>A named render configuration, stored as one JSON file in the presets directory.</summary>
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;
/// <summary>Render configuration of the preset.</summary>
public RenderConfig Config { get; set; } = null!;
/// <summary>Returns a copy with a copied configuration.</summary>
public RenderPreset Clone()
{
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 &lt;game root&gt;/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
{
/// <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()
{
new RenderPreset
@@ -96,6 +111,10 @@ public static class PresetManager
}
};
/// <summary>
/// &lt;root&gt;/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()
{
string? root = null;
@@ -125,6 +144,10 @@ public static class PresetManager
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()
{
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)
{
if (string.IsNullOrWhiteSpace(name))
@@ -195,6 +224,13 @@ public static class PresetManager
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()
{
EnsureDefaultPresetsCreated();
@@ -332,6 +368,10 @@ public static class PresetManager
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)
{
var legacyToken = configObj[keyBefore];
@@ -341,6 +381,12 @@ public static class PresetManager
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)
{
if (string.IsNullOrWhiteSpace(name)) return;
+379 -10
View File
@@ -25,24 +25,61 @@ using ThreadPriority = System.Threading.ThreadPriority;
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
{
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
{
/// <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;
/// <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;
/// <summary>Enables the Screen.width / Screen.height patches (report the render resolution) while the render is active.</summary>
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;
/// <summary>Whether the default OnComplete callback shows an OS notification after a successful render.</summary>
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();
/// <summary>Default end condition: the level is cleared (scrController state Won).</summary>
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;
/// <summary>Default completion callback: shows an OS notification for a successful render when Notification is true.</summary>
private static readonly Action<RenderResult> OnCompleteDefault = result =>
{
if (result.Success && Notification)
@@ -54,11 +91,23 @@ internal static class RenderContext
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;
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;
/// <summary>Restores every option to its default and clears the session frame hooks.</summary>
public static void Reset()
{
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
{
// 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!;
/// <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;
/// <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;
/// <summary>
@@ -104,26 +176,66 @@ internal class RenderManager : MonoBehaviour
public int QueueFrameLimit { get; private set; } = MaxQueuedFrames;
// 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;
/// <summary>Wall-clock time of the render from the end of the countdown (SetupPlay) to BuildResult.</summary>
private readonly Stopwatch _renderStopwatch = new();
// 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;
/// <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;
/// <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;
/// <summary>Path of the level file to render (ADOBase.levelPath of the editor, or the path given to RenderSession.Start).</summary>
public string levelPath = "";
/// <summary>Serializes changes of the abort state (_isRenderCancelled, _renderFailed, _failureReason).</summary>
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;
/// <summary>The render failed (MarkFailed). Written under _abortStateLock, read by any thread.</summary>
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;
/// <summary>True when the render was cancelled. Thread-safe read.</summary>
// ReSharper disable once MemberCanBePrivate.Global
public bool IsRenderCancelled => _isRenderCancelled;
/// <summary>True when the render failed. Thread-safe read.</summary>
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;
/// <summary>Marks the render as cancelled unless it has already failed (a failure keeps its reason).</summary>
private void MarkCancelled()
{
lock (_abortStateLock)
@@ -132,7 +244,10 @@ internal class RenderManager : MonoBehaviour
}
}
// The first failure reason wins; ignored once the render is already cancelled or failed.
/// <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)
{
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()
{
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
? $"Rendering failed: {_failureReason ?? "unknown error"}"
: "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!;
// 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;
/// <summary>Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS.</summary>
public double timeBase;
/// <summary>Time.unscaledTimeAsDouble when capture started. The time patch returns unscaledTimeBase + frameCount / RenderFPS.</summary>
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;
/// <summary>Time.frameCount when capture started. The frameCount patch returns frameCountBase + frameCount.</summary>
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; }
/// <summary>Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands.</summary>
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; }
/// <summary>GPU capture of frames (color conversion and AsyncGPUReadback). Created in SetupPlay after the encoder started.</summary>
private FrameReader? _frameReader;
/// <summary>Frame hook runner of this render. Created in SetupPlay, disposed by ResetFrameHookState.</summary>
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();
/// <summary>RenderSession.GlobalHooksVersion the cache was built for; -1 forces a rebuild.</summary>
private int _cachedGlobalHooksVersion = -1;
/// <summary>True when _activeHooksCache is not empty.</summary>
private bool _hasActiveHooksCached;
/// <summary>True when at least one session or global frame hook is registered. Main thread.</summary>
public bool HasActiveFrameHooks
{
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()
{
var globalVersion = RenderSession.GlobalHooksVersion;
@@ -201,12 +362,14 @@ internal class RenderManager : MonoBehaviour
_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()
{
EnsureActiveHooksUpToDate();
return _activeHooksCache;
}
/// <summary>Disposes the frame hook pipeline (ping-pong buffers) and clears the hook cache.</summary>
private void ResetFrameHookState()
{
_frameHookPipeline?.Dispose();
@@ -216,46 +379,132 @@ internal class RenderManager : MonoBehaviour
_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;
/// <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;
/// <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)
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;
/// <summary>Number of frames on which the main thread had to wait for the render thread (throttle loop entries).</summary>
public int throttleCount;
/// <summary>True while the main thread is inside the throttle wait of OnPostFrame.</summary>
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&lt;byte&gt;.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
{
/// <summary>Start of the native frame data (ReadbackState.NativeBuffer); zero for a managed frame.</summary>
public IntPtr NativePointer;
/// <summary>Number of valid bytes of the frame data.</summary>
public int Length;
/// <summary>Managed frame data rented from ArrayPool&lt;byte&gt;.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)
/// <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
/// <summary>True when the GPU converted the frame to the encoder's YUV format; false for RGBA data.</summary>
public bool IsColorConverted;
}
// 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();
/// <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);
// 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);
/// <summary>The render thread of the current render (background, AboveNormal priority); null before the first render.</summary>
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;
/// <summary>Set by StopFFmpeg: no more frames will be captured; the render thread drains the queue and stops.</summary>
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;
/// <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;
// Private Instance Fields
/// <summary>Encoder of the current render; null when none runs. Cleared when the render thread releases it.</summary>
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();
/// <summary>The user's input offset (scrConductor.currentPreset.inputOffset), set to 0 during the render and restored afterwards.</summary>
private int _originalInputOffset;
/// <summary>Debug monitor overlay object, created when the encoder starts and destroyed when the render ends.</summary>
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;
/// <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;
// Output file of the current render that is not finished yet.
// Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End).
// Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile).
private string? _incompleteOutputPath;
/// <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 void Awake()
@@ -268,6 +517,10 @@ internal class RenderManager : MonoBehaviour
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)
{
if (success) _isRenderCancelled = false;
@@ -275,6 +528,11 @@ internal class RenderManager : MonoBehaviour
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()
{
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()
{
// scrPressToStart reads lm.hideDifficultyUI to decide whether the difficulty UI is shown (scrPressToStart.cs).
if (_renderSetup && ADOBase.lm)
{
ADOBase.lm.hideDifficultyUI = true;
@@ -356,18 +622,24 @@ internal class RenderManager : MonoBehaviour
if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading)
{
_renderSetup = false;
// scrCamera.Awake calls SetupRTCam(enable: true) when forceRTCam is set (scrCamera.cs), so the flag applies to
// the next scrCamera.Awake. scnGame also calls SetupRTCam(enable: true) when it starts playing (scnGame.cs).
scrCamera.instance.forceRTCam = true;
if (!scrCamera.instance.gameObject.GetComponent<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;
scrConductor.currentPreset.inputOffset = 0;
// Autoplay; disableCongratsMessage clears the "congratulations" text shown on the win screen (scrController.cs).
RDC.auto = RenderContext.Auto;
AccessTools.Field(typeof(scrController), "disableCongratsMessage")
.SetValue(scrController.instance, true);
// No-fail with an infinite hit margin, so a missed hit does not fail the level during the render.
if (scrController.instance)
{
scrController.instance.noFail = true;
@@ -384,6 +656,9 @@ internal class RenderManager : MonoBehaviour
DontDestroyOnLoad(_escapeHandlerObj);
}
// While non-zero, Unity advances Time.time by exactly this interval per frame regardless of real time,
// so the game simulation steps 1 / RenderFPS seconds per frame. Reset to 0 by RestoreRenderEnvironment.
// Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html
Time.captureDeltaTime = (float)(1 / Configuration.RenderFPS);
scrController.instance!.StartCoroutine(SetupPlay());
}
@@ -404,6 +679,8 @@ internal class RenderManager : MonoBehaviour
}
else if (RenderContext.EndCondition())
{
// The first frame the end condition holds starts the DelayAfterEnd period; afterwards the end condition is
// still polled every frame, but endFrame is kept.
if (endFrame == -1)
{
endFrame = frameCount;
@@ -417,6 +694,9 @@ internal class RenderManager : MonoBehaviour
}
}
/// <summary>
/// Called by FirstUpdateHook before every other Update of the frame. Advances frameCount by one from the OnScene stage.
/// </summary>
public void OnPreUpdate()
{
if (Instance.stage < RenderStage.OnScene) return;
@@ -428,6 +708,10 @@ internal class RenderManager : MonoBehaviour
private List<scrFloor>? _holdFloorsSource;
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()
{
if (Instance.stage < RenderStage.Load) return;
@@ -479,6 +763,15 @@ internal class RenderManager : MonoBehaviour
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
public void OnPostFrame(RenderTexture? texture)
{
@@ -532,10 +825,13 @@ internal class RenderManager : MonoBehaviour
var currentFrame = frameCountForVideo++;
// The video cannot have more frames per second than the game renders: effective video FPS = min(VideoFPS, RenderFPS).
var renderFPS = Configuration.RenderFPS;
var videoFps = Configuration.VideoFPS;
var effectiveVideoFps = Math.Min(videoFps, renderFPS);
// Video frame that contains this game frame's time (currentFrame / renderFPS seconds), rounded down.
// Only the first game frame of each video frame is captured.
var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS);
if (videoFrameIndex > lastVideoFrameIndex)
@@ -562,6 +858,13 @@ internal class RenderManager : MonoBehaviour
}
}
/// <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()
{
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()
{
var renderDir = GlobalSettings.EffectiveOutputDirectory;
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()
{
GCS.customLevelPaths = new[] { levelPath };
@@ -605,8 +914,18 @@ internal class RenderManager : MonoBehaviour
_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()
{
// The audio sample rate must be one the audio codec paired with the video codec supports
// (CodecUtil.AudioCodecMatch, CodecUtil.AvailableSampleRate); otherwise 48000 Hz is used.
if (Configuration.AudioConfig.RenderAudio)
{
if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate))
@@ -624,6 +943,8 @@ internal class RenderManager : MonoBehaviour
frameCount = 0; framesSent = 0;
_renderStopwatch.Restart();
// Clock values at the start of capture. The time patches are not active yet (stage OnScene), so these are
// Unity's own values; from the Render stage the patched clocks continue from them at frameCount / RenderFPS.
timeBase = Time.timeAsDouble;
unscaledTimeBase = Time.unscaledTimeAsDouble;
dspTimeBase = AudioSettings.dspTime;
@@ -635,6 +956,7 @@ internal class RenderManager : MonoBehaviour
_frameHookPipeline = new FrameHookPipeline();
_cachedGlobalHooksVersion = -1;
EnsureActiveHooksUpToDate();
// The frame reader converts on the GPU to the pixel format of the encoder that was actually opened.
try
{
StartFFmpegThread();
@@ -663,6 +985,7 @@ internal class RenderManager : MonoBehaviour
}
Instance.stage = RenderStage.Render;
// levelWasSkipped ends the press-to-start wait loop of scrController (scrController.cs, WaitForStartCo).
scrController.instance.paused = scrController.instance.audioPaused = false;
scrController.instance.levelWasSkipped = true;
@@ -681,10 +1004,18 @@ internal class RenderManager : MonoBehaviour
AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip, AudioMixer);
}
// The song is played by the offline mixer only: the clip is removed from the game's song AudioSource so Unity
// does not play it.
scrConductor.instance.song.clip = null;
scrConductor.instance.song.time = -1000;
}
/// <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()
{
if (!_debugMonitorObj)
@@ -770,6 +1101,7 @@ internal class RenderManager : MonoBehaviour
throw;
}
// Background thread: it does not keep the process alive when the game quits.
var encoder = videoFFmpeg;
RenderThread = new Thread(() => RunRenderThread(encoder))
{
@@ -858,8 +1190,10 @@ internal class RenderManager : MonoBehaviour
}
/// <summary>
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount.
/// Before the Render stage, passCount jumps to the first queued key (the first captured frame index is not 0).
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount. Render thread.
/// While the stage is below Render, passCount jumps forward to the first queued key when that key is greater:
/// frames are only captured in the Render stage, so this happens after the stage was set back to None at the end
/// of the render while the thread drains the queue, and a frame whose readback has not arrived yet is skipped.
/// Returns false when the queue is empty or the next frame has not arrived yet.
/// </summary>
private bool TryDequeueNextFrame(out CapturedFrame frame)
@@ -886,7 +1220,7 @@ internal class RenderManager : MonoBehaviour
/// <summary>
/// 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>
private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame)
{
@@ -914,8 +1248,11 @@ internal class RenderManager : MonoBehaviour
/// <summary>
/// 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>
/// <exception cref="OperationCanceledException">The mixer was cancelled (render aborted) while waiting.</exception>
private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg)
{
if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return;
@@ -945,6 +1282,7 @@ internal class RenderManager : MonoBehaviour
return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex);
}
/// <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)
{
lock (_allVideoFFmpegInstances)
@@ -954,6 +1292,11 @@ internal class RenderManager : MonoBehaviour
_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()
{
ResetRenderState(success: false);
@@ -962,6 +1305,15 @@ internal class RenderManager : MonoBehaviour
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)
{
endRequested = true;
@@ -993,7 +1345,8 @@ internal class RenderManager : MonoBehaviour
RenderThread.Join(2000);
}
// 2. After ensuring thread termination, safely release FFmpeg native encoder resources
// 2. Release the encoder. The render thread normally released it already (_activeVideoFFmpeg is then null).
// When the thread is still running after the 2 s wait, the encoder is disposed while that thread may still use it.
if (_activeVideoFFmpeg != null)
{
_activeVideoFFmpeg.Dispose();
@@ -1055,6 +1408,11 @@ internal class RenderManager : MonoBehaviour
}
}
/// <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()
{
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
@@ -1071,6 +1429,7 @@ internal class RenderManager : MonoBehaviour
EndRender();
}
/// <summary>Restores the render environment and loads the level editor (scnEditor).</summary>
private void EndRender()
{
RestoreRenderEnvironment();
@@ -1079,7 +1438,11 @@ internal class RenderManager : MonoBehaviour
Cursor.visible = true;
}
// Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene.
/// <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()
{
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)
{
_renderStopwatch.Stop();
@@ -1133,6 +1500,7 @@ internal class RenderManager : MonoBehaviour
: 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)
{
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()
{
try