<< All versions

Skill v1.0.0

currentAutomated scan100/100
apple/game-porting-toolkit/translating-to-metal4-api
──Details
PublishedSeptember 29, 2026 at 10:20 PM
Content Hashsha256:6efc437664e156b3...
Git SHA
──Files
Files (1 file, 32.0 KB)
SKILL.md32.0 KBactive
SKILL.md · 547 lines · 32.0 KB

version: "1.0.0" name: translating-to-metal4-api description: ALWAYS use as the Metal 4 API reference and cross-API translation map when porting graphics code to Metal 4 — translating Metal 3, D3D12, or Vulkan calls, looking up any MTL4 / Metal 4 API or renamed type, finding the Metal 4 equivalent of a source-API call, or learning what the redesign removed or changed. Covers the Metal 3 → Metal 4 namespace/type table, removed/changed APIs (ParallelRenderEncoder, set*Bytes), Apple GPU TBDR (tile-based deferred rendering) architecture, and the D3D12 / Vulkan / Metal 3 → Metal 4 translation tables. Do NOT trigger for buffer/texture/heap/residency/storage-mode management — use managing-metal4-resources; barriers, fences, or events — use managing-metal4-synchronization; pipeline state creation, MTL4Compiler, or reflection — use creating-metal4-shader-pipelines; CAMetalLayer drawable presentation — use presenting-metal-drawables.


Metal 4 API Reference

Overview

Metal 4 is a ground-up API redesign — not a minor version bump. It introduces the MTL4 prefix (ObjC protocols/classes) / MTL4:: namespace (metal-cpp). All Metal 4 objects are created from MTLDevice / MTL::Device. Key removals: blit encoder, parallel render encoder, set*Bytes, managed storage mode, per-encoder resource binding, addCompletedHandler.

Metal 4 requires macOS 26+, iOS 26+. Feature detection:

objc
// ObjC
if ([device supportsFamily:MTLGPUFamilyMetal4]) { /* Metal 4 path */ }
cpp
// metal-cpp
if (device->supportsFamily(MTL::GPUFamilyMetal4)) { /* Metal 4 path */ }

Metal 4 can coexist with Metal 3 queues in the same app for incremental adoption.

Apple GPU Architecture: TBDR

All Apple Silicon GPUs use Tile-Based Deferred Rendering (TBDR), which is fundamentally different from IMR (Immediate-Mode Rendering) used by desktop NVIDIA/AMD GPUs. TBDR affects render pass structure, storage modes, synchronization, and performance decisions throughout the Metal API surface. Read [tbdr-architecture.md](references/tbdr-architecture.md) before working with any Metal code — it covers how TBDR works, the bandwidth cost model, efficient and costly patterns, hard constraints, and how to reason about whether an engine's existing rendering pipeline will work well on Apple Silicon.

References

Read the relevant Metal SDK header before writing translation code — the headers are the source of truth for property names, types, and method signatures.

  • Metal SDK headers - $(xcrun --show-sdk-path)/System/Library/Frameworks/Metal.framework/Headers/MTL4*.h
  • metal-cpp headers (if using C++) - Metal 4 C++ wrappers live in the MTL4/ subdirectory (e.g., MTL4CommandBuffer.hpp, MTL4ComputeCommandEncoder.hpp)
  • Apple documentation - Understanding the Metal 4 core API

Design Patterns

Namespace Translation Table

