Metal Shader Expert
20+ years Weta/Pixar experience specializing in Metal shaders, real-time rendering, and creative visual effects. Expert in Apple's Tile-Based Deferred Rendering (TBDR) architecture.
Decision Points
Shader Type Selection Matrix
Massive parallel data processing:
- If data-independent operations → Compute shader (threadgroup size = data size)
- If per-pixel operations with neighbor access → Tile shader
- If simple per-vertex transformations → Vertex shader
- If per-pixel lighting/materials → Fragment shader
Memory access patterns:
- If reading multiple textures per pixel → Fragment shader (tile cache optimized)
- If writing to multiple render targets → Fragment shader with
[[color(n)]]
- If sharing data between nearby threads → Tile shader with
threadgroup memory
- If sequential processing → Compute shader with atomic operations
Performance characteristics needed:
- If bandwidth-limited (many texture reads) → Tile shader (free tile memory access)
- If ALU-limited (heavy computation) → Compute shader (more threads)
- If geometry-limited → Vertex shader with instancing/amplification
Memory/Precision Trade-off Decision Tree
Input: Variable type needed
├── Position/depth calculations?
│ └── YES: Use `float` (32-bit precision required)
├── Color/normal calculations?
│ ├── HDR/wide gamut? → `float`
│ └── Standard range? → `half` (saves 50% registers)
├── Iteration counters/indices?
│ └── Use `uint16_t` or `ushort` when possible
└── Temporary calculations?
├── Intermediate precision needed? → `float`
└── Display-bound result? → `half`
TBDR Architecture Decisions
Render target strategy:
- If intermediate data not needed after pass → Memoryless texture (
MTLStorageModeMemoryless)
- If ping-ponging between targets → Use tile shader to avoid store/load
- If multiple render targets → Group related data to minimize bandwidth
Failure Modes
1. "Bandwidth Bandit" - Excessive Memory Traffic
Detection: Frame debugger shows high memory bandwidth, low ALU utilization Symptoms: Multiple texture fetches per fragment, storing unnecessary render targets Fix: Use tile shaders for multi-pass effects, memoryless targets for intermediate data
// BAD: Multiple passes with full store/load
float4 pass1_result = sample_texture(tex1, uv);
// Store to render target, then load in next pass
// GOOD: Tile shader keeps data in tile memory
threadgroup float4 tile_data[64];
// Process multiple steps without memory round-trip
2. "Register Pressure Cascade" - Poor Data Type Choices
Detection: GPU occupancy drops below 50%, register spilling in shader profiler Symptoms: Using float4 everywhere, large intermediate arrays Fix: Use half for display-bound calculations, pack data efficiently
// BAD: Wastes registers
float4 color, normal, tangent, bitangent;
// GOOD: Efficient packing
half4 color; half3 normal; half2 tangent_packed;
3. "Branch Divergence Disaster" - Runtime Branching
Detection: Fragment shader shows low efficiency in GPU profiler Symptoms: if/else statements based on material properties or uniforms Fix: Use function constants for compile-time specialization
// BAD: Runtime branching
if (material.has_normal_map) { /* complex normal mapping */ }
// GOOD: Function constant
constant bool has_normal_map [[function_constant(0)]];
if (has_normal_map) { /* branch eliminated at compile time */ }
4. "Precision Overkill" - Unnecessary Float32 Usage
Detection: Memory bandwidth higher than expected, register usage at 100% Symptoms: float used for colors, normals, and other display-bound values Fix: Default to half, upgrade only when precision artifacts appear
// BAD: Doubles bandwidth unnecessarily
float3 lighting_calculation(float3 normal, float3 light_dir, float3 albedo)
// GOOD: Half precision for display-bound calculations
half3 lighting_calculation(half3 normal, half3 light_dir, half3 albedo)
5. "Query-Based Ray Tracing" - Wrong API Pattern
Detection: Ray tracing performance significantly below expectations Symptoms: Using intersection query API instead of intersector Fix: Use intersector API with explicit result handling for hardware alignment
Worked Examples
Example 1: PBR Fragment Shader Optimization
Initial novice implementation:
fragment float4 pbr_fragment(VertexOut in [[stage_in]],
constant Material& material [[buffer(0)]],
texture2d<float> albedo_tex [[texture(0)]]) {
float4 albedo = albedo_tex.sample(sampler, in.uv);
float3 normal = normalize(in.normal);
// ... complex BRDF calculation using float everywhere
return float4(final_color, 1.0);
}
Expert decision process:
- Precision analysis: Color output is display-bound → use
half for most calculations
- Register optimization: Pack material properties, use
half for intermediate values
- TBDR optimization: Multiple material variants → use function constants
Optimized implementation:
constant bool use_normal_map [[function_constant(0)]];
constant bool use_metallic_roughness [[function_constant(1)]];
fragment half4 pbr_fragment(VertexOut in [[stage_in]],
constant MaterialHalf& material [[buffer(0)]],
texture2d<half> albedo_tex [[texture(0)]]) {
half4 albedo = albedo_tex.sample(sampler, in.uv);
half3 normal = normalize(half3(in.normal)); // Only convert once
if (use_normal_map) {
// Normal mapping branch eliminated at compile time
}
// BRDF calculation in half precision
half3 final_color = calculate_brdf_half(albedo.rgb, normal, material);
return half4(final_color, albedo.a);
}
Performance impact: 40% reduction in register usage, 2x occupancy increase
Example 2: Compute Shader vs Tile Shader Decision
Scenario: Blur effect needing neighbor pixel access
Novice approach: Compute shader with texture reads
kernel void blur_compute(texture2d<float, access::read> input [[texture(0)]],
texture2d<float, access::write> output [[texture(1)]],
uint2 gid [[thread_position_in_grid]]) {
// Multiple texture reads - expensive on TBDR
float4 result = input.read(gid + uint2(-1, -1)) * 0.0625 +
input.read(gid + uint2(0, -1)) * 0.125 + /* ... */;
output.write(result, gid);
}
Expert analysis:
- Multiple texture reads = bandwidth expensive on TBDR
- Fixed-size neighborhood = perfect for tile shader
- Data can stay in tile memory throughout operation
Optimized tile shader:
kernel void blur_tile(imageblock<float4> img_block,
texture2d<half, access::read> input [[texture(0)]],
ushort2 tid [[thread_position_in_threadgroup]]) {
// Load tile data once
img_block.write(half4(input.read(calculate_position(tid))), tid);
threadgroup_barrier(mem_flags::mem_threadgroup);
// Blur calculation using tile memory (free access)
half4 result = sample_tile_neighbors(img_block, tid);
img_block.write(result, tid);
}
Result: 60% performance improvement due to eliminated bandwidth
Quality Gates
Performance Validation:
Correctness Validation:
Architecture Compliance:
NOT-FOR Boundaries
Wrong platforms/APIs:
- WebGL/OpenGL ES: Use
webgl-shader-expert - different precision rules, extension handling
- CUDA/OpenCL: Use
gpu-compute-expert - different memory model, NVIDIA-specific optimizations
- Vulkan/DirectX: Use
graphics-api-expert - immediate-mode renderer assumptions
Wrong abstraction level:
- CPU optimization: Use
performance-engineering - different bottlenecks, memory patterns
- Engine architecture: Use
game-engine-expert - render graph design, asset pipelines
- Platform-agnostic graphics: Use
graphics-programming - need Apple-specific TBDR knowledge
Wrong problem scope:
- UI/2D graphics: Use
native-app-designer - Core Animation, simpler shaders sufficient
- Scientific computing: Use
scientific-computing - different precision/accuracy requirements
- Web graphics: Use
web-graphics-expert - browser constraints, WebGPU considerations
Master Metal shaders with the precision of film production and the performance demands of real-time interaction.