Tutorial 109: Metal on macOS
What you’ll learn
- How to build CNA's native Metal renderer, and why CMake accepts it only for macOS and iOS/iPadOS.
- Exactly how much SDL is involved — the window, and nothing below it.
- The renderer's capability contract at this snapshot, and the limits that remain.
- What was measured on a physical Mac mini M4, what the automatic macOS CI job runs, and what neither proves.
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 a renderer whose capability contract changed a great deal between CNA snapshots. Until early October 2026 several capabilities were reported false and refused, because the evidence that they worked had not been re-established for the adapted source. CNA's Apple campaign then ran the renderer on a physical Mac mini M4, repaired what the measurements found and implemented the missing features; CNA's own verdict for it is now supported, but not yet primary-production. This tutorial describes that state and keeps the remaining limits in view.
macOS and iOS only, enforced by a hard error
Configuring METAL for any other system fails immediately:
CNA: METAL renderer targets macOS and iOS only.
This is a FATAL_ERROR in cmake/RendererSelection.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.
iOS/iPadOS. On iOS the Apple allow-list admits exactly two renderers, SDL_RENDERER and METAL; any other renderer is a configure error unless the explicitly unsupported -DCNA_APPLE_ALLOW_UNVALIDATED_RENDERER=ON is given. The renderer attaches its layer-backed view to SDL's UIWindow there. The iOS evidence is the iOS Simulator only (an iPhone 17 profile): a pixel probe that draws a clear colour, a SpriteBatch quad and a BasicEffect triangle and reads them back was exact on 29 of 31 launches in the first campaign run, the two all-zero results being the first two after the initial install; after an SDL UIKit view-frame fix (AM4-120) the stricter probe — frame 1 plus the ten frames after it — was exact in 8 of 8 launches. The device build is final-linked but has never run: no physical iPhone or iPad, signing identity or Apple account was available. An iOS configure needs -DCNA_ENABLE_NET=OFF, because the iOS SDK ships no libcurl. tvOS, watchOS and visionOS are refused.
Deployment floor. CNA's macOS floor is 13.3 (CNA_MACOS_DEPLOYMENT_TARGET; iOS 16.3). Sampler LOD bias additionally needs the macOS/iOS 26 SDK and OS: built with an older SDK, or run on an older system, a non-zero MipMapLevelOfDetailBias is refused by name. A binary that links Homebrew's FFmpeg starts only on the macOS that FFmpeg was built for, whatever the floor says; build with -DCNA_ENABLE_VIDEO=OFF or an FFmpeg built for the floor if that matters. No Intel Mac has run the renderer.
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 (Cocoa on macOS, UIKit on iOS) and its native handle — NSWindow* or UIWindow*, through the platform window's GetNativeHandle(). |
Its own CAMetalLayer-backed view added to that window, device and command-queue creation, every render pass and encoder, all resource allocation, the drawable, and presentation. |
The renderer's descriptor declares a Metal window intent and asks for a high-density window, and the SDL3 platform turns that into SDL_WINDOW_METAL | SDL_WINDOW_HIGH_PIXEL_DENSITY — 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. METAL runs on the SDL3 platform, the one windowing platform CNA has; on Apple, SDL's Cocoa and UIKit drivers create windows only on the process's main thread, so create and run the game there.
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; and every renderer entry point that sends Objective-C messages opens its own autorelease pool, so per-frame drawables, command buffers and encoders do not accumulate (measured: a 6,000-frame loop that grew by about 3.3 KB per frame before this change grows by nothing after it). A constructor exception rolls back every partially acquired native object. 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
brew install ccache ffmpeg nlohmann-json # nlohmann-json is required by Gamer Services
cmake -S . -B build-metal \
-DCNA_GRAPHICS_RENDERER=METAL \
-DCNA_METAL_COMPILED_EFFECTS=ON \
-DCNA_BUILD_TESTS=ON \
-DCNA_USE_CCACHE=ON \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
cmake --build build-metal --parallel
# 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
CNA_METAL_COMPILED_EFFECTS is off by default, like every other renderer's compiled-effects option; leave it off and Effect bytecode from .xnb files is refused, while the stock effects and SpriteBatch work as usual. The toolchain must be recent: CNA's macOS CI uses Xcode 26, the M4 campaign used Xcode 27 with Apple clang 21, and Xcode 15.4 is too old (its library has no std::jthread, which Gamer Services use). Like every CNA build you need the sharp-runtime sibling checkout beside cna/ on its apple/m4-stabilization branch. Desktop networking needs a libcurl: macOS's own works for TLS but has no WebSocket support, so a build that found it refuses the online relay by name — install Homebrew's curl if you need online play. FFmpeg is optional (CNA_ENABLE_VIDEO=AUTO uses it when found).
What was measured on a physical Mac mini M4
CNA's Apple campaign (October 2026, recorded task by task in plans/plan_apple_m4.md and summarised in docs/apple-platforms.md) ran the renderer on real Apple hardware: a Mac mini M4 (arm64, 16 GB) with macOS 27.0.1, Xcode 27 and Apple clang 21. These are local measurements, not CI results, and nothing was re-run for this page:
| Measurement | Result |
|---|---|
Metal-labelled tests under MTL_DEBUG_LAYER/MTL_SHADER_VALIDATION (101 shared pixel fixtures, 146 renderer-neutral oracles, 14 native tests) | 261 of 261, in a Debug and a Release tree |
The full CNA test tree with METAL as the renderer | 11,015 of 11,015, 0 failures, 114 tests declining by name (another renderer, an absent capability or a Linux-only facility) |
Compiled XNA effects (CNA_METAL_COMPILED_EFFECTS=ON) | 43 Metal tests and 1,237 effect and SpriteBatch tests under the validation layers |
| cna-samples, Release multi-renderer tree with compiled effects on | 91 of 91 automated passes on METAL |
| cna-examples demos | 248 of 248 render on METAL |
| House Simulator, Release, with its compiled effects | its walk scene runs at the 66.7 fps cap and matches the OPENGL33 frame except for one lamp where both renderers z-fight differently |
How far that reaches. The full-tree run was made with the console session locked, which makes macOS report every window as occluded: the renderer's output was verified by reading back pixels, but on-screen presentation and PresentInterval pacing were never observed. No soak run bounded the renderer's caches, no Intel Mac was used, and no physical iOS device ran it. CNA's own verdict follows from exactly those gaps: supported, not primary-production.
Its automatic CI job
METAL has a dedicated, automatically triggered workflow, metal-macos-ci.yml, running on GitHub's macos-26 runner. It fires on pull requests, on pushes to next, develop or main that touch the files its path filter lists, and on manual dispatch. That filter is kept by hand: at this snapshot it still names three files that no longer exist and omits the files that register the Metal tests, so a push that changes only those does not trigger it.
The job checks out CNA and the sharp-runtime sibling on the matching branch, configures with -DCNA_GRAPHICS_RENDERER=METAL, builds, and runs ctest -R '^Metal' — 518 tests, the shared pixel fixtures and oracles included — with Metal's API validation layer on. A separate step re-runs the renderer compile-definition control so the build cannot drift into claiming to be a different renderer.
The hosted runner is not the Mac mini. Its GPU is an “Apple Paravirtual device” on which Metal's shader validation reports false texture-usage errors even for a 30-line program with no CNA code, so the job turns shader validation off on that device (API validation stays on) and runs the GPU tests one process at a time, because the device hung under parallel runs. Running there did find two real product defects — a missing CPU/GPU synchronisation for managed-storage textures and a sampler LOD bias the device silently ignores — both fixed. The campaign records hosted runs that went from 154 failures to one; it records no fully green hosted run after the last CI change. The evidence for pixels is the M4 run above, not CI.
The current capability contract
GraphicsCapability has 19 members, and every one is answered explicitly — there is no permissive default. In a default build on an Apple-silicon Mac with a HiDef device, 16 report true; with CNA_METAL_COMPILED_EFFECTS=ON, 17. Remember that XNA's default Reach profile still refuses HiDef features (MRT, occlusion queries, float targets) until you request HiDef:
| Capability | Reported | Boundary |
|---|---|---|
ThreeD | true | All five stock effects plus CNA's PBR effects. |
DepthStencilBuffer, StencilBuffer | true | A target — the back buffer included — has exactly the planes its depth format names; Depth16 is stored natively. RasterizerState.DepthBias is applied in normalized depth, as Direct3D 9 does. |
MultiSampleAntiAliasing | per device | Back buffer, RenderTarget2D, RenderTargetCube and MRT sets; a request is rounded down to a count the GPU supports (the M4: 2 and 4, not 8). |
MultipleRenderTargets | true | Up to eight RenderTarget2D or cube-face members; the stock effects write the first target only. |
AnisotropicFiltering, WireFrame, AdditiveBlending | true | Native sampler, fill-mode and blend mapping; ColorWriteChannels is honoured. |
OcclusionQuery | true | Counts samples across encoder boundaries; one query may be open at a time. |
CustomEffects | true | A ShaderEffect written in MSL, for SpriteBatch only — see below. |
CompiledEffects | with the option only | XNA Effect bytecode through MojoShader's SPIR-V profile, translated to MSL by SPIRV-Cross, for 3D draws and SpriteBatch. |
Texture3D | true | Every uncompressed classic format, like TextureCube. |
MultiStreamVertexInput, Instancing | true | Up to sixteen streams; DrawInstancedPrimitives with every stock effect, the per-instance world matrix applied before the effect's World. |
FloatRenderTargets, HalfFloatRenderTargets, HalfFloatTextureLinearFiltering | true | Render targets in Single, Vector2/4, the half formats, HdrBlendable, Rgba64, Rg32 and Rgba1010102 keep their range. |
ComputeShaders, IndirectDraw | false | The renderer has no compute or indirect-draw path. |
Texture formats follow the same pattern: Texture2D stores every uncompressed XNA format natively, the packed 16-bit formats only on Apple-family GPUs (an Intel Mac's GPU refuses them by name), and Dxt1/Dxt3/Dxt5 textures and cube maps keep their exact blocks — sampled as BC formats on every Mac and decoded beside the blocks where a GPU has no BC support. As Tutorial 101 explains, branch on these answers rather than on the renderer name.
Custom shader effects: SpriteBatch-scoped MSL
A ShaderEffect on Metal is written in the Metal Shading Language: ShaderEffect(device, vertexSource, fragmentSource) compiles each source as its own runtime library, and SpriteBatch.Begin(..., effect) draws the batch's sprites through it. A source that does not compile gives an effect whose IsEffectValid() is false and whose compile error carries Metal's message; construction does not throw. The binding layout (sprite vertices, the clip-space mapping and the uniform slots) is documented in CNA's metal-shader-effect-contract.md.
Two boundaries to plan for. An ordinary 3D draw with a custom effect throws System::NotSupportedException, as on Direct3D 11 and Vulkan, whose custom-effect facilities are SpriteBatch-shaped too. And with several render targets bound, only the first receives a custom effect's output. For full 3D effects, use compiled XNA effects (CNA_METAL_COMPILED_EFFECTS=ON) — the route the House Simulator project takes on Metal.
#include "CNA/GraphicsCapability.hpp"
using CNA::GraphicsCapability;
void MyGame::LoadContent()
{
auto& device = getGraphicsDeviceProperty();
// Metal answers true, but the effect is a SpriteBatch facility there:
// use it in SpriteBatch.Begin(..., effect), never for a 3D draw.
if (device.SupportsCapability(GraphicsCapability::CustomEffects))
gradeEffect_ = std::make_unique<ShaderEffect>(device, kVertexMsl, kFragmentMsl);
// Stock effects work on every renderer, Metal included.
basicEffect_ = std::make_unique<BasicEffect>(device);
}
Deterministic refusals, not silent degradation
The renderer's design rule is unchanged: a request it cannot serve fails by name; it never renders something plausible-but-wrong and never returns fabricated data. At this snapshot the refusals that remain are narrow:
- Back-buffer readback works and answers in the game's back-buffer coordinates, through the logical viewport (Retina scale and letterbox included). It throws while a render target is bound, and a frame with no drawable reads zero.
- Array slices of render targets are refused, and a multisampled
RenderTargetCuberefusesSetData(its next resolve would overwrite the upload). - A non-zero sampler LOD bias is refused below macOS/iOS 26, and on any device measured to ignore it.
- A stream layout the stock vertex functions cannot read (for example an undeclared per-instance stream) throws
System::NotSupportedExceptionnaming it; malformed stream metadata throwsstd::invalid_argument. NormalizedByte2/4are refused for cube and volume textures, and DXT for volume textures, as XNA keeps them toTexture2D.
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.
What remains, and why the verdict is cautious
The renderer's history explains the caution. An earlier macOS CI run built the historical Metal renderer and exercised its tests, but six draw-and-readback checks saw only the clear colour and an MSAA check produced a binary edge. The campaign found both causes — readback had been disabled, and every 3D vertex was clipped by a transposed world-view-projection matrix — fixed them, and only then re-enabled each capability with its own evidence. What it still records as open:
PresentInterval.Twopresents asOne(checking it needs an unlocked console).- The back buffer is always BGRA8, reported as
Color. - A multisampled
RenderTarget2Dloses aSetDataunderPreserveContents. - Compiled XNA effects are opt-in, and custom MSL is SpriteBatch-scoped; compute, indirect draws and render-target array slices are not provided.
- Gaps shared with every renderer: vertex textures in compiled effects,
LightingEnabledwithout a vertex normal, and back-bufferPreserveContents. - The renderer's caches are unbounded and no soak run has measured them.
What you can build on today
Within that contract the renderer covers what an XNA HiDef game uses: forward and deferred rendering with the stock effects, MRT, MSAA, HDR and float render targets, DXT content kept compressed, cube and volume textures, instancing and multi-stream input, occlusion queries, SpriteBatch with sprite batching and sub-pixel positioning, and — with the option on — the game's own compiled XNA effects. Pixel tests are possible on Metal too, because back-buffer readback works.
What you should still budget for is the evidence gap rather than a feature gap: test your game on a real, unlocked Mac display (presentation and pacing were never observed by CNA), on the macOS versions you intend to ship to, and on Intel Macs if you target them.
Keeping a Metal build in a portable codebase
A conventional macOS Metal build is a separate single-renderer configuration from your Linux or Windows build. This snapshot can opt into multiple compatible renderers in one binary, but renderer/platform gates still apply (an Apple-only renderer cannot share a binary with Windows-only or Emscripten-only ones) and the selected renderer latches before device creation. Capability queries keep one source tree honest across the configurations:
// 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 with a HiDef device on an Apple-silicon Mac: all three true.
}
Two habits pay off. Log device.GetGraphicsRendererName() at startup — it answers for the renderer the device actually resolved to, so a bug report from a Mac user tells you immediately which renderer produced it. And keep a CPU renderer (SOFTWARE) in your regression matrix as well, so image-level tests also run where no GPU is available.
Where to go next
- Tutorial 101: Querying Renderer Capabilities
- Tutorial 128: Compiled XNA Effects
- Tutorial 107: CPU-Only Renderers
- Tutorial 72: Choosing a Renderer
- Tutorial 80: Cross-Platform Build Guide
- Platforms reference, Renderers reference and Verification
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- Android and Apple targets: structure, lifecycle, assets and evidence — How CNA's Android application and NDK code are structured, how Game handles mobile lifecycle events, how assets and saves are found on a device, and what the Mac mini M4 qualification, the iOS Simulator probe and the Apple workflows show.