Metal 4 Presentation & Frame Pacing
Overview
Metal 4 splits the cmd-buffer-level [cmd presentDrawable:] into a queue-level sequence: [queue waitForDrawable:] before commit, [queue signalDrawable:] after commit, and [drawable present]. The conceptual flow — acquire drawable, encode, present, submit — is unchanged; only the API surface is more explicit. MTL4CommandBuffer does not expose presentDrawable:, so any code path using MTL4CommandQueue must use the queue-level form. Code paths still using the Metal 3 MTLCommandQueue keep [cmd presentDrawable:] and remain correct in a Metal 4 app.
Ownership
Owns:
- Drawable lifecycle (
nextDrawable,waitForDrawable,signalDrawable,present) CAMetalLayerconfiguration and properties- Vsync semantics (
displaySyncEnabled,presentAfterMinimumDuration:) CAMetalDisplayLinkAPI basics; variable refresh rate detection- HDR/EDR layer configuration; pixel format ↔ colorspace pairing
- Direct-to-display vs composited presentation rules
- Drawable residency set (the layer-owned read-only
residencySet) - Apply-after-present rule
Doesn't own:
- Render-thread / run-loop / fullscreen / resize integration →
setting-up-macos-window - General
MTLResidencySetsemantics →managing-metal4-resources - Cross-API translation tables for non-presentation APIs →
translating-to-metal4-api - Engine-internal frame pacing on a dedicated render thread →
setting-up-macos-window - MetalFX-driven dual-frame presentation →
using-metalfx-frame-interpolation
References
Read the relevant framework header before writing presentation code — the headers are the source of truth for property names, types, and method signatures.
- QuartzCore framework headers -
$(xcrun --show-sdk-path)/System/Library/Frameworks/QuartzCore.framework/Headers/CAMetalLayer.h— declares bothCAMetalLayerand theCAMetalDrawableprotocol - Apple documentation - CAMetalLayer
- Apple documentation - Managing your game window for Metal in macOS
- Apple documentation - Metal Performance HUD
Design Patterns
macOS Rendering Stack
NSWindow
└── NSView (engine's view subclass)
└── CAMetalLayer (backing layer)
└── CAMetalDrawable (per-frame drawable texture)
The engine typically owns the window and view. The Metal backend configures the CAMetalLayer and acquires drawables from it each frame.
CAMetalLayer Configuration
// ObjC
CAMetalLayer* metalLayer = (CAMetalLayer*)view.layer;
metalLayer.device = device;
metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm; // must match pipeline output; affects direct vs composited presentation
metalLayer.drawableSize = CGSizeMake(width, height);
metalLayer.maximumDrawableCount = 2; // or 3 for triple buffering
metalLayer.framebufferOnly = YES; // enables GPU compression, no readback
metalLayer.opaque = YES;
// macOS only
metalLayer.displaySyncEnabled = enableVsync; // sole vsync control
Key properties:
pixelFormat— must match render pipeline color attachment formatdrawableSize— update when window resizesmaximumDrawableCount— 2 (double buffering) or 3 (triple buffering). Controls pipeline depth.framebufferOnly— set YES unless you need to read back the drawable texture. Enables GPU compression.displaySyncEnabled— the sole vsync control. YES = cap to display refresh rate. NO = uncapped.
Presentation flow (Metal 4 queue-level)
The per-frame presentation sequence:
// ObjC — Metal 4 queue-level presentation
// 1. Acquire drawable (CPU-side block until a drawable is available)
id<CAMetalDrawable> drawable = [metalLayer nextDrawable];
// 2. Encode render commands into MTL4CommandBuffer(s)
// ... encode render commands ...
// 3. GPU-side wait for drawable's backing texture to be released by display
// (must be before commit, but encoding can happen before the wait)
[queue waitForDrawable:drawable];
// 4. Submit GPU work
[queue commit:cmds count:count];
// 5. Signal that GPU work targeting this drawable is complete
[queue signalDrawable:drawable];
// 6. Present — the layer handles timing based on displaySyncEnabled
[drawable present];
Acquiring the drawable — choose one of two schemes:
Scheme A — minimum latency: acquire the drawable at frame start, present at frame end. Simplest; the GPU may idle waiting for previous-frame work to drain before this frame's drawable becomes available.
// Frame start
id<CAMetalDrawable> drawable = [metalLayer nextDrawable];
[queue waitForDrawable:drawable];
// Encode all GPU work targeting the drawable
[queue commit:cmds count:count];
[queue signalDrawable:drawable];
[drawable present];
Scheme B — early GPU submission: commit work that doesn't depend on the drawable before calling nextDrawable/waitForDrawable. The GPU stays busy on independent work while the drawable becomes available.
// Encode + commit work that does NOT depend on the drawable (shadow maps, GPU culling, etc.)
[queue commit:earlyCmds count:earlyCount];
// Now acquire the drawable — the GPU is already working
id<CAMetalDrawable> drawable = [metalLayer nextDrawable];
[queue waitForDrawable:drawable];
// Encode + commit drawable-dependent work
[queue commit:lateCmds count:lateCount];
[queue signalDrawable:drawable];
[drawable present];
Key points:
nextDrawableis a CPU-side block — it waits until a drawable is available from the pool.waitForDrawableis a GPU-side wait — ensures the drawable's backing texture is released by the display before the GPU writes to it. Place beforecommit, but encoding can happen before the wait.signalDrawable+presentis the Metal 4 queue-level form of[cmd presentDrawable:]. The conceptual sequence is identical; the API surface differs.- Always use plain
[drawable present]— the layer'sdisplaySyncEnabledcontrols timing. - Do not hold strong references to drawables longer than necessary. Release drawables as soon as possible — holding them prevents the system from recycling them, which can starve the drawable pool.
Converting from Metal 3 cmd-buffer form
The Metal 3 cmd-buffer pattern (nextDrawable → encode → [cmd presentDrawable:] → [cmd commit]) translates mechanically to the queue-level form:
Metal 3 (MTLCommandBuffer) |
Metal 4 (MTL4CommandQueue) |
|---|---|
[cmd presentDrawable:drawable] then [cmd commit] |
[queue waitForDrawable:drawable] before commit → [queue commit:cmds] → [queue signalDrawable:drawable] → [drawable present] |
[cmd presentDrawable:drawable afterMinimumDuration:d] |
[queue signalDrawable:drawable] → [drawable presentAfterMinimumDuration:d] |
Both present and presentAfterMinimumDuration: are declared on MTLDrawable, which CAMetalDrawable conforms to. waitForDrawable: is part of the queue-level scheme only — do not call it alongside [cmd presentDrawable:].
Vsync Control
displaySyncEnabled on CAMetalLayer is the sole vsync control:
// Enable vsync — frame rate caps to display refresh rate
metalLayer.displaySyncEnabled = YES;
// Disable vsync — frame rate uncapped (limited by drawable pool size)
metalLayer.displaySyncEnabled = NO;
Toggling at runtime:
// Toggle vsync at runtime
vsyncEnabled = !vsyncEnabled;
metalLayer.displaySyncEnabled = vsyncEnabled;
Custom Frame Pacing
For capping frame rate to a specific target (e.g., 30 FPS on a 60Hz display):
// Present after a minimum duration (frame rate cap)
NSTimeInterval frameDuration = 1.0 / targetFPS;
[drawable presentAfterMinimumDuration:frameDuration];
This is for custom frame rate caps, not for vsync. Vsync is controlled by displaySyncEnabled.
Variable Refresh Rate Displays and CAMetalDisplayLink
Modern Apple displays support variable refresh rates — Adaptive-Sync on Mac (40–120Hz) and ProMotion on Mac and iPad (24–120Hz). CAMetalDisplayLink is the modern way to drive the render loop on macOS, providing display-synced callbacks with presentation timestamps.
Key rules:
- Do not hardcode display refresh rates — query at runtime; the available rate can change with Low Power Mode, thermal throttling, or accessibility settings.
- Use
update.targetPresentationTimestamp(orlink.targetTimestamponCADisplayLink) for animation timing — nottimestamp.targetTimestampstays consistent across frame-rate transitions;timestampcauses hitches. - Present at the highest rate the app can sustain evenly; stay above the display minimum (~40Hz) so the display doesn't fall out of adaptive sync.
CAMetalDisplayLinkpre-acquires the drawable and delivers it to the delegate — do not call[metalLayer nextDrawable]on this path.
For full setup code (delegate wiring, preferredFrameLatency, run-loop integration), variable-refresh-rate detection on macOS and iOS, and frame-rate transition smoothness: see references/frame-pacing.md.
For engine integration on a dedicated render thread (run-loop ownership, frame semaphore, switching between vsync-on and vsync-off at runtime): see the setting-up-macos-window skill.
Cross-API Swap Chain Equivalents
| Concept | D3D12 | Vulkan | Metal 4 |
|---|---|---|---|
| Swap chain | IDXGISwapChain |
VkSwapchainKHR |
CAMetalLayer |
| Back buffer | ID3D12Resource (from swap chain) |
VkImage (from swapchain) |
drawable.texture |
| Acquire image | Implicit (Present returns next) | vkAcquireNextImageKHR |
[layer nextDrawable] |
| Present | IDXGISwapChain::Present |
vkQueuePresentKHR |
[queue signalDrawable:] + [drawable present] |
| Vsync | Present(1, 0) vs Present(0, 0) |
VK_PRESENT_MODE_FIFO vs MAILBOX/IMMEDIATE |
displaySyncEnabled on layer |
| Image count | BufferCount in desc |
minImageCount in create info |
maximumDrawableCount on layer |
| Resize | ResizeBuffers |
Recreate swapchain | Update drawableSize |
Direct-to-Display vs Composited Presentation
macOS can present Metal content in two modes:
- Direct-to-display: The hardware composites the drawable directly to the display with very low overhead, with high-quality upscaling/downscaling. Lowest latency, best performance.
- Composited: The window server composites the drawable alongside other UI elements. Higher latency, additional memory bandwidth.
Direct-to-display is preferred for rendering. The Metal HUD (MTL_HUD_ENABLED=1) and Instruments can verify which mode is active.
Requirements for direct-to-display (per Apple documentation):
- Full-screen mode (use
[window toggleFullScreen:nil]) - Opaque
CAMetalLayer(metalLayer.opaque = YES) - RGB content (all RGB formats supported by Metal layers are capable of direct-to-display)
- There may be additional edge case conditions depending on hardware and system software
// Minimum setup for direct-to-display eligibility
metalLayer.opaque = YES;
metalLayer.framebufferOnly = YES; // no readback, enables GPU compression
[window toggleFullScreen:nil]; // enter full-screen mode
Pixel format considerations:
All RGB formats supported by CAMetalLayer are capable of direct-to-display when the above conditions are met. However, HDR formats may have additional constraints in practice — some HDR formats (e.g., extended sRGB, HDR10) may require vsync enabled to qualify for direct presentation. Verify with Metal HUD during development.
// Standard SDR — directly presentable
metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm;
metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm_sRGB;
// HDR formats — directly presentable in full-screen, but verify with Metal HUD
metalLayer.pixelFormat = MTLPixelFormatRGBA16Float; // extended sRGB
metalLayer.pixelFormat = MTLPixelFormatBGR10_XR; // HDR10
metalLayer.pixelFormat = MTLPixelFormatBGR10_XR_sRGB; // HDR10 sRGB
Window resize handling: keep drawableSize in sync with the view's backing size, but do not mutate drawableSize from windowDidResize: if the render thread is separate from the UI thread — a frame that began at the old size can finish against a resized layer, producing glitches or (with framebufferOnly = YES) validation failures. The correct pattern is: capture the new backing size on the UI thread, queue it, and apply it on the render thread between frames after in-flight work has drained. For the full render-thread / live-resize / fullscreen-transition implementation, see setting-up-macos-window.
Drawable size does not need to match display size. The macOS compositor efficiently sends the drawable directly to the display even if the drawable size doesn't match the monitor size — it applies high-quality scaling automatically.
HDR and Color Space Configuration
The pixel format and colorspace on CAMetalLayer must be paired correctly, and wantsExtendedDynamicRangeContent = YES must be set to enable HDR output. Without it, the OS compositor silently clips values above 1.0 to SDR.
| Output | Pixel format | Colorspace | Transfer function applied by |
|---|---|---|---|
| SDR | MTLPixelFormatBGRA8Unorm_sRGB |
kCGColorSpaceSRGB |
hardware (sRGB on write) |
| HDR 10-bit PQ | MTLPixelFormatBGR10A2Unorm |
kCGColorSpaceDisplayP3_PQ |
engine shader |
| HDR 16-bit linear | MTLPixelFormatRGBA16Float |
kCGColorSpaceExtendedLinearSRGB |
OS compositor |
If the colorspace name contains _PQ, the engine shader must write PQ-encoded values. With extended-linear colorspaces, the shader writes linear values and the OS applies the transfer.
For the full pixel-format table (P3 / Rec. 2100 / 16-bit PQ variants), per-platform EDR-headroom queries (macOS maximumExtendedDynamicRangeColorComponentValue and iOS currentEDRHeadroom), and display-profile-change handling: see references/hdr-colorspace.md.
Render Loop Modes: VSync-On vs VSync-Off
Use different render loop mechanisms depending on whether vsync is enabled:
VSync-On — use CAMetalDisplayLink:
- The display link pre-acquires a drawable before calling your delegate, so do NOT call
nextDrawabledirectly. - The drawable reflects the layer properties from the previous frame — update
drawableSize/pixelFormat/colorspaceafter presenting, not before (apply-after-present rule). - Set
displaySyncEnabled = YESon the layer.
VSync-Off — use a semaphore-based dispatch queue loop:
- Set
displaySyncEnabled = NOon the layer. - Use a serial dispatch queue with a
dispatch_semaphore(count 2 or 3) to limit in-flight frames. - Call
nextDrawableexplicitly each frame. - Use
[drawable presentAfterMinimumDuration:]to cap frame rate, or[drawable present]for uncapped.
For the apply-after-present rule with a code example: see references/frame-pacing.md. For a complete dual-path implementation that switches between modes at runtime on a dedicated render thread (updateFramePacing, frame semaphore drain, run-loop wiring): see the setting-up-macos-window skill.
Drawable Residency
The MTLTextures backing drawables must be resident before the GPU can render to them. CAMetalLayer provides a read-only residencySet property that automatically tracks the drawable textures.
// Queue-level — attach once, covers all command buffers on this queue
[commandQueue addResidencySet:metalLayer.residencySet];
// Or command-buffer-level — attach per frame
[commandBuffer useResidencySet:metalLayer.residencySet];
Key points:
- The layer's residency set is read-only — do not add or remove allocations from it. The layer manages it automatically.
- The set automatically tracks the latest drawable resources.
- For most engines, attaching at the queue level is simplest — it ensures all drawables are always resident.
For general MTLResidencySet semantics (creating sets, addAllocation/removeAllocation, requestResidency, the kernel-side commit() cost, the per-queue and per-command-buffer 32-set cap, frame-delayed destruction): see managing-metal4-resources. The layer's residencySet is a specialization of those rules — read-only and auto-managed.
Anti-Patterns / Common Mistakes
Using
presentAtTime:0for vsync-off. This conflicts withsignalDrawableand breaks frame pacing. UsedisplaySyncEnabled = NOon the layer instead. For standard presentation, plain[drawable present]is correct. UsepresentAfterMinimumDuration:only for custom frame rate caps.Skipping
waitForDrawable.nextDrawableblocks on the CPU until a drawable is available, butwaitForDrawableis a GPU-side wait ensuring the drawable's backing texture is released by the display. Both are needed — removingwaitForDrawablecan cause rendering to a texture still being displayed.Holding drawable references too long. Keeping a strong reference to the drawable beyond the present call prevents the display system from recycling it. This starves the drawable pool and can cause stuttering or
nextDrawableto block indefinitely.Forgetting to update
drawableSizeon resize. If the window resizes butdrawableSizeis not updated, rendering will be at the wrong resolution (stretched or cropped).Not setting
framebufferOnly = YES. Unless the engine needs to read back the drawable texture (rare), this should be YES. It enables GPU compression and is a free performance win.Not making drawable textures resident. The drawable's backing
MTLTextures must be in a residency set before GPU use. AttachmetalLayer.residencySetto the queue or command buffer — without it, rendering to the drawable can cause GPU faults.
Debugging Strategies
- Blank window: Check drawable count,
waitForDrawablepresence, and whethersignalDrawable+presentare both called. White screen often indicates the drawable is never presented or is presented before rendering completes. - Frame rate not matching vsync: Verify
displaySyncEnabledis being set on the correctCAMetalLayerinstance. Use Metal HUD (MTL_HUD_ENABLED=1) to see actual frame rate. If the difference between vsync on and off is unclear, ask the user to set their display to a lower refresh rate (e.g., 60Hz) where the gap is obvious — the agent cannot change display settings. - Stuttering: Check for frame pacing issues. Use Metal HUD to observe frame time consistency. Verify drawable pool isn't starved (are drawables held too long?). Check if the render loop is display-synced or timer-based.
- Metal HUD:
MTL_HUD_ENABLED=1provides immediate visual feedback on frame rate, GPU time, memory, and present mode (direct vs composited). AddMTL_HUD_LOG_ENABLED=1to log per-frame statistics to the console — the agent can parse this output to diagnose frame pacing issues without user interaction. AddMTL_HUD_ENCODER_TIMING_ENABLED=1for per-encoder GPU time breakdown.
Good Practices
- Always use
[drawable present]— let the layer handle timing viadisplaySyncEnabled. Only usepresentAfterMinimumDuration:for explicit frame rate caps. - Keep drawable lifetime short — acquire at frame start or late in the frame, present at frame end, release immediately after present.
- Use
@autoreleasepoolaround drawable acquisition and presentation to ensure timely cleanup of temporary ObjC objects. - Test vsync at a low refresh rate (e.g., 60Hz) where the difference between capped and uncapped is obvious. At high refresh rates (144Hz+), the uncapped rate may be close to the refresh rate, making it hard to tell if vsync is working.
- Use Metal HUD during development —
MTL_HUD_ENABLED=1gives immediate visual feedback without adding logging code. - Choose drawable count for the workload, not by default. 2 drawables (double-buffer) caps vsync-off frame rate but minimizes latency; 3 drawables enable deeper pipelining at one extra frame of latency per added drawable. Test against the engine's specific workload before settling.