Metal Tutorial 2 β Sample Object
Tutorial 2 β Sample Object
Prerequisites
Make sure you have completed Tutorial 1 β Hello Triangle.
New concepts in this tutorial:
- Loading a mesh with Model I/O (
MDLAssetβMTKMesh) - Depth stencil state to handle occlusion correctly
- Uniform buffers for MVP matrices and light position
- GGX BRDF physically-based lighting in the fragment shader
- Moving the renderer into its own class (
MT2ObjRenderer)
Setting up the project
In MTMetalTutorialsApp.swift set MT2ContentView() and run:
@main
struct MetalTutorialsApp: App {
var body: some Scene {
WindowGroup {
// substitute here to choose the tutorial
MT2ContentView()
}
}
}

Files
| File | Purpose |
|---|---|
MT2ContentView.swift |
SwiftUI entry point |
MT2SampleObjectMetalView.swift |
UIViewRepresentable, creates the renderer |
MT2ObjRenderer.swift |
Standalone MTKViewDelegate class |
MT2SampleObjectShaders.metal |
Vertex shader + GGX fragment shader |
MT2Uniforms.h |
Shared uniform structs |
The key structural difference from Tutorial 1 is that the renderer is now a standalone class (MT2ObjRenderer) rather than an inline coordinator:
classDiagram
class MT2ContentView {
+body: some View
}
class MT2SampleObjectMetalView {
+makeUIView() MTKView
+makeCoordinator() MT2ObjRenderer
}
class MT2ObjRenderer {
-_device: MTLDevice
-_commandQueue: MTLCommandQueue
-_pipelineState: MTLRenderPipelineState
-_depthStencilState: MTLDepthStencilState
-_meshes: [MTKMesh]
+draw(in: MTKView)
+mtkView(_:drawableSizeWillChange:)
}
class MTKViewDelegate {
<<protocol>>
+draw(in:)
+mtkView(_:drawableSizeWillChange:)
}
MT2ContentView --> MT2SampleObjectMetalView : contains
MT2SampleObjectMetalView --> MT2ObjRenderer : creates
MT2ObjRenderer ..|> MTKViewDelegate : implements
Loading the mesh with Model I/O
Model I/O (ModelIO.framework) handles .obj asset loading and lets us describe a vertex layout that Metal can consume directly via a vertex descriptor.
// MT2ObjRenderer._loadObj
let modelURL = Bundle.main.url(forResource: objName, withExtension: "obj")!
let vertexDescriptor = MDLVertexDescriptor()
// attribute 0: position (float3)
vertexDescriptor.attributes[0] = MDLVertexAttribute(
name: MDLVertexAttributePosition, format: .float3, offset: 0, bufferIndex: 0)
// attribute 1: normal (float3, right after position)
vertexDescriptor.attributes[1] = MDLVertexAttribute(
name: MDLVertexAttributeNormal, format: .float3,
offset: MemoryLayout<Float>.size * 3, bufferIndex: 0)
// attribute 2: UV (float2)
vertexDescriptor.attributes[2] = MDLVertexAttribute(
name: MDLVertexAttributeTextureCoordinate, format: .float2,
offset: MemoryLayout<Float>.size * 6, bufferIndex: 0)
vertexDescriptor.layouts[0] = MDLVertexBufferLayout(stride: MemoryLayout<Float>.size * 8)
let bufferAllocator = MTKMeshBufferAllocator(device: device)
let meshAsset = MDLAsset(url: modelURL,
vertexDescriptor: vertexDescriptor,
bufferAllocator: bufferAllocator)
We then extract MTKMesh objects from the asset and convert the vertex descriptor into a Metal-native one for the pipeline:
(_, _meshes) = try MTKMesh.newMeshes(asset: meshAsset, device: _device)
// convert Model I/O descriptor β Metal vertex descriptor (used in the pipeline)
_vertexDescriptor = MTKMetalVertexDescriptorFromModelIO(meshAsset.vertexDescriptor!)
The full loading pipeline:
flowchart LR
A[".obj file\n(URL)"] --> B["MDLVertexDescriptor\n(position, normal, UV)"]
B --> C["MDLAsset\n(meshAsset)"]
C --> D["MTKMesh.newMeshes\nβ [MTKMesh]"]
D --> E["MTKMetalVertexDescriptorFromModelIO\nβ MTLVertexDescriptor"]
E --> F["MTLRenderPipelineDescriptor\nβ MTLRenderPipelineState"]
Depth stencil state
Without a depth test the GPU has no way to know which fragments are in front of others. We add a depth buffer and tell the pipeline to keep only the fragment closest to the camera:
let depthStencilDescriptor = MTLDepthStencilDescriptor()
depthStencilDescriptor.depthCompareFunction = .less // keep nearer fragment
depthStencilDescriptor.isDepthWriteEnabled = true // update depth buffer after passing
_depthStencilState = device.makeDepthStencilState(descriptor: depthStencilDescriptor)!
The MTKView also needs matching pixel formats:
metalView.depthStencilPixelFormat = .depth32Float
// in the pipeline descriptor:
pipelineDescriptor.depthAttachmentPixelFormat = .depth32Float
Uniforms β MVP matrices and light
The shared header defines two structs (used on both the Swift and Metal side):
// MT2Uniforms.h
struct MT2VertexUniforms {
matrix_float4x4 modelViewMatrix;
matrix_float3x3 modelViewInverseTransposeMatrix;
matrix_float4x4 modelViewProjectionMatrix;
};
struct MT2FragmentUniforms {
simd_float4 viewLightPosition;
};
Why the inverse-transpose for normals? When an object is scaled non-uniformly, transforming normals with the regular modelView matrix skews them. The inverse-transpose keeps normals perpendicular to the surface they belong to.
The matrices are recomputed every frame in _buildUniforms:
// model β world: rotate around bounding box center
let modelMatrix = float4x4(rotationAbout: SIMD3<Float>(0, 1, 0), by: Float(_currentAngle))
* float4x4(translationBy: -center)
// world β camera (look-at)
let viewMatrix = float4x4(origin: origin, target: target, up: SIMD3<Float>(0, 1, 0))
// perspective projection
let projectionMatrix = float4x4(perspectiveProjectionFov: _camera.fov,
aspectRatio: aspectRatio,
nearZ: _camera.nearZ, farZ: _camera.farZ)
let modelView = viewMatrix * modelMatrix
let modelViewProjection = projectionMatrix * modelView
They are uploaded to the GPU at index: 1 (index 0 is the vertex buffer):
commandEncoder.setVertexBytes(&uniforms.0,
length: MemoryLayout<MT2VertexUniforms>.size, index: 1)
commandEncoder.setFragmentBytes(&uniforms.1,
length: MemoryLayout<MT2FragmentUniforms>.size, index: 1)
Drawing the mesh
Instead of a hardcoded vertex array we iterate over the loaded meshes and their submeshes:
for mesh in _meshes {
let vertexBuffer = mesh.vertexBuffers.first!
commandEncoder.setVertexBuffer(vertexBuffer.buffer,
offset: vertexBuffer.offset,
index: 0)
for submesh in mesh.submeshes {
let indexBuffer = submesh.indexBuffer
commandEncoder.drawIndexedPrimitives(
type: submesh.primitiveType,
indexCount: submesh.indexCount,
indexType: submesh.indexType,
indexBuffer: indexBuffer.buffer,
indexBufferOffset: indexBuffer.offset)
}
}
Indexed drawing reuses shared vertices instead of duplicating them for every triangle β essential for real meshes with thousands of polygons.
Metal shaders β MT2SampleObjectShaders.metal
Vertex shader
The vertex and fragment data structs use Metalβs [[stage_in]] attribute, which means Metal loads and unpacks vertex buffer attributes automatically according to the vertex descriptor:
namespace MT2 {
struct VertexIn {
float3 position [[attribute(0)]];
float3 normal [[attribute(1)]];
float2 texCoords [[attribute(2)]];
};
struct VertexOut {
float4 clipSpacePosition [[position]];
float3 viewNormal;
float4 viewPosition;
float2 texCoords;
};
vertex VertexOut vertex_main(VertexIn vertexIn [[stage_in]],
constant MT2VertexUniforms &uniforms [[buffer(1)]])
{
VertexOut vertexOut;
vertexOut.clipSpacePosition = uniforms.modelViewProjectionMatrix * float4(vertexIn.position, 1);
vertexOut.viewNormal = uniforms.modelViewInverseTransposeMatrix * vertexIn.normal;
vertexOut.viewPosition = uniforms.modelViewMatrix * float4(vertexIn.position, 1);
return vertexOut;
}
Fragment shader β GGX BRDF
The fragment shader computes physically-based reflectance using real-world microfacet lighting formulas.
GGX / Trowbridge-Reitz normal distribution β models how surface microfacets are oriented given a roughness value:
// GGX / Trowbridge-Reitz
// [Walter et al. 2007, "Microfacet models for refraction through rough surfaces"]
float D_GGX( float a2, float NoH )
{
if(NoH<=0)
{
return 0;
}
float d = ( NoH * a2 - NoH ) * NoH + 1; // 2 mad
return a2 / ( M_PI_F*d*d ); // 4 mul, 1 rcp
}
Joint Smith visibility term β accounts for mutual masking and shadowing between microfacets:
// Appoximation of joint Smith term for GGX
// [Heitz 2014, "Understanding the Masking-Shadowing Function in Microfacet-Based BRDFs"]
float Vis_SmithJointApprox( float a2, float NoV, float NoL )
{
NoV = abs(NoV);
NoL = abs(NoL);
float a = sqrt(a2);
float x = 2 * NoV * NoL;
float y = NoV + NoL;
return 0.5 * rcp( mix(x,y,a) );
}
Schlick Fresnel β models the increase in reflectance at glancing angles:
// [Schlick 1994, "An Inexpensive BRDF Model for Physically-Based Rendering"]
float3 F_Schlick( float3 SpecularColor, float VoH )
{
float Fc = pow(( 1 - VoH ),5); // 1 sub, 3 mul
return Fc + (1 - Fc) * SpecularColor;
}
Combining into the full BRDF β the fragment_main computes all dot products, combines the three terms, and adds a diffuse component:
fragment float4 fragment_main(VertexOut fragmentIn [[stage_in]],
constant MT2FragmentUniforms &uniforms [[buffer(1)]]) {
const float3 V = normalize(-float3(fragmentIn.viewPosition));
const float3 N = normalize(fragmentIn.viewNormal);
const float3 L = normalize(float3(uniforms.viewLightPosition));
const float3 specColor = float3(1);
const float3 Lcolor = float3(10);
const float roughness = 0.2;
const float3 rho(0.01);
const float sqrRoughness = roughness*roughness;
float3 H = normalize(V+L);
float NdotL = saturate(dot(N,L));
float NdotV = saturate(dot(N,V));
float NdotH = saturate(dot(N,H));
float VdotH = saturate(dot(V,H));
float a2 = sqrRoughness*sqrRoughness;
float Vis = Vis_SmithJointApprox(a2, NdotV, NdotL);
float D = D_GGX(a2, NdotH);
float3 F = F_Schlick(specColor, VdotH);
const float3 f_reflection = (D * Vis) * F;
const float3 f_diffuse = rho / M_PI_F;
const float3 L_o = M_PI_F * NdotL * Lcolor * (f_reflection + f_diffuse);
return float4(L_o,1);
}
GGX specular lobe β polar diagram
The D_GGX term controls the shape of the specular highlight. Low roughness concentrates reflected light into a tight spike (sharp, mirror-like highlight). High roughness spreads it across a wide lobe (diffuse-like). The polar diagram below shows this directly: the radial distance at each angle from the surface normal equals D_GGX(cos ΞΈ) Β· cos ΞΈ. Ghost curves show reference roughness values for comparison.
Key concepts recap
| Concept | What it does |
|---|---|
| Model I/O | Loads .obj files, describes vertex layout Metal can consume directly |
| Depth stencil state | Keeps the nearest fragment; discards occluded ones |
| Uniform buffers | Ship MVP matrices and light position to the GPU each frame |
| GGX BRDF | D (distribution) Γ Vis (masking) Γ F (Fresnel) = physically-based specular reflectance |
| Indexed drawing | Reuse shared vertices across triangles via an index buffer |