SHA256
1528 lines
66 KiB
C#
1528 lines
66 KiB
C#
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 thread when the render finishes (success) or is cancelled or fails.
|
||
/// A session callback replaces the default one, so no notification is shown when a session sets OnComplete.
|
||
/// Exceptions are caught and logged (RenderManager.TriggerOnComplete).
|
||
/// </summary>
|
||
public static Action<RenderResult> OnComplete = OnCompleteDefault;
|
||
|
||
private static readonly Action OnLastUpdateDefault = () => { };
|
||
|
||
/// <summary>
|
||
/// Called on the main thread every frame after all other LateUpdate calls, through PostRenderHook (attached when the
|
||
/// game scene of the render has loaded) while the stage is Load or later.
|
||
/// Exceptions are caught and logged (RenderManager.TriggerOnLastUpdate).
|
||
/// </summary>
|
||
public static Action OnLastUpdate = OnLastUpdateDefault;
|
||
|
||
/// <summary>Restores every option to its default and clears the session frame hooks.</summary>
|
||
public static void Reset()
|
||
{
|
||
Auto = true;
|
||
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 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 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 thread, the render
|
||
/// thread (audio settings) and the patches. Null until the first render starts.
|
||
/// </summary>
|
||
public RenderConfig Configuration = null!;
|
||
|
||
// Render Sessions State (OOP Instance Fields)
|
||
|
||
/// <summary>Current stage of the render. Written on the main thread; read by the render thread in TryDequeueNextFrame.</summary>
|
||
public RenderStage stage = RenderStage.None;
|
||
|
||
/// <summary>Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS.</summary>
|
||
public double timeBase;
|
||
|
||
/// <summary>Time.unscaledTimeAsDouble when capture started. The time patch returns unscaledTimeBase + frameCount / RenderFPS.</summary>
|
||
public double unscaledTimeBase;
|
||
|
||
/// <summary>
|
||
/// AudioSettings.dspTime when capture started (seconds). The dspTime patch returns dspTimeBase + frameCount / RenderFPS;
|
||
/// audio command times are stored relative to it (scheduled dspTime - dspTimeBase).
|
||
/// </summary>
|
||
public double dspTimeBase;
|
||
|
||
/// <summary>Time.frameCount when capture started. The frameCount patch returns frameCountBase + frameCount.</summary>
|
||
public int frameCountBase;
|
||
|
||
/// <summary>
|
||
/// Offline mixer of the song and sound effects of this render; null when audio is not rendered or no render runs.
|
||
/// Created in SetupPlay. Commands are added on the main thread (OnPostFrame, audio patches), mixing runs on the
|
||
/// render thread (WriteAudioForFrame); the mixer synchronizes the two internally.
|
||
/// </summary>
|
||
public OfflineAudioMixer? AudioMixer { get; private set; }
|
||
|
||
/// <summary>Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands.</summary>
|
||
public double AudioTime => frameCount / Configuration.RenderFPS;
|
||
/// <summary>
|
||
/// Number of audio samples (per channel) to write after each video frame, so that the audio stays aligned with
|
||
/// the video frame times at the effective video frame rate. Created in SetupPlay when audio is rendered;
|
||
/// used by the render thread only.
|
||
/// </summary>
|
||
private AudioSyncCalculator? SyncCalculator { get; set; }
|
||
|
||
/// <summary>GPU capture of frames (color conversion and AsyncGPUReadback). Created in SetupPlay after the encoder started.</summary>
|
||
private FrameReader? _frameReader;
|
||
|
||
/// <summary>Frame hook runner of this render. Created in SetupPlay, disposed by ResetFrameHookState.</summary>
|
||
private FrameHookPipeline? _frameHookPipeline;
|
||
|
||
/// <summary>Session hooks followed by global hooks, rebuilt when the global hook version changes.</summary>
|
||
private readonly List<Action<FrameHookContext>> _activeHooksCache = new();
|
||
|
||
/// <summary>RenderSession.GlobalHooksVersion the cache was built for; -1 forces a rebuild.</summary>
|
||
private int _cachedGlobalHooksVersion = -1;
|
||
|
||
/// <summary>True when _activeHooksCache is not empty.</summary>
|
||
private bool _hasActiveHooksCached;
|
||
|
||
/// <summary>True when at least one session or global frame hook is registered. Main thread.</summary>
|
||
public bool HasActiveFrameHooks
|
||
{
|
||
get
|
||
{
|
||
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 thread, read by the
|
||
/// render thread to know when every requested frame has been sent after endRequested.
|
||
/// </summary>
|
||
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
|
||
public long lastRequestedVideoFrameIndex = -1;
|
||
|
||
/// <summary>
|
||
/// Number of GPU readbacks that failed and were replaced by a black frame (shown by the debug monitor).
|
||
/// Incremented in the readback completion callback.
|
||
/// </summary>
|
||
public int readbackFailureCount;
|
||
|
||
/// <summary>Number of frames on which the main thread had to wait for the render thread (throttle loop entries).</summary>
|
||
public int throttleCount;
|
||
|
||
/// <summary>True while the main thread is inside the throttle wait of OnPostFrame.</summary>
|
||
public bool isThrottling;
|
||
|
||
/// <summary>
|
||
/// One frame waiting in ImageQueue: either a native readback buffer (owned by ReadbackState until it is recycled)
|
||
/// or a managed buffer rented from ArrayPool<byte>.Shared (black frame for a failed readback).
|
||
/// The data is YUV 4:2:0 in the encoder's pixel format when IsColorConverted, RGBA otherwise.
|
||
/// </summary>
|
||
internal struct CapturedFrame
|
||
{
|
||
/// <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<byte>.Shared (may be longer than Length); null for a native frame.</summary>
|
||
public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame)
|
||
|
||
/// <summary>Readback state owning NativePointer; returned to the FrameReader pool after the frame is encoded or dropped.</summary>
|
||
public ReadbackRequestState? ReadbackState; // Associated state to recycle
|
||
|
||
/// <summary>True when the GPU converted the frame to the encoder's YUV format; false for RGBA data.</summary>
|
||
public bool IsColorConverted;
|
||
}
|
||
|
||
// Thread and Queue State
|
||
|
||
/// <summary>
|
||
/// Captured frames waiting for the render thread, keyed by video frame index. Readbacks may complete in any order;
|
||
/// the render thread takes them in index order (passCount). Every access locks the list itself.
|
||
/// Frames are added by readback completions (main thread) and removed by the render thread, or released by the
|
||
/// cleanup in StopFFmpeg / RestoreRenderEnvironment.
|
||
/// </summary>
|
||
internal SortedList<long, CapturedFrame> ImageQueue = new();
|
||
|
||
/// <summary>Set when a frame is added to ImageQueue or the end is requested; the render thread waits on it.</summary>
|
||
internal AutoResetEvent FrameAvailableEvent = new(false);
|
||
|
||
/// <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 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 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}");
|
||
}
|
||
}
|
||
} |