| name | presenting-metal-drawables |
| description | ALWAYS use when implementing or debugging the Metal drawable presentation contract — `CAMetalLayer` configuration, drawable lifecycle (`nextDrawable`, `waitForDrawable`, `signalDrawable`, `present`), vsync (`displaySyncEnabled`), `CAMetalDisplayLink`, HDR/EDR layer setup, direct-to-display vs composited, or drawable-residency-set rules. Trigger for CAMetalLayer, CAMetalDrawable, nextDrawable, signalDrawable, waitForDrawable, displaySyncEnabled, presentAfterMinimumDuration, drawableSize, framebufferOnly, wantsExtendedDynamicRangeContent, EDR headroom, ProMotion, swap chain port, "blank window", "vsync not working". Do NOT trigger for render-thread / run-loop / resize / fullscreen integration on macOS — use `setting-up-macos-window`. General `MTLResidencySet` semantics — use `managing-metal4-resources`. Cross-API translation tables for non-presentation APIs — use `translating-to-metal4-api`. For dual-frame presentation via a present thread — use `using-metalfx-frame-interpolation`. |
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)
CAMetalLayer configuration and properties
- Vsync semantics (
displaySyncEnabled, presentAfterMinimumDuration:)
CAMetalDisplayLink API 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
MTLResidencySet semantics → 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.
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
CAMetalLayer* metalLayer = (CAMetalLayer*)view.layer;
metalLayer.device = device;
metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm;
metalLayer.drawableSize = CGSizeMake(width, height);
metalLayer.maximumDrawableCount = 2;
metalLayer.framebufferOnly = YES;
metalLayer.opaque = YES;
metalLayer.displaySyncEnabled = enableVsync;
Key properties:
pixelFormat — must match render pipeline color attachment format
drawableSize — update when window resizes
maximumDrawableCount — 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:
id<CAMetalDrawable> drawable = [metalLayer nextDrawable];
[queue waitForDrawable:drawable];
[queue commit:cmds count:count];
[queue signalDrawable:drawable];
[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.
id<CAMetalDrawable> drawable = [metalLayer nextDrawable];
[queue waitForDrawable: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.
[queue commit:earlyCmds count:earlyCount];
id<CAMetalDrawable> drawable = [metalLayer nextDrawable];
[queue waitForDrawable:drawable];
[queue commit:lateCmds count:lateCount];
[queue signalDrawable:drawable];
[drawable present];
Key points:
nextDrawable is a CPU-side block — it waits until a drawable is available from the pool.
waitForDrawable is a GPU-side wait — ensures the drawable's backing texture is released by the display before the GPU writes to it. Place before commit, but encoding can happen before the wait.
signalDrawable + present is 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's displaySyncEnabled controls 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:
metalLayer.displaySyncEnabled = YES;
metalLayer.displaySyncEnabled = NO;
Toggling 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):
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 (or link.targetTimestamp on CADisplayLink) for animation timing — not timestamp. targetTimestamp stays consistent across frame-rate transitions; timestamp causes 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.
CAMetalDisplayLink pre-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
metalLayer.opaque = YES;
metalLayer.framebufferOnly = YES;
[window toggleFullScreen:nil];
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.
metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm;
metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm_sRGB;
metalLayer.pixelFormat = MTLPixelFormatRGBA16Float;
metalLayer.pixelFormat = MTLPixelFormatBGR10_XR;
metalLayer.pixelFormat = MTLPixelFormatBGR10_XR_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
nextDrawable directly.
- The drawable reflects the layer properties from the previous frame — update
drawableSize/pixelFormat/colorspace after presenting, not before (apply-after-present rule).
- Set
displaySyncEnabled = YES on the layer.
VSync-Off — use a semaphore-based dispatch queue loop:
- Set
displaySyncEnabled = NO on the layer.
- Use a serial dispatch queue with a
dispatch_semaphore (count 2 or 3) to limit in-flight frames.
- Call
nextDrawable explicitly 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.
[commandQueue addResidencySet:metalLayer.residencySet];
[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:0 for vsync-off. This conflicts with signalDrawable and breaks frame pacing. Use displaySyncEnabled = NO on the layer instead. For standard presentation, plain [drawable present] is correct. Use presentAfterMinimumDuration: only for custom frame rate caps.
-
Skipping waitForDrawable. nextDrawable blocks on the CPU until a drawable is available, but waitForDrawable is a GPU-side wait ensuring the drawable's backing texture is released by the display. Both are needed — removing waitForDrawable can 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 nextDrawable to block indefinitely.
-
Forgetting to update drawableSize on resize. If the window resizes but drawableSize is 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. Attach metalLayer.residencySet to the queue or command buffer — without it, rendering to the drawable can cause GPU faults.
Debugging Strategies
- Blank window: Check drawable count,
waitForDrawable presence, and whether signalDrawable + present are both called. White screen often indicates the drawable is never presented or is presented before rendering completes.
- Frame rate not matching vsync: Verify
displaySyncEnabled is being set on the correct CAMetalLayer instance. 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=1 provides immediate visual feedback on frame rate, GPU time, memory, and present mode (direct vs composited). Add MTL_HUD_LOG_ENABLED=1 to log per-frame statistics to the console — the agent can parse this output to diagnose frame pacing issues without user interaction. Add MTL_HUD_ENCODER_TIMING_ENABLED=1 for per-encoder GPU time breakdown.
Good Practices
- Always use
[drawable present] — let the layer handle timing via displaySyncEnabled. Only use presentAfterMinimumDuration: 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
@autoreleasepool around 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=1 gives 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.