fix(apple): stage-4 deadline presenter (CAMetalDisplayLink) — iOS default; gate depth back to 1

Field verdict on the depth-2 gate (M4 iPad Pro, 2752x2064@120): display stage
22-28 ms vs depth-1's 14 — a REGRESSION, not the predicted 5-8. Post-mortem:
any bounded-FIFO pacing keeps a STANDING queue on the always-vsync-latch
platforms. One burst fills every admitted slot and, with arrivals and latches
then running at the same rate, occupancy never returns to zero — each gate
slot costs one full refresh, permanently (the ladder fits exactly: arrival ~3
slots -> 30+ ms, depth 2 -> 22-28, depth 1 -> 14). A bounded FIFO caps the
queue; nothing ever drains it. And depth 1 serializes presents on the on-glass
callback's delivery lag, so neither rung can approach the sub-refresh floor.

Stage-4 (PresentPacing.deadline) inverts drawable ownership instead:
- A CAMetalDisplayLink on its own runloop thread vends ONE deadline-timed
  drawable per refresh (preferredFrameLatency 1) into a newest-wins LatestBox;
  an unpresented vend is replaced by the next (back to the pool).
- The render thread pairs it with the newest decoded frame the moment either
  half arrives — the common case presents a frame INSTANTLY into an already-
  vended drawable, latching the upcoming refresh. No image queue can form and
  nothing waits on on-glass callbacks (they only feed the meters now).
- A stashed drawable can lag a mid-session HDR reconfigure by one vend:
  encodePresent skips the mismatched-format vend (frame re-rings) instead of
  tripping Metal validation.
- iOS/iPadOS defaults to stage-4; tvOS keeps stage-3 until its own A/B
  (PUNKTFUNK_PRESENTER=stage4); macOS resolves "stage4" back to its default
  (the sync-off/DCP-panic saga — deadline pacing lands there deliberately or
  not at all). Settings picker gains Stage 4 with the derived default marker.
- Gate depth defaults to 1 everywhere again; PUNKTFUNK_GATE_DEPTH stays only
  to reproduce the standing-queue ladder on-device.
- PUNKTFUNK_PRESENT_DEBUG gains latchMs p50/max (present-issue -> on-glass:
  standing queue reads ~n x period, healthy reads < 1 period) + noDrawable=.

swift test (14/14) + full Punktfunk-iOS device build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 13:35:43 +02:00
parent d2daeacc60
commit ab2bcc8e68
7 changed files with 480 additions and 117 deletions
@@ -412,12 +412,13 @@ extension SettingsView {
#endif
}
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) vs stage-3 (the same
// pipeline with glass-gated present pacing). The default is per-platform glass on iOS/tvOS,
// whose layers always vsync-latch, arrival on macOS (see Stage2Pipeline's PresentPacing for
// the queue-saturation rationale) so the "(default)" marker rides presenterDefault instead
// of a hardcoded row. Stage-1 (compressed video straight to the system layer) stays a
// DEBUG-only diagnostic it freezes hard on a lost HEVC reference.
// The present-pacing ladder: stage-2 (Metal/VTDecompressionSession, present on frame
// arrival), stage-3 (glass-gated), stage-4 (CAMetalDisplayLink deadline pacing iOS/tvOS
// only). The default is per-platform deadline on iOS, glass on tvOS, arrival on macOS
// (see Stage2Pipeline's PresentPacing for the queue-saturation rationale) so the
// "(default)" marker rides presenterDefault instead of a hardcoded row. Stage-1 (compressed
// video straight to the system layer) stays a DEBUG-only diagnostic it freezes hard on a
// lost HEVC reference.
@ViewBuilder var presenterSection: some View {
Section {
Picker("Presenter", selection: $presenter) {
@@ -432,10 +433,13 @@ extension SettingsView {
} footer: {
Text("Stage 2: each frame is shown the moment it's decoded — but on displays "
+ "running near the stream's frame rate, queued frames can add two to three "
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
+ "to the display — a strict cap on undisplayed frames, always the freshest, "
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
+ "display time to compare. Applies from the next session.")
+ "refreshes of display latency that never drains. Stage 3: presents wait for "
+ "the previous frame to reach the glass — a strict cap on undisplayed frames, "
+ "always the freshest, dropping late frames instead of queueing them. "
+ "Stage 4: the display itself hands out each refresh's frame slot and the "
+ "freshest decoded frame is shown in it — no queue can form at all, the lowest "
+ "display latency. Watch the statistics overlay's display time to compare. "
+ "Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}