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;