perf(apple): windowed macOS presents — off-main transactional commit, surface prototype, safe-present setting
ci / docs-site (push) Successful in 52s
ci / web (push) Successful in 56s
decky / build-publish (push) Successful in 21s
apple / swift (push) Successful in 1m24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / bench (push) Successful in 5m53s
release / apple (push) Successful in 9m19s
deb / build-publish (push) Successful in 12m27s
arch / build-publish (push) Successful in 12m52s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 13m18s
apple / screenshots (push) Successful in 6m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m51s
android / android (push) Successful in 18m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m15s
ci / rust (push) Successful in 27m38s
ci / docs-site (push) Successful in 52s
ci / web (push) Successful in 56s
decky / build-publish (push) Successful in 21s
apple / swift (push) Successful in 1m24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / bench (push) Successful in 5m53s
release / apple (push) Successful in 9m19s
deb / build-publish (push) Successful in 12m27s
arch / build-publish (push) Successful in 12m52s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 13m18s
apple / screenshots (push) Successful in 6m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m51s
android / android (push) Successful in 18m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m15s
ci / rust (push) Successful in 27m38s
Task 1 (latency): the transactional present now commits from the RENDER
thread inside an explicit CATransaction + flush() instead of hopping to
main. The present harness (2026-07-21, 240 Hz Studio, full-size window)
measured the main hop batching presents at runloop-iteration rate when
main is busy — an active implicit transaction NESTS the explicit one —
which is the field's presents=55 @ fps=240 / display_p50 18.6 ms.
Off-main commits measured immune to main churn: glass p50 ~10 ms,
cadence a clean 4.17 ms at 240. flush() is load-bearing: without it the
render thread's own implicit transaction (drawableSize/colour writes)
swallows the explicit commit and NOTHING reaches glass — the harness
reproduced the exact freeze (every present dropped). Legacy main-hop
kept as PUNKTFUNK_TXN_PRESENT=main for field A/B. New pf-windowed
Console line (subsystem io.unom.punktfunk, category presenter)
decomposes the issue side per second.
Task 1 lever D: the f407f418 IOSurface contents-swap path is
resurrected format-aware as WindowedPresentMode.surface — bgra8 SDR /
rgba16Float+PQ-tagged HDR pool, swap committed off-main from the
completion handler; EDR anchored by the metal layer underneath.
Env-only prototype (PUNKTFUNK_WINDOWED_PRESENT=surface) until the HDR
composite is eyeballed on glass.
Task 2 (setting): punktfunk.windowedSafePresent (default ON =
transactional mitigation; OFF = the fast async path whose panic the
saga is about — the caption says so in plain words). Rows in the touch
Settings (Presentation) and gamepad settings, macOS-only.
PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface overrides for dev
A/B. maximumDrawableCount stays 3 — the harness measured 2 starving the
render loop (172/s) and worsening glass p50.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,9 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.presentPriority) private var presentPriority =
|
||||
SettingsOptions.presentPriorityDefault
|
||||
@AppStorage(DefaultsKey.smoothBuffer) private var smoothBuffer = 0
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.windowedSafePresent) private var windowedSafePresent = true
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@@ -345,6 +348,22 @@ struct GamepadSettingsView: View {
|
||||
detail: "Turn off to use the touch interface even with a controller connected.",
|
||||
value: $gamepadUIEnabled),
|
||||
]
|
||||
#if os(macOS)
|
||||
// The windowed safe-present toggle slots in after "Smoothness buffer" (staying inside
|
||||
// the Video group) — macOS only, mirroring the touch SettingsView's Presentation row
|
||||
// (the DCP swapID-panic mitigation; see DefaultsKey.windowedSafePresent).
|
||||
if let at = list.firstIndex(where: { $0.id == "smoothBuffer" }) {
|
||||
list.insert(
|
||||
toggleRow(
|
||||
id: "windowedSafePresent", icon: "macwindow.badge.plus",
|
||||
label: "Safe windowed presentation",
|
||||
detail: "Windowed streams present in step with the compositor — avoids a "
|
||||
+ "macOS display-driver crash on high-refresh displays, at a small "
|
||||
+ "latency cost. Fullscreen always uses the fastest path.",
|
||||
value: $windowedSafePresent),
|
||||
at: at + 1)
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// The device-rumble mirror slots in after "Controller type" (staying inside the
|
||||
// Controller group — the next row carries the "Interface" header). iPhone only in
|
||||
|
||||
@@ -300,6 +300,18 @@ extension SettingsView {
|
||||
+ "of added latency. Off shows frames as soon as they're ready.") {
|
||||
Toggle("V-Sync", isOn: $vsync)
|
||||
}
|
||||
// The DCP swapID-panic mitigation's user handle (see DefaultsKey.windowedSafePresent
|
||||
// for the saga). Default ON: turning it off re-arms a WHOLE-MACHINE kernel panic on
|
||||
// affected setups, so the caption says so in plain words.
|
||||
described(windowedSafePresent
|
||||
? "Windowed streams present in step with the system compositor — avoids a macOS "
|
||||
+ "display-driver crash seen on high-refresh displays, at a small latency "
|
||||
+ "cost. Fullscreen always uses the fastest path."
|
||||
: "Windowed streams use the fastest present path. On some high-refresh setups "
|
||||
+ "this can crash macOS itself (kernel panic) — turn back on if your Mac "
|
||||
+ "restarts during windowed streaming.") {
|
||||
Toggle("Safe windowed presentation", isOn: $windowedSafePresent)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.smoothBuffer) var smoothBuffer = 0
|
||||
#if os(macOS)
|
||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||
@AppStorage(DefaultsKey.windowedSafePresent) var windowedSafePresent = true
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
@AppStorage(DefaultsKey.allowVRR) var allowVRR = true
|
||||
|
||||
@@ -14,12 +14,43 @@
|
||||
#if canImport(Metal) && canImport(QuartzCore)
|
||||
import CoreGraphics
|
||||
import CoreVideo
|
||||
#if os(macOS)
|
||||
import IOSurface
|
||||
#endif
|
||||
import Metal
|
||||
import QuartzCore
|
||||
import os
|
||||
|
||||
private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "presenter")
|
||||
|
||||
#if os(macOS)
|
||||
/// HOW a windowed (composited) macOS session pushes finished frames to glass — the DCP
|
||||
/// "mismatched swapID's" kernel-panic saga's mechanism picker. Fullscreen always presents
|
||||
/// `async` (direct-scanout promotion, lowest latency, no panic reports there); the windowed
|
||||
/// mechanism is resolved per session by SessionPresenter (user setting +
|
||||
/// PUNKTFUNK_WINDOWED_PRESENT env override) and routed here via `setWindowedPresent`.
|
||||
///
|
||||
/// - `async`: the CAMetalLayer image queue (`commandBuffer.present`) — the fastest composited
|
||||
/// path and the PANIC TRIGGER on high-refresh displays (the out-of-band swaps race
|
||||
/// WindowServer's compositor; it survived glass pacing and every codec).
|
||||
/// - `transaction`: `CAMetalLayer.presentsWithTransaction` — the swap commits WITH the layer
|
||||
/// tree, in lockstep with the compositor (Apple's documented remedy; validated no-panic on
|
||||
/// the 240 Hz repro machine). The present is committed from the RENDER thread inside an
|
||||
/// explicit CATransaction + flush — see `encodePresent` for why that beats the original
|
||||
/// main-thread hop.
|
||||
/// - `surface`: no image queue at all — render into a pooled IOSurface and swap it into a plain
|
||||
/// CALayer's `contents` (the f407f418 PyroWave mitigation, resurrected format-aware:
|
||||
/// rgba16Float + PQ tagging keeps HDR). WindowServer treats it as ordinary layer damage on
|
||||
/// its own composite cadence. PROTOTYPE: whether the compositor honors PQ/EDR for plain-layer
|
||||
/// IOSurface contents still needs an on-glass eyeball — the metal layer stays underneath with
|
||||
/// `wantsExtendedDynamicRangeContent` as the EDR anchor.
|
||||
enum WindowedPresentMode: String, Sendable {
|
||||
case async
|
||||
case transaction
|
||||
case surface
|
||||
}
|
||||
#endif
|
||||
|
||||
/// HDR reference white (BT.2408 "HDR Reference White"): the absolute luminance, in nits, that the
|
||||
/// PQ signal's diffuse white sits at. Passed to `CAEDRMetadata.hdr10(opticalOutputScale:)`, it anchors
|
||||
/// 203-nit diffuse white at EDR 1.0 (the display's SDR-white level) and lets the system tone-map the
|
||||
@@ -196,7 +227,7 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||
// The shared PQ→display-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
|
||||
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff →
|
||||
// BT.709 OETF. Used by the tvOS biplanar tone-map and the tvOS planar (PyroWave) tone-map (the
|
||||
// no-HDR-headroom fallback). macOS keeps real HDR windowed now — see `transactionalPresentStaged`.
|
||||
// no-HDR-headroom fallback). macOS keeps real HDR windowed now — see `WindowedPresentMode`.
|
||||
static inline float3 pqToSdr(float3 pq) {
|
||||
const float m1 = 2610.0/16384.0;
|
||||
const float m2 = 78.84375;
|
||||
@@ -275,11 +306,95 @@ public final class MetalVideoPresenter {
|
||||
/// presentation drifting out of sync with CA). Fullscreen keeps the async path (direct-scanout
|
||||
/// promotion, lowest latency, no compositor and no panic reports there).
|
||||
///
|
||||
/// Staged under `stagingLock` (main pushes it via `setComposited`→`setTransactionalPresent`);
|
||||
/// the render thread drains it and toggles the layer property + present style. `Active` is the
|
||||
/// render-thread copy so the layer property flips exactly once per mode change.
|
||||
private var transactionalPresentStaged = false
|
||||
private var transactionalPresentActive = false
|
||||
/// 2026-07-21 latency rework: the mitigation MECHANISM is now a three-way pick
|
||||
/// (`WindowedPresentMode`) and the transactional present commits from the RENDER thread —
|
||||
/// see `encodePresent`. Staged under `stagingLock` (main pushes it via
|
||||
/// `setComposited`→`setWindowedPresent`); the render thread drains it and toggles the layer
|
||||
/// property + present style. `Active` is the render-thread copy so the layer property flips
|
||||
/// exactly once per mode change.
|
||||
private var windowedPresentStaged: WindowedPresentMode = .async
|
||||
private var windowedPresentActive: WindowedPresentMode = .async
|
||||
|
||||
/// PUNKTFUNK_TXN_PRESENT=main — the ORIGINAL transactional present (commit →
|
||||
/// waitUntilScheduled → hop to the MAIN thread and present inside its CATransaction), kept
|
||||
/// as a field A/B lever. The default is the render-thread commit: the present harness
|
||||
/// (2026-07-21, this saga) measured the main hop landing a runloop turn late on a busy main
|
||||
/// thread, and an ACTIVE implicit transaction there NESTS the explicit one — presents batch
|
||||
/// at runloop-iteration rate (the field's presents=55 @ fps=240, display_p50 18.6 ms).
|
||||
/// Off-main commits measured immune to main-thread churn (~10 ms glass p50 at 240 Hz
|
||||
/// full-size vs 14+ ms under a churned main hop).
|
||||
private let txnPresentOnMain =
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_TXN_PRESENT"] == "main"
|
||||
|
||||
/// The WINDOWED-mode `surface` present target: a plain CALayer sized like `layer` (installed
|
||||
/// as a sibling ABOVE it by SessionPresenter), fed IOSurfaces via `contents` inside explicit
|
||||
/// CATransactions. Transparent (nil contents) whenever surface mode is off, so the metal
|
||||
/// layer below shows through. See `WindowedPresentMode.surface`.
|
||||
let surfaceLayer: CALayer = {
|
||||
let l = CALayer()
|
||||
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
|
||||
l.isOpaque = true
|
||||
l.actions = ["contents": NSNull(), "bounds": NSNull(), "position": NSNull()]
|
||||
return l
|
||||
}()
|
||||
|
||||
/// One IOSurface-backed render target of the windowed surface-present pool. All pool state
|
||||
/// is RENDER-THREAD confined; only the immutable surface refs cross threads (contents swap).
|
||||
private struct SurfaceSlot {
|
||||
let surface: IOSurfaceRef
|
||||
let texture: MTLTexture
|
||||
/// Monotonic use stamp — the reuse picker takes the least-recently-rendered free slot.
|
||||
var seq: UInt64 = 0
|
||||
}
|
||||
|
||||
private var surfacePool: [SurfaceSlot] = []
|
||||
private var surfacePoolSize: CGSize = .zero
|
||||
private var surfacePoolHDR = false
|
||||
private var surfaceSeq: UInt64 = 0
|
||||
/// Index of the slot most recently handed to the layer — never rewritten next, even if its
|
||||
/// use count already dropped (the compositor may still be scanning out the previous frame).
|
||||
private var lastHandedOff: Int?
|
||||
|
||||
/// Once-per-second decomposition of the ACTIVE windowed present path (the field-diagnosis
|
||||
/// half of the DCP-latency work): scheduled/completed wait + commit/flush cost per present,
|
||||
/// and how many presents/swaps were issued. The pf-present line shows the GLASS side
|
||||
/// (latchMs / dropped); this shows the ISSUE side. Logged via `presenterLog` only while a
|
||||
/// windowed mechanism is active (zero cost fullscreen). Lock-guarded: transaction mode
|
||||
/// records from the render thread, surface mode from Metal completion threads.
|
||||
private final class WindowedPresentDiag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var presents = 0
|
||||
private var schedMs: [Double] = []
|
||||
private var commitMs: [Double] = []
|
||||
private var last = CACurrentMediaTime()
|
||||
|
||||
func record(schedMs sched: Double, commitMs commit: Double, mode: WindowedPresentMode) {
|
||||
lock.lock()
|
||||
presents += 1
|
||||
schedMs.append(sched)
|
||||
commitMs.append(commit)
|
||||
let now = CACurrentMediaTime()
|
||||
guard now - last >= 1 else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
last = now
|
||||
let sSched = schedMs.sorted()
|
||||
let sCommit = commitMs.sorted()
|
||||
let line = String(
|
||||
format: "pf-windowed mode=%@ presents=%d schedMs p50=%.2f max=%.2f "
|
||||
+ "commitMs p50=%.2f max=%.2f",
|
||||
mode.rawValue, presents, sSched[sSched.count / 2], sSched.last ?? 0,
|
||||
sCommit[sCommit.count / 2], sCommit.last ?? 0)
|
||||
presents = 0
|
||||
schedMs.removeAll(keepingCapacity: true)
|
||||
commitMs.removeAll(keepingCapacity: true)
|
||||
lock.unlock()
|
||||
presenterLog.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private let windowedDiag = WindowedPresentDiag()
|
||||
#endif
|
||||
|
||||
private let device: MTLDevice
|
||||
@@ -570,14 +685,14 @@ public final class MetalVideoPresenter {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Park the windowed-vs-fullscreen present routing (MAIN thread — the hosting view pushes its
|
||||
/// window state on every layout). true = COMPOSITED (windowed): present the drawable through a
|
||||
/// Core Animation transaction (`CAMetalLayer.presentsWithTransaction` — the DCP swapID-panic
|
||||
/// mitigation, see `transactionalPresentStaged`); false = FULLSCREEN: the async image queue.
|
||||
/// Park the windowed present mechanism (MAIN thread — the hosting view pushes its window
|
||||
/// state on every layout; SessionPresenter resolves the mechanism per session). `.async` =
|
||||
/// FULLSCREEN (or the user opted out of the mitigation): the image queue. `.transaction` /
|
||||
/// `.surface` = COMPOSITED (windowed) mitigation mechanisms — see `WindowedPresentMode`.
|
||||
/// Applied by the render thread on the next frame, like every other staged value here.
|
||||
public func setTransactionalPresent(_ on: Bool) {
|
||||
func setWindowedPresent(_ mode: WindowedPresentMode) {
|
||||
stagingLock.lock()
|
||||
transactionalPresentStaged = on
|
||||
windowedPresentStaged = mode
|
||||
stagingLock.unlock()
|
||||
}
|
||||
#endif
|
||||
@@ -781,18 +896,33 @@ public final class MetalVideoPresenter {
|
||||
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// Windowed (composited) → transactional present, the DCP swapID-panic mitigation (see
|
||||
// `transactionalPresentStaged`). Toggle the layer property BEFORE vending a drawable so
|
||||
// the vend matches how it will be presented; drained here on the render thread, flipped
|
||||
// Windowed (composited) → the DCP swapID-panic mitigation mechanism (see
|
||||
// `WindowedPresentMode`). Toggle the layer property BEFORE vending a drawable so the
|
||||
// vend matches how it will be presented; drained here on the render thread, flipped
|
||||
// exactly once per mode change.
|
||||
stagingLock.lock()
|
||||
let wantsTransactional = transactionalPresentStaged
|
||||
let windowedMode = windowedPresentStaged
|
||||
stagingLock.unlock()
|
||||
if wantsTransactional != transactionalPresentActive {
|
||||
transactionalPresentActive = wantsTransactional
|
||||
layer.presentsWithTransaction = wantsTransactional
|
||||
if windowedMode != windowedPresentActive {
|
||||
windowedPresentActive = windowedMode
|
||||
layer.presentsWithTransaction = windowedMode == .transaction
|
||||
if windowedMode != .surface, !surfacePool.isEmpty {
|
||||
// Leaving surface mode (fullscreen entry / mechanism A/B): drop the pool — at 5K
|
||||
// it holds >100 MB, and re-entering rebuilds it in one frame. SessionPresenter
|
||||
// clears the surface layer's contents on main.
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = .zero
|
||||
lastHandedOff = nil
|
||||
}
|
||||
presenterLog.info(
|
||||
"stage2: windowed transactional present \(wantsTransactional ? "ON" : "OFF", privacy: .public) (DCP swapID-panic mitigation)")
|
||||
"stage2: windowed present mode \(windowedMode.rawValue, privacy: .public) (DCP swapID-panic mitigation)")
|
||||
}
|
||||
if windowedMode == .surface {
|
||||
// No image queue at all: render into a pooled IOSurface and swap it into the
|
||||
// sibling layer's contents. The drawable/queue tail below never runs.
|
||||
return encodeToSurface(
|
||||
targetSize: targetSize, pipeline: pipeline, onPresented: onPresented,
|
||||
keepAlive: keepAlive, bind: bind)
|
||||
}
|
||||
#endif
|
||||
if let providedDrawable,
|
||||
@@ -836,27 +966,55 @@ public final class MetalVideoPresenter {
|
||||
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||
#if os(macOS)
|
||||
if transactionalPresentActive {
|
||||
if windowedPresentActive == .transaction {
|
||||
// Windowed DCP mitigation: present the drawable THROUGH a Core Animation transaction
|
||||
// (`presentsWithTransaction`, set above) instead of the async image queue, so the swap
|
||||
// commits with the layer tree and stays in lockstep with the compositor (no out-of-band
|
||||
// flip to race WindowServer's swaps). The present MUST run on the MAIN thread: a
|
||||
// `presentsWithTransaction` present issued from this background render thread never
|
||||
// flushes to the render server, so the drawable is never released — after
|
||||
// maximumDrawableCount vends, `nextDrawable()` blocks forever and the stream FREEZES
|
||||
// (the fullscreen→windowed switch did exactly this). So wait until the GPU work is
|
||||
// scheduled (contents will be ready), then hop to main and present inside its
|
||||
// transaction — the same main-thread CA hop the old IOSurface surface path used
|
||||
// successfully. `presentAtMediaTime` does not apply — the CA transaction paces.
|
||||
// flip to race WindowServer's swaps). Wait until the GPU work is scheduled (contents
|
||||
// will be ready — p50 ~0.1 ms), then present inside an EXPLICIT CATransaction ON THIS
|
||||
// RENDER THREAD and `flush()`. `presentAtMediaTime` does not apply — the transaction
|
||||
// paces.
|
||||
//
|
||||
// Threading history, because BOTH failure modes shipped or nearly shipped:
|
||||
// • A bare `present()` from this thread (no transaction) never flushes — nothing
|
||||
// commits a runloop-less thread's implicit transaction, so drawables are never
|
||||
// released; after maximumDrawableCount vends `nextDrawable()` blocks forever and
|
||||
// the stream FREEZES (the fullscreen→windowed switch did exactly this).
|
||||
// • The explicit begin/commit alone is NOT enough either: this thread has an ACTIVE
|
||||
// implicit transaction (the layer mutations above — drawableSize/colour — created
|
||||
// it), so the explicit transaction NESTS inside it and its commit defers to the
|
||||
// implicit one that never comes. The harness reproduced the exact freeze: every
|
||||
// present reported presentedTime=0, nothing reached glass. `CATransaction.flush()`
|
||||
// pushes the implicit transaction (present included) to the render server NOW.
|
||||
// • The original fix hopped to MAIN and presented there — correct, but slow in the
|
||||
// field (presents=55 @ fps=240, display_p50 18.6 ms on the 240 Hz Studio): each
|
||||
// present lands a runloop turn late, and main's own implicit transaction batches
|
||||
// enrolled presents at runloop-iteration rate. Kept as PUNKTFUNK_TXN_PRESENT=main.
|
||||
// The off-main commit measured immune to main-thread churn in the harness
|
||||
// (2026-07-21: glass p50 ~10 ms at 240 Hz full-size, cadence a clean 4.17 ms).
|
||||
commandBuffer.commit()
|
||||
let schedStart = CACurrentMediaTime()
|
||||
commandBuffer.waitUntilScheduled()
|
||||
let presentedDrawable = drawable
|
||||
DispatchQueue.main.async {
|
||||
let schedMs = (CACurrentMediaTime() - schedStart) * 1000
|
||||
let commitStart = CACurrentMediaTime()
|
||||
if txnPresentOnMain {
|
||||
let presentedDrawable = drawable
|
||||
DispatchQueue.main.async {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
presentedDrawable.present()
|
||||
CATransaction.commit()
|
||||
}
|
||||
} else {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
presentedDrawable.present()
|
||||
drawable.present()
|
||||
CATransaction.commit()
|
||||
CATransaction.flush()
|
||||
}
|
||||
windowedDiag.record(
|
||||
schedMs: schedMs, commitMs: (CACurrentMediaTime() - commitStart) * 1000,
|
||||
mode: .transaction)
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
@@ -871,6 +1029,142 @@ public final class MetalVideoPresenter {
|
||||
return true
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// The WINDOWED `surface` present tail (see `WindowedPresentMode.surface`): render with the
|
||||
/// same per-frame pipeline into a pooled IOSurface and hand it to `surfaceLayer.contents`
|
||||
/// from the command buffer's COMPLETION handler, inside an explicit CATransaction + flush
|
||||
/// (the same off-main commit discipline as the transactional present — an ordinary
|
||||
/// damaged-layer update on WindowServer's own composite cadence, no image queue anywhere).
|
||||
/// RENDER THREAD. `onPresented` is stamped right after the contents swap commits — the
|
||||
/// closest observable analogue of "reached glass" here (the composite follows within a
|
||||
/// refresh, so the display-stage meters read slightly OPTIMISTIC in this mode).
|
||||
///
|
||||
/// The pool tracks `hdrActive`: bgra8 for SDR, rgba16Float tagged BT.2100 PQ for HDR —
|
||||
/// `configure` already ran, so the caller's `pipeline` attachment format always matches.
|
||||
/// HDR OPEN RISK (why this whole mode is a prototype): whether the compositor honors the
|
||||
/// PQ tag + EDR for plain-CALayer IOSurface contents needs an on-glass eyeball; the metal
|
||||
/// layer underneath keeps `wantsExtendedDynamicRangeContent` as the EDR anchor (the harness
|
||||
/// measured the display's EDR headroom engaging with this arrangement).
|
||||
private func encodeToSurface(
|
||||
targetSize: CGSize, pipeline: MTLRenderPipelineState,
|
||||
onPresented: ((Int64?) -> Void)?,
|
||||
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
|
||||
) -> Bool {
|
||||
ensureSurfacePool(size: targetSize, hdr: hdrActive)
|
||||
guard let slotIndex = takeSurfaceSlot(),
|
||||
let commandBuffer = queue.makeCommandBuffer()
|
||||
else { return false }
|
||||
let slot = surfacePool[slotIndex]
|
||||
|
||||
let pass = MTLRenderPassDescriptor()
|
||||
pass.colorAttachments[0].texture = slot.texture
|
||||
pass.colorAttachments[0].loadAction = .clear
|
||||
pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
|
||||
pass.colorAttachments[0].storeAction = .store
|
||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||
return false
|
||||
}
|
||||
encoder.setRenderPipelineState(pipeline)
|
||||
bind(encoder)
|
||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||
encoder.endEncoding()
|
||||
let surface = slot.surface
|
||||
let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self
|
||||
let diag = windowedDiag
|
||||
let commitStamp = CACurrentMediaTime()
|
||||
commandBuffer.addCompletedHandler { _ in
|
||||
_ = keepAlive // sources pinned until the GPU finished sampling
|
||||
let completedAt = CACurrentMediaTime()
|
||||
// Swap on THIS Metal completion thread: explicit transaction + flush, so the commit
|
||||
// reaches the render server now, independent of main (completion handlers for one
|
||||
// queue fire in execution order, so swaps can't reorder).
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer.contents = surface
|
||||
CATransaction.commit()
|
||||
CATransaction.flush()
|
||||
diag.record(
|
||||
schedMs: (completedAt - commitStamp) * 1000,
|
||||
commitMs: (CACurrentMediaTime() - completedAt) * 1000, mode: .surface)
|
||||
onPresented?(Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime()))
|
||||
}
|
||||
commandBuffer.commit()
|
||||
lastHandedOff = slotIndex
|
||||
return true
|
||||
}
|
||||
|
||||
/// (Re)build the pool at `size`/`hdr` — 4 IOSurface render targets (one on glass, one
|
||||
/// committed in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the
|
||||
/// pool empty; the caller returns false and the ring's putBack + display-link retry take
|
||||
/// over.
|
||||
private func ensureSurfacePool(size: CGSize, hdr: Bool) {
|
||||
guard size != surfacePoolSize || hdr != surfacePoolHDR else { return }
|
||||
surfacePool.removeAll()
|
||||
surfacePoolSize = size
|
||||
surfacePoolHDR = hdr
|
||||
lastHandedOff = nil
|
||||
let w = Int(size.width)
|
||||
let h = Int(size.height)
|
||||
guard w > 0, h > 0 else { return }
|
||||
// rgba16Float (8 B/px) carries the PQ-encoded HDR samples; bgra8 the SDR ones. 256-byte
|
||||
// row alignment satisfies both IOSurface and Metal linear-texture rules.
|
||||
let bytesPerElement = hdr ? 8 : 4
|
||||
let bytesPerRow = ((w * bytesPerElement) + 255) & ~255
|
||||
let props: [String: Any] = [
|
||||
kIOSurfaceWidth as String: w,
|
||||
kIOSurfaceHeight as String: h,
|
||||
kIOSurfaceBytesPerElement as String: bytesPerElement,
|
||||
kIOSurfaceBytesPerRow as String: bytesPerRow,
|
||||
kIOSurfacePixelFormat as String: hdr
|
||||
? kCVPixelFormatType_64RGBAHalf : kCVPixelFormatType_32BGRA,
|
||||
]
|
||||
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
||||
pixelFormat: hdr ? .rgba16Float : .bgra8Unorm, width: w, height: h, mipmapped: false)
|
||||
desc.usage = [.renderTarget]
|
||||
desc.storageMode = .shared
|
||||
for _ in 0..<4 {
|
||||
guard let surface = IOSurfaceCreate(props as CFDictionary),
|
||||
let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0)
|
||||
else {
|
||||
surfacePool.removeAll()
|
||||
return
|
||||
}
|
||||
if hdr, let name = CGColorSpace(name: CGColorSpace.itur_2100_PQ)?.name {
|
||||
// Tag the surface BT.2100 PQ so the compositor interprets the half-float
|
||||
// samples as PQ-encoded HDR (the CALayer-contents analogue of the metal
|
||||
// layer's colorspace).
|
||||
IOSurfaceSetValue(surface, "IOSurfaceColorSpace" as CFString, name)
|
||||
}
|
||||
surfacePool.append(SurfaceSlot(surface: surface, texture: texture))
|
||||
}
|
||||
// The EDR request rides the SURFACE layer too (its contents are what composite); the
|
||||
// metal layer underneath keeps its own from configureColor as the anchor. Layer flags
|
||||
// are committed by the next swap's transaction flush.
|
||||
surfaceLayer.wantsExtendedDynamicRangeContent = hdr
|
||||
}
|
||||
|
||||
/// Pick the slot to render into: never the one just handed to the layer (the compositor may
|
||||
/// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and
|
||||
/// among those the least recently rendered. Falls back to the LRU busy slot rather than
|
||||
/// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD.
|
||||
private func takeSurfaceSlot() -> Int? {
|
||||
guard !surfacePool.isEmpty else { return nil }
|
||||
var free: Int?
|
||||
var busy: Int?
|
||||
for i in surfacePool.indices where i != lastHandedOff {
|
||||
if !IOSurfaceIsInUse(surfacePool[i].surface) {
|
||||
if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i }
|
||||
} else {
|
||||
if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i }
|
||||
}
|
||||
}
|
||||
guard let pick = free ?? busy else { return nil }
|
||||
surfaceSeq += 1
|
||||
surfacePool[pick].seq = surfaceSeq
|
||||
return pick
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Returns the CVMetalTexture (not just its MTLTexture) so the caller can keep it alive past the
|
||||
/// draw — the MTLTexture is only valid while its CVMetalTexture is retained.
|
||||
private func makeTexture(
|
||||
|
||||
@@ -175,6 +175,21 @@ final class SessionPresenter {
|
||||
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
|
||||
/// that can't queue at all — that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
|
||||
///
|
||||
#if os(macOS)
|
||||
/// Resolve the windowed (composited) present MECHANISM for this session — the DCP
|
||||
/// swapID-panic mitigation picker (see `WindowedPresentMode`). The
|
||||
/// `PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface` env lever wins (dev A/B);
|
||||
/// otherwise the user's safe-present setting: ON/unset → `.transaction` (the validated
|
||||
/// mitigation), OFF → `.async` (the fast pre-mitigation path — the panic returns on
|
||||
/// affected high-refresh setups; the Settings caption says so). `.surface` is currently
|
||||
/// env-only (prototype — HDR-composite verification owed). Fullscreen always presents
|
||||
/// async regardless (`setComposited`). Internal (not private) for unit tests.
|
||||
static func windowedPresentMode(setting: Bool?, env: String?) -> WindowedPresentMode {
|
||||
if let env, let mode = WindowedPresentMode(rawValue: env) { return mode }
|
||||
return (setting ?? true) ? .transaction : .async
|
||||
}
|
||||
#endif
|
||||
|
||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) still overrides on iOS/tvOS so the standing-queue ladder
|
||||
/// stays reproducible on-device; macOS is pinned to 1, env ignored — a deeper gate only builds
|
||||
/// a standing queue (see above), and macOS glass pacing exists for PyroWave smoothness
|
||||
@@ -193,9 +208,16 @@ final class SessionPresenter {
|
||||
private var stage2Link: CADisplayLink?
|
||||
private var metalLayer: CAMetalLayer?
|
||||
#if os(macOS)
|
||||
/// The last windowed-vs-fullscreen present routing pushed to the pipeline — see
|
||||
/// `setComposited` (the DCP swapID-panic mitigation). Main-thread only, like all of this.
|
||||
private var transactionalPresentActive = false
|
||||
/// The windowed present MECHANISM this session runs while composited (resolved once per
|
||||
/// session in `start` — the user's safe-present setting + the PUNKTFUNK_WINDOWED_PRESENT
|
||||
/// dev override) and the routing last pushed to the pipeline — see `setComposited` (the DCP
|
||||
/// swapID-panic mitigation). Main-thread only, like all of this.
|
||||
private var windowedMode: WindowedPresentMode = .transaction
|
||||
private var windowedPresentApplied: WindowedPresentMode = .async
|
||||
/// The windowed `surface` present target (sibling above `metalLayer`, transparent while
|
||||
/// unused) — installed whenever stage-2 runs so a mechanism flip never has to mutate the
|
||||
/// layer tree mid-session.
|
||||
private var surfaceLayer: CALayer?
|
||||
#endif
|
||||
private var connection: PunktfunkConnection?
|
||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||
@@ -279,7 +301,17 @@ final class SessionPresenter {
|
||||
baseLayer.addSublayer(metal)
|
||||
metalLayer = metal
|
||||
#if os(macOS)
|
||||
transactionalPresentActive = false
|
||||
windowedPresentApplied = .async
|
||||
// Resolve THIS session's windowed mechanism once (setting + dev env lever) —
|
||||
// `setComposited` routes between it and fullscreen-async from every layout.
|
||||
windowedMode = Self.windowedPresentMode(
|
||||
setting: UserDefaults.standard.object(
|
||||
forKey: DefaultsKey.windowedSafePresent) as? Bool,
|
||||
env: ProcessInfo.processInfo.environment["PUNKTFUNK_WINDOWED_PRESENT"])
|
||||
// The surface present target sits ABOVE the metal layer: transparent (nil contents)
|
||||
// unless the surface mechanism actually presents, covering it while it does.
|
||||
baseLayer.addSublayer(pipeline.surfaceLayer)
|
||||
surfaceLayer = pipeline.surfaceLayer
|
||||
#endif
|
||||
stage2 = pipeline
|
||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||
@@ -389,6 +421,12 @@ final class SessionPresenter {
|
||||
CATransaction.setDisableActions(true)
|
||||
metalLayer.contentsScale = contentsScale
|
||||
metalLayer.frame = snapped
|
||||
#if os(macOS)
|
||||
// The surface present target mirrors the metal layer's geometry exactly — its IOSurfaces
|
||||
// are sized to the same snapped pixel rect, so the contents composite is a 1:1 blit too.
|
||||
surfaceLayer?.contentsScale = contentsScale
|
||||
surfaceLayer?.frame = snapped
|
||||
#endif
|
||||
CATransaction.commit()
|
||||
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
||||
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
||||
@@ -418,19 +456,30 @@ final class SessionPresenter {
|
||||
|
||||
#if os(macOS)
|
||||
/// Route presents for the window's composited state (MAIN thread — the view pushes it on
|
||||
/// every layout, which fullscreen transitions always trigger). A COMPOSITED (windowed) session
|
||||
/// presents the drawable through a Core Animation transaction
|
||||
/// (`CAMetalLayer.presentsWithTransaction`) instead of the async image queue — the DCP
|
||||
/// "mismatched swapID's" kernel-panic mitigation (see `MetalVideoPresenter`; the async-swap
|
||||
/// race survives glass pacing, so pacing alone was not enough). ALL codecs: PyroWave hit it
|
||||
/// 2026-07-18 and windowed HEVC hit the same 240 Hz Mac Studio 2026-07-21 — it is the async
|
||||
/// image queue itself, not any codec or present rate. Fullscreen keeps the async path (direct
|
||||
/// scanout, lowest latency, no panic there). The full HDR/EDR render path is preserved in both.
|
||||
/// every layout, which fullscreen transitions always trigger). A COMPOSITED (windowed)
|
||||
/// session presents through this session's resolved mitigation mechanism (`windowedMode` —
|
||||
/// transactional by default, see `windowedPresentMode`) instead of the async image queue —
|
||||
/// the DCP "mismatched swapID's" kernel-panic mitigation (see `MetalVideoPresenter`; the
|
||||
/// async-swap race survives glass pacing, so pacing alone was not enough). ALL codecs:
|
||||
/// PyroWave hit it 2026-07-18 and windowed HEVC hit the same 240 Hz Mac Studio 2026-07-21 —
|
||||
/// it is the async image queue itself, not any codec or present rate. Fullscreen keeps the
|
||||
/// async path (direct scanout, lowest latency, no panic there). The full HDR/EDR render
|
||||
/// path is preserved in every mechanism.
|
||||
func setComposited(_ composited: Bool) {
|
||||
guard let stage2 else { return }
|
||||
guard composited != transactionalPresentActive else { return }
|
||||
transactionalPresentActive = composited
|
||||
stage2.setTransactionalPresent(composited)
|
||||
let mode: WindowedPresentMode = composited ? windowedMode : .async
|
||||
guard mode != windowedPresentApplied else { return }
|
||||
let wasSurface = windowedPresentApplied == .surface
|
||||
windowedPresentApplied = mode
|
||||
stage2.setWindowedPresent(mode)
|
||||
if wasSurface {
|
||||
// Uncover the metal layer NOW (its last drawable is still attached, so fullscreen
|
||||
// entry shows the previous frame until the next present — no black flash).
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
surfaceLayer?.contents = nil
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -448,7 +497,9 @@ final class SessionPresenter {
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
#if os(macOS)
|
||||
transactionalPresentActive = false
|
||||
surfaceLayer?.removeFromSuperlayer()
|
||||
surfaceLayer = nil
|
||||
windowedPresentApplied = .async
|
||||
#endif
|
||||
connection = nil
|
||||
}
|
||||
|
||||
@@ -1114,11 +1114,15 @@ public final class Stage2Pipeline {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Forward the windowed-vs-fullscreen present routing (MAIN thread — see
|
||||
/// `MetalVideoPresenter.setTransactionalPresent`, the DCP swapID-panic mitigation).
|
||||
public func setTransactionalPresent(_ on: Bool) {
|
||||
presenter.setTransactionalPresent(on)
|
||||
/// Forward the windowed present mechanism (MAIN thread — see
|
||||
/// `MetalVideoPresenter.setWindowedPresent`, the DCP swapID-panic mitigation).
|
||||
func setWindowedPresent(_ mode: WindowedPresentMode) {
|
||||
presenter.setWindowedPresent(mode)
|
||||
}
|
||||
|
||||
/// The windowed `surface` present target the hosting SessionPresenter installs as a sibling
|
||||
/// ABOVE `layer` (transparent while unused — see `MetalVideoPresenter.surfaceLayer`).
|
||||
var surfaceLayer: CALayer { presenter.surfaceLayer }
|
||||
#endif
|
||||
|
||||
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
||||
|
||||
@@ -70,6 +70,16 @@ public enum DefaultsKey {
|
||||
/// (lowest latency — the default, OFF). Resolved once per session;
|
||||
/// PUNKTFUNK_PRESENT_MODE=immediate|vsync overrides it for A/B. See Stage2Pipeline's header.
|
||||
public static let vsync = "punktfunk.vsync"
|
||||
/// macOS: present WINDOWED sessions in lockstep with the system compositor (the DCP
|
||||
/// "mismatched swapID's" kernel-panic mitigation — see SessionPresenter.windowedPresentMode
|
||||
/// and the MetalVideoPresenter saga notes). ON/unset (the default): windowed presents ride
|
||||
/// a Core Animation transaction — validated panic-free on the 240 Hz repro machine, at a
|
||||
/// small display-latency cost vs the raw path. OFF: windowed sessions keep the fast async
|
||||
/// image queue — ON AFFECTED SETUPS (high-refresh displays) THAT PATH KERNEL-PANICS THE
|
||||
/// WHOLE MAC, which is why the default is ON. Fullscreen always presents async (fast path)
|
||||
/// regardless. Resolved once per session; PUNKTFUNK_WINDOWED_PRESENT=async|transaction|
|
||||
/// surface overrides it for dev A/B.
|
||||
public static let windowedSafePresent = "punktfunk.windowedSafePresent"
|
||||
/// Allow variable refresh rate: hand the display link a wide frame-rate RANGE (low floor,
|
||||
/// preferred = stream rate) so a ProMotion / adaptive-sync display can vary its physical
|
||||
/// refresh to match the stream. On by default; a no-op on fixed-refresh displays. When off,
|
||||
|
||||
@@ -316,6 +316,41 @@ final class PresentPacingTests: XCTestCase {
|
||||
SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline)
|
||||
}
|
||||
|
||||
// MARK: - Windowed present mechanism (the macOS DCP swapID-panic mitigation picker)
|
||||
|
||||
#if os(macOS)
|
||||
/// The safe-present setting: ON/unset → the validated transactional mitigation; an explicit
|
||||
/// OFF → the fast async path (the user accepted the affected-setup panic risk). The
|
||||
/// PUNKTFUNK_WINDOWED_PRESENT env lever overrides both ways, `surface` is env-only (the
|
||||
/// prototype mechanism), and garbage/empty env values are "unset", not an override.
|
||||
func testWindowedPresentModeResolution() {
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: nil, env: nil), .transaction,
|
||||
"unset defaults to the panic mitigation")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: true, env: nil), .transaction)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: nil), .async,
|
||||
"an explicit opt-out gets the fast async path")
|
||||
// The dev env lever wins over the setting, both directions.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: true, env: "async"), .async)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: "transaction"),
|
||||
.transaction)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: true, env: "surface"), .surface,
|
||||
"the surface prototype is reachable via env only")
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: "surface"), .surface)
|
||||
// Garbage/empty env = unset.
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: nil, env: "garbage"), .transaction)
|
||||
XCTAssertEqual(
|
||||
SessionPresenter.windowedPresentMode(setting: false, env: ""), .async)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Glass-gate depth
|
||||
|
||||
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue —
|
||||
|
||||
Reference in New Issue
Block a user