perf(render): take-4 slice 2 — IsOpaque memcpy, integer bilinear, pump scratch pool
Take 4: pacing held (sync perfect) but avg render stayed 58.9ms — 2M managed row-walk iterations + a fresh 8.3MB buffer every tick (LOH churn into GC stalls inside the render measurement). - VideoFrame.IsOpaque: producer-contract flag (screen capture + webcam — DWM/MF fill alpha 255; media/chat/web/static NOT flagged). Full-cover aligned opaque backdrop = ONE Buffer.BlockCopy; black pre-fill skipped when it covers. - General BlitContent: integer 8.8 fixed-point bilinear + blend, row invariants hoisted, no per-pixel division/Math.Round. Within ±1 of the float reference (pixel tests allow ±2). Research per derivative-work rule: libyuv row/scale kernels (chromium.googlesource.com/libyuv/libyuv). - FramePump scratch pool (max 4, length-keyed, owned-by-reference): release strictly AFTER SubmitFrameAsync returns (stdin write copies); Contains-guard makes the transition Cut alias safe. - Removed the dead per-tick fromScene render + fromSceneProvider seam — BlendFrame consumes TransitionService.FromFrame captured at Start; the pump's render fed nothing. MainViewModel call site updated (signature). Bugs caught by the pixel probes pre-ship (recorded MyMistakes): first Bilinear double-shifted both stages (solid-255 sampled to ~1 -> general path drew nothing); sentinel 0xAB collided with an x+y pixel. FakeEncoder snapshots submitted frames (mirrors real copy semantics under recycling). ONE integration test: Pump_Pools_ScratchBuffers_Across_Frames_Without_ Stale_Pixels (alternating backdrops + repeated backing identity). Direct pin: Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch. Clean build 0 warnings; 59/59 per-class + RealApp boot-smoke. take 5 verdict: expect avg render <= ~10ms, ~300/300 frames. Docs same commit: ai.md pipeline section, TASKS.md TASK 18, HANDOFF rewritten (Unit B spec + settled decisions queued).
This commit is contained in:
@@ -21,6 +21,7 @@ public class FramePumpTests
|
||||
private sealed class FakeEncoder : IFfmpegEncoder
|
||||
{
|
||||
public readonly List<VideoFrame> Frames = new();
|
||||
public readonly List<byte[]> Backings = new();
|
||||
public int StartCount;
|
||||
public int StopCount;
|
||||
public bool Disposed;
|
||||
@@ -41,7 +42,11 @@ public class FramePumpTests
|
||||
|
||||
public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Frames.Add(frame);
|
||||
// Snapshot the bytes like the production FfmpegEncoder does (WriteAsync to
|
||||
// stdin copies before returning). Holding the reference would race the
|
||||
// pump's scratch pool legitimately reusing the buffer one tick later.
|
||||
Frames.Add(new VideoFrame(frame.Width, frame.Height, (byte[])frame.BgraPixels.Clone()));
|
||||
Backings.Add(frame.BgraPixels); // the pre-copy buffer — pool-reuse witness
|
||||
FrameArrived.TrySetResult();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -387,4 +392,36 @@ public class FramePumpTests
|
||||
$"requested wait {d} reaches the full {interval} interval — render cost must subtract from the deadline (the take-3 bug)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The ONE integration test for the take-4 scratch pool: the pump recycles
|
||||
/// its master buffers across frames (the 8.3MB-per-tick LOH churn that cost GC
|
||||
/// stalls inside "render") while EVERY frame's content stays correct — stale bytes
|
||||
/// from an earlier tick would show as a wrong color. The backdrop alternates
|
||||
/// red/blue per tick (submit snapshots pin the color), and a repeated backing-array
|
||||
/// identity proves the pool actually recycles (an inert pool keeps the pixel
|
||||
/// assertions green while leaving the churn in place).</summary>
|
||||
[Fact]
|
||||
public async Task Pump_Pools_ScratchBuffers_Across_Frames_Without_Stale_Pixels()
|
||||
{
|
||||
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
|
||||
var blue = SceneCompositorTests.Solid(64, 48, 0, 0, 255);
|
||||
var flip = 0;
|
||||
var encoder = new FakeEncoder();
|
||||
using var pump = NewPump(encoder, resolve: _ => flip++ % 2 == 0 ? red : blue);
|
||||
|
||||
await pump.StartAsync();
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (DateTime.UtcNow < deadline && encoder.Frames.Count < 6)
|
||||
await Task.Delay(10);
|
||||
await pump.StopAsync();
|
||||
|
||||
Assert.True(encoder.Frames.Count >= 6, $"only {encoder.Frames.Count} frames produced");
|
||||
for (var i = 0; i < encoder.Frames.Count; i++)
|
||||
{
|
||||
if (i % 2 == 0) AssertColor(encoder.Frames[i], 0, 0, 255, 0, 0);
|
||||
else AssertColor(encoder.Frames[i], 0, 0, 0, 0, 255);
|
||||
}
|
||||
Assert.True(encoder.Backings.Distinct().Count() < encoder.Backings.Count,
|
||||
"every frame rode a fresh buffer — the scratch pool is inert");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,46 @@ public class SceneCompositorTests
|
||||
AssertColor(output, 550, 700, 255, 255, 255);
|
||||
}
|
||||
|
||||
/// <summary>The take-4 memcpy fast path: a full-cover opaque backdrop (the live
|
||||
/// capture's contract) must reproduce the source EXACTLY, and when supplied via the
|
||||
/// <c>scratch</c> pool parameter the render must overwrite every byte of the reused
|
||||
/// buffer — stale sentinels from a previous frame may not survive (this is what
|
||||
/// makes buffer recycling safe at the pump level).</summary>
|
||||
[Fact]
|
||||
public void Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch()
|
||||
{
|
||||
const int w = 128, h = 64;
|
||||
var src = new byte[w * h * 4];
|
||||
for (var y = 0; y < h; y++)
|
||||
for (var x = 0; x < w; x++)
|
||||
{
|
||||
var i = (y * w + x) * 4;
|
||||
src[i] = (byte)(x * 2);
|
||||
src[i + 1] = (byte)(y * 4);
|
||||
src[i + 2] = (byte)(x + y);
|
||||
src[i + 3] = 255; // the IsOpaque contract
|
||||
}
|
||||
var opaque = new VideoFrame(w, h, src) { IsOpaque = true };
|
||||
|
||||
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
|
||||
var scene = new Scene { Name = "Live" };
|
||||
scene.Elements.Add(background);
|
||||
|
||||
var options = new CompositorOptions
|
||||
{
|
||||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = w, SourceRectHeight = h,
|
||||
OutputWidth = w, OutputHeight = h,
|
||||
};
|
||||
|
||||
var scratch = new byte[w * h * 4];
|
||||
Array.Fill(scratch, (byte)0xFD); // sentinel: x*2 (even), y*4 (×4), x+y (≤190) can never produce 253
|
||||
|
||||
var output = new SceneCompositor().Render(scene, _ => opaque, null, options, scratch: scratch);
|
||||
|
||||
Assert.Equal(src, output.BgraPixels); // byte-exact: the memcpy path must not round
|
||||
Assert.DoesNotContain((byte)0xFD, output.BgraPixels); // full cover — nothing stale
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user