Metal 3Metal 4Notes
MTLCommandQueueMTL4CommandQueueCreated via [device newMTL4CommandQueue]
MTLCommandBuffer (transient, from queue)MTL4CommandBuffer (reusable, from device)Explicit begin/end lifecycle with allocator
MTLBlitCommandEncoderRemoved → MTL4ComputeCommandEncoderAll blit ops on unified compute encoder
MTLRenderCommandEncoderMTL4RenderCommandEncoderArgument table binding model
MTLComputeCommandEncoderMTL4ComputeCommandEncoderUnified: blit + dispatch + accel struct
MTLAccelerationStructureCommandEncoderRemoved → MTL4ComputeCommandEncoderMerged into compute
MTLParallelRenderCommandEncoderRemovedUse suspend/resume or multiple cmd buffers
N/AMTL4CommandAllocatorManages command buffer memory, pooled per frame
N/AMTL4ArgumentTable / MTL4ArgumentTableDescriptorReplaces all per-encoder resource binding
N/AMTL4Compiler / MTL4CompilerDescriptorExplicit pipeline compilation
N/AMTL4CompilerTask / MTL4CompilerTaskOptionsAsync compilation tasks
MTLRenderPassDescriptorMTL4RenderPassDescriptorRender pass with explicit width/height
MTLRenderPipelineDescriptorMTL4RenderPipelineDescriptorUses function descriptors, not MTLFunction
MTLComputePipelineDescriptorMTL4ComputePipelineDescriptorUses function descriptors
MTLMeshRenderPipelineDescriptorMTL4MeshRenderPipelineDescriptorMesh shader pipelines
MTLTileRenderPipelineDescriptorMTL4TileRenderPipelineDescriptorTile shader pipelines
N/AMTL4LibraryDescriptorLibrary from source via compiler
N/AMTL4LibraryFunctionDescriptorDescribes shader function via library + name
N/AMTL4CounterHeap / MTL4CounterHeapDescriptorReplaces MTLCounterSampleBuffer
N/AMTL4CommitFeedback / MTL4CommitOptionsGPU timing and error info (replaces addCompletedHandler)
N/AMTL4PipelineDataSetSerializerPipeline caching
N/AMTL4ArchiveBinary pipeline cache
N/AMTL4AccelerationStructureDescriptorRT acceleration structure descriptors
N/AMTL4MachineLearningCommandEncoderCoreML inference in command buffers
MTLResidencySetMTLResidencySetExplicit residency management (same namespace)
N/AMTLTextureViewPoolLightweight texture views with contiguous resource IDs

Device Factory Methods

All Metal 4 objects are created from the device:

objc
// ObjC
id<MTL4CommandQueue> queue = [device newMTL4CommandQueue];
id<MTL4CommandBuffer> cmd = [device newCommandBuffer];
id<MTL4CommandAllocator> allocator = [device newCommandAllocator];
id<MTL4Compiler> compiler = [device newCompilerWithDescriptor:desc error:&error];
id<MTL4ArgumentTable> table = [device newArgumentTableWithDescriptor:desc error:&error];
id<MTL4CounterHeap> heap = [device newCounterHeapWithDescriptor:desc error:&error];
id<MTLResidencySet> resSet = [device newResidencySetWithDescriptor:desc error:&error];
cpp
// metal-cpp
MTL4::CommandQueue* queue = device->newMTL4CommandQueue();
MTL4::CommandBuffer* cmd = device->newCommandBuffer();
MTL4::CommandAllocator* allocator = device->newCommandAllocator();
MTL4::Compiler* compiler = device->newCompiler(compDesc, &error);
MTL4::ArgumentTable* table = device->newArgumentTable(tableDesc, &error);
MTL4::CounterHeap* heap = device->newCounterHeap(heapDesc, &error);
MTL::ResidencySet* resSet = device->newResidencySet(resDesc, &error);

APIs Removed from Metal 3

  • `set*Bytes` — All variants (setVertexBytes, setFragmentBytes, setComputeBytes). All data must go through buffers. See Replacing `set*Bytes` below for the recommended pattern.
  • `setVertexBuffer`/`setFragmentTexture`/etc. — All per-encoder resource binding. Use ArgumentTable.
  • `StorageModeManaged` — Removed. Switch buffers to Shared or Private, then drop the associated didModifyRange: calls (they only apply to managed-storage buffers and become invalid).
  • `addCompletedHandler`/`addScheduledHandler` — Use SharedEvent signaling or CommitOptions feedback handlers.
  • `useResource`/`useHeap` — Use ResidencySet instead.
  • `BlitCommandEncoder` — All blit ops (copy, fill, resolve, mipmap generation, optimization) move to ComputeCommandEncoder.
  • `AccelerationStructureCommandEncoder` — Acceleration structure builds move to ComputeCommandEncoder.
  • `ParallelRenderCommandEncoder` — Use suspend/resume or multiple cmd buffers with batch commit.
  • `StoreActionOptions` — Removed. This controlled custom sample position hints for MSAA depth storage on non-Apple Silicon hardware. Apple Silicon does not need this — ignore it. Store actions themselves (Store, DontCare, etc.) are unchanged and critical.
  • Tessellation on render encoder — Use mesh shaders (MeshRenderPipelineDescriptor).

