diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs
index 743c266..0f05881 100644
--- a/Core/RenderManager.cs
+++ b/Core/RenderManager.cs
@@ -5,7 +5,6 @@ using System.IO;
using System.Threading;
using System.Buffers;
using System.Diagnostics;
-using System.Linq;
using ADOFAIRenderer.API;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
@@ -81,7 +80,27 @@ internal class RenderManager : MonoBehaviour
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
+ ///
+ /// Maximum number of frames in ImageQueue. The main thread waits (throttles) while the queue holds more frames.
+ ///
public const int MaxQueuedFrames = 64;
+
+ ///
+ /// Upper bound of the readback buffer bytes of the frames in ImageQueue (4 GiB).
+ /// The frame limit becomes min(MaxQueuedFrames, MaxQueuedBytes / bytes per frame) (see ComputeQueueFrameLimit),
+ /// so it only lowers the frame count at resolutions where MaxQueuedFrames frames exceed this size.
+ ///
+ public const long MaxQueuedBytes = 4L * 1024 * 1024 * 1024;
+
+ ///
+ /// Wait time of the throttle loop (OnPostFrame) for the render thread to take a frame from ImageQueue.
+ /// When it elapses without a dequeue, AsyncGPUReadback.WaitAllRequests() is called once so the readbacks
+ /// still in flight complete (the render thread may be waiting for one of them to fill a gap in ImageQueue).
+ ///
+ private const int ThrottleWaitMilliseconds = 10;
+
+ /// Frame limit of ImageQueue applied by the last throttle check (MaxQueuedFrames or lower).
+ public int QueueFrameLimit { get; private set; } = MaxQueuedFrames;
// Statistics
public long framesSent;
@@ -213,6 +232,8 @@ internal class RenderManager : MonoBehaviour
// Thread and Queue State
internal SortedList ImageQueue = new();
internal AutoResetEvent FrameAvailableEvent = new(false);
+ // Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.
+ internal AutoResetEvent FrameDequeuedEvent = new(false);
public Thread? RenderThread;
public long passCount;
public volatile bool endRequested;
@@ -439,14 +460,31 @@ internal class RenderManager : MonoBehaviour
_holdFloorsSourceCount = floors.Count;
}
+ ///
+ /// Frame limit of ImageQueue for the given capture texture: MaxQueuedBytes divided by the readback bytes of
+ /// one frame (YUV 4:2:0 when the GPU conversion is active, RGBA otherwise), within 4 to MaxQueuedFrames.
+ ///
+ private int ComputeQueueFrameLimit(RenderTexture texture)
+ {
+ var frameBytes = _frameReader?.GetFrameBufferBytes(texture.width, texture.height)
+ ?? (long)texture.width * texture.height * 4;
+ if (frameBytes <= 0) return MaxQueuedFrames;
+
+ return (int)Math.Min(MaxQueuedFrames, Math.Max(4, MaxQueuedBytes / frameBytes));
+ }
+
// ReSharper disable once Unity.IncorrectMethodSignature
public void OnPostFrame(RenderTexture? texture)
{
if (Instance.stage < RenderStage.Render || !texture) return;
// [Backpressure Throttling]
+ // The main thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread
+ // removes frames, and it signals FrameDequeuedEvent for each one. The wait has a timeout so that the loop
+ // re-checks the render thread state and pumps pending readback completions (see ThrottleWaitMilliseconds).
isThrottling = false;
-
+ var frameLimit = QueueFrameLimit = ComputeQueueFrameLimit(texture!);
+
while (Instance.stage >= RenderStage.Render)
{
if (RenderThread is not { IsAlive: true })
@@ -461,7 +499,7 @@ internal class RenderManager : MonoBehaviour
count = ImageQueue.Count;
}
- if (count <= MaxQueuedFrames) break;
+ if (count <= frameLimit) break;
if (!isThrottling)
{
@@ -469,7 +507,10 @@ internal class RenderManager : MonoBehaviour
throttleCount++;
}
- AsyncGPUReadback.WaitAllRequests();
+ if (!FrameDequeuedEvent.WaitOne(ThrottleWaitMilliseconds))
+ {
+ AsyncGPUReadback.WaitAllRequests();
+ }
}
isThrottling = false;
@@ -649,6 +690,7 @@ internal class RenderManager : MonoBehaviour
endRequested = false;
endSuccessful = false;
FrameAvailableEvent.Reset();
+ FrameDequeuedEvent.Reset();
var renderFPS = Configuration.RenderFPS;
var videoFPS = Configuration.VideoFPS;
@@ -712,7 +754,7 @@ internal class RenderManager : MonoBehaviour
throw;
}
- var encoder = videoFFmpeg!;
+ var encoder = videoFFmpeg;
RenderThread = new Thread(() => RunRenderThread(encoder))
{
IsBackground = true,
@@ -775,6 +817,7 @@ internal class RenderManager : MonoBehaviour
if (TryDequeueNextFrame(out var frame))
{
+ FrameDequeuedEvent.Set();
SendVideoFrame(videoFFmpeg, frame);
WriteAudioForFrame(videoFFmpeg);
continue;
diff --git a/UI/DebugMonitorUI.cs b/UI/DebugMonitorUI.cs
index f2bc2c9..0079c42 100644
--- a/UI/DebugMonitorUI.cs
+++ b/UI/DebugMonitorUI.cs
@@ -164,7 +164,7 @@ internal class DebugMonitorUI : MonoBehaviour
}
}
- sb.AppendLine($"• Queue: {queueCount} / {RenderManager.MaxQueuedFrames} frames");
+ sb.AppendLine($"• Queue: {queueCount} / {(RenderManager.Instance ? RenderManager.Instance.QueueFrameLimit : RenderManager.MaxQueuedFrames)} frames");
sb.AppendLine($"• Rendered Frames: {(RenderManager.Instance ? RenderManager.Instance.frameCount : 0)}");
sb.AppendLine($"• Frames Sent: {(RenderManager.Instance ? Interlocked.Read(ref RenderManager.Instance.framesSent) : 0)}");
diff --git a/Util/FrameReader.cs b/Util/FrameReader.cs
index 6b83af2..50508d8 100644
--- a/Util/FrameReader.cs
+++ b/Util/FrameReader.cs
@@ -73,6 +73,16 @@ namespace ADOFAIRenderer.Util
}
}
+ ///
+ /// Bytes of the readback buffer of one frame: Width x Height x 3 / 2 when the GPU conversion to NV12 / YUV420P
+ /// is used, Width x Height x 4 (RGBA) otherwise. The same size as ReadbackRequestState.Setup uses.
+ ///
+ public long GetFrameBufferBytes(int width, int height)
+ {
+ var pixels = (long)width * height;
+ return _useGPUConversion ? pixels * 3 / 2 : pixels * 4;
+ }
+
public void RequestFrameCapture(Texture texture, long currentFrame)
{
if (!RenderManager.IsCapturingActive) return;