perf(chat): slice 3 — raster-on-change cache in ChatOverlayLayer (take-5 fix)

Take 5: render 58.9 -> 25.5ms (138/300, still ~2.2x). The blits were fixed; the
resolver was not: ResolveOutputFrame -> RenderChatBox ran a FULL WPF raster
(FormattedText + RenderTargetBitmap + CopyPixels + channel swap) EVERY tick
whenever the chat buffer was non-empty — and the buffer survives sessions, so
even a signed-out record-only take paid it. Established answer (OBS text
sources): re-render on change, blit the cache every tick.

ChatOverlayLayer: content version bumped from Messages.CollectionChanged
(covers adds, the 500-cap removal, the fade Clear from any caller) + a config
key (size + all Chat* appearance props); RenderFrame returns the cached
VideoFrame by identity until either changes (compositor only reads cached
frames). Conservative ordering (version latched BEFORE render) makes a
mid-render message re-render next tick, never serve stale.

ONE integration test: ChatOverlayLayerCacheTests (RealApp, real renderer):
Same() for unchanged inputs, NotSame() on message/config change, null on
empty. Full regression green (62 across touched classes), clean build 0
warnings. Accepted cost pending take 6: one ~15-25ms tick per arriving
message; if live-chat bursts sag n/300, next slice = debounced off-tick
re-render. Docs same commit: ai.md pipeline section, TASKS.md TASK 18,
MyMistakes recipe (raster-on-change + session-surviving-buffer trap),
HANDOFF (take 6 -> then Unit B, spec unchanged).
This commit is contained in:
2026-09-04 11:01:51 -07:00
parent 432adfdaef
commit 1c48849853
6 changed files with 121 additions and 12 deletions
+30 -4
View File
@@ -37,7 +37,24 @@ public sealed class ChatOverlayLayer
/// binds it to the panel) via <c>ChatMessages =&gt; _chatLayer.Messages</c>.</summary>
public ObservableCollection<ChatMessage> Messages { get; } = new();
public ChatOverlayLayer(ChatBoxRenderer renderer) => _renderer = renderer;
// Output-frame cache (take-5 starvation fix): the pump called RenderFrame
// every tick and the renderer did a FULL WPF raster (FormattedText +
// RenderTargetBitmap + CopyPixels + channel swap) each time — 15-25ms of a
// 25.5ms render. Established answer (OBS text sources): raster on change,
// blit the cache every tick. _contentVersion bumps on every buffer mutation
// (CollectionChanged — covers adds, the 500-cap removal, and the fade
// Clear from any caller); the config key covers the box geometry/appearance.
// The cached VideoFrame is treated as immutable: the compositor only reads.
private int _contentVersion;
private int _frameVersion = -1;
private string? _frameKey;
private VideoFrame? _cachedFrame;
public ChatOverlayLayer(ChatBoxRenderer renderer)
{
_renderer = renderer;
Messages.CollectionChanged += (_, _) => _contentVersion++;
}
/// <summary>Ingest a message from the poller. UI-thread marshalled: appended
/// to the buffer (capped at 500), mock preview stopped, swipe to real
@@ -90,12 +107,21 @@ public sealed class ChatOverlayLayer
/// preview render but without writing onto the source's VideoImageSource.</summary>
public VideoFrame? RenderFrame(Source chatBox)
{
if (Messages.Count == 0) return null;
var width = (int)Math.Max(1, chatBox.Width);
var height = (int)Math.Max(1, chatBox.Height);
var key =
$"{width}x{height}|{chatBox.ChatFontSize}|{chatBox.ChatFontFamily}|{chatBox.ChatFontColor}"
+ $"|{chatBox.ChatBackgroundColor}|{chatBox.ChatMaxMessages}|{chatBox.ChatShowTimestamps}"
+ $"|{chatBox.ChatShowBadges}|{chatBox.ChatMemberColor}|{chatBox.ChatSuperChatColor}";
return _renderer.Render(
if (_frameKey == key && _frameVersion == _contentVersion)
return _cachedFrame; // same content + same config — the cached raster stands
_frameKey = key;
_frameVersion = _contentVersion;
if (Messages.Count == 0) return _cachedFrame = null;
return _cachedFrame = _renderer.Render(
Messages,
width,
height,