Files
ADOFAIRenderer/Core/RenderManager.cs
T

826 lines
28 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Buffers;
using System.Diagnostics;
using System.Linq;
using ADOFAIRenderer.API;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.Audio;
using ADOFAIRenderer.Util.FFmpeg;
using FFmpeg.AutoGen;
using HarmonyLib;
using MelonLoader;
using UnityEngine;
using UnityEngine.Rendering;
using Random = UnityEngine.Random;
using ThreadPriority = System.Threading.ThreadPriority;
namespace ADOFAIRenderer.Core;
internal enum RenderStage
{
None, Load, OnScene, Render
}
internal static class RenderContext
{
public static bool Auto = true;
public static bool TimePatch = true;
public static bool ScreenPatch = true;
public static string? OutputPath;
public static bool Notification = true;
private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won;
public static Func<bool> EndCondition = EndConditionDefault;
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);
}
};
public static Action<RenderResult> OnComplete = OnCompleteDefault;
private static readonly Action OnLastUpdateDefault = () => { };
public static Action OnLastUpdate = OnLastUpdateDefault;
public static void Reset()
{
Auto = true;
TimePatch = true; ScreenPatch = true;
OutputPath = null;
Notification = true;
EndCondition = EndConditionDefault;
OnComplete = OnCompleteDefault;
OnLastUpdate = OnLastUpdateDefault;
}
}
internal class RenderManager : MonoBehaviour
{
// Singleton Accessors
public static RenderManager Instance { get; private set; } = null!;
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
public const int MaxQueuedFrames = 64;
// Statistics
public long framesSent;
private readonly Stopwatch _renderStopwatch = new();
// Core Render Configuration and Path
public int countdown;
public long frameCount;
public bool skipPressToStart;
public string levelPath = "";
public bool IsRenderCancelled { get; private set; }
public RenderConfig Configuration = null!;
// Render Sessions State (OOP Instance Fields)
public RenderStage stage = RenderStage.None;
public double timeBase;
public double unscaledTimeBase;
public double dspTimeBase;
public int frameCountBase;
public OfflineAudioMixer? AudioMixer { get; private set; }
private AudioSyncCalculator SyncCalculator { get; set; } = null!;
private FrameReader? _frameReader;
public long frameCountForVideo;
public long lastVideoFrameIndex = -1;
public long lastRequestedVideoFrameIndex = -1;
public int readbackFailureCount;
public int throttleCount;
public bool isThrottling;
internal struct CapturedFrame
{
public IntPtr NativePointer;
public int Length;
public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame)
public ReadbackRequestState? ReadbackState; // Associated state to recycle
}
// Thread and Queue State
internal SortedList<long, CapturedFrame> ImageQueue = new();
internal AutoResetEvent FrameAvailableEvent = new(false);
public Thread? RenderThread;
public long passCount;
public bool endRequested;
public bool endSuccessful;
public long endFrame = -1;
// Private Instance Fields
private FFmpegNativeEncoder? _activeVideoFFmpeg;
private readonly List<FFmpegNativeEncoder> _allVideoFFmpegInstances = new();
private int _originalInputOffset;
private GameObject? _debugMonitorObj;
private GameObject? _escapeHandlerObj;
internal string? CurrentOutPath;
private bool _renderSetup;
private void Awake()
{
Instance = this;
}
private void OnDestroy()
{
CleanUpActiveRender();
}
public void ResetRenderState(bool success)
{
IsRenderCancelled = !success;
stage = RenderStage.None;
}
public void CleanUpActiveRender()
{
ResetRenderState(success: false);
AudioSpectrumGenerator.Clear();
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);
}
}
private void Update()
{
if (_renderSetup && ADOBase.lm)
{
ADOBase.lm.hideDifficultyUI = true;
}
if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading)
{
_renderSetup = false;
scrCamera.instance.forceRTCam = true;
if (!scrCamera.instance.gameObject.GetComponent<PostRenderHook>())
scrCamera.instance.gameObject.AddComponent<PostRenderHook>();
_originalInputOffset = scrConductor.currentPreset.inputOffset;
scrConductor.currentPreset.inputOffset = 0;
RDC.auto = RenderContext.Auto;
AccessTools.Field(typeof(scrController), "disableCongratsMessage")
.SetValue(scrController.instance, true);
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);
}
Time.captureDeltaTime = (float)(1 / Configuration.RenderFPS);
scrController.instance!.StartCoroutine(SetupPlay());
}
if (Instance.stage < RenderStage.OnScene) return;
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
{
ResetRenderState(success: false);
StopFFmpeg(false);
EndRender();
}
else if (RenderContext.EndCondition())
{
if (endFrame == -1)
{
endFrame = frameCount;
}
var extraFrames = (long)Math.Ceiling(Configuration.DelayAfterEnd * Configuration.RenderFPS);
if (frameCount - endFrame < extraFrames) return;
ResetRenderState(success: true);
StopFFmpeg(true);
}
}
public void OnPreUpdate()
{
if (Instance.stage < RenderStage.OnScene) return;
frameCount++;
}
public void OnLastUpdate()
{
if (Instance.stage < RenderStage.Load) return;
if (!Configuration.AdvancedConfig.NonTile) return;
foreach (var floor in scrLevelMaker.instance.listFloors
.Where(floor => floor.holdRenderer && floor.holdRenderer.m_meshRenderer.enabled))
{
floor.holdRenderer.m_meshRenderer.enabled = false;
}
TriggerOnLastUpdate();
}
// ReSharper disable once Unity.IncorrectMethodSignature
public void OnPostFrame(RenderTexture? texture)
{
if (Instance.stage < RenderStage.Render || !texture) return;
// [Backpressure Throttling]
isThrottling = false;
while (Instance.stage >= RenderStage.Render)
{
int count;
lock (ImageQueue)
{
count = ImageQueue.Count;
}
if (count <= MaxQueuedFrames) break;
if (!isThrottling)
{
isThrottling = true;
throttleCount++;
}
AsyncGPUReadback.WaitAllRequests();
}
isThrottling = false;
var currentFrame = frameCountForVideo++;
var renderFPS = Configuration.RenderFPS;
var videoFps = Configuration.VideoFPS;
var effectiveVideoFps = Math.Min(videoFps, renderFPS);
var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS);
if (videoFrameIndex > lastVideoFrameIndex)
{
lastVideoFrameIndex = videoFrameIndex;
lastRequestedVideoFrameIndex = videoFrameIndex;
_frameReader?.RequestFrameCapture(texture, videoFrameIndex);
}
}
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;
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
});
}
public void SetupCaptureDirectory()
{
var renderDir = GlobalSettings.EffectiveOutputDirectory;
FileUtil.CreateDirectory(renderDir);
}
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;
}
private IEnumerator SetupPlay()
{
if (Configuration.AudioConfig.RenderAudio && Configuration.VideoConfig.Codec is Codec.VP9 or Codec.AV1 or Codec.VVC)
{
Configuration.AudioConfig.AudioSampleRate = 48000;
}
for (; countdown > 0; countdown--)
{
yield return new WaitForSecondsRealtime(1);
}
frameCount = 0; framesSent = 0;
_renderStopwatch.Restart();
timeBase = Time.timeAsDouble;
unscaledTimeBase = Time.unscaledTimeAsDouble;
dspTimeBase = AudioSettings.dspTime;
frameCountBase = Time.frameCount;
_frameReader?.Dispose();
_frameReader = null;
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}");
CommonWarningDialogUI.ShowDialog(
"Encoder Error",
$"Failed to initialize video encoder.\nError: {ex.Message}",
new DialogButton("OK", () =>
{
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
})
);
yield break;
}
Instance.stage = RenderStage.Render;
scrController.instance.paused = scrController.instance.audioPaused = false;
scrController.instance.levelWasSkipped = true;
if (!scrConductor.instance || !scrConductor.instance.song) yield break;
if (Configuration.AdvancedConfig.ProcessAudio)
{
AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip);
}
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);
}
scrConductor.instance.song.clip = null;
scrConductor.instance.song.time = -1000;
}
private void StartFFmpegThread()
{
if (!_debugMonitorObj)
{
_debugMonitorObj = new GameObject("DebugMonitorCanvas");
_debugMonitorObj.AddComponent<DebugMonitorUI>();
DontDestroyOnLoad(_debugMonitorObj);
}
lock (ImageQueue)
{
ImageQueue.Clear();
}
passCount = 0;
IsRenderCancelled = false;
frameCountForVideo = 0;
lastVideoFrameIndex = -1;
lastRequestedVideoFrameIndex = -1;
endRequested = false;
endSuccessful = false;
FrameAvailableEvent.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 = 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);
}
videoFFmpeg.Start(CurrentOutPath);
}
catch (Exception)
{
if (videoFFmpeg != null)
{
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
}
videoFFmpeg.Dispose();
}
_activeVideoFFmpeg = null;
throw;
}
RenderThread = new Thread(() =>
{
try
{
while (true)
{
CapturedFrame frame = default;
bool hasFrame = false;
lock (ImageQueue)
{
if (ImageQueue.Count > 0)
{
var firstKey = ImageQueue.Keys[0];
if (firstKey > passCount && Instance.stage < RenderStage.Render)
{
passCount = firstKey;
}
if (firstKey == passCount)
{
frame = ImageQueue[firstKey];
ImageQueue.RemoveAt(0);
passCount++;
hasFrame = true;
}
}
}
if (hasFrame)
{
if (frame.ManagedBuffer != null)
{
if (frame.ManagedBuffer.Length > 0)
{
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length);
Interlocked.Increment(ref framesSent);
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
}
}
else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0)
{
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length);
Interlocked.Increment(ref framesSent);
if (frame.ReadbackState != null)
{
_frameReader?.RecycleState(frame.ReadbackState);
}
}
if (Configuration.AudioConfig.RenderAudio)
{
var frameIdx = (int)(passCount - 1);
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
var frameTime = frameIdx / effectiveVideoFps;
var channels = Configuration.AudioConfig.AudioChannels;
var samplesNeeded = SyncCalculator.GetNextFrameSamples();
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
try
{
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase);
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
}
finally
{
ArrayPool<float>.Shared.Return(audioBuffer);
}
}
continue;
}
if (IsRenderCancelled) break;
if (endRequested)
{
if (passCount > lastRequestedVideoFrameIndex) break;
}
FrameAvailableEvent.WaitOne(50);
}
if (!IsRenderCancelled) videoFFmpeg.End();
videoFFmpeg.Dispose();
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
}
_activeVideoFFmpeg = null;
endSuccessful = !IsRenderCancelled;
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
videoFFmpeg.Dispose();
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
}
_activeVideoFFmpeg = null;
}
})
{
IsBackground = true,
Priority = ThreadPriority.AboveNormal
};
RenderThread.Start();
}
public void CancelRender()
{
ResetRenderState(success: false);
StopFFmpeg(false);
EndRender();
}
private void StopFFmpeg(bool success)
{
endRequested = true;
if (!success)
{
IsRenderCancelled = true;
}
FrameAvailableEvent.Set();
skipPressToStart = false;
_renderSetup = false;
if (_escapeHandlerObj)
{
Destroy(_escapeHandlerObj);
_escapeHandlerObj = null;
}
if (success) StartCoroutine(StopRenderCoroutine());
else
{
Melon<RenderMod>.Logger.Error("Rendering failed or was cancelled. Safely cleaning up state and resources.");
// 1. Wait for render background thread to finish processing remaining queued frames and terminate
if (RenderThread is { IsAlive: true })
{
RenderThread.Join(2000);
}
// 2. After ensuring thread termination, safely release FFmpeg native encoder resources
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();
// 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 cancelled incomplete temporary video file
if (!string.IsNullOrEmpty(CurrentOutPath) && File.Exists(CurrentOutPath))
{
try
{
File.Delete(CurrentOutPath);
Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {CurrentOutPath}");
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete temporary video: {ex.Message}");
}
CurrentOutPath = null;
}
var result = BuildResult(false,
IsRenderCancelled ? "Rendering was cancelled by user." : "Rendering failed.");
TriggerOnComplete(result);
}
}
private IEnumerator StopRenderCoroutine()
{
yield return new WaitUntil(() => endSuccessful);
var result = BuildResult(success: true);
TriggerOnComplete(result);
EndRender();
}
private void EndRender()
{
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();
AudioMixer?.Reset(); AudioMixer = null;
SyncCalculator.Reset();
RenderContext.Reset();
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
Cursor.visible = true;
}
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);
}
private static void TriggerOnComplete(RenderResult result)
{
try
{
RenderContext.OnComplete.Invoke(result);
}
catch (Exception err)
{
Melon<RenderMod>.Logger.Error($"OnComplete callback caused error: {err}");
}
}
private static void TriggerOnLastUpdate()
{
try
{
RenderContext.OnLastUpdate.Invoke();
}
catch (Exception err)
{
Melon<RenderMod>.Logger.Error($"OnLastUpdate callback caused error: {err}");
}
}
private static readonly Dictionary<Codec, AVCodecID> AudioCodecMatch = new()
{
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
[Codec.H265] = AVCodecID.AV_CODEC_ID_AAC,
[Codec.VP9] = AVCodecID.AV_CODEC_ID_OPUS,
[Codec.AV1] = AVCodecID.AV_CODEC_ID_OPUS,
[Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS
};
}