Replacing set*Bytes: Transient Buffer Allocation

With set*Bytes removed, applications need to manage their own transient buffer allocations for small, short-lived data like per-draw constants and uniforms. The recommended approach is a per-frame bump allocator backed by a Shared storage mode buffer.

The transient buffer allocator hands out suballocations from a single large buffer. Each suballocation returns a GPU address that can be bound directly via the argument table. At the start of each frame (after the GPU has finished consuming the previous frame's data), reset the offset to zero.

cpp
// metal-cpp
class TransientBufferAllocator {
MTL::Buffer* _buffer;
NS::UInteger _capacity;
NS::UInteger _offset = 0;
public:
TransientBufferAllocator(MTL::Device* device, NS::UInteger capacity)
: _capacity(capacity) {
_buffer = device->newBuffer(capacity, MTL::ResourceStorageModeShared);
}
// Write data and return a GPU address suitable for argument table binding
MTL::GPUAddress write(const void* data, NS::UInteger size) {
// Align to 16 bytes (suitable for constant data packed as float4)
_offset = (_offset + 15) & ~15;
assert(_offset + size <= _capacity &&
"TransientBufferAllocator overflow — increase capacity");
memcpy(static_cast<uint8_t*>(_buffer->contents()) + _offset, data, size);
MTL::GPUAddress addr = _buffer->gpuAddress() + _offset;
_offset += size;
return addr;
}
// Reset at frame start after GPU is done with this frame's data
void reset() { _offset = 0; }
// The backing buffer — add to your ResidencySet
MTL::Buffer* buffer() const { return _buffer; }
};
cpp
// Usage per draw
PerDrawConstants constants = { mvpMatrix, normalMatrix, materialID };
MTL::GPUAddress addr = frameAllocator->write(&constants, sizeof(constants));
table->setAddress(addr, kConstantsBindingIndex);

Key points:

  • Size the buffer for worst-case per-frame usage — overflowing silently corrupts data
  • One transient buffer allocator per in-flight frame to avoid CPU/GPU data races
  • Alignment depends on how shaders consume the data — check your shader's expected layout. 16-byte alignment is a safe default for constant data packed as float4
  • Add the backing buffer to your ResidencySet
  • For engines with many draws, pre-calculate total transient allocation size per frame to right-size the buffer

Changed APIs

Fences — scope changed:

  • Metal 3: MTLFence works across multiple command queues on the same device
  • Metal 4: MTLFence only works within the same command queue
  • For cross-queue sync, use MTLEvent when the dependency stays on the GPU. Use MTLSharedEvent only when the CPU also signals or waits — it carries CPU-side machinery that's wasted on pure GPU↔GPU sync.

Events — moved to queue:

  • Metal 3: Events signaled/waited on command buffer (encodeSignalEvent:value:, encodeWaitForEvent:value:)
  • Metal 4: Events signaled/waited on the queue ([queue signalEvent:value:], [queue waitForEvent:value:])

Command buffer completion:

  • Metal 3: [commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> cb) { ... }]
  • Metal 4: MTL4CommitOptions with feedback handler, or MTLSharedEvent signaling

Render pass descriptor:

  • Metal 4 uses MTL4RenderPassDescriptor which supports explicit renderTargetWidth and renderTargetHeight (required only when no attachments are set — otherwise inferred)
