SHA256
Rename Package Component to Core
This commit is contained in:
1 parent
ddd0cc1d39
commit
32db6c93b2
19 files changed
+19
-20
No files matched your search
@@ -0,0 +1,594 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using ADOFAIRenderer.UI;
|
||||
using ADOFAIRenderer.Util;
|
||||
using ADOFAIRenderer.Util.FFmpeg;
|
||||
using MelonLoader;
|
||||
using MelonLoader.Utils;
|
||||
using UnityEngine;
|
||||
|
||||
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;
|
||||
|
||||
// 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 int Bitrate;
|
||||
public string Codec;
|
||||
public double EndWaitTime;
|
||||
public bool ShowHitText;
|
||||
|
||||
public RenderSettings(double targetFPS, double videoFPS, int renderWidth, int renderHeight, int bitrate, string codec, double endWaitTime, bool showHitText)
|
||||
{
|
||||
TargetFPS = targetFPS;
|
||||
VideoFPS = videoFPS;
|
||||
|
||||
RenderWidth = renderWidth;
|
||||
RenderHeight = renderHeight;
|
||||
|
||||
Bitrate = bitrate;
|
||||
Codec = codec;
|
||||
EndWaitTime = endWaitTime;
|
||||
ShowHitText = showHitText;
|
||||
}
|
||||
}
|
||||
|
||||
// Thread and Queue State
|
||||
public SortedList<long, byte[]> ImageQueue = new SortedList<long, byte[]>();
|
||||
public Thread? RenderThread;
|
||||
public long passCount;
|
||||
public bool endRequested;
|
||||
public bool endSuccessful;
|
||||
public long wonFrame = -1;
|
||||
|
||||
// Private Instance Fields
|
||||
private int _originalInputOffset;
|
||||
private GameObject? _debugMonitorObj;
|
||||
private GameObject? _escapeHandlerObj;
|
||||
private string? _currentOutPath;
|
||||
private bool _renderSetup;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
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 < 60)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Warning($"[Render Skipped] Frame {frameCount} rendering missed. Retrying... (Attempt {skipCount + 1}/60)");
|
||||
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 = System.Buffers.ArrayPool<byte>.Shared.Rent(byteSize);
|
||||
Array.Clear(blackFrame, 0, byteSize);
|
||||
|
||||
lock (ImageQueue)
|
||||
{
|
||||
ImageQueue[videoFrameIndex] = blackFrame;
|
||||
}
|
||||
}
|
||||
|
||||
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번 작업)]
|
||||
const int maxQueuedFrames = 60;
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
FFmpegProcess.FFmpegPath = ffmpegPath;
|
||||
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;
|
||||
float bitrate = Configuration.Bitrate;
|
||||
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(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var effectiveVideoFps = Math.Min(videoFps, targetFps);
|
||||
var videoFFmpeg = new VideoFFmpeg(Configuration.RenderWidth, Configuration.RenderHeight, effectiveVideoFps, videoFps, selectedCodec);
|
||||
|
||||
SetupCaptureDirectory();
|
||||
|
||||
videoFFmpeg.Start(_currentOutPath, bitrate);
|
||||
|
||||
while (true)
|
||||
{
|
||||
byte[]? buffer = null;
|
||||
lock (ImageQueue)
|
||||
{
|
||||
if (ImageQueue.Count > 0)
|
||||
{
|
||||
var firstKey = ImageQueue.Keys[0];
|
||||
|
||||
if (firstKey > passCount && !isCapturing)
|
||||
{
|
||||
passCount = firstKey;
|
||||
}
|
||||
|
||||
if (firstKey == passCount)
|
||||
{
|
||||
buffer = ImageQueue[firstKey];
|
||||
ImageQueue.RemoveAt(0);
|
||||
passCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer != null)
|
||||
{
|
||||
if (buffer.Length > 0)
|
||||
{
|
||||
var bufferSize = Configuration.RenderWidth * Configuration.RenderHeight * 4;
|
||||
videoFFmpeg.SendDataFromBuffer(buffer, bufferSize);
|
||||
System.Buffers.ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRenderCancelled)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (endRequested)
|
||||
{
|
||||
if (passCount > lastRequestedVideoFrameIndex)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
videoFFmpeg.Close();
|
||||
endSuccessful = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Melon<RenderMod>.Logger.Error($"FFmpeg 렌더링 스레드 오류: {ex}");
|
||||
}
|
||||
});
|
||||
|
||||
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("렌더링이 실패했거나 취소되었습니다. 상태 및 리소스를 안전하게 청소합니다.");
|
||||
|
||||
// 1. 메모리 누수 방지: 큐에 렌트된 임시 프레임 버퍼 풀 반환 및 클리어
|
||||
lock (ImageQueue)
|
||||
{
|
||||
foreach (var kvp in ImageQueue)
|
||||
{
|
||||
if (kvp.Value != null && kvp.Value.Length > 0)
|
||||
{
|
||||
System.Buffers.ArrayPool<byte>.Shared.Return(kvp.Value);
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
_frameReader?.Dispose();
|
||||
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user