Files
ADOFAIRenderer/Core/RenderManager.cs
T

729 lines
26 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using MelonLoader.Utils;
using UnityEngine;
using Random = UnityEngine.Random;
using ThreadPriority = System.Threading.ThreadPriority;
namespace ADOFAIRenderer.Core
{
public class RenderManager : MonoBehaviour
{
// Singleton Accessors
public static RenderManager Instance { get; private set; } = null!;
public static bool IsActive => Instance && Instance.isRendering;
public static bool IsCapturingActive => Instance && Instance.isCapturing;
public const int MaxQueuedFrames = 64;
public const int MaxRenderRetry = 64;
// Core Render Configuration and Path
public int countdown;
public long frameCount;
public bool skipPressToStart;
public string levelPath = "";
public bool isRenderCancelled;
public RenderSettings Configuration;
// Render Sessions State (OOP Instance Fields)
public bool isRendering;
public bool isCapturing;
public double timeBase;
public double unscaledTimeBase;
public double dspTimeBase;
public int frameCountBase;
private FrameReader? _frameReader;
public bool renderCheck = true;
public int skipCount;
public long frameCountForVideo;
public long lastVideoFrameIndex = -1;
public long lastRequestedVideoFrameIndex = -1;
public int readbackFailureCount;
public int throttleCount;
public bool isThrottling;
public struct RenderSettings
{
public double TargetFPS;
public double VideoFPS;
public int RenderWidth;
public int RenderHeight;
public RateControlMode BitrateMode;
public int Bitrate;
public int MaxBitrate;
public int QP;
public string Codec;
public double EndWaitTime;
public bool ShowHitText;
public int? RandomSeed;
public RenderSettings(double targetFPS, double videoFPS, int renderWidth, int renderHeight,
RateControlMode bitrateMode, int bitrate, int maxBitrate, int qp,
string codec, double endWaitTime, bool showHitText, int? randomSeed)
{
TargetFPS = targetFPS;
VideoFPS = videoFPS;
RenderWidth = renderWidth;
RenderHeight = renderHeight;
BitrateMode = bitrateMode;
Bitrate = bitrate;
MaxBitrate = maxBitrate;
QP = qp;
Codec = codec;
EndWaitTime = endWaitTime;
ShowHitText = showHitText;
RandomSeed = randomSeed;
}
}
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 SortedList<long, CapturedFrame>();
public Thread? RenderThread;
public long passCount;
public bool endRequested;
public bool endSuccessful;
public long wonFrame = -1;
// Private Instance Fields
private VideoFFmpeg? _activeVideoFFmpeg;
private readonly List<VideoFFmpeg> _allVideoFFmpegInstances = new List<VideoFFmpeg>();
private int _originalInputOffset;
private GameObject? _debugMonitorObj;
private GameObject? _escapeHandlerObj;
private string? _currentOutPath;
private bool _renderSetup;
private void Awake()
{
Instance = this;
}
private void OnDestroy()
{
CleanUpActiveRender();
}
public void CleanUpActiveRender()
{
isRendering = isCapturing = false;
isRenderCancelled = true;
if (_activeVideoFFmpeg != null)
{
Melon<RenderMod>.Logger.Msg("게임 종료 또는 RenderManager 파괴 감지: FFmpeg 렌더 프로세스를 강제 종료합니다.");
_activeVideoFFmpeg.Kill();
_activeVideoFFmpeg = null;
}
lock (_allVideoFFmpegInstances)
{
if (_allVideoFFmpegInstances.Count > 0)
{
Melon<RenderMod>.Logger.Msg($"게임 종료 감지: 생성된 모든 FFmpeg 프로세스({_allVideoFFmpegInstances.Count}개)를 강제 수거 및 종료합니다.");
foreach (var instance in _allVideoFFmpegInstances)
{
try
{
instance.Kill();
}
catch
{
// Ignore
}
}
_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;
scrCamera.instance.SetupRTCam(true);
if (!scrCamera.instance.gameObject.GetComponent<PostRenderHook>())
scrCamera.instance.gameObject.AddComponent<PostRenderHook>();
_originalInputOffset = scrConductor.currentPreset.inputOffset;
scrConductor.currentPreset.inputOffset = 0;
RDC.auto = true;
if (scrController.instance)
{
scrController.instance.noFail = true;
scrController.instance.noFailInfiniteMargin = true;
}
timeBase = Time.timeAsDouble;
unscaledTimeBase = Time.unscaledTimeAsDouble;
dspTimeBase = AudioSettings.dspTime;
frameCountBase = Time.frameCount;
isRendering = true;
countdown = 3;
wonFrame = -1;
if (!_escapeHandlerObj)
{
_escapeHandlerObj = new GameObject("EscapeHandlerCanvas");
_escapeHandlerObj.AddComponent<EscapeHandlerUI>();
DontDestroyOnLoad(_escapeHandlerObj);
}
Time.captureDeltaTime = (float)(1 / Configuration.TargetFPS);
scrController.instance!.StartCoroutine(SetupPlay());
}
if (!isRendering) return;
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
{
isRendering = isCapturing = false;
StopFFmpeg(false);
EndRender();
}
else if (scrController.instance.state == States.Won)
{
if (wonFrame == -1)
{
wonFrame = frameCount;
}
var extraFrames = (long)Math.Ceiling(Configuration.EndWaitTime * Configuration.TargetFPS);
if (frameCount - wonFrame < extraFrames) return;
isRendering = isCapturing = false;
StopFFmpeg(true);
}
}
public void OnPreRenderUpdate()
{
if (!isRendering) return;
// [렉으로 인한 프레임 렌더링 스킵 시 재시도 로직 (원본 모드 이식)]
if (renderCheck && isCapturing)
{
if (skipCount < MaxRenderRetry)
{
Melon<RenderMod>.Logger.Warning($"[Render Skipped] Frame {frameCount} rendering missed. Retrying... (Attempt {skipCount + 1}/{MaxRenderRetry})");
skipCount++;
Time.timeScale = 0f; // 진행 시간 멈추기 (재시도)
return; // 업데이트 진행하지 않고 대기
}
// 60회 초과 시 최후의 블랙 프레임 삽입하여 전체 파이프라인 마비 방지
Melon<RenderMod>.Logger.Error($"[Render Skipped] Frame {frameCount} rendering failed after 60 retries. Supplying black frame.");
var currentFrame = frameCountForVideo++;
var targetFps = Configuration.TargetFPS;
var videoFps = Configuration.VideoFPS;
var effectiveVideoFps = Math.Min(videoFps, targetFps);
var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / targetFps);
if (videoFrameIndex > lastVideoFrameIndex)
{
lastVideoFrameIndex = videoFrameIndex;
lastRequestedVideoFrameIndex = videoFrameIndex;
var byteSize = Configuration.RenderWidth * Configuration.RenderHeight * 4;
var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize);
Array.Clear(blackFrame, 0, byteSize);
lock (ImageQueue)
{
ImageQueue[videoFrameIndex] = new CapturedFrame
{
ManagedBuffer = blackFrame,
Length = byteSize
};
}
}
renderCheck = false; // 진행 허용
}
skipCount = 0;
renderCheck = true; // 다음 프레임 검사용으로 초기화
frameCount++;
}
// ReSharper disable once Unity.IncorrectMethodSignature
public void OnPostRender(RenderTexture? texture)
{
if (!isCapturing || !texture) return;
renderCheck = false; // 이번 프레임 정상 렌더링 완료
// [Backpressure Throttling (2번 작업)]
isThrottling = false;
while (isCapturing && isRendering)
{
int count;
lock (ImageQueue)
{
count = ImageQueue.Count;
}
if (count <= MaxQueuedFrames) break;
if (!isThrottling)
{
isThrottling = true;
throttleCount++;
}
Thread.Sleep(1);
}
isThrottling = false;
var currentFrame = frameCountForVideo++;
var targetFps = Configuration.TargetFPS;
var videoFps = Configuration.VideoFPS;
var effectiveVideoFps = Math.Min(videoFps, targetFps);
var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / targetFps);
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 == null) return;
if (ADOBase.levelPath.IsNullOrEmpty())
{
scnEditor.instance.TryQuitToMenu();
return;
}
if (Instance != null)
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 = Path.Combine(MelonEnvironment.GameRootDirectory, "Render");
if (!Directory.Exists(renderDir))
{
Directory.CreateDirectory(renderDir);
}
}
public void SetupGameScene()
{
GCS.customLevelPaths = new[] { levelPath };
GCS.checkpointNum = 0;
GCS.customLevelId = "";
GCS.nextSpeedRun = 1;
GCS.customLevelIndex = 0;
if (Configuration.RandomSeed.HasValue) Random.InitState(Configuration.RandomSeed.Value);
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnGame");
skipPressToStart = true;
_renderSetup = true;
}
private IEnumerator SetupPlay()
{
for (; countdown > 0; countdown--)
{
yield return new WaitForSecondsRealtime(1);
}
frameCount = 0;
_frameReader?.Dispose();
_frameReader = new FrameReader();
StartFFmpegThread();
isCapturing = true;
scrController.instance.paused = scrController.instance.audioPaused = false;
scrController.instance.levelWasSkipped = true;
scrConductor.instance.song.clip = null;
scrConductor.instance.song.time = -1000;
}
private string FindFFmpegPath()
{
var isWindows = Application.platform == RuntimePlatform.WindowsPlayer;
var ffmpegName = isWindows ? "ffmpeg.exe" : "ffmpeg";
// 1. Mods/ADOFAIRenderer/ffmpeg
var path1 = Path.Combine(MelonEnvironment.ModsDirectory, "ADOFAIRenderer", ffmpegName);
if (File.Exists(path1)) return path1;
// 2. Mods/ffmpeg
var path2 = Path.Combine(MelonEnvironment.ModsDirectory, ffmpegName);
if (File.Exists(path2)) return path2;
// 3. GameRootDirectory/ffmpeg
var path3 = Path.Combine(MelonEnvironment.GameRootDirectory, ffmpegName);
return File.Exists(path3) ? path3 : "";
}
private void StartFFmpegThread()
{
var ffmpegPath = FindFFmpegPath();
if (string.IsNullOrEmpty(ffmpegPath))
{
Melon<RenderMod>.Logger.Error("ffmpeg 바이너리를 찾을 수 없습니다. Mods/ADOFAIRenderer/ 에 ffmpeg를 넣어주세요.");
return;
}
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;
renderCheck = true;
skipCount = 0;
endRequested = false;
endSuccessful = false;
var targetFps = Configuration.TargetFPS;
var videoFps = Configuration.VideoFPS;
var bitrateMode = Configuration.BitrateMode;
var bitrate = Configuration.Bitrate;
var maxBitrate = Configuration.MaxBitrate;
var qp = Configuration.QP;
var codecStr = Configuration.Codec;
var selectedCodec = VideoFFmpeg.Codec.H264_Default;
if (Enum.TryParse<VideoFFmpeg.Codec>(codecStr, true, out var parsedCodec))
{
selectedCodec = parsedCodec;
}
var ext = ".mp4";
if (selectedCodec == VideoFFmpeg.Codec.AV1_Default ||
selectedCodec == VideoFFmpeg.Codec.AV1_NVIDIA ||
selectedCodec == VideoFFmpeg.Codec.AV1_QSV ||
selectedCodec == VideoFFmpeg.Codec.VP9_Default ||
selectedCodec == VideoFFmpeg.Codec.VP9_NVIDIA)
{
ext = ".webm";
}
var startTime = DateTime.Now;
var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") + ext;
_currentOutPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", fileName);
RenderThread = new Thread(() =>
{
VideoFFmpeg? videoFFmpeg = null;
try
{
var effectiveVideoFps = Math.Min(videoFps, targetFps);
videoFFmpeg = new VideoFFmpeg(Configuration.RenderWidth, Configuration.RenderHeight, effectiveVideoFps, videoFps, selectedCodec);
SetupCaptureDirectory();
_activeVideoFFmpeg = videoFFmpeg;
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Add(videoFFmpeg);
}
videoFFmpeg.Start(_currentOutPath, bitrateMode, bitrate, maxBitrate, qp);
while (true)
{
CapturedFrame frame = default;
bool hasFrame = false;
lock (ImageQueue)
{
if (ImageQueue.Count > 0)
{
var firstKey = ImageQueue.Keys[0];
if (firstKey > passCount && !isCapturing)
{
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);
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
}
}
else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0)
{
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length);
if (frame.ReadbackState != null)
{
_frameReader?.RecycleState(frame.ReadbackState);
}
}
continue;
}
if (isRenderCancelled) break;
if (endRequested)
{
if (passCount > lastRequestedVideoFrameIndex) break;
}
Thread.Sleep(1);
}
if (isRenderCancelled) videoFFmpeg.Kill();
else videoFFmpeg.Close();
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
}
_activeVideoFFmpeg = null;
endSuccessful = !isRenderCancelled;
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"FFmpeg 렌더링 스레드 오류: {ex}");
_activeVideoFFmpeg?.Kill();
if (videoFFmpeg != null)
{
lock (_allVideoFFmpegInstances)
{
_allVideoFFmpegInstances.Remove(videoFFmpeg);
}
}
_activeVideoFFmpeg = null;
}
})
{
IsBackground = true,
Priority = ThreadPriority.AboveNormal
};
RenderThread.Start();
}
public void CancelRender()
{
isRendering = isCapturing = false;
isRenderCancelled = true;
StopFFmpeg(false);
EndRender();
}
private void StopFFmpeg(bool success)
{
endRequested = true;
if (!success)
{
isRenderCancelled = true;
}
skipPressToStart = false;
_renderSetup = false;
if (_escapeHandlerObj)
{
Destroy(_escapeHandlerObj);
_escapeHandlerObj = null;
}
if (success) StartCoroutine(StopRenderCoroutine());
else
{
Melon<RenderMod>.Logger.Error("렌더링이 실패했거나 취소되었습니다. 상태 및 리소스를 안전하게 청소합니다.");
if (_activeVideoFFmpeg != null)
{
_activeVideoFFmpeg.Kill(16384);
_activeVideoFFmpeg = null;
}
// 1. 메모리 누수 방지: 큐에 렌트된 임시 프레임 버퍼 풀 반환 및 클리어
lock (ImageQueue)
{
foreach (var kvp in ImageQueue.Values)
{
if (kvp.ManagedBuffer != null && kvp.ManagedBuffer.Length > 0)
{
ArrayPool<byte>.Shared.Return(kvp.ManagedBuffer);
}
if (kvp.ReadbackState != null)
{
_frameReader?.RecycleState(kvp.ReadbackState);
}
}
ImageQueue.Clear();
}
_frameReader?.Dispose();
// 디버그 모니터 UI 정리
if (_debugMonitorObj)
{
Destroy(_debugMonitorObj);
_debugMonitorObj = null;
}
// 2. 렌더링 스레드가 큐 비어 있음을 감지하고 break 하도록 대기
if (RenderThread is { IsAlive: true })
{
RenderThread.Join(2000);
}
// 3. 인게임 카메라 및 렌더링용 RTCam 상태 안전 원복
if (scrCamera.instance)
{
scrCamera.instance.forceRTCam = false;
scrCamera.instance.SetupRTCam(false);
var hook = scrCamera.instance.GetComponent<PostRenderHook>();
if (hook) Destroy(hook);
}
// 4. 인게임 실패 방지 플래그 원상 복구
if (scrController.instance)
{
scrController.instance.noFail = false;
scrController.instance.noFailInfiniteMargin = false;
}
// 5. 취소된 미완성 임시 동영상 파일 즉각 삭제
if (!string.IsNullOrEmpty(_currentOutPath) && File.Exists(_currentOutPath))
{
try
{
File.Delete(_currentOutPath);
Melon<RenderMod>.Logger.Msg($"[File Cleanup] 미완성 동영상 삭제 완료: {_currentOutPath}");
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[File Cleanup] 임시 동영상 삭제 실패: {ex.Message}");
}
_currentOutPath = null;
}
}
}
private IEnumerator StopRenderCoroutine()
{
yield return new WaitUntil(() => endSuccessful);
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 != null && kvp.ManagedBuffer.Length > 0)
{
ArrayPool<byte>.Shared.Return(kvp.ManagedBuffer);
}
if (kvp.ReadbackState != null)
{
_frameReader?.RecycleState(kvp.ReadbackState);
}
}
ImageQueue.Clear();
}
_frameReader?.Dispose();
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
}
}
}