objc
// ObjC
MTL4RenderPassDescriptor* rpDesc = [[MTL4RenderPassDescriptor alloc] init];
rpDesc.renderTargetWidth = width; // required if no attachments — otherwise inferred
rpDesc.renderTargetHeight = height; // required if no attachments — otherwise inferred
// Color attachment — cache accessor to avoid repeated ARC traffic
MTLRenderPassColorAttachmentDescriptor* ca0 = rpDesc.colorAttachments[0];
ca0.texture = colorTexture;
ca0.loadAction = MTLLoadActionClear;
ca0.storeAction = MTLStoreActionStore;
ca0.clearColor = MTLClearColorMake(0, 0, 0, 1);
// Depth attachment
rpDesc.depthAttachment.texture = depthTexture;
rpDesc.depthAttachment.loadAction = MTLLoadActionClear;
rpDesc.depthAttachment.storeAction = MTLStoreActionDontCare; // memoryless depth
rpDesc.depthAttachment.clearDepth = 1.0;
// Create encoder
id<MTL4RenderCommandEncoder> encoder = [cmd renderCommandEncoderWithDescriptor:rpDesc];

Command Buffer Lifecycle

objc
// ObjC — One-time setup
id<MTL4CommandBuffer> cmd = [device newCommandBuffer]; // reusable
id<MTL4CommandAllocator> alloc = [device newCommandAllocator]; // per in-flight frame
// Per-frame
[alloc reset];
[cmd beginCommandBufferWithAllocator:alloc];
// encode...
[cmd endCommandBuffer];
id<MTL4CommandBuffer> cmds[] = {cmd};
[queue commit:cmds count:1];
cpp
// metal-cpp
MTL4::CommandBuffer* cmd = device->newCommandBuffer();
MTL4::CommandAllocator* alloc = device->newCommandAllocator();
alloc->reset();
cmd->beginCommandBuffer(alloc);
// encode...
cmd->endCommandBuffer();
MTL4::CommandBuffer* cmds[] = {cmd};
queue->commit(cmds, 1);

Key points:

  • Command buffers are reusable — created once from device, not per-frame from queue
  • Allocators manage backing memory — one per in-flight frame, reset at frame start
  • beginCommandBuffer / endCommandBuffer is an explicit lifecycle (Metal 3 had no equivalent)
  • Queue submission is batched via commit:count:
  • Command buffers do NOT retain resources — you manage lifetimes

Validation rules:

  • beginCommandBuffer with nil allocator → error
  • Calling beginCommandBuffer twice on an open command buffer → error
  • Using same allocator for two simultaneously open command buffers → error
  • endCommandBuffer before beginCommandBuffer → error
  • Allocator reset before command buffer is committed → error
  • Empty command buffers (no encoders) can be committed successfully

Resource Binding (Argument Tables)

objc
// ObjC — Setup
MTL4ArgumentTableDescriptor* atDesc = [[MTL4ArgumentTableDescriptor alloc] init];
atDesc.maxBufferBindCount = <count>; // must be > highest [[buffer(N)]] index used
atDesc.maxTextureBindCount = <count>; // must be > highest [[texture(N)]] index used
atDesc.maxSamplerStateBindCount = <count>; // must be > highest [[sampler(N)]] index used
atDesc.supportAttributeStrides = YES;
atDesc.initializeBindings = YES; // initialize unbound slots to null (deterministic reads)
id<MTL4ArgumentTable> table = [device newArgumentTableWithDescriptor:atDesc error:&error];

Sizing: Each count sets the maximum bind index you can use in that space — a shader using [[buffer(20)]] requires maxBufferBindCount of at least 21. The appropriate size depends on the set of shaders that will be used with the table during encoding. Since argument tables are reusable across encoders and frames, size them for the broadest set of shaders they'll encounter. There is a device maximum for each bind space — one option is to create a single argument table per encoding thread at the maximum size, which works with any shader.

Render stage binding: Render encoders accept shaders in multiple stages (vertex & fragment, or object & mesh & fragment), and each stage has independent bind spaces. You can bind the same argument table instance to all stages, or bind distinct argument tables per stage:

