Metal Migration Diagnostics
Systematic diagnosis for common Metal porting issues.
When to Use This Diagnostic Skill
Use this skill when:
- Screen is black after porting to Metal
- Shaders fail to compile in Metal
- Colors or coordinates are wrong
- Performance is worse than the original
- Rendering artifacts appear
- App crashes during GPU work
Mandatory First Step: Enable Metal Validation
Time cost: 30 seconds setup vs hours of blind debugging
Before ANY debugging, enable Metal validation:
Xcode β Edit Scheme β Run β Diagnostics
β Metal API Validation
β Metal Shader Validation
β GPU Frame Capture (Metal)
Most Metal bugs produce clear validation errors. If you're debugging without validation enabled, stop and enable it first.
Symptom 1: Black Screen
Decision Tree
Black screen after porting
β
ββ Are there Metal validation errors in console?
β ββ YES β Fix validation errors first (see below)
β
ββ Is the render pass descriptor valid?
β ββ Check: view.currentRenderPassDescriptor != nil
β ββ Check: drawable = view.currentDrawable != nil
β ββ FIX: Ensure MTKView.device is set, view is on screen
β
ββ Is the pipeline state created?
β ββ Check: makeRenderPipelineState doesn't throw
β ββ FIX: Check shader function names match library
β
ββ Are draw calls being issued?
β ββ Add: encoder.label = "Main Pass" for frame capture
β ββ DEBUG: GPU Frame Capture β verify draw calls appear
β
ββ Are resources bound?
β ββ Check: setVertexBuffer, setFragmentTexture called
β ββ FIX: Metal requires explicit binding every frame
β
ββ Is the vertex data correct?
β ββ DEBUG: GPU Frame Capture β inspect vertex buffer
β ββ FIX: Check buffer offsets, vertex count
β
ββ Are coordinates in Metal's range?
β ββ Metal NDC: X [-1,1], Y [-1,1], Z [0,1]
β ββ OpenGL NDC: X [-1,1], Y [-1,1], Z [-1,1]
β ββ FIX: Adjust projection matrix or vertex shader
β
ββ Is clear color set?
ββ Default clear color is (0,0,0,0) β transparent black
ββ FIX: Set view.clearColor or renderPassDescriptor.colorAttachments[0].clearColor
Common Fixes
Missing Drawable:
override func viewDidLoad() {
draw()
}
func draw(in view: MTKView) {
guard let drawable = view.currentDrawable else { return }
}
Wrong Function Names:
descriptor.vertexFunction = library.makeFunction(name: "vertexMain")
descriptor.vertexFunction = library.makeFunction(name: "vertexShader")
Missing Resource Binding:
encoder.setRenderPipelineState(pso)
encoder.drawPrimitives(...)
encoder.setRenderPipelineState(pso)
encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
encoder.setVertexBytes(&uniforms, length: uniformsSize, index: 1)
encoder.setFragmentTexture(texture, index: 0)
encoder.drawPrimitives(...)
Time cost: GPU Frame Capture diagnosis: 5-10 min. Guessing without tools: 1-4 hours.
Symptom 2: Shader Compilation Errors
Decision Tree
Shader fails to compile
β
ββ "Use of undeclared identifier"
β ββ Check: #include <metal_stdlib>
β ββ Check: using namespace metal;
β ββ FIX: Standard functions need metal_stdlib
β
ββ "No matching function for call to 'texture'"
β ββ GLSL texture() β MSL tex.sample(sampler, uv)
β FIX: Texture sampling is a method, needs sampler
β
ββ "Invalid type 'vec4'"
β ββ GLSL vec4 β MSL float4
β FIX: See type mapping table in metal-migration-ref
β
ββ "No matching constructor"
β ββ GLSL: vec4(vec3, float) works
β ββ MSL: float4(float3, float) works
β ββ Check: Argument types match exactly
β
ββ "Attribute index out of range"
β ββ Check: [[attribute(N)]] matches vertex descriptor
β ββ FIX: vertexDescriptor.attributes[N] must be configured
β
ββ "Buffer binding index out of range"
β ββ Check: [[buffer(N)]] where N < 31
β ββ FIX: Metal has max 31 buffer bindings per stage
β
ββ "Cannot convert value of type"
ββ MSL is stricter than GLSL about implicit conversions
ββ FIX: Add explicit casts: float(intValue), int(floatValue)
Common Conversions
// GLSL
vec4 color = texture(sampler2D, uv);
// MSL β texture and sampler are separate
float4 color = tex.sample(samp, uv);
// GLSL β mod() for floats
float x = mod(y, z);
// MSL β fmod() for floats
float x = fmod(y, z);
// GLSL β atan(y, x)
float angle = atan(y, x);
// MSL β atan2(y, x)
float angle = atan2(y, x);
// GLSL β inversesqrt
float invSqrt = inversesqrt(x);
// MSL β rsqrt
float invSqrt = rsqrt(x);
Time cost: With conversion table: 2-5 min per shader. Without: 15-30 min per shader.
Symptom 3: Wrong Colors or Coordinates
Decision Tree
Rendering looks wrong
β
ββ Image is upside down
β ββ Cause: Metal Y-axis is opposite OpenGL
β ββ FIX (vertex shader): pos.y = -pos.y
β ββ FIX (texture load): MTKTextureLoader .origin: .bottomLeft
β ββ FIX (UV): uv.y = 1.0 - uv.y in fragment shader
β
ββ Image is mirrored
β ββ Cause: Winding order or cull mode wrong
β ββ FIX: encoder.setFrontFacing(.counterClockwise)
β ββ FIX: encoder.setCullMode(.back) or .none to test
β
ββ Colors are swapped (red/blue)
β ββ Cause: Pixel format mismatch
β ββ Check: .bgra8Unorm vs .rgba8Unorm
β ββ FIX: Match texture pixel format to data format
β
ββ Colors are washed out / too bright
β ββ Cause: sRGB vs linear color space
β ββ Check: Using .bgra8Unorm_srgb for sRGB textures?
β ββ FIX: Use _srgb format variants for gamma-correct rendering
β
ββ Depth fighting / z-fighting
β ββ Cause: NDC Z range difference
β ββ OpenGL: Z in [-1, 1]
β ββ Metal: Z in [0, 1]
β ββ FIX: Adjust projection matrix for Metal's Z range
β
ββ Objects clipped incorrectly
β ββ Cause: Near/far plane or viewport
β ββ Check: Viewport size matches drawable size
β ββ FIX: encoder.setViewport(MTLViewport(...))
β
ββ Transparency wrong
ββ Cause: Blend state not configured
ββ FIX: pipelineDescriptor.colorAttachments[0].isBlendingEnabled = true
ββ FIX: Set sourceRGBBlendFactor, destinationRGBBlendFactor
Coordinate System Fix
func metalPerspectiveProjection(fovY: Float, aspect: Float, near: Float, far: Float) -> simd_float4x4 {
let yScale = 1.0 / tan(fovY * 0.5)
let xScale = yScale / aspect
let zRange = far - near
return simd_float4x4(rows: [
SIMD4<Float>(xScale, 0, 0, 0),
SIMD4<Float>(0, yScale, 0, 0),
SIMD4<Float>(0, 0, far / zRange, 1),
SIMD4<Float>(0, 0, -near * far / zRange, 0)
])
}
Time cost: With GPU Frame Capture texture inspection: 5-10 min. Without: 1-2 hours.
Symptom 4: Performance Regression
Decision Tree
Performance worse than OpenGL
β
ββ Enabling validation?
β ββ Validation adds ~30% overhead
β FIX: Disable for release builds, keep for debug
β
ββ Creating resources every frame?
β ββ BAD: device.makeBuffer() in draw()
β ββ FIX: Create buffers once, reuse with triple buffering
β
ββ Creating pipeline state every frame?
β ββ BAD: makeRenderPipelineState() in draw()
β ββ FIX: Create PSO once at init, store as property
β
ββ Too many draw calls?
β ββ DEBUG: GPU Frame Capture β count draw calls
β ββ FIX: Batch geometry, use instancing, indirect draws
β
ββ GPU-CPU sync stalls?
β ββ DEBUG: Metal System Trace β look for stalls
β ββ Cause: waitUntilCompleted() blocks CPU
β ββ FIX: Triple buffering with semaphore
β
ββ Inefficient buffer updates?
β ββ BAD: Recreating buffer to update
β ββ FIX: buffer.contents().copyMemory() for dynamic data
β
ββ Wrong storage mode?
β ββ .shared: Good for small dynamic data
β ββ .private: Good for static GPU-only data
β ββ FIX: Use .private for geometry that doesn't change
β
ββ Missing Metal-specific optimizations?
ββ Argument buffers reduce binding overhead
ββ Indirect draws reduce CPU work
ββ See WWDC sessions on Metal optimization
Triple Buffering Pattern
class TripleBufferedRenderer {
static let maxInflightFrames = 3
let inflightSemaphore = DispatchSemaphore(value: maxInflightFrames)
var uniformBuffers: [MTLBuffer