Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

MTKView on macOS: Drawable Lifetimes, Frame Pacing, and Resize Handling

Render with MTKView using late drawable acquisition, explicit command-buffer presentation, resize-aware resources, bounded frames, and pause policies.

MTKView is the MetalKit view that coordinates an onscreen render target, a drawable layer, optional depth or multisample targets, and a drawing schedule. It reduces setup, but it does not own the renderer’s resource model or make GPU work synchronous. A frame can be requested while the window is resizing, hidden, paused, or waiting for a drawable. Treat the view callback as an opportunity to encode a frame, not as a guarantee that every callback produces a visible image.

The renderer should own the Metal device, command queue, pipeline state, and resources whose lifetime spans frames. The view owns its current drawable-sized render targets. Keep those responsibilities distinct so a window resize can rebuild size-dependent resources without recreating all renderer state.

Select one drawing model

MTKView can draw on a timed schedule or in response to explicit invalidation, and it can ask a delegate to draw or use a subclass override. Choose one implementation route. Apple advises not to both subclass for drawing and provide a delegate, because two owners make frame responsibility ambiguous. For a continuously animated scene, a timed loop may fit; for a mostly static inspector or editor, pause and request redraws when state changes.

The application should set an available MTLDevice before rendering. Device creation can fail in unsupported environments, so present a fallback or disable the GPU-backed feature cleanly. Create the command queue and long-lived pipeline state once, and cache them under the device and shader configuration that produced them. Do not create expensive pipeline objects inside draw(in:).

import Metal
import MetalKit

final class FrameRenderer: NSObject, MTKViewDelegate {
    private let queue: MTLCommandQueue

    init?(view: MTKView) {
        guard let device = MTLCreateSystemDefaultDevice(),
              let queue = device.makeCommandQueue() else { return nil }
        self.queue = queue
        super.init()
        view.device = device
        view.delegate = self
    }

    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
        // Rebuild only resources whose dimensions depend on the drawable.
    }

    func draw(in view: MTKView) {
        guard let descriptor = view.currentRenderPassDescriptor,
              let drawable = view.currentDrawable,
              let commandBuffer = queue.makeCommandBuffer(),
              let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
            return
        }
        encoder.endEncoding()
        commandBuffer.present(drawable)
        commandBuffer.commit()
    }
}

The render pass in this minimal example clears the target and submits it. A real renderer also binds its pipeline and resources and issues draw calls. Keep the view and renderer alive through submitted work, and do not assume that returning from draw(in:) means the GPU has completed the frame.

Acquire drawables late and present once

Reading currentRenderPassDescriptor obtains the drawable for the current frame. Acquire it only when the CPU is ready to encode the onscreen pass; holding a drawable while doing unrelated CPU work can reduce the number available to the display layer. If the descriptor or drawable is absent, skip the frame and wait for a later draw opportunity instead of spinning until one appears.

After encoding, register presentation on the command buffer before committing it. The command buffer’s execution order ensures presentation is associated with the completed GPU work; do not wait synchronously for completion before asking it to present. A completion handler is useful for releasing per-frame resources or collecting metrics, but it runs after the frame’s GPU work and should not block the render callback.

Bound the number of in-flight frames. A renderer that creates CPU work faster than the GPU can consume it accumulates latency and memory. Use a semaphore or another explicit frame-in-flight policy only when its wait cannot freeze the UI thread; better yet, skip or coalesce outdated simulation frames when the app values interactivity over full frame retention. Measure queue depth, GPU time, and presentation latency rather than judging smoothness from CPU draw-call duration alone.

Choose the draw schedule from product behavior. Timed drawing is useful for animation that changes continuously, but an editor with no active animation can pause and request redraws when state changes. A target frames-per-second value is a preference for scheduling, not proof that the display will refresh at that cadence. Variable refresh, GPU load, occlusion, and window visibility affect actual delivery. Use the frame callback’s timing information and GPU measurements to determine whether missed deadlines come from CPU encoding or GPU execution.

If simulation and drawing run at different rates, decouple them. Advance deterministic simulation with a bounded time step, then render the newest state; do not run an unbounded catch-up loop after a long pause. Reset or clamp elapsed time when the window becomes active again so a background interval does not produce a huge simulation jump. For video or audio synchronized rendering, use the media clock that owns that timeline rather than assuming the view timer is authoritative.

Resize, scale, and color management

drawableSize is the current texture size and can differ from the AppKit view’s point size. A Retina backing scale, display movement, window resize, and selected drawable policy can all change the relationship. Recreate size-dependent depth, intermediate, and post-processing textures in drawableSizeWillChange(_:), then publish a complete resource set atomically to the render path.

Avoid reading the view’s layout state from a background render task while AppKit mutates it. Pass an immutable frame configuration containing the current dimensions and color policy. Handle zero-sized or temporarily unavailable drawables as normal transitions. If the app renders offscreen at a fixed resolution, make the scaling step explicit rather than assuming the view’s current descriptor matches the offscreen target.

Resize is also a concurrency boundary. The delegate can report a new drawable size while earlier command buffers still reference old textures. Keep old resources alive until the GPU has completed their last use, and switch the renderer’s resource bundle as one versioned unit. Never update the width in one property and the height in another while a draw callback can observe the intermediate state. Include device identity in cache keys if a renderer can move between devices or contexts.

Choose pixel format, color space, and extended-range behavior deliberately. The screen’s capabilities and the app’s content requirements matter; an HDR option does not guarantee every display or output path presents HDR identically. Validate colors, blending, and screenshots on supported target configurations, and keep a standard dynamic-range path where appropriate.

If enableSetNeedsDisplay is used, pair it with a clear invalidation path from the model. Mark the view dirty after a state change, not from inside every draw callback, or the renderer can create a self-sustaining redraw loop. Conversely, an animated scene that depends on a continuous timer should not accidentally remain paused after a window is reactivated. Keep scheduling state observable in diagnostics so a blank view can be distinguished from a view that simply is not scheduled to draw.

Pause, invalidation, and view lifecycle

When a window is hidden or the user switches to a static workflow, pause timed drawing if continuous refresh has no value. With enableSetNeedsDisplay, the app can request redraws in response to model changes. Make animation ownership explicit: a renderer should know whether its scene is active, whether simulation time advances while hidden, and whether the UI should catch up or resume from a frozen state.

When tearing down, detach the delegate if the renderer is ending before the view, release size-dependent resources, and stop display-tick or worker activity owned by the renderer. Metal resources submitted to a command buffer must remain valid until that work is complete. Prefer completion-managed reuse or a bounded resource ring over immediately mutating storage still referenced by the GPU.

Diagnostics and acceptance tests

Test device unavailable, first frame, no drawable, minimized and hidden windows, live resize, display scale change, view pause/resume, command buffer error, GPU slowdown, and repeated view creation. Verify exactly one draw owner, no busy loop when the drawable is absent, and no resource access after teardown. Test a static scene and sustained animation separately.

Record CPU encoding duration, command-buffer completion duration, drawable availability, skipped frames, in-flight count, drawable size, and resize count. Use Metal validation and GPU capture tools during development to inspect attachment formats and command submission. A reliable MTKView renderer acquires the drawable late, submits presentation in order, rebuilds only dimension-dependent resources, and adapts work to the actual view lifecycle.

Related:

Sources:

Comments