Files
ADOFAIRenderer/Core/RenderManager.cs
T
2026-10-15 18:29:22 +09:00

1528 lines
66 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Buffers;
using System.Diagnostics;
using ADOFAIRenderer.API;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Patch.Engine;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.UI.Window;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.Audio;
using ADOFAIRenderer.Util.FFmpeg;
using ADOFAIRenderer.Util.OS;
using FFmpeg.AutoGen;
using HarmonyLib;
using MelonLoader;
using UnityEngine;
using UnityEngine.Rendering;
using Random = UnityEngine.Random;
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)
{
var fileString = !string.IsNullOrEmpty(RenderManager.Instance.CurrentOutPath) ?
$"'{Path.GetFileName(RenderManager.Instance.CurrentOutPath)}'" : "Video";
var renderFolder = Path.GetDirectoryName(RenderManager.Instance.CurrentOutPath);
OSNotification.Show("Rendering Complete!", $"{fileString} saved successfully.", renderFolder);
}
};
/// <summary>
/// Called once on the main Unity 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 Unity 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;
TimePatch = true; ScreenPatch = true;
OutputPath = null;
Notification = true;
SessionFrameHooks.Clear();
EndCondition = EndConditionDefault;
OnComplete = OnCompleteDefault;
OnLastUpdate = OnLastUpdateDefault;
}
}
/// <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
{
/// <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>
/// Maximum number of frames in ImageQueue. The main Unity thread waits (throttles) while the queue holds more frames.
/// </summary>
public const int MaxQueuedFrames = 64;
/// <summary>
/// Upper bound of the readback buffer bytes of the frames in ImageQueue (4 GiB).
/// The frame limit becomes min(MaxQueuedFrames, MaxQueuedBytes / bytes per frame) (see ComputeQueueFrameLimit),
/// so it only lowers the frame count at resolutions where MaxQueuedFrames frames exceed this size.
/// </summary>
public const long MaxQueuedBytes = 4L * 1024 * 1024 * 1024;
/// <summary>
/// Wait time of the throttle loop (OnPostFrame) for the render thread to take a frame from ImageQueue.
/// When it elapses without a dequeue, AsyncGPUReadback.WaitAllRequests() is called once so the readbacks
/// still in flight complete (the render thread may be waiting for one of them to fill a gap in ImageQueue).
/// </summary>
private const int ThrottleWaitMilliseconds = 10;
/// <summary>Frame limit of ImageQueue applied by the last throttle check (MaxQueuedFrames or lower).</summary>
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 Unity 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;
/// <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)
{
if (!_renderFailed) _isRenderCancelled = true;
}
}
/// <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)
{
if (_isRenderCancelled || _renderFailed) return;
_failureReason = reason;
_renderFailed = true;
}
}
/// <summary>Clears cancelled, failed and the failure reason. Called when a new encoder starts (StartFFmpegThread).</summary>
private void ClearAbortState()
{
lock (_abortStateLock)
{
_isRenderCancelled = false;
_renderFailed = false;
_failureReason = null;
}
}
/// <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 Unity 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 Unity 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 Unity 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
{
EnsureActiveHooksUpToDate();
return _hasActiveHooksCached;
}
}
/// <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;
if (_cachedGlobalHooksVersion == globalVersion) return;
_cachedGlobalHooksVersion = globalVersion;
_activeHooksCache.Clear();
if (RenderContext.SessionFrameHooks.Count > 0)
{
_activeHooksCache.AddRange(RenderContext.SessionFrameHooks);
}
RenderSession.CopyGlobalFrameHooksTo(_activeHooksCache);
_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();
_frameHookPipeline = null;
_activeHooksCache.Clear();
_cachedGlobalHooksVersion = -1;
_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 Unity 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 Unity 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 Unity thread had to wait for the render thread (throttle loop entries).</summary>
public int throttleCount;
/// <summary>True while the main Unity 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 Unity 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);
/// <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()
{
Instance = this;
}
private void OnDestroy()
{
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;
else MarkCancelled();
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)
MarkFailed("the game exited or RenderManager was destroyed during rendering.");
ResetRenderState(success: false);
AudioSpectrumGenerator.Clear();
// Cancels the render thread's audio waits, then releases the clip store (decoder thread, readers, chunks).
AudioMixer?.Dispose(); AudioMixer = null;
ResetFrameHookState();
if (_activeVideoFFmpeg != null)
{
Melon<RenderMod>.Logger.Msg("Game exit or RenderManager destruction detected: Disposing FFmpeg native encoder resources.");
_activeVideoFFmpeg.Dispose();
_activeVideoFFmpeg = null;
}
lock (_allVideoFFmpegInstances)
{
if (_allVideoFFmpegInstances.Count > 0)
{
Melon<RenderMod>.Logger.Msg($"Game exit detected: Cleaning up and disposing all created FFmpeg encoder instances ({_allVideoFFmpegInstances.Count}).");
foreach (var instance in _allVideoFFmpegInstances)
{
try
{
instance.Dispose();
}
catch
{
// ignored
}
}
_allVideoFFmpegInstances.Clear();
}
}
if (RenderThread is { IsAlive: true })
{
RenderThread.Join(200);
}
// The encoders are disposed above, which closes the output file. An unfinished file of this render is deleted.
DeleteIncompleteOutputFile();
}
/// <summary>
/// Deletes the output file of the current render when the render did not finish it (_incompleteOutputPath).
/// Files of earlier, finished renders are never deleted.
/// Returns true when a file was deleted.
/// </summary>
private bool DeleteIncompleteOutputFile()
{
var path = Interlocked.Exchange(ref _incompleteOutputPath, null);
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return false;
try
{
File.Delete(path);
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {path}");
return true;
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete incomplete video: {ex.Message}");
return false;
}
}
/// <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;
}
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;
scrController.instance.noFailInfiniteMargin = true;
}
stage = RenderStage.OnScene;
countdown = 3; endFrame = -1;
if (!_escapeHandlerObj)
{
_escapeHandlerObj = new GameObject("EscapeHandlerCanvas");
_escapeHandlerObj.AddComponent<EscapeHandlerUI>();
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());
}
if (Instance.stage < RenderStage.OnScene) return;
if (stage >= RenderStage.Render && RenderThread is not { IsAlive: true })
{
MarkFailed("the render thread terminated unexpectedly.");
CancelRender();
return;
}
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
{
MarkFailed("the level failed or the game controller was lost during rendering.");
ResetRenderState(success: false);
StopFFmpeg(false);
EndRender();
}
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;
}
var extraFrames = (long)Math.Ceiling(Configuration.DelayAfterEnd * Configuration.RenderFPS);
if (frameCount - endFrame < extraFrames) return;
ResetRenderState(success: true);
StopFFmpeg(true);
}
}
/// <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;
frameCount++;
}
// Floors that own a hold renderer, collected from scrLevelMaker.listFloors (see RefreshHoldFloors).
private readonly List<scrFloor> _holdFloors = new();
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;
if (Configuration.AdvancedConfig.NonTile)
{
// scrHoldRenderer.UpdateColor sets m_meshRenderer.enabled = true every time it runs
// (scrHoldRenderer.cs, called from scrPlanet/scrPlayer), so the hold meshes are disabled again every frame.
RefreshHoldFloors();
foreach (var floor in _holdFloors)
{
if (!floor || !floor.holdRenderer) continue;
var meshRenderer = floor.holdRenderer.m_meshRenderer;
if (meshRenderer && meshRenderer.enabled) meshRenderer.enabled = false;
}
}
TriggerOnLastUpdate();
}
/// <summary>
/// Rebuilds the hold floor list when scrLevelMaker.listFloors is a different list or has a different count
/// (hold renderers are created together with the floors in scrLevelMaker).
/// </summary>
private void RefreshHoldFloors()
{
var floors = scrLevelMaker.instance.listFloors;
if (ReferenceEquals(floors, _holdFloorsSource) && floors.Count == _holdFloorsSourceCount) return;
_holdFloors.Clear();
foreach (var floor in floors)
{
if (floor && floor.holdRenderer) _holdFloors.Add(floor);
}
_holdFloorsSource = floors;
_holdFloorsSourceCount = floors.Count;
}
/// <summary>
/// Frame limit of ImageQueue for the given capture texture: MaxQueuedBytes divided by the readback bytes of
/// one frame (YUV 4:2:0 when the GPU conversion is active, RGBA otherwise), within 4 to MaxQueuedFrames.
/// </summary>
private int ComputeQueueFrameLimit(RenderTexture texture)
{
var frameBytes = _frameReader?.GetFrameBufferBytes(texture.width, texture.height)
?? (long)texture.width * texture.height * 4;
if (frameBytes <= 0) return MaxQueuedFrames;
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)
{
if (Instance.stage < RenderStage.Render) return;
// Audio commands of this frame are queued: update the song voice and let the render thread mix up to the next frame.
if (AudioMixer != null)
{
AudioMixer.UpdateBGM(AudioTime, dspTimeBase);
AudioMixer.Commit((frameCount + 1) / Configuration.RenderFPS);
EffectSource.DeactivateMappedSources(AudioMixer);
}
if (!texture) return;
// [Backpressure Throttling]
// The main Unity thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread
// removes frames, and it signals FrameDequeuedEvent for each one. The wait has a timeout so that the loop
// re-checks the render thread state and pumps pending readback completions (see ThrottleWaitMilliseconds).
isThrottling = false;
var frameLimit = QueueFrameLimit = ComputeQueueFrameLimit(texture!);
while (Instance.stage >= RenderStage.Render)
{
if (RenderThread is not { IsAlive: true })
{
isThrottling = false;
return;
}
int count;
lock (ImageQueue)
{
count = ImageQueue.Count;
}
if (count <= frameLimit) break;
if (!isThrottling)
{
isThrottling = true;
throttleCount++;
}
if (!FrameDequeuedEvent.WaitOne(ThrottleWaitMilliseconds))
{
AsyncGPUReadback.WaitAllRequests();
}
}
isThrottling = false;
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)
{
lastVideoFrameIndex = videoFrameIndex;
Volatile.Write(ref lastRequestedVideoFrameIndex, videoFrameIndex);
var targetTexture = texture;
if (HasActiveFrameHooks && _frameHookPipeline != null)
{
var videoTime = effectiveVideoFps > 0 ? videoFrameIndex / effectiveVideoFps : 0.0;
targetTexture = _frameHookPipeline.Process(
texture,
videoFrameIndex,
currentFrame,
videoTime,
renderFPS,
effectiveVideoFps,
GetActiveFrameHooks()
) ?? texture;
}
_frameReader?.RequestFrameCapture(targetTexture, videoFrameIndex);
}
}
/// <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",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public);
if (targetMethod == null || !scnEditor.instance) return;
if (ADOBase.levelPath.IsNullOrEmpty())
{
scnEditor.instance.TryQuitToMenu();
return;
}
if (Instance) Instance.levelPath = ADOBase.levelPath;
scnEditor.instance.CheckUnsavedChanges(() =>
{
var quitDelegate =
(Func<bool>)Delegate.CreateDelegate(typeof(Func<bool>), scnEditor.instance, targetMethod);
Application.wantsToQuit -= quitDelegate;
RenderConfigWindow.CreateWindow();
});
}
/// <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 };
GCS.checkpointNum = 0;
GCS.customLevelId = "";
GCS.nextSpeedRun = 1;
GCS.customLevelIndex = 0;
if (Configuration.AdvancedConfig.RandomSeed.HasValue) Random.InitState(Configuration.AdvancedConfig.RandomSeed.Value);
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnGame");
skipPressToStart = 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()
{
// 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))
Configuration.AudioConfig.AudioSampleRate = 48000; // most compatible value
}
for (; countdown > 0; countdown--)
{
yield return new WaitForSecondsRealtime(1);
}
// Cancelled or failed during the countdown: do not start the encoder
if (stage != RenderStage.OnScene) yield break;
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;
frameCountBase = Time.frameCount;
_frameReader?.Dispose();
_frameReader = null;
_frameHookPipeline?.Dispose();
_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();
_frameReader = _activeVideoFFmpeg != null ? new FrameReader(_activeVideoFFmpeg.SelectedPixelFormat) : new FrameReader(AVPixelFormat.AV_PIX_FMT_YUV420P);
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
// Release the encoder/thread and restore time, input offset, stage and hooks before showing the dialog
MarkFailed($"failed to initialize the video encoder: {ex.Message}");
ResetRenderState(success: false);
StopFFmpeg(false);
RestoreRenderEnvironment();
CommonWarningDialogUI.ShowDialog(
"Encoder Error",
$"Failed to initialize video encoder.\nError: {ex.Message}",
new DialogButton("OK", () =>
{
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
Cursor.visible = true;
})
);
yield break;
}
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;
if (!scrConductor.instance || !scrConductor.instance.song) yield break;
if (Configuration.AudioConfig.RenderAudio)
{
AudioMixer = new OfflineAudioMixer(Configuration.AudioConfig.AudioChannels, Configuration.AudioConfig.AudioSampleRate);
AudioMixer.InitializeBGM(scrConductor.instance.song.clip);
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
SyncCalculator = new AudioSyncCalculator(Configuration.AudioConfig.AudioSampleRate, effectiveVideoFps);
}
if (Configuration.AdvancedConfig.ProcessAudio)
{
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)
{
_debugMonitorObj = new GameObject("DebugMonitorCanvas");
_debugMonitorObj.AddComponent<DebugMonitorUI>();
DontDestroyOnLoad(_debugMonitorObj);
}
lock (ImageQueue)
{
ImageQueue.Clear();
}
passCount = 0;
ClearAbortState();
frameCountForVideo = 0;
lastVideoFrameIndex = -1;
Volatile.Write(ref lastRequestedVideoFrameIndex, -1);
endRequested = false;
endSuccessful = false;
FrameAvailableEvent.Reset();
FrameDequeuedEvent.Reset();
var renderFPS = Configuration.RenderFPS;
var videoFPS = Configuration.VideoFPS;
var startTime = DateTime.Now;
if (RenderContext.OutputPath != null) CurrentOutPath = RenderContext.OutputPath;
else {
var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") +
$".{Configuration.AdvancedConfig.FileExtension.TrimStart('.').ToLower()}";
CurrentOutPath = Path.Combine(GlobalSettings.EffectiveOutputDirectory, fileName);
}
FFmpegNativeEncoder? videoFFmpeg = null;
try
{
var effectiveVideoFps = Math.Min(videoFPS, renderFPS);
var codecId = CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec];
var settings = new FFmpegNativeEncoder.EncoderSettings
{
Width = Configuration.RenderWidth,
Height = Configuration.RenderHeight,
FPS = effectiveVideoFps,
Codec = Configuration.VideoConfig.Codec,
Mode = Configuration.VideoConfig.RateControlMode,
QualityValue = Configuration.VideoConfig.QP,
TargetBitrate = Configuration.VideoConfig.Bitrate,
MaxBitrate = Configuration.VideoConfig.MaxBitrate,
GopSize = Configuration.VideoConfig.KeyframeInterval,
ForceSoftware = Configuration.VideoConfig.ForceSoftwareEncoder,
Audio = Configuration.AudioConfig.RenderAudio ? new FFmpegNativeEncoder.AudioSettings
{
SampleRate = Configuration.AudioConfig.AudioSampleRate,
Channels = Configuration.AudioConfig.AudioChannels,
InputSampleFormat = AVSampleFormat.AV_SAMPLE_FMT_FLT,
AudioCodecId = codecId,
Bitrate = Configuration.AudioConfig.AudioBitrate
} : null
};
videoFFmpeg = new FFmpegNativeEncoder(settings);
_activeVideoFFmpeg = videoFFmpeg;
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Add(videoFFmpeg);
}
Volatile.Write(ref _incompleteOutputPath, CurrentOutPath);
videoFFmpeg.Start(CurrentOutPath);
}
catch (Exception)
{
if (videoFFmpeg != null)
{
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
}
videoFFmpeg.Dispose();
}
_activeVideoFFmpeg = null;
throw;
}
// Background thread: it does not keep the process alive when the game quits.
var encoder = videoFFmpeg;
RenderThread = new Thread(() => RunRenderThread(encoder))
{
IsBackground = true,
Priority = ThreadPriority.AboveNormal
};
RenderThread.Start();
}
/// <summary>
/// Body of the render thread. Sends queued frames and their audio to the encoder until the render ends,
/// finishes the output file, and releases the encoder. An exception is handled as a render failure.
/// </summary>
private void RunRenderThread(FFmpegNativeEncoder videoFFmpeg)
{
try
{
EncodeQueuedFrames(videoFFmpeg);
if (!IsRenderAborted)
{
// End throws when the file cannot be finished (e.g. the trailer cannot be written),
// which is handled as a render failure by the catch block below.
videoFFmpeg.End();
Volatile.Write(ref _incompleteOutputPath, null);
}
videoFFmpeg.Dispose();
UnregisterEncoder(videoFFmpeg);
endSuccessful = !IsRenderAborted;
}
catch (OperationCanceledException) when (IsRenderAborted)
{
// Audio waits end with OperationCanceledException when the render is cancelled or has failed (OfflineAudioMixer.Cancel).
Melon<RenderMod>.Logger.Msg("Render thread stopped: the render was aborted.");
DisposeEncoderAfterStop(videoFFmpeg);
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
MarkFailed("encoder thread error. See the log for details.");
DisposeEncoderAfterStop(videoFFmpeg);
}
}
/// <summary>Releases the encoder after the render thread stopped early (abort or error) and removes it from the encoder list.</summary>
private void DisposeEncoderAfterStop(FFmpegNativeEncoder videoFFmpeg)
{
try
{
videoFFmpeg.Dispose();
}
catch (Exception disposeEx)
{
Melon<RenderMod>.Logger.Error($"Encoder dispose after the render thread stopped also failed: {disposeEx.Message}");
}
UnregisterEncoder(videoFFmpeg);
}
/// <summary>
/// Takes frames from ImageQueue in passCount order and sends each one to the encoder, followed by the audio
/// samples of that frame. Waits on FrameAvailableEvent (50ms) while no frame is ready.
/// Returns when the render is aborted (checked before every frame, frames left in ImageQueue are released
/// by the cleanup after the thread ends) or all requested frames have been sent after endRequested.
/// </summary>
private void EncodeQueuedFrames(FFmpegNativeEncoder videoFFmpeg)
{
while (true)
{
if (IsRenderAborted) return;
if (TryDequeueNextFrame(out var frame))
{
FrameDequeuedEvent.Set();
SendVideoFrame(videoFFmpeg, frame);
WriteAudioForFrame(videoFFmpeg);
continue;
}
if (ShouldStopEncodeLoop()) return;
FrameAvailableEvent.WaitOne(50);
}
}
/// <summary>
/// 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)
{
frame = default;
lock (ImageQueue)
{
if (ImageQueue.Count == 0) return false;
var firstKey = ImageQueue.Keys[0];
if (firstKey > passCount && Instance.stage < RenderStage.Render)
{
passCount = firstKey;
}
if (firstKey != passCount) return false;
frame = ImageQueue[firstKey];
ImageQueue.RemoveAt(0);
passCount++;
return true;
}
}
/// <summary>
/// Sends one captured frame to the encoder and releases its buffer (managed: ArrayPool, native: readback state).
/// A frame without data is skipped (not counted in framesSent). Render thread.
/// </summary>
private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame)
{
if (frame.ManagedBuffer != null)
{
if (frame.ManagedBuffer.Length == 0) return;
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent);
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
return;
}
if (frame.NativePointer == IntPtr.Zero || frame.Length <= 0) return;
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent);
if (frame.ReadbackState != null)
{
_frameReader?.RecycleState(frame.ReadbackState);
}
}
/// <summary>
/// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder.
/// 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 Unity 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;
var channels = Configuration.AudioConfig.AudioChannels;
var samplesNeeded = syncCalculator.GetNextFrameSamples();
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
try
{
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded);
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
}
finally
{
ArrayPool<float>.Shared.Return(audioBuffer);
}
}
/// <summary>
/// True when the render is aborted, or when the end was requested and every requested frame has been sent.
/// </summary>
private bool ShouldStopEncodeLoop()
{
if (IsRenderAborted) return true;
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)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
}
_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);
StopFFmpeg(false);
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;
if (!success)
{
MarkCancelled();
}
// The abort state is set first: the render thread reads it when its audio wait is cancelled.
if (success) AudioMixer?.Complete();
else AudioMixer?.Cancel();
FrameAvailableEvent.Set();
skipPressToStart = false;
_renderSetup = false;
if (_escapeHandlerObj)
{
Destroy(_escapeHandlerObj);
_escapeHandlerObj = null;
}
if (success) StartCoroutine(StopRenderCoroutine());
else
{
var reason = IsRenderFailed ? $"FAILED ({_failureReason})" : "CANCELLED";
Melon<RenderMod>.Logger.Error($"Rendering {reason}. Safely cleaning up state and resources.");
// 1. Wait (up to 2s) for the render background thread to see the abort flag and terminate
if (RenderThread is { IsAlive: true })
{
RenderThread.Join(2000);
}
// 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();
_activeVideoFFmpeg = null;
}
// 3. Memory leak prevention: Return rented temporary frame buffer pool in queue and clear
lock (ImageQueue)
{
foreach (var kvp in ImageQueue.Values)
{
if (kvp.ManagedBuffer is { Length: > 0 })
{
ArrayPool<byte>.Shared.Return(kvp.ManagedBuffer);
}
if (kvp.ReadbackState != null)
{
_frameReader?.RecycleState(kvp.ReadbackState);
}
}
ImageQueue.Clear();
}
_frameReader?.Dispose();
ResetFrameHookState();
// Clean up Debug Monitor UI
if (_debugMonitorObj)
{
Destroy(_debugMonitorObj);
_debugMonitorObj = null;
}
// 4. Safely restore in-game camera and RTCam state for rendering
if (scrCamera.instance)
{
scrCamera.instance.forceRTCam = false;
scrCamera.instance.SetupRTCam(false);
var hook = scrCamera.instance.GetComponent<PostRenderHook>();
if (hook) Destroy(hook);
}
// 5. Restore in-game no-fail flags
if (scrController.instance)
{
scrController.instance.noFail = false;
scrController.instance.noFailInfiniteMargin = false;
}
// 6. Immediately delete the incomplete video file of this render
if (DeleteIncompleteOutputFile())
{
CurrentOutPath = null;
}
var result = BuildResult(false, BuildAbortMessage());
TriggerOnComplete(result);
}
}
/// <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 });
if (!endSuccessful)
{
MarkFailed("the render thread terminated unexpectedly.");
CancelRender();
yield break;
}
var result = BuildResult(success: true);
TriggerOnComplete(result);
EndRender();
}
/// <summary>Restores the render environment and loads the level editor (scnEditor).</summary>
private void EndRender()
{
RestoreRenderEnvironment();
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
Cursor.visible = true;
}
/// <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;
Time.captureDeltaTime = 0f;
if (_debugMonitorObj)
{
Destroy(_debugMonitorObj);
_debugMonitorObj = null;
}
RDC.auto = false;
scrConductor.currentPreset.inputOffset = _originalInputOffset;
GCS.customLevelPaths = null;
GCS.customLevelId = null;
lock (ImageQueue)
{
foreach (var kvp in ImageQueue.Values)
{
if (kvp.ManagedBuffer is { Length: > 0 })
{
ArrayPool<byte>.Shared.Return(kvp.ManagedBuffer);
}
if (kvp.ReadbackState != null)
{
_frameReader?.RecycleState(kvp.ReadbackState);
}
}
ImageQueue.Clear();
}
_frameReader?.Dispose();
ResetFrameHookState();
AudioSpectrumGenerator.Clear();
AudioMixer?.Dispose(); AudioMixer = null;
SyncCalculator = null;
RenderContext.Reset();
}
/// <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();
var effectiveFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
var sent = Interlocked.Read(ref framesSent);
return success
? RenderResult.CreateSuccess(CurrentOutPath ?? string.Empty, 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)
{
try
{
RenderContext.OnComplete.Invoke(result);
}
catch (Exception err)
{
Melon<RenderMod>.Logger.Error($"OnComplete callback caused error: {err}");
}
}
/// <summary>Invokes RenderContext.OnLastUpdate; an exception from the callback is logged.</summary>
private static void TriggerOnLastUpdate()
{
try
{
RenderContext.OnLastUpdate.Invoke();
}
catch (Exception err)
{
Melon<RenderMod>.Logger.Error($"OnLastUpdate callback caused error: {err}");
}
}
}