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()
        }
    }
}

Sample object result

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.

roughness Ξ± = 0.20

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

πŸŽ‰ Congrats!

Tutorial 3 β€” Deferred Rendering