objc
// Bind resources (all bound resources must be in a residency set)
[table setAddress:[buffer gpuAddress] atIndex:index]; // buffer
[table setAddress:[buffer gpuAddress] stride:stride atIndex:index]; // buffer with stride
[table setTexture:[texture gpuResourceID] atIndex:index]; // texture
[table setSamplerState:[sampler gpuResourceID] atIndex:index]; // sampler
// Assign to encoder
[renderEncoder setArgumentTable:table atStages:MTLStageVertex | MTLStageFragment];
[computeEncoder setArgumentTable:table]; // no stage param
cpp
// metal-cpp
MTL4::ArgumentTableDescriptor* desc = MTL4::ArgumentTableDescriptor::alloc()->init();
desc->setMaxBufferBindCount(count); // must be > highest [[buffer(N)]] index used
desc->setMaxTextureBindCount(count); // must be > highest [[texture(N)]] index used
desc->setSupportAttributeStrides(true);
desc->setInitializeBindings(true); // initialize unbound slots to null (deterministic reads)
MTL4::ArgumentTable* table = device->newArgumentTable(desc, &error);
table->setAddress(buffer->gpuAddress(), index);
table->setTexture(texture->gpuResourceID(), index);
table->setSamplerState(sampler->gpuResourceID(), index);
// Render encoder
renderEncoder->setArgumentTable(table, MTL::RenderStageVertex | MTL::RenderStageFragment);
// Compute encoder
computeEncoder->setArgumentTable(table);

Binding model — three spaces:

Shaders declare bindings in three spaces: [[buffer(N)]], [[texture(N)]], [[sampler(N)]]. The argument table stores GPU references into three corresponding internal tables. Each space accepts specific reference types:

SpaceAcceptsShader parameter type
BufferGPU address (gpuAddress())constant T&, constant T*, device T&, device T* — actual buffer bindings
BufferResource ID (gpuResourceID())Acceleration structures, tensors, and other non-buffer objects bound to [[buffer(N)]] slots
TextureResource ID (gpuResourceID())All texture types
SamplerResource ID (gpuResourceID())All sampler states

The fundamental distinction: GPU addresses support pointer arithmetic — you can offset into different regions of the same MTLBuffer on both CPU and GPU (e.g., buffer->gpuAddress() + offset). Resource IDs identify whole objects — you bind the entire object, and any sub-indexing happens through the typed shader code (e.g., tensor element access, plane indexing).

Rule of thumb: If the shader parameter is a pointer or reference to a buffer type, use gpuAddress(). For everything else, use gpuResourceID().

Key points:

  • Metal snapshots the table at each draw/dispatch call — you can mutate bindings between draws without recreating the table
  • After the initial setArgumentTable, subsequent setAddress/setTexture/setSamplerState calls on the table are visible at draw time (the table is a live mutable reference)

Validation rules:

  • Drawing without setArgumentTable when PSO requires bindings → error: "No argument table set for [stage] stage"
  • Table too small for required bindings → error: "Argument table only supports N [type] bindings"
  • Binding index not populated → error: "[type] binding at index N was never set"
  • Null buffer address (setAddress(0, index)) → error: "Buffer binding at index N cannot be null"

Draw Calls (GPU Address for Index/Indirect)

objc
// ObjC — Indexed draw (index buffer must be in a residency set)
[encoder drawIndexedPrimitives:type
indexCount:indexCount
indexType:indexType
indexBuffer:[indexBuffer gpuAddress] + offset
indexBufferLength:indexBufferLength];
// Indirect draw (indirect buffer must be in a residency set)
[encoder drawPrimitives:type indirectBuffer:[indirectBuffer gpuAddress]];
cpp
// metal-cpp
encoder->drawIndexedPrimitives(type, indexCount, indexType,
indexBuffer->gpuAddress() + offset, indexBufferLength);
encoder->drawPrimitives(type, indirectBuffer->gpuAddress());

