SHA256
Document Render Core And API
This commit is contained in:
1 parent
354c42fdd9
commit
8deb64fd4d
22 files changed
+1241
-27
No files matched your search
@@ -6,29 +6,59 @@ using Newtonsoft.Json;
|
||||
|
||||
namespace ADOFAIRenderer.Core.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Mod-wide settings that are not part of a render preset, stored as JSON in
|
||||
/// <game root>/UserData/ADOFAIRenderer/global_settings.json (see <see cref="GlobalSettings"/>).
|
||||
/// </summary>
|
||||
internal class GlobalConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Output directory chosen in the render configuration window; null or empty selects
|
||||
/// GlobalSettings.DefaultOutputDirectory. Ignored while the directory does not exist (see EffectiveOutputDirectory).
|
||||
/// </summary>
|
||||
public string? OutputDirectory { get; set; }
|
||||
/// <summary>Name of the preset last selected or saved in the render configuration window; selected again when it opens.</summary>
|
||||
public string? LastPresetName { get; set; }
|
||||
/// <summary>
|
||||
/// True once PresetManager.EnsureDefaultPresetsCreated has written the built-in preset files. Built-in presets the user
|
||||
/// deleted afterwards are not written again.
|
||||
/// </summary>
|
||||
public bool DefaultPresetsInitialized { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads and saves <see cref="GlobalConfig"/>. The file is read once by the static constructor and again by
|
||||
/// RenderMod.OnInitializeMelon (Load), and written by Save. Read and write errors are logged as warnings and never
|
||||
/// thrown; a file that cannot be read yields the default settings. Main thread.
|
||||
/// </summary>
|
||||
internal static class GlobalSettings
|
||||
{
|
||||
/// <summary>MelonLoader's game root directory, or the application base directory when it is empty.</summary>
|
||||
private static string GetGameRootDir()
|
||||
{
|
||||
var root = MelonEnvironment.GameRootDirectory;
|
||||
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
|
||||
}
|
||||
|
||||
/// <summary><game root>/UserData/ADOFAIRenderer/global_settings.json.</summary>
|
||||
private static string SettingsFilePath =>
|
||||
Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json");
|
||||
|
||||
/// <summary>
|
||||
/// Settings in memory. Changes are kept in memory until <see cref="Save"/> is called; <see cref="Load"/> replaces the
|
||||
/// object.
|
||||
/// </summary>
|
||||
public static GlobalConfig Current { get; private set; } = new();
|
||||
|
||||
/// <summary>Output directory used when none is configured: <game root>/Render.</summary>
|
||||
public static string DefaultOutputDirectory =>
|
||||
Path.Combine(GetGameRootDir(), "Render");
|
||||
|
||||
/// <summary>
|
||||
/// Directory where renders without an explicit output path are written: Current.OutputDirectory when it is set and
|
||||
/// exists, otherwise <see cref="DefaultOutputDirectory"/>. The default directory is not created here
|
||||
/// (RenderManager.SetupCaptureDirectory creates it).
|
||||
/// </summary>
|
||||
public static string EffectiveOutputDirectory
|
||||
{
|
||||
get
|
||||
@@ -47,6 +77,10 @@ internal static class GlobalSettings
|
||||
Load();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces <see cref="Current"/> with the content of the settings file. A missing, empty or unreadable file gives the
|
||||
/// default settings (an unreadable file is logged as a warning).
|
||||
/// </summary>
|
||||
public static void Load()
|
||||
{
|
||||
try
|
||||
@@ -66,6 +100,10 @@ internal static class GlobalSettings
|
||||
Current = new GlobalConfig();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes <see cref="Current"/> as indented JSON to the settings file, creating the directory when needed.
|
||||
/// Errors are logged as warnings.
|
||||
/// </summary>
|
||||
public static void Save()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -4,14 +4,30 @@ using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Core;
|
||||
|
||||
/// <summary>
|
||||
/// MonoBehaviour that runs before every other script's Update (execution order int.MinValue; scripts run from the
|
||||
/// lowest order to the highest). Lives on a DontDestroyOnLoad object created by RenderMod.
|
||||
/// It performs the start-up checks (FFmpeg libraries present, NVIDIA driver version) and then advances the render
|
||||
/// frame counter through RenderManager.OnPreUpdate once per Unity frame, so every other script of that frame sees
|
||||
/// the same render time.
|
||||
/// Reference: https://docs.unity3d.com/ScriptReference/DefaultExecutionOrder.html
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(int.MinValue)]
|
||||
internal class FirstUpdateHook : MonoBehaviour
|
||||
{
|
||||
/// <summary>True once the FFmpeg libraries were found in UserLibs (after the download UI, if it was needed).</summary>
|
||||
private bool _checkedFFmpeg;
|
||||
|
||||
/// <summary>True once the NVIDIA driver version check ran (it runs once per game session).</summary>
|
||||
private bool _checkedNvidiaDriver;
|
||||
|
||||
/// <summary>
|
||||
/// Main thread, every frame. The checks are done in order and each one blocks the following steps while its dialog
|
||||
/// is open: the frame counter is not advanced until both checks are done.
|
||||
/// </summary>
|
||||
private void Update()
|
||||
{
|
||||
// 1. FFmpeg libraries: while the download UI is open, wait. When the libraries are missing, open the download UI.
|
||||
if (!_checkedFFmpeg)
|
||||
{
|
||||
if (FFmpegDownloadUI.Instance) return;
|
||||
@@ -24,6 +40,7 @@ internal class FirstUpdateHook : MonoBehaviour
|
||||
_checkedFFmpeg = true;
|
||||
}
|
||||
|
||||
// 2. NVIDIA driver version: waits until no other warning dialog is open, then runs once.
|
||||
if (!_checkedNvidiaDriver)
|
||||
{
|
||||
if (CommonWarningDialogUI.Instance) return;
|
||||
@@ -32,12 +49,19 @@ internal class FirstUpdateHook : MonoBehaviour
|
||||
CheckNvidiaDriverWarning();
|
||||
}
|
||||
|
||||
// 3. Render frame counter (only counts while a render is in the OnScene stage or later).
|
||||
if (RenderManager.Instance)
|
||||
{
|
||||
RenderManager.Instance.OnPreUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a warning dialog when the active GPU is an NVIDIA GPU with a driver version below 610.00.
|
||||
/// No dialog is shown when the GPU is not NVIDIA or the version cannot be determined (GPUUtil returns null).
|
||||
/// Buttons: open the NVIDIA driver download page and quit the game, disable the render feature for this session
|
||||
/// (RenderMod.IsFeatureDisabled), or continue.
|
||||
/// </summary>
|
||||
private void CheckNvidiaDriverWarning()
|
||||
{
|
||||
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
|
||||
|
||||
@@ -6,16 +6,58 @@ using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the frame hooks (session and global, see RenderSession) on a captured frame before it is read back.
|
||||
/// Owns two RenderTextures (ping and pong) with the size and format of the source frame; each hook pass reads the
|
||||
/// current frame and writes the other buffer (see FrameHookContext). One instance per render, created in
|
||||
/// RenderManager.SetupPlay and disposed when the render ends. Main thread only.
|
||||
/// </summary>
|
||||
internal class FrameHookPipeline : IDisposable
|
||||
{
|
||||
/// <summary>First ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
|
||||
private RenderTexture? _pingRT;
|
||||
|
||||
/// <summary>Second ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
|
||||
private RenderTexture? _pongRT;
|
||||
|
||||
/// <summary>
|
||||
/// Buffer returned as the output of the previous Process call (ping or pong), or null when the previous frame was
|
||||
/// not modified by any hook. Its readback may still be in flight, so the next frame starts writing the other buffer.
|
||||
/// </summary>
|
||||
private RenderTexture? _lastOutputRT;
|
||||
|
||||
/// <summary>Context object passed to the hooks; reset and reused for every frame (no allocation per frame).</summary>
|
||||
private FrameHookContext? _reusableContext;
|
||||
|
||||
/// <summary>Width of the current buffers in pixels (0 when no buffers exist).</summary>
|
||||
private int _width;
|
||||
|
||||
/// <summary>Height of the current buffers in pixels (0 when no buffers exist).</summary>
|
||||
private int _height;
|
||||
|
||||
/// <summary>Format of the source texture the current buffers were created for.</summary>
|
||||
private RenderTextureFormat _format;
|
||||
|
||||
/// <summary>
|
||||
/// Runs <paramref name="hooks"/> in order on <paramref name="sourceTexture"/> and returns the resulting texture.
|
||||
/// An exception thrown by a hook is logged and the remaining hooks still run.
|
||||
/// RenderTexture.active is restored after the hooks.
|
||||
/// </summary>
|
||||
/// <param name="sourceTexture">Captured game frame. Hooks do not write into it (they write the ping-pong buffers).</param>
|
||||
/// <param name="frameIndex">Video frame index (0-based) of this frame.</param>
|
||||
/// <param name="renderFrameIndex">
|
||||
/// Render frame index (0-based, RenderManager.frameCountForVideo): counts every game frame since capture started,
|
||||
/// including game frames that were not captured because they fall in an already captured video frame.
|
||||
/// </param>
|
||||
/// <param name="videoTime">Time of this video frame in seconds from the start of the video.</param>
|
||||
/// <param name="renderFps">Render frame rate (game frames per second).</param>
|
||||
/// <param name="videoFps">Effective video frame rate (min(VideoFPS, RenderFPS)).</param>
|
||||
/// <param name="hooks">Hooks to run, in order. Session hooks come before global hooks.</param>
|
||||
/// <returns>
|
||||
/// <paramref name="sourceTexture"/> when there are no hooks, the source is not created, the buffers could not be
|
||||
/// created, or no hook modified the frame; otherwise the texture holding the final frame (a ping-pong buffer, or a
|
||||
/// texture a hook passed to FrameHookContext.ReplaceCurrent).
|
||||
/// </returns>
|
||||
public RenderTexture? Process(
|
||||
RenderTexture sourceTexture,
|
||||
long frameIndex,
|
||||
@@ -81,6 +123,8 @@ internal class FrameHookPipeline : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Current is the source when nothing was modified. A texture set by ReplaceCurrent is stored in
|
||||
// _lastOutputRT too; it then matches neither buffer and the next frame starts with ping.
|
||||
var result = _reusableContext.Current;
|
||||
_lastOutputRT = _reusableContext.HasModified ? result : null;
|
||||
return result;
|
||||
@@ -91,6 +135,12 @@ internal class FrameHookPipeline : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the ping and pong buffers when they do not exist, were released, or the source size or format changed.
|
||||
/// The buffers copy the source descriptor without depth, MSAA and mipmaps, and enable random write (UAV) when the
|
||||
/// GPU supports compute shaders and random write for that format, so compute shaders can write them.
|
||||
/// When creating them from the descriptor throws, ARGB32 buffers of the same size are created.
|
||||
/// </summary>
|
||||
private void EnsureBuffers(RenderTexture sourceTexture)
|
||||
{
|
||||
var width = sourceTexture.width;
|
||||
@@ -160,6 +210,9 @@ internal class FrameHookPipeline : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases and destroys both buffers and drops the reusable context. The next Process call creates them again.
|
||||
/// </summary>
|
||||
public void ReleaseBuffers()
|
||||
{
|
||||
if (_pingRT)
|
||||
@@ -182,6 +235,7 @@ internal class FrameHookPipeline : IDisposable
|
||||
_reusableContext = null;
|
||||
}
|
||||
|
||||
/// <summary>Same as <see cref="ReleaseBuffers"/>. Main thread (Object.Destroy is used).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
ReleaseBuffers();
|
||||
|
||||
@@ -5,10 +5,22 @@ using UnityEngine;
|
||||
|
||||
namespace ADOFAIRenderer.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame hook attached to the scrCamera GameObject by RenderManager when the game scene of a render has loaded,
|
||||
/// and destroyed again when a render is cancelled or fails.
|
||||
/// Execution order int.MaxValue: its LateUpdate runs after every other script's LateUpdate of the frame.
|
||||
/// Each frame it calls RenderManager.OnLastUpdate (LateUpdate) and, after the frame has been rendered
|
||||
/// (end of frame), RenderManager.OnPostFrame with the texture shown on scrCamera's camera quad, which is the frame
|
||||
/// that gets captured.
|
||||
/// Reference: https://docs.unity3d.com/ScriptReference/DefaultExecutionOrder.html
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(int.MaxValue)]
|
||||
internal class PostRenderHook : MonoBehaviour
|
||||
{
|
||||
/// <summary>Private field scrCamera.camQuadMesh: the MeshRenderer of the quad that displays the camera RenderTexture.</summary>
|
||||
private FieldInfo? _camQuadMeshField;
|
||||
|
||||
/// <summary>Cached value of camQuadMesh for the current scrCamera instance. Looked up again while it is null or destroyed.</summary>
|
||||
private MeshRenderer? _camQuadMeshRenderer;
|
||||
|
||||
private void Awake()
|
||||
@@ -16,6 +28,10 @@ internal class PostRenderHook : MonoBehaviour
|
||||
_camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main thread, once per frame after all other LateUpdate calls. Runs the last-update work of RenderManager
|
||||
/// (OnLastUpdate callbacks of the session) and schedules the end-of-frame capture.
|
||||
/// </summary>
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (RenderManager.Instance)
|
||||
@@ -24,6 +40,11 @@ internal class PostRenderHook : MonoBehaviour
|
||||
PostFrameAsync().Forget();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the end of the current frame (after the cameras have rendered) and passes the camera quad's main
|
||||
/// texture to RenderManager.OnPostFrame. The wait is cancelled when this component is destroyed.
|
||||
/// The texture is null when the quad's material has no RenderTexture; OnPostFrame then only updates the audio.
|
||||
/// </summary>
|
||||
private async UniTaskVoid PostFrameAsync()
|
||||
{
|
||||
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
|
||||
|
||||
@@ -11,11 +11,16 @@ using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace ADOFAIRenderer.Core;
|
||||
|
||||
/// <summary>A named render configuration, stored as one JSON file in the presets directory.</summary>
|
||||
public class RenderPreset
|
||||
{
|
||||
/// <summary>Display name of the preset. A file without a name uses the file name (without extension).</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Render configuration of the preset.</summary>
|
||||
public RenderConfig Config { get; set; } = null!;
|
||||
|
||||
/// <summary>Returns a copy with a copied configuration.</summary>
|
||||
public RenderPreset Clone()
|
||||
{
|
||||
return new RenderPreset
|
||||
@@ -26,8 +31,18 @@ public class RenderPreset
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads, saves and deletes render presets. Presets are JSON files (one per preset, file name derived from the preset
|
||||
/// name) in <game root>/UserData/ADOFAIRenderer/Presets. The built-in presets are written once, on first use
|
||||
/// (GlobalConfig.DefaultPresetsInitialized), so the user can edit or delete them. Main thread.
|
||||
/// </summary>
|
||||
public static class PresetManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Built-in presets (H.264 CBR, 48 kHz stereo AAC at 256 kbps, mkv): 1080p, 1440p and 2160p at 60 fps video.
|
||||
/// "(4x)" presets render 240 game frames per second (4 per video frame); "(1x)" renders 60.
|
||||
/// Video bitrates are in kbps.
|
||||
/// </summary>
|
||||
private static readonly List<RenderPreset> DefaultPresets = new()
|
||||
{
|
||||
new RenderPreset
|
||||
@@ -96,6 +111,10 @@ public static class PresetManager
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// <root>/UserData/ADOFAIRenderer/Presets, where root is MelonLoader's game root directory, or the application
|
||||
/// base directory when that is unavailable.
|
||||
/// </summary>
|
||||
private static string GetPresetsDirectory()
|
||||
{
|
||||
string? root = null;
|
||||
@@ -125,6 +144,10 @@ public static class PresetManager
|
||||
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the presets directory and, once per installation (GlobalConfig.DefaultPresetsInitialized), writes the
|
||||
/// built-in presets whose files do not exist yet, then saves the flag. Errors are logged, not thrown.
|
||||
/// </summary>
|
||||
public static void EnsureDefaultPresetsCreated()
|
||||
{
|
||||
try
|
||||
@@ -164,6 +187,12 @@ public static class PresetManager
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a copy of <paramref name="config"/> as preset <paramref name="name"/>, overwriting the file of the same
|
||||
/// safe file name (see GetSafeFileName).
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">The name is empty or white space.</exception>
|
||||
/// <exception cref="IOException">The directory or file cannot be written (not caught).</exception>
|
||||
public static void SavePreset(string name, RenderConfig config)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
@@ -195,6 +224,13 @@ public static class PresetManager
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads every *.json preset file (after creating the built-in presets on first use). Files written by older
|
||||
/// versions are migrated in memory (old key names and flat properties moved into the nested configs); the files are
|
||||
/// not rewritten. A file that cannot be read is logged and skipped. When two files have the same preset name
|
||||
/// (case-insensitive), the first one in directory order is used. Returns copies of the built-in presets when the
|
||||
/// directory does not exist.
|
||||
/// </summary>
|
||||
public static List<RenderPreset> GetPresets()
|
||||
{
|
||||
EnsureDefaultPresetsCreated();
|
||||
@@ -332,6 +368,10 @@ public static class PresetManager
|
||||
return presets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames key <paramref name="keyBefore"/> to <paramref name="targetKey"/>: a non-null old value overwrites the target
|
||||
/// key, and the old key is always removed.
|
||||
/// </summary>
|
||||
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
|
||||
{
|
||||
var legacyToken = configObj[keyBefore];
|
||||
@@ -341,6 +381,12 @@ public static class PresetManager
|
||||
configObj.Remove(keyBefore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the file of preset <paramref name="name"/>: first the file with the safe file name; otherwise the first
|
||||
/// file whose base name or stored "Name" matches (case-insensitive). Does nothing for an empty name or when no file
|
||||
/// matches.
|
||||
/// </summary>
|
||||
/// <exception cref="IOException">The file with the safe file name exists but cannot be deleted (not caught).</exception>
|
||||
public static void DeletePreset(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return;
|
||||
|
||||
+379
-10
@@ -25,24 +25,61 @@ using ThreadPriority = System.Threading.ThreadPriority;
|
||||
|
||||
namespace ADOFAIRenderer.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Progress of the current render (RenderManager.stage). Values are ordered: checks use comparisons such as
|
||||
/// stage >= OnScene.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>None</c>: no render. Set at start-up and whenever a render ends, is cancelled or fails (ResetRenderState).</item>
|
||||
/// <item><c>Load</c>: RenderSession.Start was called; the game scene (scnGame) is being loaded.</item>
|
||||
/// <item><c>OnScene</c>: the game scene has loaded (RenderManager.Update set up the scene): the frame counter runs, the
|
||||
/// 3-second countdown is shown, input is ignored. Nothing is captured yet. RenderManager.IsActive is true from this stage.</item>
|
||||
/// <item><c>Render</c>: the encoder and render thread run and frames are captured (set by SetupPlay after the countdown).
|
||||
/// RenderManager.IsCapturingActive is true; the time patches replace Unity's clock with the render clock.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal enum RenderStage
|
||||
{
|
||||
None, Load, OnScene, Render
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options of the current render, copied from a <see cref="RenderSession"/> by RenderSession.Start and read by
|
||||
/// RenderManager and the Harmony patches. <see cref="Reset"/> restores the defaults when the render environment is
|
||||
/// restored (RenderManager.RestoreRenderEnvironment). Main thread only.
|
||||
/// </summary>
|
||||
internal static class RenderContext
|
||||
{
|
||||
/// <summary>Value assigned to RDC.auto (the game's autoplay flag) when the game scene of the render has loaded.</summary>
|
||||
public static bool Auto = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enables the time patches (Time.time / unscaledTime / deltaTime / frameCount, AudioSettings.dspTime) while capturing,
|
||||
/// which drive the game by the render clock (frameCount / RenderFPS) instead of real time.
|
||||
/// </summary>
|
||||
public static bool TimePatch = true;
|
||||
|
||||
/// <summary>Enables the Screen.width / Screen.height patches (report the render resolution) while the render is active.</summary>
|
||||
public static bool ScreenPatch = true;
|
||||
|
||||
/// <summary>Output file path of the render, or null for a time-stamped file in GlobalSettings.EffectiveOutputDirectory.</summary>
|
||||
public static string? OutputPath;
|
||||
|
||||
/// <summary>Whether the default OnComplete callback shows an OS notification after a successful render.</summary>
|
||||
public static bool Notification = true;
|
||||
|
||||
/// <summary>Frame hooks of the current session, run before the global hooks (see RenderSession.AddFrameHook).</summary>
|
||||
public static readonly List<Action<FrameHookContext>> SessionFrameHooks = new();
|
||||
|
||||
/// <summary>Default end condition: the level is cleared (scrController state Won).</summary>
|
||||
private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won;
|
||||
|
||||
/// <summary>
|
||||
/// Polled by RenderManager.Update every frame from the OnScene stage. When it first returns true, the render
|
||||
/// continues for RenderConfig.DelayAfterEnd seconds of render time and then ends successfully.
|
||||
/// </summary>
|
||||
public static Func<bool> EndCondition = EndConditionDefault;
|
||||
|
||||
/// <summary>Default completion callback: shows an OS notification for a successful render when Notification is true.</summary>
|
||||
private static readonly Action<RenderResult> OnCompleteDefault = result =>
|
||||
{
|
||||
if (result.Success && Notification)
|
||||
@@ -54,11 +91,23 @@ internal static class RenderContext
|
||||
OSNotification.Show("Rendering Complete!", $"{fileString} saved successfully.", renderFolder);
|
||||
}
|
||||
};
|
||||
/// <summary>
|
||||
/// Called once on the main thread when the render finishes (success) or is cancelled or fails.
|
||||
/// A session callback replaces the default one, so no notification is shown when a session sets OnComplete.
|
||||
/// Exceptions are caught and logged (RenderManager.TriggerOnComplete).
|
||||
/// </summary>
|
||||
public static Action<RenderResult> OnComplete = OnCompleteDefault;
|
||||
|
||||
private static readonly Action OnLastUpdateDefault = () => { };
|
||||
|
||||
/// <summary>
|
||||
/// Called on the main thread every frame after all other LateUpdate calls, through PostRenderHook (attached when the
|
||||
/// game scene of the render has loaded) while the stage is Load or later.
|
||||
/// Exceptions are caught and logged (RenderManager.TriggerOnLastUpdate).
|
||||
/// </summary>
|
||||
public static Action OnLastUpdate = OnLastUpdateDefault;
|
||||
|
||||
/// <summary>Restores every option to its default and clears the session frame hooks.</summary>
|
||||
public static void Reset()
|
||||
{
|
||||
Auto = true;
|
||||
@@ -74,11 +123,34 @@ internal static class RenderContext
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives a render from scene setup to the finished video file. Singleton MonoBehaviour on a DontDestroyOnLoad object
|
||||
/// created by RenderMod.
|
||||
///
|
||||
/// Flow: RenderSession.Start (stage Load) → SetupGameScene loads scnGame → Update sets up the loaded scene
|
||||
/// (stage OnScene, 3-second countdown) → SetupPlay starts the encoder and the render thread (stage Render) →
|
||||
/// every frame OnPreUpdate advances frameCount, OnPostFrame queues the audio commands and requests the GPU capture of
|
||||
/// the frame (FrameReader, AsyncGPUReadback) → readback completions add frames to ImageQueue → the render thread
|
||||
/// (RunRenderThread) encodes them in frame order together with the audio of each frame.
|
||||
///
|
||||
/// End: success (EndCondition plus DelayAfterEnd) → StopFFmpeg(true) → StopRenderCoroutine waits for the render
|
||||
/// thread to finish the file → EndRender. Cancel (ESC) or failure → StopFFmpeg(false) → EndRender, which deletes the
|
||||
/// unfinished file. Encoder initialization failure → StopFFmpeg(false) → RestoreRenderEnvironment and an error dialog.
|
||||
///
|
||||
/// Threads: the main Unity thread runs every public method unless noted otherwise. The render thread reads
|
||||
/// ImageQueue (lock on ImageQueue), passCount (render thread only while it runs), endRequested / endSuccessful
|
||||
/// (volatile), the abort state (volatile, written under _abortStateLock) and lastRequestedVideoFrameIndex
|
||||
/// (Volatile.Read/Write). FrameAvailableEvent wakes the render thread, FrameDequeuedEvent wakes the throttle loop.
|
||||
/// </summary>
|
||||
internal class RenderManager : MonoBehaviour
|
||||
{
|
||||
// Singleton Accessors
|
||||
/// <summary>The single instance, set in Awake. Unity null checks (<c>if (Instance)</c>) are used before access.</summary>
|
||||
public static RenderManager Instance { get; private set; } = null!;
|
||||
|
||||
/// <summary>True from the OnScene stage (scene set up, countdown running) until the render ends.</summary>
|
||||
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
|
||||
|
||||
/// <summary>True while frames are captured and encoded (Render stage). The time patches apply only then.</summary>
|
||||
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
|
||||
|
||||
/// <summary>
|
||||
@@ -104,26 +176,66 @@ internal class RenderManager : MonoBehaviour
|
||||
public int QueueFrameLimit { get; private set; } = MaxQueuedFrames;
|
||||
|
||||
// Statistics
|
||||
|
||||
/// <summary>
|
||||
/// Number of video frames sent to the encoder in this render. Incremented by the render thread (Interlocked),
|
||||
/// read with Interlocked.Read by the main thread (result, debug monitor). Reset in SetupPlay.
|
||||
/// </summary>
|
||||
public long framesSent;
|
||||
|
||||
/// <summary>Wall-clock time of the render from the end of the countdown (SetupPlay) to BuildResult.</summary>
|
||||
private readonly Stopwatch _renderStopwatch = new();
|
||||
|
||||
// Core Render Configuration and Path
|
||||
|
||||
/// <summary>
|
||||
/// Seconds left in the countdown before capture starts (3 when the scene is set up, decremented once per real-time
|
||||
/// second by SetupPlay). Shown by scrShowIfDebugPatch and read by scrPlayerManagerPatch.
|
||||
/// </summary>
|
||||
public int countdown;
|
||||
|
||||
/// <summary>
|
||||
/// Render frame counter: incremented once per Unity frame by OnPreUpdate from the OnScene stage and reset to 0 when
|
||||
/// capture starts (SetupPlay). The render clock is frameCount / RenderFPS seconds (AudioTime, time patches).
|
||||
/// Main thread only.
|
||||
/// </summary>
|
||||
public long frameCount;
|
||||
|
||||
/// <summary>
|
||||
/// Set by SetupGameScene; scrPressToStartPatch consumes it (sets it back to false) to skip the press-to-start
|
||||
/// screen once. Cleared by StopFFmpeg.
|
||||
/// </summary>
|
||||
public bool skipPressToStart;
|
||||
|
||||
/// <summary>Path of the level file to render (ADOBase.levelPath of the editor, or the path given to RenderSession.Start).</summary>
|
||||
public string levelPath = "";
|
||||
|
||||
/// <summary>Serializes changes of the abort state (_isRenderCancelled, _renderFailed, _failureReason).</summary>
|
||||
private readonly object _abortStateLock = new();
|
||||
|
||||
/// <summary>The render was cancelled (ESC, or a non-successful stop). Written under _abortStateLock, read by any thread.</summary>
|
||||
private volatile bool _isRenderCancelled;
|
||||
|
||||
/// <summary>The render failed (MarkFailed). Written under _abortStateLock, read by any thread.</summary>
|
||||
private volatile bool _renderFailed;
|
||||
|
||||
/// <summary>Reason given to the first MarkFailed call of this render; null when the render has not failed.</summary>
|
||||
private volatile string? _failureReason;
|
||||
|
||||
/// <summary>True when the render was cancelled. Thread-safe read.</summary>
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public bool IsRenderCancelled => _isRenderCancelled;
|
||||
|
||||
/// <summary>True when the render failed. Thread-safe read.</summary>
|
||||
public bool IsRenderFailed => _renderFailed;
|
||||
|
||||
// Cancelled and failed are mutually exclusive; this is true for either (render must stop).
|
||||
/// <summary>
|
||||
/// True when the render was cancelled or failed (the two are mutually exclusive) and must stop.
|
||||
/// Read by the render thread before each frame and by readback completions. Thread-safe read.
|
||||
/// </summary>
|
||||
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
|
||||
|
||||
/// <summary>Marks the render as cancelled unless it has already failed (a failure keeps its reason).</summary>
|
||||
private void MarkCancelled()
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
@@ -132,7 +244,10 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
// The first failure reason wins; ignored once the render is already cancelled or failed.
|
||||
/// <summary>
|
||||
/// Marks the render as failed with <paramref name="reason"/> (shown in the result message as "Rendering failed: reason").
|
||||
/// The first failure reason wins; ignored once the render is already cancelled or failed. Callable from any thread.
|
||||
/// </summary>
|
||||
private void MarkFailed(string reason)
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
@@ -143,6 +258,7 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears cancelled, failed and the failure reason. Called when a new encoder starts (StartFFmpegThread).</summary>
|
||||
private void ClearAbortState()
|
||||
{
|
||||
lock (_abortStateLock)
|
||||
@@ -153,30 +269,70 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Error message of the RenderResult of an aborted render (failure reason, or the cancel message).</summary>
|
||||
private string BuildAbortMessage() => IsRenderFailed
|
||||
? $"Rendering failed: {_failureReason ?? "unknown error"}"
|
||||
: "Rendering was cancelled by user.";
|
||||
|
||||
/// <summary>
|
||||
/// Configuration of the current render: a copy made by RenderSession.Start, so values adjusted during the render
|
||||
/// (the audio sample rate in SetupPlay) do not change the caller's object. Read by the main thread, the render
|
||||
/// thread (audio settings) and the patches. Null until the first render starts.
|
||||
/// </summary>
|
||||
public RenderConfig Configuration = null!;
|
||||
|
||||
// Render Sessions State (OOP Instance Fields)
|
||||
|
||||
/// <summary>Current stage of the render. Written on the main thread; read by the render thread in TryDequeueNextFrame.</summary>
|
||||
public RenderStage stage = RenderStage.None;
|
||||
|
||||
/// <summary>Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS.</summary>
|
||||
public double timeBase;
|
||||
|
||||
/// <summary>Time.unscaledTimeAsDouble when capture started. The time patch returns unscaledTimeBase + frameCount / RenderFPS.</summary>
|
||||
public double unscaledTimeBase;
|
||||
|
||||
/// <summary>
|
||||
/// AudioSettings.dspTime when capture started (seconds). The dspTime patch returns dspTimeBase + frameCount / RenderFPS;
|
||||
/// audio command times are stored relative to it (scheduled dspTime - dspTimeBase).
|
||||
/// </summary>
|
||||
public double dspTimeBase;
|
||||
|
||||
/// <summary>Time.frameCount when capture started. The frameCount patch returns frameCountBase + frameCount.</summary>
|
||||
public int frameCountBase;
|
||||
|
||||
/// <summary>
|
||||
/// Offline mixer of the song and sound effects of this render; null when audio is not rendered or no render runs.
|
||||
/// Created in SetupPlay. Commands are added on the main thread (OnPostFrame, audio patches), mixing runs on the
|
||||
/// render thread (WriteAudioForFrame); the mixer synchronizes the two internally.
|
||||
/// </summary>
|
||||
public OfflineAudioMixer? AudioMixer { get; private set; }
|
||||
|
||||
/// <summary>Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands.</summary>
|
||||
public double AudioTime => frameCount / Configuration.RenderFPS;
|
||||
/// <summary>
|
||||
/// Number of audio samples (per channel) to write after each video frame, so that the audio stays aligned with
|
||||
/// the video frame times at the effective video frame rate. Created in SetupPlay when audio is rendered;
|
||||
/// used by the render thread only.
|
||||
/// </summary>
|
||||
private AudioSyncCalculator? SyncCalculator { get; set; }
|
||||
|
||||
/// <summary>GPU capture of frames (color conversion and AsyncGPUReadback). Created in SetupPlay after the encoder started.</summary>
|
||||
private FrameReader? _frameReader;
|
||||
|
||||
/// <summary>Frame hook runner of this render. Created in SetupPlay, disposed by ResetFrameHookState.</summary>
|
||||
private FrameHookPipeline? _frameHookPipeline;
|
||||
|
||||
/// <summary>Session hooks followed by global hooks, rebuilt when the global hook version changes.</summary>
|
||||
private readonly List<Action<FrameHookContext>> _activeHooksCache = new();
|
||||
|
||||
/// <summary>RenderSession.GlobalHooksVersion the cache was built for; -1 forces a rebuild.</summary>
|
||||
private int _cachedGlobalHooksVersion = -1;
|
||||
|
||||
/// <summary>True when _activeHooksCache is not empty.</summary>
|
||||
private bool _hasActiveHooksCached;
|
||||
|
||||
/// <summary>True when at least one session or global frame hook is registered. Main thread.</summary>
|
||||
public bool HasActiveFrameHooks
|
||||
{
|
||||
get
|
||||
@@ -186,6 +342,11 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the active hook list when the global hook registrations changed since the last build.
|
||||
/// Session hooks are only read on a rebuild; SetupPlay forces one (version -1) at the start of capture, so changes to
|
||||
/// RenderContext.SessionFrameHooks after that are picked up only together with a global hook change.
|
||||
/// </summary>
|
||||
private void EnsureActiveHooksUpToDate()
|
||||
{
|
||||
var globalVersion = RenderSession.GlobalHooksVersion;
|
||||
@@ -201,12 +362,14 @@ internal class RenderManager : MonoBehaviour
|
||||
_hasActiveHooksCached = _activeHooksCache.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>Returns the cached active hook list (session hooks first, then global hooks). The list is reused.</summary>
|
||||
private List<Action<FrameHookContext>> GetActiveFrameHooks()
|
||||
{
|
||||
EnsureActiveHooksUpToDate();
|
||||
return _activeHooksCache;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the frame hook pipeline (ping-pong buffers) and clears the hook cache.</summary>
|
||||
private void ResetFrameHookState()
|
||||
{
|
||||
_frameHookPipeline?.Dispose();
|
||||
@@ -216,46 +379,132 @@ internal class RenderManager : MonoBehaviour
|
||||
_hasActiveHooksCached = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of game frames offered for capture since capture started (OnPostFrame with a texture, after throttling).
|
||||
/// The value before the increment is the render frame index of the current frame. Main thread only.
|
||||
/// </summary>
|
||||
public long frameCountForVideo;
|
||||
|
||||
/// <summary>
|
||||
/// Video frame index of the last captured frame (-1 before the first). A game frame is captured only when its video
|
||||
/// frame index (render frame index × effective video FPS / RenderFPS, rounded down) is greater. Main thread only.
|
||||
/// </summary>
|
||||
public long lastVideoFrameIndex = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Video frame index of the last capture request (-1 before the first). Written on the main thread, read by the
|
||||
/// render thread to know when every requested frame has been sent after endRequested.
|
||||
/// </summary>
|
||||
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
|
||||
public long lastRequestedVideoFrameIndex = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Number of GPU readbacks that failed and were replaced by a black frame (shown by the debug monitor).
|
||||
/// Incremented in the readback completion callback.
|
||||
/// </summary>
|
||||
public int readbackFailureCount;
|
||||
|
||||
/// <summary>Number of frames on which the main thread had to wait for the render thread (throttle loop entries).</summary>
|
||||
public int throttleCount;
|
||||
|
||||
/// <summary>True while the main thread is inside the throttle wait of OnPostFrame.</summary>
|
||||
public bool isThrottling;
|
||||
|
||||
/// <summary>
|
||||
/// One frame waiting in ImageQueue: either a native readback buffer (owned by ReadbackState until it is recycled)
|
||||
/// or a managed buffer rented from ArrayPool<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);
|
||||
// Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.
|
||||
|
||||
/// <summary>Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.</summary>
|
||||
internal AutoResetEvent FrameDequeuedEvent = new(false);
|
||||
|
||||
/// <summary>The render thread of the current render (background, AboveNormal priority); null before the first render.</summary>
|
||||
public Thread? RenderThread;
|
||||
|
||||
/// <summary>
|
||||
/// Video frame index the render thread sends next. Reset by StartFFmpegThread before the thread starts; afterwards
|
||||
/// read and written only by the render thread, under the ImageQueue lock.
|
||||
/// </summary>
|
||||
public long passCount;
|
||||
|
||||
/// <summary>Set by StopFFmpeg: no more frames will be captured; the render thread drains the queue and stops.</summary>
|
||||
public volatile bool endRequested;
|
||||
|
||||
/// <summary>Set by the render thread after the output file was finished and the encoder released without an abort.</summary>
|
||||
public volatile bool endSuccessful;
|
||||
|
||||
/// <summary>
|
||||
/// frameCount at which EndCondition first returned true; -1 until then. The render ends DelayAfterEnd seconds of
|
||||
/// render time later.
|
||||
/// </summary>
|
||||
public long endFrame = -1;
|
||||
|
||||
// Private Instance Fields
|
||||
|
||||
/// <summary>Encoder of the current render; null when none runs. Cleared when the render thread releases it.</summary>
|
||||
private FFmpegNativeEncoder? _activeVideoFFmpeg;
|
||||
|
||||
/// <summary>
|
||||
/// Every encoder created and not yet released, disposed by CleanUpActiveRender on exit. Locked on the list itself
|
||||
/// (the render thread removes its encoder when it finishes).
|
||||
/// </summary>
|
||||
private readonly List<FFmpegNativeEncoder> _allVideoFFmpegInstances = new();
|
||||
|
||||
/// <summary>The user's input offset (scrConductor.currentPreset.inputOffset), set to 0 during the render and restored afterwards.</summary>
|
||||
private int _originalInputOffset;
|
||||
|
||||
/// <summary>Debug monitor overlay object, created when the encoder starts and destroyed when the render ends.</summary>
|
||||
private GameObject? _debugMonitorObj;
|
||||
|
||||
/// <summary>ESC handler overlay object (cancels the render), created when the scene is set up and destroyed by StopFFmpeg.</summary>
|
||||
private GameObject? _escapeHandlerObj;
|
||||
|
||||
/// <summary>
|
||||
/// Output file path of the current or last render. Set when the encoder starts; set to null when an aborted render's
|
||||
/// file was deleted.
|
||||
/// </summary>
|
||||
internal string? CurrentOutPath;
|
||||
|
||||
// Output file of the current render that is not finished yet.
|
||||
// Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End).
|
||||
// Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile).
|
||||
private string? _incompleteOutputPath;
|
||||
|
||||
/// <summary>
|
||||
/// True from SetupGameScene until Update has set up the loaded game scene (or the render stops). While true, Update
|
||||
/// hides the difficulty UI and waits for scnGame to finish loading.
|
||||
/// </summary>
|
||||
private bool _renderSetup;
|
||||
|
||||
private void Awake()
|
||||
@@ -268,6 +517,10 @@ internal class RenderManager : MonoBehaviour
|
||||
CleanUpActiveRender();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the stage to None. <paramref name="success"/> true clears the cancelled flag; false marks the render as
|
||||
/// cancelled (unless it already failed). Called at every end path before StopFFmpeg.
|
||||
/// </summary>
|
||||
public void ResetRenderState(bool success)
|
||||
{
|
||||
if (success) _isRenderCancelled = false;
|
||||
@@ -275,6 +528,11 @@ internal class RenderManager : MonoBehaviour
|
||||
stage = RenderStage.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emergency cleanup when the game quits or this object is destroyed: marks a running render as failed, releases
|
||||
/// the audio, the frame hooks and every encoder, waits up to 200 ms for the render thread, and deletes the
|
||||
/// unfinished output file. Does not restore the game scene or settings. Main thread.
|
||||
/// </summary>
|
||||
public void CleanUpActiveRender()
|
||||
{
|
||||
if (stage != RenderStage.None)
|
||||
@@ -346,8 +604,16 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main thread, every frame.
|
||||
/// 1. While the game scene of a render loads: hides the difficulty UI of the press-to-start screen.
|
||||
/// 2. Once scnGame has finished loading: sets up the scene for rendering and starts SetupPlay (stage OnScene).
|
||||
/// 3. From the OnScene stage: fails the render when the render thread died, the level failed or the controller is
|
||||
/// gone; ends it successfully DelayAfterEnd seconds (render time) after EndCondition first returned true.
|
||||
/// </summary>
|
||||
private void Update()
|
||||
{
|
||||
// scrPressToStart reads lm.hideDifficultyUI to decide whether the difficulty UI is shown (scrPressToStart.cs).
|
||||
if (_renderSetup && ADOBase.lm)
|
||||
{
|
||||
ADOBase.lm.hideDifficultyUI = true;
|
||||
@@ -356,18 +622,24 @@ internal class RenderManager : MonoBehaviour
|
||||
if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading)
|
||||
{
|
||||
_renderSetup = false;
|
||||
// scrCamera.Awake calls SetupRTCam(enable: true) when forceRTCam is set (scrCamera.cs), so the flag applies to
|
||||
// the next scrCamera.Awake. scnGame also calls SetupRTCam(enable: true) when it starts playing (scnGame.cs).
|
||||
scrCamera.instance.forceRTCam = true;
|
||||
|
||||
if (!scrCamera.instance.gameObject.GetComponent<PostRenderHook>())
|
||||
scrCamera.instance.gameObject.AddComponent<PostRenderHook>();
|
||||
|
||||
// The input offset shifts the song position (calibration_i = inputOffset / 1000 in scrConductor);
|
||||
// the render runs with 0 so audio and visuals follow the level timing exactly. Restored by RestoreRenderEnvironment.
|
||||
_originalInputOffset = scrConductor.currentPreset.inputOffset;
|
||||
scrConductor.currentPreset.inputOffset = 0;
|
||||
|
||||
// Autoplay; disableCongratsMessage clears the "congratulations" text shown on the win screen (scrController.cs).
|
||||
RDC.auto = RenderContext.Auto;
|
||||
AccessTools.Field(typeof(scrController), "disableCongratsMessage")
|
||||
.SetValue(scrController.instance, true);
|
||||
|
||||
// No-fail with an infinite hit margin, so a missed hit does not fail the level during the render.
|
||||
if (scrController.instance)
|
||||
{
|
||||
scrController.instance.noFail = true;
|
||||
@@ -384,6 +656,9 @@ internal class RenderManager : MonoBehaviour
|
||||
DontDestroyOnLoad(_escapeHandlerObj);
|
||||
}
|
||||
|
||||
// While non-zero, Unity advances Time.time by exactly this interval per frame regardless of real time,
|
||||
// so the game simulation steps 1 / RenderFPS seconds per frame. Reset to 0 by RestoreRenderEnvironment.
|
||||
// Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html
|
||||
Time.captureDeltaTime = (float)(1 / Configuration.RenderFPS);
|
||||
scrController.instance!.StartCoroutine(SetupPlay());
|
||||
}
|
||||
@@ -404,6 +679,8 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
else if (RenderContext.EndCondition())
|
||||
{
|
||||
// The first frame the end condition holds starts the DelayAfterEnd period; afterwards the end condition is
|
||||
// still polled every frame, but endFrame is kept.
|
||||
if (endFrame == -1)
|
||||
{
|
||||
endFrame = frameCount;
|
||||
@@ -417,6 +694,9 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by FirstUpdateHook before every other Update of the frame. Advances frameCount by one from the OnScene stage.
|
||||
/// </summary>
|
||||
public void OnPreUpdate()
|
||||
{
|
||||
if (Instance.stage < RenderStage.OnScene) return;
|
||||
@@ -428,6 +708,10 @@ internal class RenderManager : MonoBehaviour
|
||||
private List<scrFloor>? _holdFloorsSource;
|
||||
private int _holdFloorsSourceCount = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Called by PostRenderHook after every other LateUpdate of the frame, from the Load stage.
|
||||
/// Hides the hold meshes when AdvancedConfig.NonTile is set, then runs RenderContext.OnLastUpdate.
|
||||
/// </summary>
|
||||
public void OnLastUpdate()
|
||||
{
|
||||
if (Instance.stage < RenderStage.Load) return;
|
||||
@@ -479,6 +763,15 @@ internal class RenderManager : MonoBehaviour
|
||||
return (int)Math.Min(MaxQueuedFrames, Math.Max(4, MaxQueuedBytes / frameBytes));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by PostRenderHook at the end of every frame (after rendering) with the frame texture. In the Render stage:
|
||||
/// 1. Audio: updates the song voice, commits the audio commands of this frame, and deactivates the effect
|
||||
/// AudioSources that were mapped to mixer voices.
|
||||
/// 2. Throttle: blocks while ImageQueue holds more than QueueFrameLimit frames (render thread behind).
|
||||
/// 3. Capture: when this game frame starts a new video frame, runs the frame hooks and requests the GPU readback.
|
||||
/// With RenderFPS above the effective video FPS, only some game frames are captured.
|
||||
/// </summary>
|
||||
/// <param name="texture">Frame shown by the camera quad; null skips throttling and capture.</param>
|
||||
// ReSharper disable once Unity.IncorrectMethodSignature
|
||||
public void OnPostFrame(RenderTexture? texture)
|
||||
{
|
||||
@@ -532,10 +825,13 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
var currentFrame = frameCountForVideo++;
|
||||
|
||||
// The video cannot have more frames per second than the game renders: effective video FPS = min(VideoFPS, RenderFPS).
|
||||
var renderFPS = Configuration.RenderFPS;
|
||||
var videoFps = Configuration.VideoFPS;
|
||||
var effectiveVideoFps = Math.Min(videoFps, renderFPS);
|
||||
|
||||
// Video frame that contains this game frame's time (currentFrame / renderFPS seconds), rounded down.
|
||||
// Only the first game frame of each video frame is captured.
|
||||
var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS);
|
||||
|
||||
if (videoFrameIndex > lastVideoFrameIndex)
|
||||
@@ -562,6 +858,13 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler of the render button in the level editor (added by scnEditorPatch).
|
||||
/// When the level has no file path, scnEditor.TryQuitToMenu is called and no render starts.
|
||||
/// Otherwise, after the editor's unsaved-changes prompt, removes scnEditor.TryApplicationQuit from
|
||||
/// Application.wantsToQuit (it would block quitting with an unsaved-changes prompt; scnEditor registers it again
|
||||
/// when the editor starts) and opens the render configuration window. Main thread.
|
||||
/// </summary>
|
||||
public static void StartRender()
|
||||
{
|
||||
var targetMethod = typeof(scnEditor).GetMethod("TryApplicationQuit",
|
||||
@@ -585,12 +888,18 @@ internal class RenderManager : MonoBehaviour
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Creates the default output directory (GlobalSettings.EffectiveOutputDirectory) when it does not exist.</summary>
|
||||
public void SetupCaptureDirectory()
|
||||
{
|
||||
var renderDir = GlobalSettings.EffectiveOutputDirectory;
|
||||
FileUtil.CreateDirectory(renderDir);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads scnGame with levelPath as the only custom level (checkpoint 0, speed trial multiplier 1) and arms the
|
||||
/// press-to-start skip and the scene setup in Update. Seeds UnityEngine.Random when AdvancedConfig.RandomSeed is set,
|
||||
/// so random effects repeat between renders. Called by RenderSession.Start.
|
||||
/// </summary>
|
||||
public void SetupGameScene()
|
||||
{
|
||||
GCS.customLevelPaths = new[] { levelPath };
|
||||
@@ -605,8 +914,18 @@ internal class RenderManager : MonoBehaviour
|
||||
_renderSetup = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coroutine started on scrController when the game scene is set up (stage OnScene).
|
||||
/// Waits the countdown (real-time seconds), sets the time bases, starts the encoder, the render thread and the
|
||||
/// frame reader, switches to the Render stage, starts the level without the press-to-start wait, and creates the
|
||||
/// offline audio mixer and spectrum generator. Stops without starting when the render was aborted during the
|
||||
/// countdown. On an encoder initialization error, the render fails, the environment is restored and an error
|
||||
/// dialog returns to the editor.
|
||||
/// </summary>
|
||||
private IEnumerator SetupPlay()
|
||||
{
|
||||
// The audio sample rate must be one the audio codec paired with the video codec supports
|
||||
// (CodecUtil.AudioCodecMatch, CodecUtil.AvailableSampleRate); otherwise 48000 Hz is used.
|
||||
if (Configuration.AudioConfig.RenderAudio)
|
||||
{
|
||||
if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate))
|
||||
@@ -624,6 +943,8 @@ internal class RenderManager : MonoBehaviour
|
||||
frameCount = 0; framesSent = 0;
|
||||
_renderStopwatch.Restart();
|
||||
|
||||
// Clock values at the start of capture. The time patches are not active yet (stage OnScene), so these are
|
||||
// Unity's own values; from the Render stage the patched clocks continue from them at frameCount / RenderFPS.
|
||||
timeBase = Time.timeAsDouble;
|
||||
unscaledTimeBase = Time.unscaledTimeAsDouble;
|
||||
dspTimeBase = AudioSettings.dspTime;
|
||||
@@ -635,6 +956,7 @@ internal class RenderManager : MonoBehaviour
|
||||
_frameHookPipeline = new FrameHookPipeline();
|
||||
_cachedGlobalHooksVersion = -1;
|
||||
EnsureActiveHooksUpToDate();
|
||||
// The frame reader converts on the GPU to the pixel format of the encoder that was actually opened.
|
||||
try
|
||||
{
|
||||
StartFFmpegThread();
|
||||
@@ -663,6 +985,7 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
Instance.stage = RenderStage.Render;
|
||||
// levelWasSkipped ends the press-to-start wait loop of scrController (scrController.cs, WaitForStartCo).
|
||||
scrController.instance.paused = scrController.instance.audioPaused = false;
|
||||
scrController.instance.levelWasSkipped = true;
|
||||
|
||||
@@ -681,10 +1004,18 @@ internal class RenderManager : MonoBehaviour
|
||||
AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip, AudioMixer);
|
||||
}
|
||||
|
||||
// The song is played by the offline mixer only: the clip is removed from the game's song AudioSource so Unity
|
||||
// does not play it.
|
||||
scrConductor.instance.song.clip = null;
|
||||
scrConductor.instance.song.time = -1000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the queue and render thread state, opens the encoder on the output file (RenderContext.OutputPath, or
|
||||
/// "yyyy-MM-dd HH-mm-ss.ext" in the output directory with the configured extension), and starts the render thread.
|
||||
/// The output path is registered as incomplete before the encoder opens the file.
|
||||
/// </summary>
|
||||
/// <exception cref="Exception">The encoder could not be created or started; it is disposed before the exception is rethrown.</exception>
|
||||
private void StartFFmpegThread()
|
||||
{
|
||||
if (!_debugMonitorObj)
|
||||
@@ -770,6 +1101,7 @@ internal class RenderManager : MonoBehaviour
|
||||
throw;
|
||||
}
|
||||
|
||||
// Background thread: it does not keep the process alive when the game quits.
|
||||
var encoder = videoFFmpeg;
|
||||
RenderThread = new Thread(() => RunRenderThread(encoder))
|
||||
{
|
||||
@@ -858,8 +1190,10 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount.
|
||||
/// Before the Render stage, passCount jumps to the first queued key (the first captured frame index is not 0).
|
||||
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount. Render thread.
|
||||
/// While the stage is below Render, passCount jumps forward to the first queued key when that key is greater:
|
||||
/// frames are only captured in the Render stage, so this happens after the stage was set back to None at the end
|
||||
/// of the render while the thread drains the queue, and a frame whose readback has not arrived yet is skipped.
|
||||
/// Returns false when the queue is empty or the next frame has not arrived yet.
|
||||
/// </summary>
|
||||
private bool TryDequeueNextFrame(out CapturedFrame frame)
|
||||
@@ -886,7 +1220,7 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
/// <summary>
|
||||
/// Sends one captured frame to the encoder and releases its buffer (managed: ArrayPool, native: readback state).
|
||||
/// A frame without data is skipped.
|
||||
/// A frame without data is skipped (not counted in framesSent). Render thread.
|
||||
/// </summary>
|
||||
private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame)
|
||||
{
|
||||
@@ -914,8 +1248,11 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
/// <summary>
|
||||
/// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder.
|
||||
/// Does nothing when audio rendering is off.
|
||||
/// The sample count per frame comes from SyncCalculator (sample rate / effective video FPS, with the remainder
|
||||
/// carried to later frames). Mixing waits until the main thread has committed the audio commands up to that time.
|
||||
/// Does nothing when audio rendering is off. Render thread.
|
||||
/// </summary>
|
||||
/// <exception cref="OperationCanceledException">The mixer was cancelled (render aborted) while waiting.</exception>
|
||||
private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg)
|
||||
{
|
||||
if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return;
|
||||
@@ -945,6 +1282,7 @@ internal class RenderManager : MonoBehaviour
|
||||
return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex);
|
||||
}
|
||||
|
||||
/// <summary>Removes the encoder from the cleanup list and clears _activeVideoFFmpeg. Called by the render thread when it finishes.</summary>
|
||||
private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg)
|
||||
{
|
||||
lock (_allVideoFFmpegInstances)
|
||||
@@ -954,6 +1292,11 @@ internal class RenderManager : MonoBehaviour
|
||||
_activeVideoFFmpeg = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the render without finishing the file (cancelled, or failed when MarkFailed was called first), deletes
|
||||
/// the unfinished file, reports the result through OnComplete, restores the environment and returns to the editor.
|
||||
/// Called by the ESC handler and on failures. Main thread.
|
||||
/// </summary>
|
||||
public void CancelRender()
|
||||
{
|
||||
ResetRenderState(success: false);
|
||||
@@ -962,6 +1305,15 @@ internal class RenderManager : MonoBehaviour
|
||||
EndRender();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests the end of encoding. Main thread.
|
||||
/// <paramref name="success"/> true: completes the audio commands and starts StopRenderCoroutine, which waits for
|
||||
/// the render thread to send the remaining frames and finish the file.
|
||||
/// <paramref name="success"/> false: marks the render as cancelled (unless failed), cancels the audio waits, waits up
|
||||
/// to 2 s for the render thread, disposes the encoder, releases the queued frames, the frame reader, the hooks and
|
||||
/// the debug monitor, restores the camera and no-fail flags, deletes the unfinished file and calls OnComplete with
|
||||
/// a failure result. Does not restore time, input offset or the scene (EndRender / RestoreRenderEnvironment do).
|
||||
/// </summary>
|
||||
private void StopFFmpeg(bool success)
|
||||
{
|
||||
endRequested = true;
|
||||
@@ -993,7 +1345,8 @@ internal class RenderManager : MonoBehaviour
|
||||
RenderThread.Join(2000);
|
||||
}
|
||||
|
||||
// 2. After ensuring thread termination, safely release FFmpeg native encoder resources
|
||||
// 2. Release the encoder. The render thread normally released it already (_activeVideoFFmpeg is then null).
|
||||
// When the thread is still running after the 2 s wait, the encoder is disposed while that thread may still use it.
|
||||
if (_activeVideoFFmpeg != null)
|
||||
{
|
||||
_activeVideoFFmpeg.Dispose();
|
||||
@@ -1055,6 +1408,11 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Successful end: waits (one check per frame) until the render thread reports endSuccessful or has died.
|
||||
/// On success calls OnComplete with a success result and EndRender; when the thread died without finishing
|
||||
/// the file, the render fails and is cancelled (file deleted).
|
||||
/// </summary>
|
||||
private IEnumerator StopRenderCoroutine()
|
||||
{
|
||||
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
|
||||
@@ -1071,6 +1429,7 @@ internal class RenderManager : MonoBehaviour
|
||||
EndRender();
|
||||
}
|
||||
|
||||
/// <summary>Restores the render environment and loads the level editor (scnEditor).</summary>
|
||||
private void EndRender()
|
||||
{
|
||||
RestoreRenderEnvironment();
|
||||
@@ -1079,7 +1438,11 @@ internal class RenderManager : MonoBehaviour
|
||||
Cursor.visible = true;
|
||||
}
|
||||
|
||||
// Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene.
|
||||
/// <summary>
|
||||
/// Undoes what the render setup changed (time scale and capture delta time, input offset, autoplay, custom level
|
||||
/// paths, queued frames, frame reader, frame hooks, audio mixer and spectrum, RenderContext options), without
|
||||
/// leaving the scene.
|
||||
/// </summary>
|
||||
private void RestoreRenderEnvironment()
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
@@ -1122,6 +1485,10 @@ internal class RenderManager : MonoBehaviour
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the render stopwatch and creates the result: frames sent, video duration (frames / effective video FPS),
|
||||
/// wall-clock render time, and the output path and file size on success.
|
||||
/// </summary>
|
||||
private RenderResult BuildResult(bool success, string? errorMessage = null)
|
||||
{
|
||||
_renderStopwatch.Stop();
|
||||
@@ -1133,6 +1500,7 @@ internal class RenderManager : MonoBehaviour
|
||||
: RenderResult.CreateFailure(errorMessage, sent, effectiveFps, _renderStopwatch.Elapsed);
|
||||
}
|
||||
|
||||
/// <summary>Invokes RenderContext.OnComplete; an exception from the callback is logged.</summary>
|
||||
private static void TriggerOnComplete(RenderResult result)
|
||||
{
|
||||
try
|
||||
@@ -1145,6 +1513,7 @@ internal class RenderManager : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Invokes RenderContext.OnLastUpdate; an exception from the callback is logged.</summary>
|
||||
private static void TriggerOnLastUpdate()
|
||||
{
|
||||
try
|
||||
|
||||
Reference in new issue
Block a user