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;
///
/// Progress of the current render (RenderManager.stage). Values are ordered: checks use comparisons such as
/// stage >= OnScene.
///
/// - None: no render. Set at start-up and whenever a render ends, is cancelled or fails (ResetRenderState).
/// - Load: RenderSession.Start was called; the game scene (scnGame) is being loaded.
/// - OnScene: the game scene has loaded (RenderManager.Update set up the scene): the frame counter runs, the
/// 3-second countdown is shown, input is ignored. Nothing is captured yet. RenderManager.IsActive is true from this stage.
/// - Render: the encoder and render thread run and frames are captured (set by SetupPlay after the countdown).
/// RenderManager.IsCapturingActive is true; the time patches replace Unity's clock with the render clock.
///
///
internal enum RenderStage
{
None, Load, OnScene, Render
}
///
/// Options of the current render, copied from a by RenderSession.Start and read by
/// RenderManager and the Harmony patches. restores the defaults when the render environment is
/// restored (RenderManager.RestoreRenderEnvironment). Main thread only.
///
internal static class RenderContext
{
/// Value assigned to RDC.auto (the game's autoplay flag) when the game scene of the render has loaded.
public static bool Auto = true;
///
/// Enables the time patches (Time.time / unscaledTime / deltaTime / frameCount, AudioSettings.dspTime) while capturing,
/// which drive the game by the render clock (frameCount / RenderFPS) instead of real time.
///
public static bool TimePatch = true;
/// Enables the Screen.width / Screen.height patches (report the render resolution) while the render is active.
public static bool ScreenPatch = true;
/// Output file path of the render, or null for a time-stamped file in GlobalSettings.EffectiveOutputDirectory.
public static string? OutputPath;
/// Whether the default OnComplete callback shows an OS notification after a successful render.
public static bool Notification = true;
/// Frame hooks of the current session, run before the global hooks (see RenderSession.AddFrameHook).
public static readonly List> SessionFrameHooks = new();
/// Default end condition: the level is cleared (scrController state Won).
private static readonly Func EndConditionDefault = () => scrController.instance.state == States.Won;
///
/// Polled by RenderManager.Update every frame from the OnScene stage. When it first returns true, the render
/// continues for RenderConfig.DelayAfterEnd seconds of render time and then ends successfully.
///
public static Func EndCondition = EndConditionDefault;
/// Default completion callback: shows an OS notification for a successful render when Notification is true.
private static readonly Action OnCompleteDefault = result =>
{
if (result.Success && Notification)
{
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);
}
};
///
/// 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).
///
public static Action OnComplete = OnCompleteDefault;
private static readonly Action OnLastUpdateDefault = () => { };
///
/// 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).
///
public static Action OnLastUpdate = OnLastUpdateDefault;
/// Restores every option to its default and clears the session frame hooks.
public static void Reset()
{
Auto = true;
TimePatch = true; ScreenPatch = true;
OutputPath = null;
Notification = true;
SessionFrameHooks.Clear();
EndCondition = EndConditionDefault;
OnComplete = OnCompleteDefault;
OnLastUpdate = OnLastUpdateDefault;
}
}
///
/// Drives a render from scene setup to the finished video file. Singleton MonoBehaviour on a DontDestroyOnLoad object
/// created by RenderMod.
///
/// Flow: RenderSession.Start (stage Load) → SetupGameScene loads scnGame → Update sets up the loaded scene
/// (stage OnScene, 3-second countdown) → SetupPlay starts the encoder and the render thread (stage Render) →
/// every frame OnPreUpdate advances frameCount, OnPostFrame queues the audio commands and requests the GPU capture of
/// the frame (FrameReader, AsyncGPUReadback) → readback completions add frames to ImageQueue → the render thread
/// (RunRenderThread) encodes them in frame order together with the audio of each frame.
///
/// End: success (EndCondition plus DelayAfterEnd) → StopFFmpeg(true) → StopRenderCoroutine waits for the render
/// thread to finish the file → EndRender. Cancel (ESC) or failure → StopFFmpeg(false) → EndRender, which deletes the
/// unfinished file. Encoder initialization failure → StopFFmpeg(false) → RestoreRenderEnvironment and an error dialog.
///
/// Threads: the main Unity thread runs every public method unless noted otherwise. The render thread reads
/// ImageQueue (lock on ImageQueue), passCount (render thread only while it runs), endRequested / endSuccessful
/// (volatile), the abort state (volatile, written under _abortStateLock) and lastRequestedVideoFrameIndex
/// (Volatile.Read/Write). FrameAvailableEvent wakes the render thread, FrameDequeuedEvent wakes the throttle loop.
///
internal class RenderManager : MonoBehaviour
{
/// The single instance, set in Awake. Unity null checks (if (Instance)) are used before access.
public static RenderManager Instance { get; private set; } = null!;
/// True from the OnScene stage (scene set up, countdown running) until the render ends.
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
/// True while frames are captured and encoded (Render stage). The time patches apply only then.
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
///
/// Maximum number of frames in ImageQueue. The main Unity thread waits (throttles) while the queue holds more frames.
///
public const int MaxQueuedFrames = 64;
///
/// 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.
///
public const long MaxQueuedBytes = 4L * 1024 * 1024 * 1024;
///
/// 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).
///
private const int ThrottleWaitMilliseconds = 10;
/// Frame limit of ImageQueue applied by the last throttle check (MaxQueuedFrames or lower).
public int QueueFrameLimit { get; private set; } = MaxQueuedFrames;
// Statistics
///
/// Number of video frames sent to the encoder in this render. Incremented by the render thread (Interlocked),
/// read with Interlocked.Read by the main Unity thread (result, debug monitor). Reset in SetupPlay.
///
public long framesSent;
/// Wall-clock time of the render from the end of the countdown (SetupPlay) to BuildResult.
private readonly Stopwatch _renderStopwatch = new();
// Core Render Configuration and Path
///
/// Seconds left in the countdown before capture starts (3 when the scene is set up, decremented once per real-time
/// second by SetupPlay). Shown by scrShowIfDebugPatch and read by scrPlayerManagerPatch.
///
public int countdown;
///
/// Render frame counter: incremented once per Unity frame by OnPreUpdate from the OnScene stage and reset to 0 when
/// capture starts (SetupPlay). The render clock is frameCount / RenderFPS seconds (AudioTime, time patches).
/// Main thread only.
///
public long frameCount;
///
/// Set by SetupGameScene; scrPressToStartPatch consumes it (sets it back to false) to skip the press-to-start
/// screen once. Cleared by StopFFmpeg.
///
public bool skipPressToStart;
/// Path of the level file to render (ADOBase.levelPath of the editor, or the path given to RenderSession.Start).
public string levelPath = "";
/// Serializes changes of the abort state (_isRenderCancelled, _renderFailed, _failureReason).
private readonly object _abortStateLock = new();
/// The render was cancelled (ESC, or a non-successful stop). Written under _abortStateLock, read by any thread.
private volatile bool _isRenderCancelled;
/// The render failed (MarkFailed). Written under _abortStateLock, read by any thread.
private volatile bool _renderFailed;
/// Reason given to the first MarkFailed call of this render; null when the render has not failed.
private volatile string? _failureReason;
/// True when the render was cancelled. Thread-safe read.
// ReSharper disable once MemberCanBePrivate.Global
public bool IsRenderCancelled => _isRenderCancelled;
/// True when the render failed. Thread-safe read.
public bool IsRenderFailed => _renderFailed;
///
/// True when the render was cancelled or failed (the two are mutually exclusive) and must stop.
/// Read by the render thread before each frame and by readback completions. Thread-safe read.
///
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
/// Marks the render as cancelled unless it has already failed (a failure keeps its reason).
private void MarkCancelled()
{
lock (_abortStateLock)
{
if (!_renderFailed) _isRenderCancelled = true;
}
}
///
/// Marks the render as failed with (shown in the result message as "Rendering failed: reason").
/// The first failure reason wins; ignored once the render is already cancelled or failed. Callable from any thread.
///
private void MarkFailed(string reason)
{
lock (_abortStateLock)
{
if (_isRenderCancelled || _renderFailed) return;
_failureReason = reason;
_renderFailed = true;
}
}
/// Clears cancelled, failed and the failure reason. Called when a new encoder starts (StartFFmpegThread).
private void ClearAbortState()
{
lock (_abortStateLock)
{
_isRenderCancelled = false;
_renderFailed = false;
_failureReason = null;
}
}
/// Error message of the RenderResult of an aborted render (failure reason, or the cancel message).
private string BuildAbortMessage() => IsRenderFailed
? $"Rendering failed: {_failureReason ?? "unknown error"}"
: "Rendering was cancelled by user.";
///
/// Configuration of the current render: a copy made by RenderSession.Start, so values adjusted during the render
/// (the audio sample rate in SetupPlay) do not change the caller's object. Read by the main Unity thread, the render
/// thread (audio settings) and the patches. Null until the first render starts.
///
public RenderConfig Configuration = null!;
// Render Sessions State (OOP Instance Fields)
/// Current stage of the render. Written on the main Unity thread; read by the render thread in TryDequeueNextFrame.
public RenderStage stage = RenderStage.None;
/// Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS.
public double timeBase;
/// Time.unscaledTimeAsDouble when capture started. The time patch returns unscaledTimeBase + frameCount / RenderFPS.
public double unscaledTimeBase;
///
/// AudioSettings.dspTime when capture started (seconds). The dspTime patch returns dspTimeBase + frameCount / RenderFPS;
/// audio command times are stored relative to it (scheduled dspTime - dspTimeBase).
///
public double dspTimeBase;
/// Time.frameCount when capture started. The frameCount patch returns frameCountBase + frameCount.
public int frameCountBase;
///
/// Offline mixer of the song and sound effects of this render; null when audio is not rendered or no render runs.
/// Created in SetupPlay. Commands are added on the main Unity thread (OnPostFrame, audio patches), mixing runs on the
/// render thread (WriteAudioForFrame); the mixer synchronizes the two internally.
///
public OfflineAudioMixer? AudioMixer { get; private set; }
/// Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands.
public double AudioTime => frameCount / Configuration.RenderFPS;
///
/// Number of audio samples (per channel) to write after each video frame, so that the audio stays aligned with
/// the video frame times at the effective video frame rate. Created in SetupPlay when audio is rendered;
/// used by the render thread only.
///
private AudioSyncCalculator? SyncCalculator { get; set; }
/// GPU capture of frames (color conversion and AsyncGPUReadback). Created in SetupPlay after the encoder started.
private FrameReader? _frameReader;
/// Frame hook runner of this render. Created in SetupPlay, disposed by ResetFrameHookState.
private FrameHookPipeline? _frameHookPipeline;
/// Session hooks followed by global hooks, rebuilt when the global hook version changes.
private readonly List> _activeHooksCache = new();
/// RenderSession.GlobalHooksVersion the cache was built for; -1 forces a rebuild.
private int _cachedGlobalHooksVersion = -1;
/// True when _activeHooksCache is not empty.
private bool _hasActiveHooksCached;
/// True when at least one session or global frame hook is registered. Main thread.
public bool HasActiveFrameHooks
{
get
{
EnsureActiveHooksUpToDate();
return _hasActiveHooksCached;
}
}
///
/// Rebuilds the active hook list when the global hook registrations changed since the last build.
/// Session hooks are only read on a rebuild; SetupPlay forces one (version -1) at the start of capture, so changes to
/// RenderContext.SessionFrameHooks after that are picked up only together with a global hook change.
///
private void EnsureActiveHooksUpToDate()
{
var globalVersion = RenderSession.GlobalHooksVersion;
if (_cachedGlobalHooksVersion == globalVersion) return;
_cachedGlobalHooksVersion = globalVersion;
_activeHooksCache.Clear();
if (RenderContext.SessionFrameHooks.Count > 0)
{
_activeHooksCache.AddRange(RenderContext.SessionFrameHooks);
}
RenderSession.CopyGlobalFrameHooksTo(_activeHooksCache);
_hasActiveHooksCached = _activeHooksCache.Count > 0;
}
/// Returns the cached active hook list (session hooks first, then global hooks). The list is reused.
private List> GetActiveFrameHooks()
{
EnsureActiveHooksUpToDate();
return _activeHooksCache;
}
/// Disposes the frame hook pipeline (ping-pong buffers) and clears the hook cache.
private void ResetFrameHookState()
{
_frameHookPipeline?.Dispose();
_frameHookPipeline = null;
_activeHooksCache.Clear();
_cachedGlobalHooksVersion = -1;
_hasActiveHooksCached = false;
}
///
/// Number of game frames offered for capture since capture started (OnPostFrame with a texture, after throttling).
/// The value before the increment is the render frame index of the current frame. Main thread only.
///
public long frameCountForVideo;
///
/// Video frame index of the last captured frame (-1 before the first). A game frame is captured only when its video
/// frame index (render frame index × effective video FPS / RenderFPS, rounded down) is greater. Main thread only.
///
public long lastVideoFrameIndex = -1;
///
/// Video frame index of the last capture request (-1 before the first). Written on the main Unity thread, read by the
/// render thread to know when every requested frame has been sent after endRequested.
///
// long cannot be volatile: access via Volatile.Read/Write (written on main Unity thread, read on render thread)
public long lastRequestedVideoFrameIndex = -1;
///
/// Number of GPU readbacks that failed and were replaced by a black frame (shown by the debug monitor).
/// Incremented in the readback completion callback.
///
public int readbackFailureCount;
/// Number of frames on which the main Unity thread had to wait for the render thread (throttle loop entries).
public int throttleCount;
/// True while the main Unity thread is inside the throttle wait of OnPostFrame.
public bool isThrottling;
///
/// One frame waiting in ImageQueue: either a native readback buffer (owned by ReadbackState until it is recycled)
/// or a managed buffer rented from ArrayPool<byte>.Shared (black frame for a failed readback).
/// The data is YUV 4:2:0 in the encoder's pixel format when IsColorConverted, RGBA otherwise.
///
internal struct CapturedFrame
{
/// Start of the native frame data (ReadbackState.NativeBuffer); zero for a managed frame.
public IntPtr NativePointer;
/// Number of valid bytes of the frame data.
public int Length;
/// Managed frame data rented from ArrayPool<byte>.Shared (may be longer than Length); null for a native frame.
public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame)
/// Readback state owning NativePointer; returned to the FrameReader pool after the frame is encoded or dropped.
public ReadbackRequestState? ReadbackState; // Associated state to recycle
/// True when the GPU converted the frame to the encoder's YUV format; false for RGBA data.
public bool IsColorConverted;
}
// Thread and Queue State
///
/// Captured frames waiting for the render thread, keyed by video frame index. Readbacks may complete in any order;
/// the render thread takes them in index order (passCount). Every access locks the list itself.
/// Frames are added by readback completions (main Unity thread) and removed by the render thread, or released by the
/// cleanup in StopFFmpeg / RestoreRenderEnvironment.
///
internal SortedList ImageQueue = new();
/// Set when a frame is added to ImageQueue or the end is requested; the render thread waits on it.
internal AutoResetEvent FrameAvailableEvent = new(false);
/// Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.
internal AutoResetEvent FrameDequeuedEvent = new(false);
/// The render thread of the current render (background, AboveNormal priority); null before the first render.
public Thread? RenderThread;
///
/// Video frame index the render thread sends next. Reset by StartFFmpegThread before the thread starts; afterwards
/// read and written only by the render thread, under the ImageQueue lock.
///
public long passCount;
/// Set by StopFFmpeg: no more frames will be captured; the render thread drains the queue and stops.
public volatile bool endRequested;
/// Set by the render thread after the output file was finished and the encoder released without an abort.
public volatile bool endSuccessful;
///
/// frameCount at which EndCondition first returned true; -1 until then. The render ends DelayAfterEnd seconds of
/// render time later.
///
public long endFrame = -1;
// Private Instance Fields
/// Encoder of the current render; null when none runs. Cleared when the render thread releases it.
private FFmpegNativeEncoder? _activeVideoFFmpeg;
///
/// Every encoder created and not yet released, disposed by CleanUpActiveRender on exit. Locked on the list itself
/// (the render thread removes its encoder when it finishes).
///
private readonly List _allVideoFFmpegInstances = new();
/// The user's input offset (scrConductor.currentPreset.inputOffset), set to 0 during the render and restored afterwards.
private int _originalInputOffset;
/// Debug monitor overlay object, created when the encoder starts and destroyed when the render ends.
private GameObject? _debugMonitorObj;
/// ESC handler overlay object (cancels the render), created when the scene is set up and destroyed by StopFFmpeg.
private GameObject? _escapeHandlerObj;
///
/// Output file path of the current or last render. Set when the encoder starts; set to null when an aborted render's
/// file was deleted.
///
internal string? CurrentOutPath;
// Output file of the current render that is not finished yet.
// Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End).
// Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile).
private string? _incompleteOutputPath;
///
/// True from SetupGameScene until Update has set up the loaded game scene (or the render stops). While true, Update
/// hides the difficulty UI and waits for scnGame to finish loading.
///
private bool _renderSetup;
private void Awake()
{
Instance = this;
}
private void OnDestroy()
{
CleanUpActiveRender();
}
///
/// Sets the stage to None. true clears the cancelled flag; false marks the render as
/// cancelled (unless it already failed). Called at every end path before StopFFmpeg.
///
public void ResetRenderState(bool success)
{
if (success) _isRenderCancelled = false;
else MarkCancelled();
stage = RenderStage.None;
}
///
/// Emergency cleanup when the game quits or this object is destroyed: marks a running render as failed, releases
/// the audio, the frame hooks and every encoder, waits up to 200 ms for the render thread, and deletes the
/// unfinished output file. Does not restore the game scene or settings. Main thread.
///
public void CleanUpActiveRender()
{
if (stage != RenderStage.None)
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.Logger.Msg("Game exit or RenderManager destruction detected: Disposing FFmpeg native encoder resources.");
_activeVideoFFmpeg.Dispose();
_activeVideoFFmpeg = null;
}
lock (_allVideoFFmpegInstances)
{
if (_allVideoFFmpegInstances.Count > 0)
{
Melon.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();
}
///
/// 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.
///
private bool DeleteIncompleteOutputFile()
{
var path = Interlocked.Exchange(ref _incompleteOutputPath, null);
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return false;
try
{
File.Delete(path);
Melon.Logger.Msg($"[File Cleanup] Deleted incomplete video: {path}");
return true;
}
catch (Exception ex)
{
Melon.Logger.Error($"[File Cleanup] Failed to delete incomplete video: {ex.Message}");
return false;
}
}
///
/// Main thread, every frame.
/// 1. While the game scene of a render loads: hides the difficulty UI of the press-to-start screen.
/// 2. Once scnGame has finished loading: sets up the scene for rendering and starts SetupPlay (stage OnScene).
/// 3. From the OnScene stage: fails the render when the render thread died, the level failed or the controller is
/// gone; ends it successfully DelayAfterEnd seconds (render time) after EndCondition first returned true.
///
private void Update()
{
// scrPressToStart reads lm.hideDifficultyUI to decide whether the difficulty UI is shown (scrPressToStart.cs).
if (_renderSetup && ADOBase.lm)
{
ADOBase.lm.hideDifficultyUI = true;
}
if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading)
{
_renderSetup = false;
// scrCamera.Awake calls SetupRTCam(enable: true) when forceRTCam is set (scrCamera.cs), so the flag applies to
// the next scrCamera.Awake. scnGame also calls SetupRTCam(enable: true) when it starts playing (scnGame.cs).
scrCamera.instance.forceRTCam = true;
if (!scrCamera.instance.gameObject.GetComponent())
scrCamera.instance.gameObject.AddComponent();
// The input offset shifts the song position (calibration_i = inputOffset / 1000 in scrConductor);
// the render runs with 0 so audio and visuals follow the level timing exactly. Restored by RestoreRenderEnvironment.
_originalInputOffset = scrConductor.currentPreset.inputOffset;
scrConductor.currentPreset.inputOffset = 0;
// Autoplay; disableCongratsMessage clears the "congratulations" text shown on the win screen (scrController.cs).
RDC.auto = RenderContext.Auto;
AccessTools.Field(typeof(scrController), "disableCongratsMessage")
.SetValue(scrController.instance, true);
// No-fail with an infinite hit margin, so a missed hit does not fail the level during the render.
if (scrController.instance)
{
scrController.instance.noFail = true;
scrController.instance.noFailInfiniteMargin = true;
}
stage = RenderStage.OnScene;
countdown = 3; endFrame = -1;
if (!_escapeHandlerObj)
{
_escapeHandlerObj = new GameObject("EscapeHandlerCanvas");
_escapeHandlerObj.AddComponent();
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);
}
}
///
/// Called by FirstUpdateHook before every other Update of the frame. Advances frameCount by one from the OnScene stage.
///
public void OnPreUpdate()
{
if (Instance.stage < RenderStage.OnScene) return;
frameCount++;
}
// Floors that own a hold renderer, collected from scrLevelMaker.listFloors (see RefreshHoldFloors).
private readonly List _holdFloors = new();
private List? _holdFloorsSource;
private int _holdFloorsSourceCount = -1;
///
/// Called by PostRenderHook after every other LateUpdate of the frame, from the Load stage.
/// Hides the hold meshes when AdvancedConfig.NonTile is set, then runs RenderContext.OnLastUpdate.
///
public void OnLastUpdate()
{
if (Instance.stage < RenderStage.Load) return;
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();
}
///
/// 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).
///
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;
}
///
/// 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.
///
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));
}
///
/// Called by PostRenderHook at the end of every frame (after rendering) with the frame texture. In the Render stage:
/// 1. Audio: updates the song voice, commits the audio commands of this frame, and deactivates the effect
/// AudioSources that were mapped to mixer voices.
/// 2. Throttle: blocks while ImageQueue holds more than QueueFrameLimit frames (render thread behind).
/// 3. Capture: when this game frame starts a new video frame, runs the frame hooks and requests the GPU readback.
/// With RenderFPS above the effective video FPS, only some game frames are captured.
///
/// Frame shown by the camera quad; null skips throttling and capture.
// ReSharper disable once Unity.IncorrectMethodSignature
public void OnPostFrame(RenderTexture? texture)
{
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);
}
}
///
/// Handler of the render button in the level editor (added by scnEditorPatch).
/// When the level has no file path, scnEditor.TryQuitToMenu is called and no render starts.
/// Otherwise, after the editor's unsaved-changes prompt, removes scnEditor.TryApplicationQuit from
/// Application.wantsToQuit (it would block quitting with an unsaved-changes prompt; scnEditor registers it again
/// when the editor starts) and opens the render configuration window. Main thread.
///
public static void StartRender()
{
var targetMethod = typeof(scnEditor).GetMethod("TryApplicationQuit",
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)Delegate.CreateDelegate(typeof(Func), scnEditor.instance, targetMethod);
Application.wantsToQuit -= quitDelegate;
RenderConfigWindow.CreateWindow();
});
}
/// Creates the default output directory (GlobalSettings.EffectiveOutputDirectory) when it does not exist.
public void SetupCaptureDirectory()
{
var renderDir = GlobalSettings.EffectiveOutputDirectory;
FileUtil.CreateDirectory(renderDir);
}
///
/// Loads scnGame with levelPath as the only custom level (checkpoint 0, speed trial multiplier 1) and arms the
/// press-to-start skip and the scene setup in Update. Seeds UnityEngine.Random when AdvancedConfig.RandomSeed is set,
/// so random effects repeat between renders. Called by RenderSession.Start.
///
public void SetupGameScene()
{
GCS.customLevelPaths = new[] { levelPath };
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;
}
///
/// Coroutine started on scrController when the game scene is set up (stage OnScene).
/// Waits the countdown (real-time seconds), sets the time bases, starts the encoder, the render thread and the
/// frame reader, switches to the Render stage, starts the level without the press-to-start wait, and creates the
/// offline audio mixer and spectrum generator. Stops without starting when the render was aborted during the
/// countdown. On an encoder initialization error, the render fails, the environment is restored and an error
/// dialog returns to the editor.
///
private IEnumerator SetupPlay()
{
// The audio sample rate must be one the audio codec paired with the video codec supports
// (CodecUtil.AudioCodecMatch, CodecUtil.AvailableSampleRate); otherwise 48000 Hz is used.
if (Configuration.AudioConfig.RenderAudio)
{
if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate))
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.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;
}
///
/// Resets the queue and render thread state, opens the encoder on the output file (RenderContext.OutputPath, or
/// "yyyy-MM-dd HH-mm-ss.ext" in the output directory with the configured extension), and starts the render thread.
/// The output path is registered as incomplete before the encoder opens the file.
///
/// The encoder could not be created or started; it is disposed before the exception is rethrown.
private void StartFFmpegThread()
{
if (!_debugMonitorObj)
{
_debugMonitorObj = new GameObject("DebugMonitorCanvas");
_debugMonitorObj.AddComponent();
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();
}
///
/// 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.
///
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.Logger.Msg("Render thread stopped: the render was aborted.");
DisposeEncoderAfterStop(videoFFmpeg);
}
catch (Exception ex)
{
Melon.Logger.Error($"FFmpeg rendering thread error: {ex}");
MarkFailed("encoder thread error. See the log for details.");
DisposeEncoderAfterStop(videoFFmpeg);
}
}
/// Releases the encoder after the render thread stopped early (abort or error) and removes it from the encoder list.
private void DisposeEncoderAfterStop(FFmpegNativeEncoder videoFFmpeg)
{
try
{
videoFFmpeg.Dispose();
}
catch (Exception disposeEx)
{
Melon.Logger.Error($"Encoder dispose after the render thread stopped also failed: {disposeEx.Message}");
}
UnregisterEncoder(videoFFmpeg);
}
///
/// 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.
///
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);
}
}
///
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount. Render thread.
/// While the stage is below Render, passCount jumps forward to the first queued key when that key is greater:
/// frames are only captured in the Render stage, so this happens after the stage was set back to None at the end
/// of the render while the thread drains the queue, and a frame whose readback has not arrived yet is skipped.
/// Returns false when the queue is empty or the next frame has not arrived yet.
///
private bool TryDequeueNextFrame(out CapturedFrame frame)
{
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;
}
}
///
/// 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.
///
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.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);
}
}
///
/// 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.
///
/// The mixer was cancelled (render aborted) while waiting.
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.Shared.Rent(samplesNeeded * channels);
try
{
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded);
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
}
finally
{
ArrayPool.Shared.Return(audioBuffer);
}
}
///
/// True when the render is aborted, or when the end was requested and every requested frame has been sent.
///
private bool ShouldStopEncodeLoop()
{
if (IsRenderAborted) return true;
return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex);
}
/// Removes the encoder from the cleanup list and clears _activeVideoFFmpeg. Called by the render thread when it finishes.
private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg)
{
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
}
_activeVideoFFmpeg = null;
}
///
/// Stops the render without finishing the file (cancelled, or failed when MarkFailed was called first), deletes
/// the unfinished file, reports the result through OnComplete, restores the environment and returns to the editor.
/// Called by the ESC handler and on failures. Main thread.
///
public void CancelRender()
{
ResetRenderState(success: false);
StopFFmpeg(false);
EndRender();
}
///
/// Requests the end of encoding. Main thread.
/// true: completes the audio commands and starts StopRenderCoroutine, which waits for
/// the render thread to send the remaining frames and finish the file.
/// false: marks the render as cancelled (unless failed), cancels the audio waits, waits up
/// to 2 s for the render thread, disposes the encoder, releases the queued frames, the frame reader, the hooks and
/// the debug monitor, restores the camera and no-fail flags, deletes the unfinished file and calls OnComplete with
/// a failure result. Does not restore time, input offset or the scene (EndRender / RestoreRenderEnvironment do).
///
private void StopFFmpeg(bool success)
{
endRequested = true;
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.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.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();
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);
}
}
///
/// Successful end: waits (one check per frame) until the render thread reports endSuccessful or has died.
/// On success calls OnComplete with a success result and EndRender; when the thread died without finishing
/// the file, the render fails and is cancelled (file deleted).
///
private IEnumerator StopRenderCoroutine()
{
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
if (!endSuccessful)
{
MarkFailed("the render thread terminated unexpectedly.");
CancelRender();
yield break;
}
var result = BuildResult(success: true);
TriggerOnComplete(result);
EndRender();
}
/// Restores the render environment and loads the level editor (scnEditor).
private void EndRender()
{
RestoreRenderEnvironment();
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
Cursor.visible = true;
}
///
/// Undoes what the render setup changed (time scale and capture delta time, input offset, autoplay, custom level
/// paths, queued frames, frame reader, frame hooks, audio mixer and spectrum, RenderContext options), without
/// leaving the scene.
///
private void RestoreRenderEnvironment()
{
Time.timeScale = 1f;
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.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();
}
///
/// Stops the render stopwatch and creates the result: frames sent, video duration (frames / effective video FPS),
/// wall-clock render time, and the output path and file size on success.
///
private RenderResult BuildResult(bool success, string? errorMessage = null)
{
_renderStopwatch.Stop();
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);
}
/// Invokes RenderContext.OnComplete; an exception from the callback is logged.
private static void TriggerOnComplete(RenderResult result)
{
try
{
RenderContext.OnComplete.Invoke(result);
}
catch (Exception err)
{
Melon.Logger.Error($"OnComplete callback caused error: {err}");
}
}
/// Invokes RenderContext.OnLastUpdate; an exception from the callback is logged.
private static void TriggerOnLastUpdate()
{
try
{
RenderContext.OnLastUpdate.Invoke();
}
catch (Exception err)
{
Melon.Logger.Error($"OnLastUpdate callback caused error: {err}");
}
}
}