From game-porting-skills
Guides implementation and debugging of Metal 4's drawable presentation contract: CAMetalLayer configuration, drawable lifecycle (nextDrawable/waitForDrawable/signalDrawable/present), vsync, CAMetalDisplayLink, HDR/EDR layer setup, direct-to-display vs composited, and drawable-residency-set rules.
How this skill is triggered — by the user, by Claude, or both
Slash command
/game-porting-skills:presenting-metal-drawablesThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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 `...
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.
Owns:
nextDrawable, waitForDrawable, signalDrawable, present)CAMetalLayer configuration and propertiesdisplaySyncEnabled, presentAfterMinimumDuration:)CAMetalDisplayLink API basics; variable refresh rate detectionresidencySet)Doesn't own:
setting-up-macos-windowMTLResidencySet semantics → managing-metal4-resourcestranslating-to-metal4-apisetting-up-macos-windowusing-metalfx-frame-interpolationRead the relevant framework header before writing presentation code — the headers are the source of truth for property names, types, and method signatures.
$(xcrun --show-sdk-path)/System/Library/Frameworks/QuartzCore.framework/Headers/CAMetalLayer.h — declares both CAMetalLayer and the CAMetalDrawable protocolNSWindow
└── 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.
// 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.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:
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.[drawable present] — the layer's displaySyncEnabled controls timing.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:].
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;
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.
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:
update.targetPresentationTimestamp (or link.targetTimestamp on CADisplayLink) for animation timing — not timestamp. targetTimestamp stays consistent across frame-rate transitions; timestamp causes hitches.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.
| 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 |
macOS can present Metal content in two modes:
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):
[window toggleFullScreen:nil])CAMetalLayer (metalLayer.opaque = YES)// 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.
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.
Use different render loop mechanisms depending on whether vsync is enabled:
VSync-On — use CAMetalDisplayLink:
nextDrawable directly.drawableSize/pixelFormat/colorspace after presenting, not before (apply-after-present rule).displaySyncEnabled = YES on the layer.VSync-Off — use a semaphore-based dispatch queue loop:
displaySyncEnabled = NO on the layer.dispatch_semaphore (count 2 or 3) to limit in-flight frames.nextDrawable explicitly each frame.[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.
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:
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.
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.
waitForDrawable presence, and whether signalDrawable + present are both called. White screen often indicates the drawable is never presented or is presented before rendering completes.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.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.[drawable present] — let the layer handle timing via displaySyncEnabled. Only use presentAfterMinimumDuration: for explicit frame rate caps.@autoreleasepool around drawable acquisition and presentation to ensure timely cleanup of temporary ObjC objects.MTL_HUD_ENABLED=1 gives immediate visual feedback without adding logging code.npx claudepluginhub apple/game-porting-toolkit --plugin game-porting-skillsTranslates graphics code to Metal 4 with cross-API mappings from Metal 3, D3D12, or Vulkan, and covers Apple GPU TBDR architecture.
Routes to specialized skills for Metal migration, shader conversion, RealityKit AR, USD handling, and display performance on Apple devices.
Guides advanced SwiftUI animations, transitions, matched geometry effects, and Metal shader integration for iOS/macOS apps.