diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs index f3fb513..fa1a52a 100644 --- a/Services/Compositor/SceneCompositor.cs +++ b/Services/Compositor/SceneCompositor.cs @@ -25,8 +25,9 @@ public sealed class SceneCompositor // target rect" ⇒ same raster. Bounds-checked eviction clears wholesale. private sealed record PasteKey(byte[] Pixels, long Epoch, int SrcW, int SrcH, int DstW, int DstH, bool Round, bool Mirror); private static readonly Dictionary _pasteCache = new(); + private static readonly Dictionary _elementRasters = new(ReferenceEqualityComparer.Instance); private static readonly object _pasteGate = new(); - private const int MaxPasteEntries = 48; + private const int MaxPasteEntries = 256; /// /// Composite into the tier's output frame. Layer order (back → @@ -294,14 +295,26 @@ public sealed class SceneCompositor // Rasterize in ELEMENT space on a TRANSPARENT base: the sampler writes 255 // alpha only where it actually draws, so untouched margins (outside the // round clip, past the UniformToFill edges) stay alpha 0 and the paste - // reveals the master beneath instead of black. - var pixels = new byte[dw * dh * 4]; - BlitContentRaw(pixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror); - raster = new VideoFrame(dw, dh, pixels); + // reveals the master beneath instead of black. The ARRAY is reused per + // element (see _elementRasters note) — new source generations re-raster + // in place, so the steady state allocates nothing. lock (_pasteGate) { + if (_elementRasters.TryGetValue(element, out var existing) + && existing.Width == dw && existing.Height == dh) + raster = existing; + } + if (raster == null) raster = new VideoFrame(dw, dh, new byte[dw * dh * 4]); + Array.Clear(raster.BgraPixels); + BlitContentRaw(raster.BgraPixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror); + lock (_pasteGate) + { + // Only the SOURCE frame's identity is a real key owner; drop any older + // entry that shared this element's reused raster so a stale key can never + // paste a mid-update array. if (_pasteCache.Count >= MaxPasteEntries) _pasteCache.Clear(); _pasteCache[key!] = raster; + _elementRasters[element] = raster; } } diff --git a/ytLive.Tests/SceneCompositorTests.cs b/ytLive.Tests/SceneCompositorTests.cs index d160e34..aff1314 100644 --- a/ytLive.Tests/SceneCompositorTests.cs +++ b/ytLive.Tests/SceneCompositorTests.cs @@ -254,6 +254,69 @@ public class SceneCompositorTests Assert.Equal(out2.BgraPixels, out3.BgraPixels); } + /// take-16 regression gate — GC churn convicted mechanically: a ring-style + /// producer hands out a small set of rotating arrays with a NEW EPOCH every frame + /// (exactly what the capture/camera rings do). The compositor must re-rasterize INTO + /// its reused per-element raster, so steady-state allocation per render is near zero. + /// Before the fix every new cache key minted a fresh raster array (~625KB for the + /// webcam at 30fps ≈ 18MB/s) — gen2 pauses followed, and they ate camera frames in + /// the real app. The content assertion rides along: the pasted pixels must match the + /// CURRENT ring generation, never a stale one. + [Fact] + public void PasteCache_RingRecycledSources_ReRasterInPlace_WithoutAllocationChurn() + { + const int SrcW = 640, SrcH = 360; + var ring = new byte[4][]; + for (var s = 0; s < ring.Length; s++) + { + ring[s] = new byte[SrcW * SrcH * 4]; + for (var i = 0; i < ring[s].Length; i += 4) + { + ring[s][i] = 0; ring[s][i + 1] = 0; + ring[s][i + 2] = (byte)(40 + s * 50); + ring[s][i + 3] = 255; + } + } + + var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" }; + var webcam = new WebcamSceneConfig { X = 100, Y = 100, Width = 320, Height = 180 }; + var scene = new Scene { Name = "Live" }; + scene.Elements.Add(background); + scene.Elements.Add(webcam); + var options = new CompositorOptions + { + SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1280, SourceRectHeight = 720, + OutputWidth = 1280, OutputHeight = 720, + }; + + var bg = Solid(1280, 720, 0, 255, 0); + var compositor = new SceneCompositor(); + long epoch = 0; + VideoFrame? FrameFor(SceneElement e) => e switch + { + WebcamSceneConfig => new VideoFrame(SrcW, SrcH, ring[epoch % ring.Length]) + { IsOpaque = true, Epoch = ++epoch }, + _ => bg, + }; + + var scratch = new byte[1280 * 720 * 4]; + + for (var i = 0; i < 8; i++) compositor.Render(scene, FrameFor, null, options, scratch: scratch); + + var before = GC.GetAllocatedBytesForCurrentThread(); + const int iterations = 200; + VideoFrame? last = null; + for (var i = 0; i < iterations; i++) last = compositor.Render(scene, FrameFor, null, options, scratch: scratch); + + var perFrame = (GC.GetAllocatedBytesForCurrentThread() - before) / (double)iterations; + Assert.True(perFrame < 16 * 1024, + $"compositor allocated {perFrame:F0} bytes/frame under a ring producer — raster reuse regressed"); + + Assert.NotNull(last); + AssertColor(last!, 260, 190, (byte)(40 + 3 * 50), 0, 0); + AssertColor(last!, 5, 700, 0, 255, 0); + } + [Fact] public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop() {