Important: indexBufferLength must be the full accessible range from the fetch point, not just indexCount * indexSize. Metal 4 uses this for bounds-checking on vertex index values. See Anti-Patterns #9.

Compute Dispatch

objc
// ObjC — Direct dispatch
[computeEncoder dispatchThreadgroups:threadgroupsPerGrid
threadsPerThreadgroup:threadsPerThreadgroup];
[computeEncoder dispatchThreads:threadsPerGrid
threadsPerThreadgroup:threadsPerThreadgroup];
// Indirect dispatch — GPU address
[computeEncoder dispatchThreadgroups:[indirectBuffer gpuAddress]
threadsPerThreadgroup:threadsPerThreadgroup];
cpp
// metal-cpp
computeEncoder->dispatchThreadgroups(threadgroupsPerGrid, threadsPerThreadgroup);
computeEncoder->dispatchThreads(threadsPerGrid, threadsPerThreadgroup);
// Indirect
computeEncoder->dispatchThreadgroups(indirectBuffer->gpuAddress(), threadsPerThreadgroup);

Cross-API equivalents:

  • D3D12: ID3D12GraphicsCommandList::Dispatch / ExecuteIndirect
  • Vulkan: vkCmdDispatch / vkCmdDispatchIndirect

Blit Operations (Compute Encoder)

Metal 4 removes BlitCommandEncoder. All blit operations go through the unified ComputeCommandEncoder:

objc
// ObjC
id<MTL4ComputeCommandEncoder> enc = [cmd computeCommandEncoder];
[enc copyFromBuffer:src sourceOffset:srcOff toBuffer:dst destinationOffset:dstOff size:size];
[enc copyFromBuffer:src sourceOffset:srcOff sourceBytesPerRow:bpr sourceBytesPerImage:bpi
sourceSize:srcSize toTexture:tex destinationSlice:slice destinationLevel:level
destinationOrigin:origin];
[enc generateMipmapsForTexture:texture];
[enc fillBuffer:buffer range:range value:value];
[enc endEncoding];

Additional operations on unified compute encoder:

  • optimizeContentsForCPUAccess: / optimizeContentsForGPUAccess:
  • copyIndirectCommandBuffer:sourceRange:destination:destinationIndex:
  • optimizeIndirectCommandBuffer:withRange:
  • All acceleration structure operations (build, refit, compact, copy)

When interleaving blit and dispatch operations on the same compute encoder, explicit barriers between StageBlit and StageDispatch are needed if they share resources.

Synchronization Quick Reference

ScopeMechanismAPI
Within passIntrapass barrierbarrierAfterEncoderStages:beforeEncoderStages:visibilityOptions:
Between passes (coarse)Queue barrier (producer)barrierAfterStages:beforeQueueStages:visibilityOptions:
Between passes (coarse)Queue barrier (consumer)barrierAfterQueueStages:beforeStages:visibilityOptions:
Between passes (precise)FenceupdateFence:afterEncoderStages: / waitForFence:beforeEncoderStages:
Cross-queueEvent[queue signalEvent:value:] / [queue waitForEvent:value:]
Cross-device/CPUSharedEvent[queue signalEvent:value:] / [event waitUntilSignaledValue:timeoutMS:]

Always choose narrowest scope. Queue barriers affect all encoders with matching stages; fences target specific encoders for more precise ordering. For full synchronization details, see the managing-metal4-synchronization skill.

Drawable Presentation

Brief reference — for full presentation and frame pacing details, see the presenting-metal-drawables skill.

objc
// ObjC — Metal 4 presentation flow
id<CAMetalDrawable> drawable = [layer nextDrawable];
[queue waitForDrawable:drawable]; // GPU-side wait for drawable availability
[queue commit:cmds count:count]; // submit GPU work
[queue signalDrawable:drawable]; // signal when GPU is done
[drawable present]; // present to screen
cpp
// metal-cpp
CA::MetalDrawable* drawable = layer->nextDrawable();
queue->wait(drawable);
queue->commit(cmds, count);
queue->signalDrawable(drawable);
drawable->present();

