From 2556bc7f6279df31d0eb86053c28163f4a8a8ccb641e3f4baf04db0100c36ff5 Mon Sep 17 00:00:00 2001 From: copecone Date: Fri, 22 May 2026 19:31:44 +0900 Subject: [PATCH] Add Renderer Feature --- .gitignore | 3 + ADOFAIRenderer.csproj | 6 + Component/DebugMonitorUI.cs | 115 +++++++++++ Component/PostRenderHook.cs | 6 +- Component/RenderManager.cs | 236 +++++++++++++++++++++- Patch/ClassPatch/scrControllerPatch.cs | 29 +++ Patch/ClassPatch/scrDecorationPatch.cs | 55 +++++ Patch/ClassPatch/scrPlayerManagerPatch.cs | 2 +- Patch/RenderEnvironmentPatches.cs | 12 +- RenderMod.cs | 126 +++++++++++- UI/RenderButton.cs | 11 +- Util/FFmpegProcess.cs | 71 +++++++ Util/VideoFFmpeg.cs | 76 +++++++ 13 files changed, 727 insertions(+), 21 deletions(-) create mode 100644 Component/DebugMonitorUI.cs create mode 100644 Patch/ClassPatch/scrDecorationPatch.cs create mode 100644 Util/FFmpegProcess.cs create mode 100644 Util/VideoFFmpeg.cs diff --git a/.gitignore b/.gitignore index 04ea031..803283b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ desktop.ini .antigravitycli/ .agent/ GEMINI.md + +# Decompiled Game Code +*.cs.txt \ No newline at end of file diff --git a/ADOFAIRenderer.csproj b/ADOFAIRenderer.csproj index 8224aba..4ab520a 100644 --- a/ADOFAIRenderer.csproj +++ b/ADOFAIRenderer.csproj @@ -41,6 +41,12 @@ $(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\UnityEngine.CoreModule.dll + + $(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\UnityEngine.IMGUIModule.dll + + + $(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\UnityEngine.TextRenderingModule.dll + $(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\UnityEngine.InputLegacyModule.dll diff --git a/Component/DebugMonitorUI.cs b/Component/DebugMonitorUI.cs new file mode 100644 index 0000000..3a7005f --- /dev/null +++ b/Component/DebugMonitorUI.cs @@ -0,0 +1,115 @@ +using System; +using System.Text; +using UnityEngine; +using UnityEngine.UI; + +namespace ADOFAIRenderer.Component +{ + public class DebugMonitorUI : MonoBehaviour + { + private Text _text = null!; + + private void Start() + { + // UI 레이어(5) 설정 + gameObject.layer = 5; + + // Canvas 설정 + Canvas canvas = gameObject.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvas.sortingOrder = 9999; + + // 해상도 다이내믹 스케일링 설정 (Reference: 1920x1080) + CanvasScaler scaler = gameObject.AddComponent(); + scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + scaler.referenceResolution = new Vector2(1920, 1080); + scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight; + scaler.matchWidthOrHeight = 0.5f; + + gameObject.AddComponent(); + + // 배경 패널 생성 + GameObject panelObj = new GameObject("BackgroundPanel"); + panelObj.transform.SetParent(transform, false); + panelObj.layer = 5; + + Image image = panelObj.AddComponent(); + image.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 약간 더 고급스러운 다크톤 아크릴 감성 + + RectTransform panelRect = panelObj.GetComponent(); + panelRect.anchorMin = new Vector2(1, 1); + panelRect.anchorMax = new Vector2(1, 1); + panelRect.pivot = new Vector2(1, 1); + panelRect.anchoredPosition = new Vector2(-30, -30); + panelRect.sizeDelta = new Vector2(340, 250); + + // 텍스트 생성 + GameObject textObj = new GameObject("MonitorText"); + textObj.transform.SetParent(panelObj.transform, false); + textObj.layer = 5; + + _text = textObj.AddComponent(); + _text.supportRichText = true; + _text.fontSize = 13; + _text.lineSpacing = 1.3f; + _text.alignment = TextAnchor.UpperLeft; + + // uGUI 특유의 텍스트 잘림 현상 원천 예방 + _text.horizontalOverflow = HorizontalWrapMode.Wrap; + _text.verticalOverflow = VerticalWrapMode.Overflow; + + Font? font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + if (font == null) + { + font = Resources.GetBuiltinResource("Arial.ttf"); + } + if (font == null) + { + var fonts = Resources.FindObjectsOfTypeAll(); + if (fonts.Length > 0) font = fonts[0]; + } + _text.font = font; + + RectTransform textRect = textObj.GetComponent(); + textRect.anchorMin = Vector2.zero; + textRect.anchorMax = Vector2.one; + textRect.offsetMin = new Vector2(20, 20); // 좀 더 넉넉하고 아름다운 패딩 여백 + textRect.offsetMax = new Vector2(-20, -20); + } + + private void Update() + { + if (!_text) return; + + StringBuilder sb = new StringBuilder(); + sb.AppendLine("ADOFAI Renderer Realtime Monitor"); + sb.AppendLine(); + sb.AppendLine($"• Rendered Progress: {RenderManager.FrameCount} frames"); + + int queueCount; + lock (RenderManager.ImageQueue) + { + queueCount = RenderManager.ImageQueue.Count; + } + sb.AppendLine($"• Queued Buffers: {queueCount} / 60 frames"); + + if (RenderMod.IsThrottling) + { + sb.AppendLine("• Status: THROTTLING (Safe Mode)"); + } + else + { + sb.AppendLine("• Status: NORMAL (Encoding)"); + } + sb.AppendLine(); + sb.AppendLine($"• Throttling Controls: {RenderMod.ThrottleCount} times"); + sb.AppendLine($"• Readback Hard Failures: {RenderMod.ReadbackFailureCount} times"); + sb.AppendLine($"• Render Skip-Retry: {RenderMod.SkipCount} / 60"); + + double memoryMb = GC.GetTotalMemory(false) / (1024.0 * 1024.0); + sb.AppendLine($"• Mono Heap Memory: {memoryMb:F2} MB"); + + _text.text = sb.ToString(); + } + } +} diff --git a/Component/PostRenderHook.cs b/Component/PostRenderHook.cs index 9118731..dacebe9 100644 --- a/Component/PostRenderHook.cs +++ b/Component/PostRenderHook.cs @@ -1,3 +1,4 @@ +using HarmonyLib; using UnityEngine; namespace ADOFAIRenderer.Component @@ -5,9 +6,10 @@ namespace ADOFAIRenderer.Component [DefaultExecutionOrder(int.MaxValue)] public class PostRenderHook : MonoBehaviour { - private void OnPostRender() + private void LateUpdate() { - RenderMod.OnPostRender(); + var val = (MeshRenderer)AccessTools.Field(typeof(scrCamera), "camQuadMesh").GetValue(scrCamera.instance); + RenderMod.OnPostRender(val.material.mainTexture as RenderTexture); } } } diff --git a/Component/RenderManager.cs b/Component/RenderManager.cs index bbae85f..69559c1 100644 --- a/Component/RenderManager.cs +++ b/Component/RenderManager.cs @@ -1,5 +1,10 @@ +using System; using System.Collections; +using System.Collections.Generic; using System.IO; +using System.Threading; +using ADOFAIRenderer.Util; +using MelonLoader; using MelonLoader.Utils; using UnityEngine; @@ -7,12 +12,13 @@ namespace ADOFAIRenderer.Component { public class RenderManager : MonoBehaviour { - public static int Countdown = 0; - public static int FrameCount = 0; - public static bool SkipPressToStart = false; + public static int Countdown; + public static int FrameCount; + public static bool SkipPressToStart; public static string LevelPath = ""; public static RenderManager Instance = null!; private static int _originalInputOffset; + private static GameObject? _debugMonitorObj; public static RenderSettings Configuration; @@ -26,8 +32,9 @@ namespace ADOFAIRenderer.Component public int Bitrate; public string Codec; + public double EndWaitTime; - public RenderSettings(double targetFPS, double videoFPS, int renderWidth, int renderHeight, int bitrate, string codec) + public RenderSettings(double targetFPS, double videoFPS, int renderWidth, int renderHeight, int bitrate, string codec, double endWaitTime) { TargetFPS = targetFPS; VideoFPS = videoFPS; @@ -37,9 +44,16 @@ namespace ADOFAIRenderer.Component Bitrate = bitrate; Codec = codec; + EndWaitTime = endWaitTime; } } + public static SortedList ImageQueue = new SortedList(); + public static Thread? RenderThread; + public static int PassCount = 0; + public static bool EndRequested = false; + public static bool EndSuccessful = false; + private static int _wonFrame = -1; private static bool _renderSetup; @@ -67,17 +81,46 @@ namespace ADOFAIRenderer.Component _originalInputOffset = scrConductor.currentPreset.inputOffset; scrConductor.currentPreset.inputOffset = 0; RDC.auto = true; + if (scrController.instance) + { + scrController.instance.noFail = true; + scrController.instance.noFailInfiniteMargin = true; + } + + RenderMod.TimeBase = Time.timeAsDouble; + RenderMod.UnscaledTimeBase = Time.unscaledTimeAsDouble; + RenderMod.DspTimeBase = AudioSettings.dspTime; + RenderMod.FrameCountBase = Time.frameCount; RenderMod.IsRendering = true; Countdown = 3; - scrController.instance.StartCoroutine(SetupPlay()); + scrController.instance!.StartCoroutine(SetupPlay()); } - - if (RenderMod.IsRendering && scrController.instance.state == States.Won) + + if (!RenderMod.IsRendering) return; + if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2) { RenderMod.IsRendering = RenderMod.IsCapturing = false; + + Time.timeScale = 1f; scrConductor.currentPreset.inputOffset = _originalInputOffset; + StopFFmpeg(false); + } + else if (scrController.instance.state == States.Won) + { + if (_wonFrame == -1) + { + _wonFrame = FrameCount; + } + + var extraFrames = (int)Math.Ceiling(Configuration.EndWaitTime * Configuration.TargetFPS); + + if (FrameCount - _wonFrame < extraFrames) return; + RenderMod.IsRendering = RenderMod.IsCapturing = false; + Time.timeScale = 1f; + scrConductor.currentPreset.inputOffset = _originalInputOffset; + StopFFmpeg(true); } } @@ -109,8 +152,11 @@ namespace ADOFAIRenderer.Component { yield return new WaitForSecondsRealtime(1); } - + FrameCount = 0; + + StartFFmpegThread(); + RenderMod.IsCapturing = true; scrController.instance.paused = scrController.instance.audioPaused = false; scrController.instance.levelWasSkipped = true; @@ -118,5 +164,179 @@ namespace ADOFAIRenderer.Component scrConductor.instance.song.clip = null; scrConductor.instance.song.time = -1000; } + + private string FindFFmpegPath() + { + bool isWindows = Application.platform == RuntimePlatform.WindowsPlayer; + string 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); + if (File.Exists(path3)) return path3; + + return ""; + } + + private void StartFFmpegThread() + { + var ffmpegPath = FindFFmpegPath(); + if (string.IsNullOrEmpty(ffmpegPath)) + { + Melon.Logger.Error("ffmpeg 바이너리를 찾을 수 없습니다. Mods/ADOFAIRenderer/ 에 ffmpeg를 넣어주세요."); + return; + } + + if (_debugMonitorObj == null) + { + _debugMonitorObj = new GameObject("DebugMonitorCanvas"); + _debugMonitorObj.AddComponent(); + DontDestroyOnLoad(_debugMonitorObj); + } + + FFmpegProcess.FFmpegPath = ffmpegPath; + lock (ImageQueue) + { + ImageQueue.Clear(); + } + PassCount = 0; + RenderMod.FrameCountForVideo = 0; + RenderMod.RenderCheck = true; + RenderMod.SkipCount = 0; + EndRequested = false; + EndSuccessful = false; + _wonFrame = -1; + + double targetFps = Configuration.TargetFPS; + double videoFps = Configuration.VideoFPS; + float bitrate = Configuration.Bitrate; + string codecStr = Configuration.Codec; + + VideoFFmpeg.Codec selectedCodec = VideoFFmpeg.Codec.H264_Default; + if (Enum.TryParse(codecStr, true, out var parsedCodec)) + { + selectedCodec = parsedCodec; + } + + string 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"; + } + + RenderThread = new Thread(() => + { + try + { + float workingFps = (float)(targetFps % videoFps == 0.0 ? videoFps : targetFps); + VideoFFmpeg videoFFmpeg = new VideoFFmpeg(Configuration.RenderWidth, Configuration.RenderHeight, (int)workingFps, (int)videoFps, selectedCodec); + + var outPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", "out" + ext); + SetupCaptureDirectory(); + + videoFFmpeg.Start(outPath, bitrate); + + int num = targetFps % videoFps != 0.0 ? 1 : (int)(targetFps / videoFps); + + while (true) + { + byte[]? buffer = null; + lock (ImageQueue) + { + if (ImageQueue.Count > 0) + { + int firstKey = ImageQueue.Keys[0]; + if (firstKey == PassCount) + { + buffer = ImageQueue[firstKey]; + ImageQueue.RemoveAt(0); + PassCount += num; + } + } + } + + if (buffer != null) + { + if (buffer.Length > 0) + { + int bufferSize = Configuration.RenderWidth * Configuration.RenderHeight * 4; + videoFFmpeg.SendDataFromBuffer(buffer, bufferSize); + System.Buffers.ArrayPool.Shared.Return(buffer); + } + continue; + } + + if (EndRequested) + { + bool hasMore; + lock (ImageQueue) + { + hasMore = ImageQueue.Count > 0; + } + if (!hasMore) break; + } + + Thread.Sleep(1); + } + + videoFFmpeg.Close(); + EndSuccessful = true; + } + catch (Exception ex) + { + Melon.Logger.Error($"FFmpeg 렌더링 스레드 오류: {ex}"); + } + }); + + RenderThread.Start(); + } + + private void StopFFmpeg(bool success) + { + EndRequested = true; + if (success) + { + StartCoroutine(StopRenderCoroutine()); + } + else + { + Melon.Logger.Error("렌더링이 실패했거나 취소되었습니다."); + if (_debugMonitorObj) + { + Destroy(_debugMonitorObj); + _debugMonitorObj = null; + } + if (RenderThread != null && RenderThread.IsAlive) + { + RenderThread.Join(2000); + } + } + } + + private static IEnumerator StopRenderCoroutine() + { + yield return new WaitUntil(() => EndSuccessful); + + if (_debugMonitorObj) + { + Destroy(_debugMonitorObj); + _debugMonitorObj = null; + } + + RDC.auto = false; + ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); + } + } } \ No newline at end of file diff --git a/Patch/ClassPatch/scrControllerPatch.cs b/Patch/ClassPatch/scrControllerPatch.cs index bdac1db..3d22a48 100644 --- a/Patch/ClassPatch/scrControllerPatch.cs +++ b/Patch/ClassPatch/scrControllerPatch.cs @@ -1,4 +1,5 @@ using HarmonyLib; +using MelonLoader; namespace ADOFAIRenderer.Patch.ClassPatch { @@ -12,5 +13,33 @@ namespace ADOFAIRenderer.Patch.ClassPatch { return !RenderMod.IsRendering; } + + [HarmonyPrefix] + [HarmonyPatch("PortalTravelAction")] + public static bool PreventPortalTravel() + { + return !RenderMod.IsRendering; + } + + [HarmonyPrefix] + [HarmonyPatch("FailAction")] + public static bool PreventFailAction() + { + return !RenderMod.IsRendering; + } + + [HarmonyPrefix] + [HarmonyPatch("Fail2Action")] + public static bool PreventFail2Action() + { + return !RenderMod.IsRendering; + } + + [HarmonyPrefix] + [HarmonyPatch("OnPlayerDied")] + public static bool PreventPlayerDied() + { + return !RenderMod.IsRendering; + } } } \ No newline at end of file diff --git a/Patch/ClassPatch/scrDecorationPatch.cs b/Patch/ClassPatch/scrDecorationPatch.cs new file mode 100644 index 0000000..08c65cd --- /dev/null +++ b/Patch/ClassPatch/scrDecorationPatch.cs @@ -0,0 +1,55 @@ +using HarmonyLib; +using UnityEngine; + +namespace ADOFAIRenderer.Patch.ClassPatch +{ + [HarmonyPatch(typeof(scrDecoration))] + // ReSharper disable once InconsistentNaming + public static class scrDecorationPatch + { + [HarmonyPrefix] + [HarmonyPatch("SetRotation")] + // ReSharper disable once InconsistentNaming + public static bool SetRotationPrefix(scrDecoration __instance) + { + if (__instance == null || !__instance || __instance.pivotTrans == null) + { + return false; + } + return true; + } + + [HarmonyPrefix] + [HarmonyPatch("SetScale")] + public static bool SetScalePrefix(scrDecoration __instance) + { + if (__instance == null || !__instance || __instance.pivotTrans == null) + { + return false; + } + return true; + } + + [HarmonyPrefix] + [HarmonyPatch("SetOpacity")] + public static bool SetOpacityPrefix(scrDecoration __instance) + { + if (__instance == null || !__instance) + { + return false; + } + return true; + } + + [HarmonyPrefix] + [HarmonyPatch("UpdatePosition")] + public static bool UpdatePositionPrefix(scrDecoration __instance) + { + if (__instance == null || !__instance || __instance.pivotTrans == null) + { + return false; + } + return true; + } + } +} diff --git a/Patch/ClassPatch/scrPlayerManagerPatch.cs b/Patch/ClassPatch/scrPlayerManagerPatch.cs index 6b82988..79254b3 100644 --- a/Patch/ClassPatch/scrPlayerManagerPatch.cs +++ b/Patch/ClassPatch/scrPlayerManagerPatch.cs @@ -14,7 +14,7 @@ namespace ADOFAIRenderer.Patch.ClassPatch { if (!RenderMod.IsRendering) return true; - __result = RenderManager.Countdown == 0; + __result = RenderManager.FrameCount == 0 && RenderManager.Countdown == 0; return false; } } diff --git a/Patch/RenderEnvironmentPatches.cs b/Patch/RenderEnvironmentPatches.cs index 443ce9c..39812f6 100644 --- a/Patch/RenderEnvironmentPatches.cs +++ b/Patch/RenderEnvironmentPatches.cs @@ -16,7 +16,7 @@ namespace ADOFAIRenderer.Patch public static bool Prefix(ref float __result) { if (!RenderMod.IsCapturing || !RenderPatch) return true; - __result = (float)(RenderManager.FrameCount / RenderManager.Configuration.TargetFPS); + __result = (float)(RenderManager.FrameCount / RenderManager.Configuration.TargetFPS + RenderMod.TimeBase); return false; } } @@ -28,7 +28,7 @@ namespace ADOFAIRenderer.Patch public static bool Prefix(ref double __result) { if (!RenderMod.IsCapturing || !RenderPatch) return true; - __result = RenderManager.FrameCount / RenderManager.Configuration.TargetFPS; + __result = RenderManager.FrameCount / RenderManager.Configuration.TargetFPS + RenderMod.TimeBase; return false; } } @@ -40,7 +40,7 @@ namespace ADOFAIRenderer.Patch public static bool Prefix(ref float __result) { if (!RenderMod.IsCapturing || !RenderPatch) return true; - __result = (float)(RenderManager.FrameCount / RenderManager.Configuration.TargetFPS); + __result = (float)(RenderManager.FrameCount / RenderManager.Configuration.TargetFPS + RenderMod.UnscaledTimeBase); return false; } } @@ -52,7 +52,7 @@ namespace ADOFAIRenderer.Patch public static bool Prefix(ref double __result) { if (!RenderMod.IsCapturing || !RenderPatch) return true; - __result = RenderManager.FrameCount / RenderManager.Configuration.TargetFPS; + __result = RenderManager.FrameCount / RenderManager.Configuration.TargetFPS + RenderMod.UnscaledTimeBase; return false; } } @@ -64,7 +64,7 @@ namespace ADOFAIRenderer.Patch public static bool Prefix(ref double __result) { if (!RenderMod.IsCapturing || !RenderPatch) return true; - __result = RenderManager.FrameCount / RenderManager.Configuration.TargetFPS; + __result = RenderManager.FrameCount / RenderManager.Configuration.TargetFPS + RenderMod.DspTimeBase; return false; } @@ -87,7 +87,7 @@ namespace ADOFAIRenderer.Patch public static bool Prefix(ref int __result) { if (!RenderMod.IsCapturing || !RenderPatch) return true; - __result = RenderManager.FrameCount; + __result = RenderManager.FrameCount + RenderMod.FrameCountBase; return false; } } diff --git a/RenderMod.cs b/RenderMod.cs index 54140ae..6258a6f 100644 --- a/RenderMod.cs +++ b/RenderMod.cs @@ -6,6 +6,7 @@ using ADOFAIRenderer.Util; using MelonLoader; using UnityEngine; using UnityEngine.EventSystems; +using UnityEngine.Rendering; using Object = UnityEngine.Object; [assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.0.3-INDEV", "ADOFAI Editor Forum")] @@ -17,15 +18,24 @@ namespace ADOFAIRenderer public class RenderMod : MelonMod { public static AssetBundle FontBundle = null!; + // ReSharper disable once MemberCanBePrivate.Global public static AssetBundle SceneBundle = null!; public static bool WatchGameLoaded; public static string[] ModScenes = {}; - private static double _beforeTime = 0; + private static double _beforeTime; public static bool IsRendering = false; public static bool IsCapturing = false; - + + public static double TimeBase; + public static double UnscaledTimeBase; + public static double DspTimeBase; + public static int FrameCountBase; + + public static bool RenderCheck = true; + public static int SkipCount = 0; + public override void OnInitializeMelon() { var renderManagerObj = new GameObject("Render Manager"); @@ -65,14 +75,124 @@ namespace ADOFAIRenderer _beforeTime = Time.realtimeSinceStartupAsDouble; if (!IsRendering) return; + + // [렉으로 인한 프레임 렌더링 스킵 시 재시도 로직 (원본 모드 이식)] + if (RenderCheck) + { + if (SkipCount < 60) + { + Melon.Logger.Warning($"[Render Skipped] Frame {RenderManager.FrameCount} rendering missed. Retrying... (Attempt {SkipCount + 1}/60)"); + SkipCount++; + Time.timeScale = 0f; // 진행 시간 멈추기 (재시도) + return; // 업데이트 진행하지 않고 대기 + } + + // 60회 초과 시 최후의 블랙 프레임 삽입하여 전체 파이프라인 마비 방지 + Melon.Logger.Error($"[Render Skipped] Frame {RenderManager.FrameCount} rendering failed after 60 retries. Supplying black frame."); + + int targetFps = (int)RenderManager.Configuration.TargetFPS; + int videoFps = (int)RenderManager.Configuration.VideoFPS; + int num = targetFps % videoFps != 0 ? 1 : targetFps / videoFps; + + if (FrameCountForVideo % num == 0) + { + int byteSize = RenderManager.Configuration.RenderWidth * RenderManager.Configuration.RenderHeight * 4; + byte[] blackFrame = System.Buffers.ArrayPool.Shared.Rent(byteSize); + Array.Clear(blackFrame, 0, byteSize); + + lock (RenderManager.ImageQueue) + { + RenderManager.ImageQueue[FrameCountForVideo] = blackFrame; + } + } + + FrameCountForVideo++; + RenderCheck = false; // 진행 허용 + } + + SkipCount = 0; + RenderCheck = true; // 다음 프레임 검사용으로 초기화 RenderManager.FrameCount++; Time.timeScale = (float)(Time.deltaTime / realDeltaTime); } - public static void OnPostRender() + public static int FrameCountForVideo = 0; + public static int ReadbackFailureCount = 0; + public static int ThrottleCount = 0; + public static bool IsThrottling = false; + + public static 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 (RenderManager.ImageQueue) + { + count = RenderManager.ImageQueue.Count; + } + + if (count <= maxQueuedFrames) break; + + if (!IsThrottling) + { + IsThrottling = true; + ThrottleCount++; + } + System.Threading.Thread.Sleep(1); + } + IsThrottling = false; + + var currentFrame = FrameCountForVideo++; + + var targetFps = RenderManager.Configuration.TargetFPS; + var videoFps = RenderManager.Configuration.VideoFPS; + var num = targetFps % videoFps != 0.0 ? 1 : (int)(targetFps / videoFps); + + if (currentFrame % num != 0) + { + return; + } + + AsyncGPUReadback.Request(texture, 0, delegate(AsyncGPUReadbackRequest req) + { + if (!IsCapturing) return; + + byte[] rawBytes; + if (req.hasError) + { + ReadbackFailureCount++; + Melon.Logger.Warning($"[OOM Prevent] GPU Readback failed for frame {currentFrame}. Queue deadlock prevented by sending an empty frame."); + rawBytes = Array.Empty(); // 1번 작업: 빈 배열로 대체하여 정체 방지 + } + else + { + try + { + var data = req.GetData(); + rawBytes = FFmpegProcess.RentRawBytes(data); // 3번 작업: ArrayPool에서 대여 + } + catch (Exception ex) + { + ReadbackFailureCount++; + Melon.Logger.Error($"[OOM Prevent] Native conversion failed for frame {currentFrame}: {ex}"); + rawBytes = Array.Empty(); + } + } + + lock (RenderManager.ImageQueue) + { + RenderManager.ImageQueue[currentFrame] = rawBytes; + } + }); } public static void StartRender() diff --git a/UI/RenderButton.cs b/UI/RenderButton.cs index bd39580..0efe7bb 100644 --- a/UI/RenderButton.cs +++ b/UI/RenderButton.cs @@ -1,3 +1,4 @@ +using System.Linq; using ADOFAIRenderer.Component; using MelonLoader; using TMPro; @@ -26,11 +27,18 @@ namespace ADOFAIRenderer.UI _buttonComponent.onClick.AddListener(OnClick); RenderMod.FixEventSystem(); + + if (codecDropdown == null) return; + codecDropdown.ClearOptions(); + + var options = System.Enum.GetNames(typeof(Util.VideoFFmpeg.Codec)).ToList(); + codecDropdown.AddOptions(options); } public void OnClick() { if (IsAnyFieldEmpty()) return; + if (RenderMod.IsRendering) return; Melon.Logger.Msg($"레벨 로드: {RenderManager.LevelPath}"); double.TryParse(targetFPSInput.text, out var targetFPS); @@ -49,7 +57,8 @@ namespace ADOFAIRenderer.UI RenderWidth = renderWidth, RenderHeight = renderHeight, Bitrate = bitrate, - Codec = selectedCodecName.ToLower(), + Codec = selectedCodecName, + EndWaitTime = 15.0 }; RenderManager.Instance.SetupCaptureDirectory(); diff --git a/Util/FFmpegProcess.cs b/Util/FFmpegProcess.cs new file mode 100644 index 0000000..075abdf --- /dev/null +++ b/Util/FFmpegProcess.cs @@ -0,0 +1,71 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.IO; +using Unity.Collections; + +namespace ADOFAIRenderer.Util +{ + public class FFmpegProcess + { + public static string FFmpegPath = ""; + + private Process? _ffmpegProcess; + private Stream? _inputStream; + + public bool IsEnd => _ffmpegProcess is { HasExited: true }; + + public void Execute(string command) + { + if (string.IsNullOrEmpty(command)) + { + throw new ArgumentException("Command is empty"); + } + if (string.IsNullOrEmpty(FFmpegPath) || !File.Exists(FFmpegPath)) + { + throw new FileNotFoundException($"ffmpeg binary not found at '{FFmpegPath}'"); + } + + _ffmpegProcess = new Process(); + _ffmpegProcess.StartInfo.FileName = FFmpegPath; + _ffmpegProcess.StartInfo.Arguments = command; + _ffmpegProcess.StartInfo.UseShellExecute = false; + _ffmpegProcess.StartInfo.RedirectStandardInput = true; + _ffmpegProcess.StartInfo.CreateNoWindow = true; + _ffmpegProcess.Start(); + _inputStream = _ffmpegProcess.StandardInput.BaseStream; + } + + public void SendDataFromBuffer(byte[] buffer, int length = -1) + { + if (_inputStream == null) return; + int writeLength = length < 0 ? buffer.Length : length; + _inputStream.Write(buffer, 0, writeLength); + _inputStream.Flush(); + } + + public void Close() + { + if (_ffmpegProcess == null || _ffmpegProcess.HasExited || _inputStream == null) return; + _inputStream.Flush(); + _inputStream.Close(); + _ffmpegProcess.WaitForExit(); + } + + public static byte[] RentRawBytes(NativeArray arr) where T : struct + { + var val = new NativeSlice(arr).SliceConvert(); + var array = ArrayPool.Shared.Rent(val.Length); + val.CopyTo(array); + return array; + } + + public static byte[] ToRawBytes(NativeArray arr) where T : struct + { + var val = new NativeSlice(arr).SliceConvert(); + var array = new byte[val.Length]; + val.CopyTo(array); + return array; + } + } +} diff --git a/Util/VideoFFmpeg.cs b/Util/VideoFFmpeg.cs new file mode 100644 index 0000000..fc69812 --- /dev/null +++ b/Util/VideoFFmpeg.cs @@ -0,0 +1,76 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Unity.Collections; +using UnityEngine; + +namespace ADOFAIRenderer.Util +{ + public class VideoFFmpeg : FFmpegProcess + { + [SuppressMessage("ReSharper", "InconsistentNaming")] + public enum Codec + { + H264_Default, + H264_NVIDIA, + H264_QSV, + HEVC_Default, + HEVC_NVIDIA, + HEVC_QSV, + AV1_Default, + AV1_NVIDIA, + AV1_QSV, + VP9_Default, + VP9_NVIDIA + } + + public int Width; + public int Height; + public int FrameRate; + public int VideoFPS; + public Codec VideoCodec; + + private static string Codec2String(Codec c) + { + return c switch + { + Codec.H264_Default => "-vf vflip", + Codec.H264_NVIDIA => "-c:v h264_nvenc -vf vflip", + Codec.H264_QSV => "-c:v h264_qsv -vf vflip", + Codec.HEVC_Default => "-c:v libx265 -vf vflip", + Codec.HEVC_NVIDIA => "-c:v hevc_nvenc -vf vflip", + Codec.HEVC_QSV => "-c:v hevc_qsv -vf vflip", + Codec.AV1_Default => "-c:v libaom-av1 -vf vflip", + Codec.AV1_NVIDIA => "-c:v av1_nvenc -vf vflip", + Codec.AV1_QSV => "-c:v av1_qsv -vf vflip", + Codec.VP9_Default => "-c:v libvpx-vp9 -vf vflip", + Codec.VP9_NVIDIA => "-c:v vp9_nvenc -vf vflip", + _ => "", + }; + } + + public VideoFFmpeg(int w, int h, int frame, int videoFrame, Codec codec) + { + Width = w; + Height = h; + FrameRate = frame; + VideoFPS = videoFrame; + VideoCodec = codec; + } + + public void SendData(NativeArray source) + { + var buffer = ToRawBytes(source); + SendDataFromBuffer(buffer); + } + + public void Start(string path, float bitrate) + { + if (File.Exists(path)) + { + File.Delete(path); + } + var command = "-y -f rawvideo -vcodec rawvideo -pixel_format rgba -colorspace rgb -color_range 2 -video_size " + Width + "x" + Height + " -framerate " + FrameRate + " -threads 4 -loglevel quiet -i - " + Codec2String(VideoCodec) + " -pix_fmt yuv420p -fps_mode cfr " + " -r " + VideoFPS + $" -b:v {(int)bitrate}k" + " \"" + path + "\""; + Execute(command); + } + } +}