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:
@@ -617,10 +617,15 @@ public final class MetalVideoPresenter {
|
||||
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
|
||||
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
|
||||
/// (`PUNKTFUNK_PRESENT_MODE=immediate` — the pre-fix behavior, kept as a diagnostic A/B).
|
||||
///
|
||||
/// `into drawable` (deadline pacing) supplies the CAMetalDisplayLink-vended drawable to
|
||||
/// render into instead of calling `nextDrawable()` — see `encodePresent` for the format
|
||||
/// guard that skips a vend the layer's config outran.
|
||||
@discardableResult
|
||||
public func render(
|
||||
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
|
||||
presentAtMediaTime: CFTimeInterval? = nil,
|
||||
into drawable: CAMetalDrawable? = nil,
|
||||
onPresented: ((Int64?) -> Void)? = nil
|
||||
) -> Bool {
|
||||
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
|
||||
@@ -674,7 +679,8 @@ public final class MetalVideoPresenter {
|
||||
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
|
||||
return encodePresent(
|
||||
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
||||
onPresented: onPresented,
|
||||
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
|
||||
// finishes sampling — releasing them at scope exit could free the backing mid-read.
|
||||
keepAlive: [luma, chroma, pixelBuffer]
|
||||
@@ -693,6 +699,7 @@ public final class MetalVideoPresenter {
|
||||
func renderPlanar(
|
||||
_ planes: WaveletPlanes,
|
||||
presentAtMediaTime: CFTimeInterval? = nil,
|
||||
into drawable: CAMetalDrawable? = nil,
|
||||
onPresented: ((Int64?) -> Void)? = nil
|
||||
) -> Bool {
|
||||
stagingLock.lock()
|
||||
@@ -742,7 +749,8 @@ public final class MetalVideoPresenter {
|
||||
return encodePresent(
|
||||
decodedSize: CGSize(width: planes.width, height: planes.height),
|
||||
targetFromLayout: targetFromLayout, pipeline: planarPipeline,
|
||||
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
|
||||
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
|
||||
onPresented: onPresented,
|
||||
// The ring textures stay valid by ring depth; retaining them here also pins the
|
||||
// slot's set until the sample completes (mirrors the biplanar keep-alive).
|
||||
keepAlive: [planes.y, planes.cb, planes.cr]
|
||||
@@ -869,9 +877,17 @@ public final class MetalVideoPresenter {
|
||||
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||
/// the present and the on-glass callback.
|
||||
///
|
||||
/// `providedDrawable` (deadline pacing) is the CAMetalDisplayLink-vended drawable to render
|
||||
/// into instead of `nextDrawable()`. It was vended against the layer's config at vend time,
|
||||
/// so after a mid-session reconfigure (HDR flip: `configure` above already retagged the
|
||||
/// layer) its pixel format can lag the pipeline's attachment format — encoding would be a
|
||||
/// Metal validation failure. The guard returns false instead: the drawable drops back to
|
||||
/// the pool, the caller re-rings the frame, and the link's next vend carries the new format.
|
||||
private func encodePresent(
|
||||
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
|
||||
presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
|
||||
presentAtMediaTime: CFTimeInterval?, providedDrawable: CAMetalDrawable? = nil,
|
||||
onPresented: ((Int64?) -> Void)?,
|
||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||
) -> Bool {
|
||||
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
|
||||
@@ -884,11 +900,17 @@ public final class MetalVideoPresenter {
|
||||
// (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine).
|
||||
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
|
||||
? targetFromLayout : decodedSize
|
||||
// Under a provided (link-vended) drawable this sizes the NEXT vend — the one in hand
|
||||
// keeps its size, and a live-resize transient composites via contentsGravity as ever.
|
||||
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
|
||||
#if DEBUG
|
||||
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
||||
#endif
|
||||
guard let drawable = layer.nextDrawable(),
|
||||
if let providedDrawable,
|
||||
providedDrawable.texture.pixelFormat != layer.pixelFormat {
|
||||
return false // config outran the vend (HDR flip) — next vend has the new format
|
||||
}
|
||||
guard let drawable = providedDrawable ?? layer.nextDrawable(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user