Tutorial 109: Metal on macOS
What you’ll learn
- How to build CNA's native Metal renderer, and why CMake refuses anywhere but macOS.
- Exactly how much SDL is involved — the window and the layer, and nothing else.
- The renderer's current capability surface, which is narrower than you may expect, and why.
- What its automatic macOS CI job does, and what it does not yet prove.
Before you start — Tutorial 80: Cross-Platform Build Guide covers CNA's per-platform build story; this tutorial is the macOS chapter of it.
METAL is CNA's direct native Metal renderer. It is not SDL_Renderer with a Metal renderer, not SDL_GPU, and not a third-party graphics abstraction that happens to target Metal: draws go through MTLDevice, MTLCommandQueue, MTLRenderCommandEncoder and CAMetalLayer directly, in Objective-C++.
It is also the renderer where CNA is most visibly conservative about what it advertises. Several capabilities that the code contains an implementation for are reported false and refuse at runtime, because the evidence that they work has not been re-established since the renderer was adapted to CNA's current interfaces. That policy is the most useful thing to understand before you build against it, so this tutorial says what works, what refuses, and why the line is drawn where it is.
macOS only, enforced by a hard error
Configuring METAL anywhere other than macOS fails immediately:
CNA: METAL renderer is currently supported only on macOS; iOS and tvOS remain unvalidated.
This is a FATAL_ERROR in CMake, raised before Objective-C++ is enabled or any .mm source is collected — so a Linux or Windows configure stops with a clear message rather than producing an obscure compile failure later. It is the same hard-gate shape CNA uses for its Windows-only and Emscripten-only renderers.
The wording matters. iOS and tvOS are not supported at any level: there is no toolchain file, and the platforms have never been validated. Metal being Apple's API across all of them does not make CNA's Metal renderer an iOS renderer, and treating it as an almost-working starting point would be a mistake. CNA also sets no explicit macOS deployment target, so compatibility below whatever SDK and deployment defaults your successful build used is simply not established.
What SDL does, and where Metal takes over
The division of labour is unusually clean, and worth knowing if you are debugging a window-level problem:
| SDL3 provides | Metal owns |
|---|---|
The window itself, plus SDL_Metal_CreateView() and SDL_Metal_GetLayer() to obtain a CAMetalLayer. |
Device and command-queue creation, every render pass and encoder, all resource allocation, the drawable, and presentation. |
The Metal selection branch adds SDL_WINDOW_METAL | SDL_WINDOW_HIGH_PIXEL_DENSITY to the window flags — the high-density flag is requested deliberately, so a Retina display gets a full-resolution drawable rather than an upscaled one. Other renderers' window flags are untouched by this.
Shaders are Metal Shading Language embedded in the renderer's own Objective-C++ source and compiled at runtime with newLibraryWithSource:. There is no offline .metallib step in the build, and no shader asset to ship.
One detail that shapes the code: the Objective-C++ file uses manual retain/release rather than ARC. Objects returned by create-rule calls carry exactly one owning reference; borrowed layer, command-buffer, encoder and drawable results are retained only when they outlive the acquiring call. A constructor exception rolls back every partially acquired native object and destroys the SDL Metal view after drawable and layer use has ended. You do not have to think about this to use the renderer, but it explains why the source looks the way it does.
Building it
cmake -S . -B build-metal \
-DCNA_GRAPHICS_RENDERER=METAL \
-DCNA_BUILD_TESTS=ON \
-DCNA_USE_CCACHE=ON \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
cmake --build build-metal --parallel 3
# Run the renderer's tests with Metal's own validation layers switched on
MTL_SHADER_VALIDATION=1 MTL_DEBUG_LAYER=1 \
ctest --test-dir build-metal -R '^Metal' --output-on-failure --parallel 3
Nothing is fetched at configure time — Metal ships with the OS. Like every CNA build you still need the sharp-runtime sibling checkout beside cna/, and FFmpeg's development libraries, which on macOS is a brew install ffmpeg away.
It has its own automatic CI job
Most of CNA's 46 renderers have no continuous integration at all. METAL is one of the few with a dedicated, automatically triggered workflow — metal-macos-ci.yml, running on a macos-14 runner. It fires on pull requests, on pushes that touch the files which can affect the renderer, and on manual dispatch.
The job does four things: checks out CNA and the sharp-runtime sibling, configures with -DCNA_GRAPHICS_RENDERER=METAL, builds with three parallel jobs, and runs the supported Metal contract tests with Metal's shader validation and debug layer enabled. A separate step re-runs the renderer compile-definition control so the build cannot drift into claiming to be a different renderer.
Real macOS hardware in CI is a genuine advantage here. CNA's Windows Direct3D renderers are routinely verified through Wine with DXVK and vkd3d-proton on Linux rather than on real Windows; the Metal renderer builds and runs on an actual Apple runner every time the relevant files change. That said, CI across CNA as a whole remains thin, and this job gates the renderer's own contract tests rather than the full unit suite — see Verification.
The current capability contract
Every one of CNA's 13 capabilities is answered explicitly on Metal. There is no permissive default here — which, given that the shared default returns true for almost everything, is itself worth noticing. This table is the renderer's actual contract:
| Capability | Reported | Boundary |
|---|---|---|
ThreeD | true | Built-in fixed-layout Metal pipelines only. |
DepthStencilBuffer | true | Native combined depth/stencil attachments. |
StencilBuffer | true | Native stencil plane and state mapping. |
AnisotropicFiltering | true | Native sampler-state mapping. |
WireFrame | true | FillMode::WireFrame maps to Metal's lines fill mode. |
Texture3D | true | Colour-format native 3D textures. |
AdditiveBlending | true | Native blend factors and operations. |
MultiSampleAntiAliasing | false | Every requested sample count is clamped to zero. |
MultipleRenderTargets | false | More than one descriptor is rejected before any binding state changes. |
OcclusionQuery | false | Creation throws. |
CustomEffects | false | Effect creation and non-null SpriteBatch custom effects both throw. |
MultiStreamVertexInput | false | More than one per-vertex stream is rejected. |
Instancing | false | Instance streams and instance counts other than one are rejected. |
Six of the thirteen are false. As Tutorial 101 explains, an authored false is the most trustworthy answer this API gives — somebody looked at that capability on this renderer and decided to say no. Take these at face value and branch on them.
ShaderEffect throws on Metal
Constructing a ShaderEffect under METAL raises System::NotSupportedException: “Metal custom effects are disabled until the adapted renderer has passing macOS shader and pixel evidence.” This is not a construction that quietly yields an invalid effect — it is a throw.
The refusal is complete and consistent rather than partial: effect creation throws, a non-null custom effect handed to SpriteBatch throws, and an ordinary 3D draw carrying a custom effect is rejected before submission. There is dormant MSL scaffolding for a SpriteBatch-scoped custom-effect path in the source, but it is unreachable and explicitly not a public contract; it must not be re-enabled by flipping a capability bit.
So on Metal you are on the stock-effect path. Guard the branch rather than catching the exception:
#include "CNA/GraphicsCapability.hpp"
using CNA::GraphicsCapability;
void MyGame::LoadContent()
{
auto& device = getGraphicsDeviceProperty();
// A `false` here is authored. Do not construct the effect at all.
if (device.SupportsCapability(GraphicsCapability::CustomEffects))
gradeEffect_ = std::make_unique<ShaderEffect>(device, kVertexSrc, kFragmentSrc);
// Metal lands here: BasicEffect and friends, which are fully supported.
basicEffect_ = std::make_unique<BasicEffect>(device);
}
Deterministic refusals, not silent degradation
The consistent design choice across this renderer is that an unsupported request fails. It never renders something plausible-but-wrong and never returns fabricated data:
- Back-buffer readback throws.
GetBackBufferData()raisesSystem::NotSupportedExceptionrather than returning known-wrong pixels. This is the one to plan around if you write pixel tests — on Metal you cannot. UseSOFTWAREfor image-level assertions, as Tutorial 107 describes. - MSAA reports zero and allocates single-sample attachments, on the back buffer and on render targets alike, instead of reporting a sample count it does not deliver. See Tutorial 65 for the portable pattern.
- Render-target binding accepts zero descriptors (restore the back buffer) or exactly one normalised 2D or cube-face descriptor. An MRT set throws. Array slices are rejected. See Tutorial 62.
- Malformed stream metadata throws
std::invalid_argument; multi-stream input and instancing throwSystem::NotSupportedException. TextureCube,Texture3Dand render targets accept onlySurfaceFormat::Color; any other format throws.- Non-default per-target colour-write masks, multisample coverage masks, sampler maximum mip level and sampler LOD bias all throw rather than being accepted and ignored.
One behaviour is a deliberate non-error: if Metal cannot hand over a drawable — the window is minimised or in the background — the renderer makes one attempt and then skips that logical frame. Clear, draws, markers and present do nothing for that frame and nothing retries; off-screen render-target work still proceeds. Presentation resets availability for the next frame. Your game loop keeps running rather than throwing on a backgrounded window.
Why the surface is this narrow
It is worth understanding, because the answer is a policy rather than a set of missing features.
An earlier macOS CI run did build the historical Metal renderer successfully with Metal validation enabled and exercised its test set. Seven checks could not demonstrate their result: six read back only the frame's clear colour after real draws — among them the custom-effect, MRT, MSAA and user-primitives tests — and a seventh applied a sample count of four but produced an edge that was still binary. The capability check in that run did not fail — but it asserted only a small boolean set, and did not prove the rendering paths those booleans advertised.
The renderer has since been adapted to CNA's current graphics interfaces, and a source audit during that adaptation found and fixed a substantial list of real defects: a cached drawable held without an owned reference, a partially constructed renderer with no rollback, a doubly retained device, draws that selected pipelines by vertex stride alone and could reinterpret same-stride layouts, unchecked cube and volume transfers, viewport and scissor state lost when an encoder was recreated, render-target uploads that silently did nothing, and swapped channels on BGRA targets. Those are fixed in the source. What does not yet exist is a fresh macOS run proving the adapted renderer's pixels.
So the capabilities without that evidence were switched off rather than left advertised. That is the whole rationale: a capability bit is a promise to callers, and advertising an unverified historical path would make it part of the supported API. Enabling CustomEffects again is defined as one coherent change that supplies a successful build, clean Metal validation, a native test proving output without relying on the known-broken readback path, and tests for uniform slots, texture binding, blend state, render-target use and effect lifetime.
Read the falses on this page as “not yet demonstrated on the current source”, not as “impossible”. The distinction is exactly the one CNA's verification page makes throughout.
What you can build on today
Inside that contract the renderer is a real one, not a placeholder. Ordinary 3D draws consume the current vertex-stream metadata with one valid per-vertex stream; pipeline selection uses the effect's own flags and canonical stride rather than guessing from stride alone; fog uses the FNA-compatible four-component fog-vector contract. Requested viewport and scissor state is kept independent of the attachment extent and survives both a Clear and an encoder recreation — a fresh encoder re-applies the requested viewport unchanged and intersects only an enabled scissor with the current target, and a logically empty enabled scissor installs a legal placeholder while suppressing draws.
Texture transfers validate face, mip level, coordinates, extents and length before any native work, use a macOS-safe 256-byte staging row alignment, and de-pad on the way back. A SetData call preserves untouched subresources and swaps in a completed replacement, so a prior draw cannot sample a half-written texture. Every used sampler slot binds either a real texture or an owned white / flat-normal / white-cube fallback, so output cannot become draw-order dependent through a stale binding.
Practically, that means: forward rendering with the stock effects, one render target at a time, no MSAA, no instancing, and no pixel readback. A 2D game or a straightforward 3D scene fits comfortably. A deferred renderer, a hardware-instanced crowd or a custom post-processing chain does not — not on Metal, today.
Keeping a Metal build in a portable codebase
Because exactly one renderer is compiled into a build, a macOS Metal build is a separate configuration from your Linux or Windows one. The capability queries are what keep a single source tree honest across all of them:
// Resolve once at startup and cache the answers; do not query per frame.
struct RenderPathPlan
{
bool deferred = false;
bool antialiased = false;
bool instancedCrowd = false;
};
RenderPathPlan PlanRenderPath(GraphicsDevice& device)
{
RenderPathPlan plan;
plan.deferred = device.SupportsCapability(GraphicsCapability::MultipleRenderTargets);
plan.antialiased = device.SupportsCapability(GraphicsCapability::MultiSampleAntiAliasing);
plan.instancedCrowd = device.SupportsCapability(GraphicsCapability::Instancing);
return plan; // On METAL: all three false, and all three answers are authored.
}
Two habits pay off. Log device.GetGraphicsRendererName() at startup — it is a compile-time constant matching the CNA_GRAPHICS_RENDERER value exactly, so a bug report from a Mac user tells you immediately which renderer produced it. And keep your image-level regression tests on a CPU renderer rather than on the platform renderer, so a Metal build failing to read back pixels never becomes a gap in your coverage.
Where to go next
- Tutorial 101: Querying Renderer Capabilities
- Tutorial 107: CPU-Only Renderers — where to run the pixel tests Metal cannot
- Tutorial 72: Choosing a Renderer
- Tutorial 80: Cross-Platform Build Guide
- Platforms reference, Renderers reference and Verification