Files
LlamaCasty/Services/ScreenCaptureFrameSource.cs
gramps b37b8a30f9 perf(capture): overlap GPU readbacks with monotonic publish gate (slice 17)
Measure take ty-1824 on the slice-16 build: the downscale fix worked (conv
~47ms, ring allocs 0) but desktop band was still 88% frozen at 6.8 updates/s.
Telemetry isolated the real wall — CreateCopyFromSurfaceAsync readback ~45ms
of each conversion, serialized one-in-flight => ~17/s capture cap. Docs fact:
pool-sized surfaces CLIP, not scale (Microsoft Learn), so readback stays
native; the lever is concurrency.

- MaxConcurrentConversions=3 with pool 2->5 buffers (in-flight frames fit)
- new MonotonicGate (Interlocked compare-exchange): stale OLDER completions
  are dropped, never overwrite a newer LatestFrame (mirror of 1742 tear)
- FrameRingBuffer.Rent/ConsumeAllocations now lock; downscale row scratch is
  per-conversion locals
- Good Dog test PublishGate_TryPublish_OnlyStrictlyNewerWins; 296/296 green,
  0 warnings; docs cited Microsoft screen-capture page + libyuv fixed-point.

Local only, no push.
2026-09-14 18:40:20 -07:00

331 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading;
using System.Threading.Tasks;
using Windows.Graphics;
using Windows.Graphics.Capture;
using Windows.Graphics.DirectX;
using Windows.Graphics.DirectX.Direct3D11;
using Windows.Graphics.Imaging;
using ytLive.Helpers;
namespace ytLive.Services;
/// <summary>
/// A live screen capture for one target key ("monitor:&lt;n&gt;" or
/// "window:&lt;hwnd&gt;"). Owns the GraphicsCaptureItem, a free-threaded
/// Direct3D11CaptureFramePool and the capture session; frames are converted on
/// the capture worker thread from the GPU surface to a CPU BGRA VideoFrame.
/// Known OS limits: DRM content captures as black frames; capture pauses while
/// the app is minimized (the frame pool simply stops delivering frames).
/// </summary>
public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
{
private readonly object _gate = new();
private GraphicsCaptureItem _item;
private Direct3D11CaptureFramePool? _framePool;
private GraphicsCaptureSession? _session;
private SizeInt32 _poolSize;
private bool _started;
private int _convertInFlight;
private DateTime _lastErrorLog = DateTime.MinValue;
// Buffer recycling (take-11 spikes, 2026-09-04; slice 16, 2026-09-14): a fresh
// ~8.3MB byte[] per DWM frame ≈ 500MB/s of LOH churn — the gen2 pauses it forced
// surfaced as the "worst render 35-65ms" spikes that capped fps at ~42. The pool
// is a reuse-distance ring: a buffer is only rewritten after ≥ redLine rents have
// cycled since its last hand-out (FrameRingBuffer), so a frame a consumer still
// holds — session.LatestFrame survives across conversions and the dispatcher
// preview copy lags — can never be overwritten in place (the 1742 tear, a ring
// slot rewritten under the consumer's read, handed the compositor one
// new-top/old-bottom frame). Each hand-out still carries an Epoch so identity-keyed
// consumers (the compositor's paste cache) cannot false-hit a recycled array.
private readonly FrameRingBuffer _ring = new(8, redLine: 4);
private long _epoch;
private readonly MonotonicGate _publishGate = new();
// The composition master frame (see ai.md "Resolution tiers"): the background
// is an input layer, so we never hold a CPU frame bigger than the master.
private const int MaxBackgroundWidth = 1920;
private const int MaxBackgroundHeight = 1080;
// A failing conversion must not re-flood the log at frame rate.
private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5);
// Conversion cadence (slice 16, 2026-09-14; slice 17, 2026-09-14): the monitor
// delivers at the 240Hz DWM cadence (~4.2ms) while slots are 16.6ms. A 10ms floor
// between conversion starts keeps the open edge above the ~60 conversions/s the pump
// can use. What actually bounded the desktop feed on takes 1742/1824 was the SERIAL
// GPU→CPU readback (`CreateCopyFromSurfaceAsync` ≈ 40-50ms of the ~47ms conversion
// on a 240Hz-HDR box shared with the encoder), capping captures at ~17-20/s.
// Slice 17 overlaps up to MaxConcurrentConversions readbacks (pool sized to
// accommodate in-flight frames) and publishes only monotonically newer frames
// (MonotonicGate — a slow older completion must never overwrite a newer LatestFrame).
private static readonly TimeSpan MinConvertInterval = TimeSpan.FromMilliseconds(10);
private static readonly TimeSpan TelemetryInterval = TimeSpan.FromSeconds(2);
private const int MaxConcurrentConversions = 3;
private const int PoolBufferCount = 5;
private DateTime _lastConvertAt = DateTime.MinValue;
private DateTime _telemetryFrom = DateTime.UtcNow;
private DateTime _lastTelemetry = DateTime.UtcNow;
private long _skippedCadence;
private long _skippedBusy;
private long _conversions;
private long _convertMsTotal;
private long _convertMsMax;
public string Key { get; }
public event Action<VideoFrame>? FrameAvailable;
internal ScreenCaptureFrameSource(string key, GraphicsCaptureItem item)
{
Key = key;
_item = item;
}
/// <summary>Wraps an item chosen through the OS GraphicsCapturePicker.</summary>
public static ScreenCaptureFrameSource CreateForPicker(string key, GraphicsCaptureItem item)
=> new(key, item);
public static ScreenCaptureFrameSource CreateForMonitor(int monitorIndex)
{
var hmonitor = Win32FullScreenDetector.GetMonitorHandle(monitorIndex);
if (hmonitor == IntPtr.Zero)
throw new InvalidOperationException($"Monitor {monitorIndex} is not connected");
var item = CaptureInterop.CreateForMonitor(hmonitor);
return new ScreenCaptureFrameSource($"monitor:{monitorIndex}", item);
}
public static ScreenCaptureFrameSource CreateForWindow(IntPtr hwnd)
{
var item = CaptureInterop.CreateForWindow(hwnd);
return new ScreenCaptureFrameSource($"window:0x{hwnd.ToInt64():X}", item);
}
public Task StartAsync()
{
lock (_gate)
{
if (_started) return Task.CompletedTask;
var item = _item;
var device = Direct3D11Helper.CreateDevice();
var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded(
device, DirectXPixelFormat.B8G8R8A8UIntNormalized, PoolBufferCount, item.Size);
_poolSize = item.Size;
var session = framePool.CreateCaptureSession(item);
framePool.FrameArrived += OnFrameArrived;
session.StartCapture();
_framePool = framePool;
_session = session;
_started = true;
}
return Task.CompletedTask;
}
public Task StopAsync()
{
lock (_gate)
{
_started = false;
_convertInFlight = 0;
if (_framePool != null)
_framePool.FrameArrived -= OnFrameArrived;
_session?.Dispose();
_session = null;
_framePool?.Dispose();
_framePool = null;
}
return Task.CompletedTask;
}
private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args)
{
var frame = sender.TryGetNextFrame();
if (frame == null) return;
lock (_gate)
{
if (!_started)
{
frame.Dispose();
return;
}
if (frame.ContentSize.Width != _poolSize.Width || frame.ContentSize.Height != _poolSize.Height)
{
sender.Recreate(Direct3D11Helper.CreateDevice(),
DirectXPixelFormat.B8G8R8A8UIntNormalized, PoolBufferCount, frame.ContentSize);
_poolSize = frame.ContentSize;
}
// Up to MaxConcurrentConversions readbacks in flight (slice 17), spaced by
// MinConvertInterval (slice 16): the 240Hz delivery otherwise queued one
// conversion every ~4.2ms, and the serial ~47ms readback pinned the desktop
// layer to ~17 updates/s on the 1824 take.
if (_convertInFlight >= MaxConcurrentConversions)
{
_skippedBusy++;
frame.Dispose();
return;
}
var now = DateTime.UtcNow;
if (now - _lastConvertAt < MinConvertInterval)
{
_skippedCadence++;
frame.Dispose();
return;
}
_lastConvertAt = now;
_convertInFlight++;
}
_ = ProcessFrameAsync(frame);
}
private async Task ProcessFrameAsync(Direct3D11CaptureFrame frame)
{
var sw = Stopwatch.StartNew();
try
{
using (frame)
using (var softwareBitmap = await SoftwareBitmap.CreateCopyFromSurfaceAsync(
frame.Surface, BitmapAlphaMode.Ignore))
{
// Monotonic sequencing across the overlapping conversions (slice 17): a
// completed readback is published only if strictly newer than the last
// one published — a slow older completion must never overwrite a newer
// LatestFrame (that would be a time hole in the other direction).
var epoch = Interlocked.Increment(ref _epoch);
var videoFrame = CopyToVideoFrame(softwareBitmap, epoch);
if (_publishGate.TryPublish(epoch))
FrameAvailable?.Invoke(videoFrame);
}
}
catch (Exception ex)
{
var now = DateTime.UtcNow;
if (now - _lastErrorLog >= ErrorLogThrottle)
{
_lastErrorLog = now;
AppLog.Write($"ScreenCaptureFrameSource: frame conversion failed: {ex.Message}");
}
}
finally
{
lock (_gate)
{
EmitTelemetry(sw.ElapsedMilliseconds);
_convertInFlight--;
}
}
}
private void EmitTelemetry(long conversionMs)
{
_conversions++;
_convertMsTotal += conversionMs;
if (conversionMs > _convertMsMax) _convertMsMax = conversionMs;
var now = DateTime.UtcNow;
if (now - _lastTelemetry < TelemetryInterval) return;
var span = now - _telemetryFrom;
var perSecond = span.TotalSeconds > 0 ? _conversions / span.TotalSeconds : 0;
AppLog.Write(
$"ScreenCapture telemetry [{Key}]: {_conversions} frames in {span.TotalSeconds:F1}s " +
$"({perSecond:F0}/s), conv avg {( _conversions == 0 ? 0 : _convertMsTotal / _conversions )}ms " +
$"max {_convertMsMax}ms, skip busy {_skippedBusy} cadence {_skippedCadence}, " +
$"ring allocs {_ring.ConsumeAllocations()}");
_lastTelemetry = now;
_telemetryFrom = now;
_conversions = 0;
_convertMsTotal = 0;
_convertMsMax = 0;
_skippedBusy = 0;
_skippedCadence = 0;
}
private VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap, long epoch)
{
var sw = bitmap.PixelWidth;
var sh = bitmap.PixelHeight;
using var buffer = bitmap.LockBuffer(BitmapBufferAccessMode.Read);
using var reference = buffer.CreateReference();
if (!WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var data, out var capacity))
throw new InvalidOperationException("Could not access the frame buffer.");
var srcStride = sw * 4;
var count = (int)Math.Min(capacity, (uint)(sh * srcStride));
// The master frame is 1920×1080; a larger monitor is scaled down here so
// the CPU never holds a frame above the master (the rendered layer fills
// the frame with UniformToFill regardless).
if (sw > MaxBackgroundWidth || sh > MaxBackgroundHeight)
{
var scale = Math.Min(MaxBackgroundWidth / (double)sw, MaxBackgroundHeight / (double)sh);
var dw = Math.Max(1, (int)(sw * scale));
var dh = Math.Max(1, (int)(sh * scale));
// DWM delivers an opaque surface (alpha 255); bilinear keeps it 255.
var scaled = _ring.Rent(dw * dh * 4);
return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh, scaled))
{ IsOpaque = true, Epoch = epoch };
}
var pixels = _ring.Rent(count);
Marshal.Copy(data, pixels, 0, pixels.Length);
return new VideoFrame(sw, sh, pixels) { IsOpaque = true, Epoch = epoch };
}
// Integer 8.8 fixed-point bilinear downscale to the master frame (slice 16,
// 2026-09-14: the two-stage math from SceneCompositor.Bilinear — the same "shift
// only at the end" rule from MyMistakes item on fixed-point blending). The previous
// double-per-pixel version ran ~30-45ms per 2.5K→1080p frame on a quiet desktop
// and the 1742 take's stall measured ~150ms/frame under load (90%-frozen desktop,
// ~6 fresh frames/s); integer math keeps the same bilinear result within ±1 while
// dropping the cost to the row-pair Marshal.Copy. Rows are read once per source
// row pair through Marshal.Copy (no unsafe), writing tightly packed BGRA output.
private byte[] DownscaleBgra(IntPtr src, int sw, int sh, int srcStride, int dw, int dh, byte[] dst)
{
// Row scratch is per-conversion (overlapping conversions since slice 17 each
// bring their own — two 10KB arrays, no shared state). Size: a 2560-wide row
// pair, the largest the monitor path delivers before the downscale.
var row0 = new byte[srcStride];
var row1 = new byte[srcStride];
for (var y = 0; y < dh; y++)
{
var sy8 = (int)((long)y * sh * 256 / dh);
var sy = sy8 >> 8;
var sy1 = Math.Min(sh - 1, sy + 1);
var fy8 = sy8 & 255;
var fyInv = 256 - fy8;
Marshal.Copy(IntPtr.Add(src, sy * srcStride), row0, 0, srcStride);
Marshal.Copy(IntPtr.Add(src, sy1 * srcStride), row1, 0, srcStride);
var dRow = y * dw * 4;
for (var x = 0; x < dw; x++)
{
var sx8 = (int)((long)x * sw * 256 / dw);
var sx = Math.Min(sw - 1, sx8 >> 8);
var sx1 = Math.Min(sw - 1, sx + 1);
var fx8 = sx8 & 255;
var fxInv = 256 - fx8;
var i0 = sx * 4;
var i1 = sx1 * 4;
var j = dRow + x * 4;
for (var c = 0; c < 4; c++, i0++, i1++, j++)
{
// Two-stage in one 16.8 scale: top/bot ≤ 65280, ×256 + round ≤ 33.5M — int-safe.
var top = row0[i0] * fxInv + row0[i1] * fx8;
var bot = row1[i0] * fxInv + row1[i1] * fx8;
dst[j] = (byte)((top * fyInv + bot * fy8 + 32768) >> 16);
}
}
}
return dst;
}
}