GPU Queries and Device Capabilities

For timestamp queries, occlusion queries, pipeline statistics, and device capability querying, see gpu-queries-and-capabilities.md. Covers:

  • Timestamp queries with MTL4CounterHeap (create, write, resolve, convert to nanoseconds)
  • Occlusion queries with visibility result buffers
  • Pipeline statistics (not available on Metal — use Xcode profiler)
  • Device capability querying (supportsFamily:, max buffer length, unified memory, timestamp support)
  • Cross-API equivalents for D3D12 and Vulkan query types

Suspend/Resume (Replaces ParallelRenderEncoder)

objc
// ObjC
id<MTL4RenderCommandEncoder> enc0 = [cmd0 renderCommandEncoderWithDescriptor:rpDesc
options:MTL4RenderEncoderOptionSuspending];
// encode draws...
[enc0 endEncoding];
id<MTL4RenderCommandEncoder> enc1 = [cmd1 renderCommandEncoderWithDescriptor:rpDesc
options:MTL4RenderEncoderOptionResuming];
// encode more draws...
[enc1 endEncoding];
// Must commit all in order as single batch
id<MTL4CommandBuffer> cmds[] = {cmd0, cmd1};
[queue commit:cmds count:2];

Rules:

  • Suspended pass MUST be immediately resumed in the next command buffer in the commit batch
  • Cannot interleave other encoders between suspend and resume
  • Resume without preceding suspend is an error
  • Suspended and resumed render pass descriptors must match (render targets, width, height, sample counts)
  • After a suspending encoder, no new encoders can be created in the same command buffer
  • The total number of unique residency sets across all command buffers committed together, plus the command queue's residency sets, may not exceed 32 — otherwise the commit fails

Cross-API Translation

For detailed D3D12→Metal 4, Vulkan→Metal 4, and Metal 3→Metal 4 translation tables, see cross-api-translation.md. Covers concept-by-concept mappings for command infrastructure, resource binding, synchronization, pipeline states, and presentation.

Anti-Patterns / Common Mistakes

  1. Using `set*Bytes` or per-encoder binding — These don't exist in Metal 4. Use argument tables (see Argument Tables section above).
  2. Creating command buffers from queue — Create from device, not queue.
  3. Forgetting `beginCommandBuffer`/`endCommandBuffer` — Explicit lifecycle required. Missing either causes validation errors.
  4. Using fences across queues — Metal 4 fences are same-queue only. Use events for cross-queue.
  5. Not managing resource lifetimes — Command buffers don't retain resources. Use deferred destruction.
  6. Using `StorageModeManaged` — Removed. Use Shared or Private.
  7. Calling `useResource`/`useHeap` on encoders — Use ResidencySet instead.
  8. Passing `Buffer*` to draw calls — Metal 4 uses GPUAddress for index/indirect buffers.
  9. Passing consumed range instead of accessible range for buffer lengths — Metal 4 address-based APIs (e.g., drawIndexedPrimitives:indexBufferLength:) expect the full valid range the GPU may access from the given address, not just the bytes the current operation reads. Getting this wrong causes silent data clamping, not crashes or validation errors.
  10. Using same allocator for two open command buffers — One allocator per simultaneously open command buffer. Reuse after commit + reset.
  11. Misaligned index buffer addresses. Metal requires index buffer addresses to be aligned to the index type size (2 bytes for UInt16, 4 bytes for UInt32). Engines that pack multiple meshes into a shared geometry buffer can produce misaligned offsets — an odd number of UInt16 indices shifts subsequent meshes by 2 bytes, breaking 4-byte alignment assumptions. The symptom is degenerate or corrupted geometry with no validation error. Pad index data to 4-byte boundaries when sharing buffers. For general buffer alignment, query MTLDevice properties: minimumLinearTextureAlignmentForPixelFormat: for linear textures, minimumTextureBufferAlignmentForPixelFormat: for texture buffer views. See MTL4RenderCommandEncoder.h for the authoritative index buffer alignment specification.

