From 036216c0060feeafdeca09fa68b20aa19e122bd537be9b3b7ad82e188087a3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4DELTA?= Date: Fri, 29 May 2026 12:03:03 +0900 Subject: [PATCH 1/3] Move Memory Copy Handling from Main Thread to Render Thread --- Core/RenderManager.cs | 61 +++++++++++++++++++++++++++++++++++-------- Util/FrameReader.cs | 32 +++++++++++++---------- 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index d71a2c2..a163986 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -79,8 +79,16 @@ namespace ADOFAIRenderer.Core } } + 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 - public SortedList ImageQueue = new SortedList(); + internal SortedList ImageQueue = new SortedList(); public Thread? RenderThread; public long passCount; public bool endRequested; @@ -202,7 +210,11 @@ namespace ADOFAIRenderer.Core lock (ImageQueue) { - ImageQueue[videoFrameIndex] = blackFrame; + ImageQueue[videoFrameIndex] = new CapturedFrame + { + ManagedBuffer = blackFrame, + Length = byteSize + }; } } @@ -416,7 +428,8 @@ namespace ADOFAIRenderer.Core while (true) { - byte[]? buffer = null; + CapturedFrame frame = default; + bool hasFrame = false; lock (ImageQueue) { if (ImageQueue.Count > 0) @@ -430,20 +443,42 @@ namespace ADOFAIRenderer.Core if (firstKey == passCount) { - buffer = ImageQueue[firstKey]; + frame = ImageQueue[firstKey]; ImageQueue.RemoveAt(0); passCount++; + hasFrame = true; } } } - if (buffer != null) + if (hasFrame) { - if (buffer.Length > 0) + if (frame.ManagedBuffer != null) { - var bufferSize = Configuration.RenderWidth * Configuration.RenderHeight * 4; - videoFFmpeg.SendDataFromBuffer(buffer, bufferSize); + if (frame.ManagedBuffer.Length > 0) + { + videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length); + ArrayPool.Shared.Return(frame.ManagedBuffer); + } + } + else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0) + { + var buffer = FFmpegProcess.RentRawBytes(frame.Length); + unsafe + { + fixed (byte* destPtr = buffer) + { + Buffer.MemoryCopy((void*)frame.NativePointer, destPtr, buffer.Length, frame.Length); + } + } + + videoFFmpeg.SendDataFromBuffer(buffer, frame.Length); ArrayPool.Shared.Return(buffer); + + if (frame.ReadbackState != null) + { + _frameReader?.RecycleState(frame.ReadbackState); + } } continue; } @@ -514,11 +549,15 @@ namespace ADOFAIRenderer.Core // 1. 메모리 누수 방지: 큐에 렌트된 임시 프레임 버퍼 풀 반환 및 클리어 lock (ImageQueue) { - foreach (var kvp in ImageQueue) + foreach (var kvp in ImageQueue.Values) { - if (kvp.Value != null && kvp.Value.Length > 0) + if (kvp.ManagedBuffer != null && kvp.ManagedBuffer.Length > 0) { - ArrayPool.Shared.Return(kvp.Value); + ArrayPool.Shared.Return(kvp.ManagedBuffer); + } + if (kvp.ReadbackState != null) + { + _frameReader?.RecycleState(kvp.ReadbackState); } } ImageQueue.Clear(); diff --git a/Util/FrameReader.cs b/Util/FrameReader.cs index 6a05c91..e8074d8 100644 --- a/Util/FrameReader.cs +++ b/Util/FrameReader.cs @@ -91,28 +91,32 @@ namespace ADOFAIRenderer.Util var byteSize = state.NativeBuffer.Length; var blackFrame = ArrayPool.Shared.Rent(byteSize); Array.Clear(blackFrame, 0, byteSize); - PushToQueue(frame, blackFrame); + + var capFrame = new RenderManager.CapturedFrame + { + ManagedBuffer = blackFrame, + Length = byteSize + }; + PushToQueue(frame, capFrame); + RecycleState(state); } else { unsafe { - var ptr = state.NativeBuffer.GetUnsafeReadOnlyPtr(); - var rawBytes = FFmpegProcess.RentRawBytes(state.NativeBuffer.Length); - - fixed (byte* destPtr = rawBytes) + var ptr = (IntPtr)state.NativeBuffer.GetUnsafeReadOnlyPtr(); + var capFrame = new RenderManager.CapturedFrame { - Buffer.MemoryCopy(ptr, destPtr, rawBytes.Length, state.NativeBuffer.Length); - } - - PushToQueue(frame, rawBytes); + NativePointer = ptr, + Length = state.NativeBuffer.Length, + ReadbackState = state + }; + PushToQueue(frame, capFrame); } } - - RecycleState(state); } - private void RecycleState(ReadbackRequestState state) + internal void RecycleState(ReadbackRequestState state) { lock (_poolLock) { @@ -127,12 +131,12 @@ namespace ADOFAIRenderer.Util } } - private static void PushToQueue(long frame, byte[] rawBytes) + private static void PushToQueue(long frame, RenderManager.CapturedFrame capturedFrame) { if (!RenderManager.Instance) return; lock (RenderManager.Instance.ImageQueue) { - RenderManager.Instance.ImageQueue[frame] = rawBytes; + RenderManager.Instance.ImageQueue[frame] = capturedFrame; } } From 6bd4a8975e56202e65006a6771a76b40f5ccf69a2f59f7f84aa2b864ddcf6e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4DELTA?= Date: Mon, 1 Jun 2026 11:59:39 +0900 Subject: [PATCH 2/3] Reduce Memory Copy When Frame Capture Success --- Core/RenderManager.cs | 12 +----------- Util/FFmpeg/FFmpegProcess.cs | 7 +++++++ Util/StreamUtil.cs | 24 ++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 Util/StreamUtil.cs diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index a163986..9bdf07c 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -463,17 +463,7 @@ namespace ADOFAIRenderer.Core } else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0) { - var buffer = FFmpegProcess.RentRawBytes(frame.Length); - unsafe - { - fixed (byte* destPtr = buffer) - { - Buffer.MemoryCopy((void*)frame.NativePointer, destPtr, buffer.Length, frame.Length); - } - } - - videoFFmpeg.SendDataFromBuffer(buffer, frame.Length); - ArrayPool.Shared.Return(buffer); + videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length); if (frame.ReadbackState != null) { diff --git a/Util/FFmpeg/FFmpegProcess.cs b/Util/FFmpeg/FFmpegProcess.cs index 5424d45..68c0293 100644 --- a/Util/FFmpeg/FFmpegProcess.cs +++ b/Util/FFmpeg/FFmpegProcess.cs @@ -44,6 +44,13 @@ namespace ADOFAIRenderer.Util.FFmpeg _inputStream.Flush(); } + public void SendDataFromNativeBuffer(IntPtr nativePtr, int length) + { + if (_inputStream == null) return; + StreamUtil.WriteNativePtrToStream(nativePtr, length, _inputStream); + _inputStream.Flush(); + } + public void Close() { if (_ffmpegProcess == null || _ffmpegProcess.HasExited || _inputStream == null) return; diff --git a/Util/StreamUtil.cs b/Util/StreamUtil.cs new file mode 100644 index 0000000..8d73c04 --- /dev/null +++ b/Util/StreamUtil.cs @@ -0,0 +1,24 @@ +using System; +using System.IO; + +namespace ADOFAIRenderer.Util +{ + public static class StreamUtil + { + public static void WriteNativePtrToStream(IntPtr nativePtr, int length, Stream destinationStream) + { + if (nativePtr == IntPtr.Zero) + throw new ArgumentNullException(nameof(nativePtr)); + if (length <= 0) return; + + unsafe + { + // Create an unmanaged memory stream that wraps the native pointer + // (This does not allocate new memory; it references the existing pointer) + using var unmanagedStream = new UnmanagedMemoryStream((byte*)nativePtr, length); + // Copy the data directly into your target stream + unmanagedStream.CopyTo(destinationStream); + } + } + } +} \ No newline at end of file From bc7a7eca7514e4d0a38ada94d3d7078c09dd9c1f5e97c33bebaa680aa38fa07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4DELTA?= Date: Mon, 1 Jun 2026 12:35:26 +0900 Subject: [PATCH 3/3] Fix Little Memory Leak Risk --- Core/RenderManager.cs | 16 ++++++++++++++++ Util/FrameReader.cs | 27 +++++++++++++++++++++------ Util/StreamUtil.cs | 15 ++++++++++----- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index 9bdf07c..e78f74c 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -624,6 +624,22 @@ namespace ADOFAIRenderer.Core GCS.customLevelPaths = null; GCS.customLevelId = null; + lock (ImageQueue) + { + foreach (var kvp in ImageQueue.Values) + { + if (kvp.ManagedBuffer != null && kvp.ManagedBuffer.Length > 0) + { + ArrayPool.Shared.Return(kvp.ManagedBuffer); + } + if (kvp.ReadbackState != null) + { + _frameReader?.RecycleState(kvp.ReadbackState); + } + } + ImageQueue.Clear(); + } + _frameReader?.Dispose(); ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); } diff --git a/Util/FrameReader.cs b/Util/FrameReader.cs index e8074d8..9b05308 100644 --- a/Util/FrameReader.cs +++ b/Util/FrameReader.cs @@ -1,8 +1,8 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.Linq; using ADOFAIRenderer.Core; -using ADOFAIRenderer.Util.FFmpeg; using MelonLoader; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; @@ -16,6 +16,7 @@ namespace ADOFAIRenderer.Util private const int InitialBufferSize = 16; private readonly Stack _statePool = new Stack(); + private readonly List _allocatedStates = new List(); private readonly object _poolLock = new object(); private bool _isInitialized; private volatile bool _isDisposed; @@ -27,13 +28,14 @@ namespace ADOFAIRenderer.Util private void InitializePool() { - lock (_poolLock) { if (_isInitialized) return; for (var i = 0; i < InitialBufferSize; i++) { - _statePool.Push(new ReadbackRequestState(this)); + var state = new ReadbackRequestState(this); + _statePool.Push(state); + _allocatedStates.Add(state); } _isInitialized = true; @@ -47,7 +49,17 @@ namespace ADOFAIRenderer.Util lock (_poolLock) { - state = _statePool.Count == 0 ? new ReadbackRequestState(this) : _statePool.Pop(); + if (_statePool.Count == 0) + { + state = new ReadbackRequestState(this); + _allocatedStates.Add(state); + } + else + { + state = _statePool.Pop(); + } + + state.IsInFlight = true; } var bufferSize = texture.width * texture.height * 4; // RGBA = 4 channel @@ -67,6 +79,7 @@ namespace ADOFAIRenderer.Util lock (_poolLock) { + state.IsInFlight = false; if (_isDisposed) { state.Dispose(); @@ -146,11 +159,12 @@ namespace ADOFAIRenderer.Util { if (_isDisposed) return; _isDisposed = true; - while (_statePool.Count > 0) + foreach (var state in _allocatedStates.Where(state => !state.IsInFlight)) { - var state = _statePool.Pop(); state.Dispose(); } + _statePool.Clear(); + _allocatedStates.Clear(); _isInitialized = false; } } @@ -161,6 +175,7 @@ namespace ADOFAIRenderer.Util private readonly FrameReader _owner; public long FrameIndex { get; private set; } public NativeArray NativeBuffer; + public bool IsInFlight { get; internal set; } public readonly Action CachedCallback; diff --git a/Util/StreamUtil.cs b/Util/StreamUtil.cs index 8d73c04..fc21487 100644 --- a/Util/StreamUtil.cs +++ b/Util/StreamUtil.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using MelonLoader; namespace ADOFAIRenderer.Util { @@ -13,11 +14,15 @@ namespace ADOFAIRenderer.Util unsafe { - // Create an unmanaged memory stream that wraps the native pointer - // (This does not allocate new memory; it references the existing pointer) - using var unmanagedStream = new UnmanagedMemoryStream((byte*)nativePtr, length); - // Copy the data directly into your target stream - unmanagedStream.CopyTo(destinationStream); + try + { + var span = new ReadOnlySpan((void*)nativePtr, length); + destinationStream.Write(span); + } + catch (IOException err) + { + Melon.Logger.Error($"FFmpeg 파이프가 예상치 못하게 닫혔습니다: {err.Message}"); + } } } }