Debugging Strategies

Validation

For Metal validation setup (API validation, shader validation, load/store-action diagnostics), see using-metal-validation or man MetalValidation.

Metal HUD

Set these before launching the app (before any Metal device is created):

MTL_HUD_ENABLED=1 # visual overlay: FPS, GPU time, memory, present mode
MTL_HUD_LOG_ENABLED=1 # log per-frame stats to console (requires HUD enabled)
MTL_HUD_LOG_SHADER_ENABLED=1 # log shader compilation activities (requires HUD enabled)
MTL_HUD_ENCODER_TIMING_ENABLED=1 # per-encoder GPU time tracking (vertex/fragment/compute)
MTL_HUD_SHOW_VALUE_RANGE=1 # show min/max/avg over 1200 frames

The HUD overlay shows: Metal device, resolution, present mode (direct vs composited), memory usage, Game Mode status, FPS, GPU time, and frame interval chart.

With logging enabled, the HUD writes per-frame statistics to the console once per second — the agent can parse this output to diagnose frame rate, GPU time, and memory issues without user interaction.

With shader compiler logging, the HUD emits signposts for each compiled shader with compilation time and cache status — useful for detecting runtime compilation hitches.

Reference: Apple documentation - Monitoring your Metal app's graphics performance

What Validation Catches

  • Missing bindings: Reports exact binding index and type: "Buffer binding at index N was never set"
  • Wrong API usage: Reports removed API calls and suggests Metal 4 equivalents
  • Command buffer lifecycle errors: Catches double-begin, missing begin/end, allocator reuse
  • Resource lifetime issues: Reports access to released resources
  • Out-of-bounds GPU access: Shader validation catches buffer overflows, nil texture reads
  • Non-resident resources: Shader validation detects access to resources not in a residency set
  • Incorrect load/store actions: Visual validation makes bad actions immediately obvious (fuchsia/checkerboard)

Diagnostic References

Good Practices

  • Check the SDK headers directly when unsure about an API. The Metal 4 headers (MTL4*.h) are well-documented with comments.
  • Use incremental adoption when porting from Metal 3 — Metal 3 and Metal 4 queues can coexist in the same app, allowing subsystems to be ported individually.
  • When porting from Metal 3, Metal 3 PSOs work on Metal 4 encoders (and vice versa) — pipeline creation can be ported incrementally.
  • Ensure all Metal API struct members are sourced from the engine, not hardcoded. Every value passed to Metal (pixel formats, sample counts, load/store actions, blend factors, vertex formats, etc.) should trace back to the engine's data — not be a hardcoded constant. If the engine provides a value, use it. If it doesn't, discuss with the user rather than guessing a default. Hardcoded values that happen to work for one sample will silently break on others.

Performance Considerations

A naive 1:1 translation from D3D12, Vulkan, or Metal 3 to Metal 4 produces correct code but creates more encoders than necessary, leaving significant performance on the table. The cross-API translation reference (references/cross-api-translation.md) carries inline Perf: hints on rows where the naive mapping has a meaningful optimization.

For the full set of optimization principles — encoder count minimization, grouping compute-class operations, command reordering, color attachment mapping, resource access range tracking, and pipeline reflection-driven barrier elision — see managing-metal4-synchronization Performance Considerations. Most performance optimization work in a Metal 4 port is concentrated there because encoder boundaries are fundamentally a synchronization concern.

Companion Skills

  • Resource management skill (managing-metal4-resources) — for residency sets, storage modes, descriptor heaps
  • Synchronization skill (managing-metal4-synchronization) — for barriers, fences, events, cross-API sync translation
  • Pipeline skill (creating-metal4-shader-pipelines) — for shader compilation (MTL4Compiler), metallib loading, shader reflection, and pipeline state creation
  • Presentation skill (presenting-metal-drawables) — for drawable lifecycle, frame pacing, vsync, CAMetalLayer
  • Metal shader converter skill (integrating-metal-shaderconverter-shaders) — for binding model, argument buffers, descriptor tables
All versions