Build a Lit 3D Scene with Metal

Render a real-time 3D scene with custom shaders and Phong lighting in Metal.

Introduction

30 Second Summary

Every 3D game, VR headset, and AR app you use works the same way under the hood. Your computer's graphics chip transforms raw numbers into millions of colored pixels, sixty times per second.

In this project, you will build a real-time 3D scene from scratch using Metal and Swift. You will program the GPU directly with custom shaders and hand-built matrix math.

What You'll Build

A macOS app that renders a real-time 3D scene of multiple colored cubes on a lit floor plane, with an interactive first-person camera controlled by keyboard and mouse.

By the end of this project, you'll have:

  • A complete Metal rendering pipeline with custom vertex and fragment shaders written in Metal Shading Language.
  • Hand-built matrix math utilities for translation, rotation, perspective projection, and look-at transformations.
  • Multiple lit 3D objects with Phong lighting and an interactive first-person camera you control with WASD and mouse.
  • A dynamically orbiting point light with distance-based attenuation (Secret Mission).

Are there any prerequisites?

No prior graphics programming experience is required. You just need a Mac with Xcode installed. This project walks you through every concept from scratch.

Create Your Metal Project in Xcode

Before you can render anything with Metal, you need three things in place: an Xcode project to run your code, a shared header file so your Swift code and your GPU shaders agree on the shape of data, and a Metal shader file for your GPU programs.

In this step, you will create the macOS app project, set up that shared header with bridging so Swift can read it, and add your first shader file.

In this step, get ready to:
  • Create a new macOS App project in Xcode.
  • Set up a shared C header for type-safe CPU-GPU data definitions.
  • Create the Metal shader file for your vertex and fragment functions.
Create a new macOS App project
  • Press Cmd+Space to open Spotlight.
  • Type Xcode and press Enter to open it.
  • Click File in the menu bar.
  • Click New.
  • Click Project.
  • Select the macOS tab at the top of the template chooser.
  • Select App.
  • Click Next.
  • Enter MetalScene in the Product Name field.
  • Set Interface to SwiftUI.
  • Set Language to Swift.
  • Click Next.
  • Choose your Desktop as the save location.
  • Click Create.

Xcode opens your new project. You should see ContentView.swift and MetalSceneApp.swift listed in the project navigator on the left.

What did Xcode create?

Xcode generated a basic SwiftUI app with two files. MetalSceneApp.swift is the entry point that launches the window. ContentView.swift defines what appears inside that window. You will replace its default "Hello, world!" view with a Metal rendering view in a later step.

Set up the shared types header

Your Swift code on the CPU and your Metal shaders on the GPU both need to agree on the exact shape of the data they pass back and forth. A plain C header file is the bridge that makes this possible.

C types like vector_float3 map to SIMD3<Float> in Swift and float3 in Metal Shading Language. One header, three languages, identical memory layout.

You will create the header file first, then use an Objective-C bridging header trick so Swift can read it.

  • Click File in the menu bar.
  • Click New.
  • Click File from Template.
  • Select Header File from the template list.
  • Click Next.
  • Name the file ShaderTypes.h.
  • Click Create.

You should see ShaderTypes.h appear in the project navigator. Now you need to create a bridging header so Swift can import this C header.

Why do I need a bridging header?

Swift cannot directly read C header files. An Objective-C bridging header acts as a translator. Any C header you import into the bridging header becomes automatically available in all your Swift files.

The quickest way to get Xcode to generate a bridging header is to create a temporary Objective-C file. Xcode detects the language mismatch and offers to create the bridging header for you.

  • Click File in the menu bar.
  • Click New.
  • Click File from Template.
  • Select Objective-C File.
  • Click Next.
  • Name the file Bridge.
  • Click Next.
  • Click Create.

Xcode will ask about a bridging header. Say yes.

A dialog appears asking "Would you like to configure an Objective-C bridging header?" Click Create Bridging Header. This generates a file called MetalScene-Bridging-Header.h in your project.

The Bridge.m file was only needed to trigger that prompt. You can delete it now.

  • Right-click Bridge.m in the project navigator.
  • Click Delete.
  • Select Move to Trash when prompted.

Now wire the bridging header to your shared types header.

  • Open MetalScene-Bridging-Header.h by clicking it in the project navigator.
  • Add this import line inside the file:
#import "ShaderTypes.h"

This single line makes every struct defined in ShaderTypes.h available across all your Swift files automatically.

Now open ShaderTypes.h and replace its entire contents with the struct definitions below.

  • Click ShaderTypes.h in the project navigator to open it.
  • Select all the existing content with Cmd+A.
  • Replace it by pasting this code:
#ifndef ShaderTypes_h
#define ShaderTypes_h

#include <simd/simd.h>

// A single point in 3D space with a normal direction and a color
typedef struct {
    vector_float3 position;
    vector_float3 normal;
    vector_float3 color;
} Vertex;

// Per-object transformation matrices and camera info, sent to the GPU each frame
typedef struct {
    matrix_float4x4 modelMatrix;
    matrix_float4x4 viewMatrix;
    matrix_float4x4 projectionMatrix;
    matrix_float3x3 normalMatrix;
    vector_float3 cameraPosition;
} Uniforms;

// Light properties for the Phong lighting calculation
typedef struct {
    vector_float3 direction;
    vector_float3 ambientColor;
    vector_float3 diffuseColor;
    vector_float3 specularColor;
    float shininess;
} LightUniforms;

#endif

What do these structs define?

  • Vertex describes a single point in 3D space. Each vertex carries a position, a surface normal (the direction the surface faces), and a color.
  • Uniforms holds the matrices that transform an object from its local 3D space onto your 2D screen. It also carries the camera position so shaders can calculate lighting relative to the viewer.
  • LightUniforms defines the light source. The direction, colors, and shininess value feed into the Phong lighting equation you will write later.
  • Save ShaderTypes.h with Cmd+S.
  • Press Cmd+B to build the project. You should see Build Succeeded in the Xcode toolbar.

Seeing a build error?

  • Check that #include <simd/simd.h> is at the top of ShaderTypes.h, inside the header guard. Without it, the SIMD types like vector_float3 are undefined.
  • Make sure MetalScene-Bridging-Header.h contains #import "ShaderTypes.h" (using #import, not #include).
  • Verify the header guard names match: #ifndef ShaderTypes_h and #define ShaderTypes_h must use the exact same name.

Still stuck? Ask for help.

Create the Metal shader file

Metal shaders are GPU programs written in Metal Shading Language, which is based on C++. Xcode compiles .metal files automatically and bundles them into your app so you can load them at runtime.

  • Click File in the menu bar.
  • Click New.
  • Click File from Template.
  • Select Metal File.
  • Click Next.
  • Name the file Shaders.metal.
  • Click Create.

Xcode creates the file with a default Metal Standard Library include. You need to also include your shared types header so your shaders can use the same struct definitions as Swift.

  • Replace the entire contents of Shaders.metal with this starter code:
#include <metal_stdlib>
using namespace metal;

// Import the shared type definitions from the C header
#include "ShaderTypes.h"

What do these lines do?

  • #include <metal_stdlib> brings in Metal's built-in math and shading functions like normalize, dot, and reflect.
  • using namespace metal; lets you write float4 instead of metal::float4 everywhere.
  • #include "ShaderTypes.h" imports your Vertex, Uniforms, and LightUniforms structs so the GPU reads the exact same data layout the CPU writes.
  • Save Shaders.metal with Cmd+S.
  • Press Cmd+B to build. You should see Build Succeeded in the toolbar, confirming the shader file compiles and can find your header.

Build failing on the shader file?

  • If you see 'ShaderTypes.h' file not found, make sure ShaderTypes.h is in the same folder as Shaders.metal in the project navigator.
  • Check that you used double quotes (#include "ShaderTypes.h"), not angle brackets. Angle brackets search system paths only.

Need more help debugging?

Your project navigator should now show these files inside the MetalScene group: MetalSceneApp.swift, ContentView.swift, ShaderTypes.h, MetalScene-Bridging-Header.h, and Shaders.metal.

Your project foundation is in place. The CPU and GPU now share a common data contract through ShaderTypes.h, and your shader file is ready for code. Next up, you will build the Metal render loop and see your first frame on screen.

Build the Metal Render Loop

Your project has the skeleton files in place. Now it's time to bring Metal to life by creating a render loop. A render loop is a cycle where your CPU prepares instructions every frame and sends them to the GPU for drawing.

In this step, you'll create the Renderer class that talks to the GPU, wrap it in a SwiftUI-compatible view, and confirm that Metal is actively rendering by displaying a solid-color window.

In this step, get ready to:
  • Create the Renderer class with a Metal device and command queue.
  • Build a SwiftUI wrapper for the MetalKit view.
  • Wire up the app and verify the render loop is running.
Create the Renderer class

The Renderer is the core of your app's graphics pipeline. It holds references to the Metal device (your GPU) and a command queue (the channel for sending work to the GPU).

Every frame, MetalKit calls the Renderer's draw(in:) method. That method is the heartbeat of the render loop.

  • Create a new Swift file in your project. Click File in the menu bar, then New, then File.
  • Choose Swift File and name it Renderer.swift.
  • Replace the contents of the file with this code:
import MetalKit
import simd

class Renderer: NSObject, MTKViewDelegate {
    // The GPU device and command queue for submitting render work
    let device: MTLDevice
    let commandQueue: MTLCommandQueue

    init(metalView: MTKView) {
        // Get the default Metal device (your Mac's GPU)
        guard let device = MTLCreateSystemDefaultDevice(),
              let commandQueue = device.makeCommandQueue() else {
            fatalError("Metal is not supported on this device")
        }
        self.device = device
        self.commandQueue = commandQueue

        super.init()

        // Configure the view for Metal rendering
        metalView.device = device
        metalView.colorPixelFormat = .bgra8Unorm_srgb
        metalView.depthStencilPixelFormat = .depth32Float
        metalView.clearColor = MTLClearColorMake(0.05, 0.05, 0.1, 1.0)
        metalView.delegate = self
    }

    // Called when the view resizes
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}

    // Called every frame - we'll add rendering code here next
    func draw(in view: MTKView) {}
}

What does this code do?

  • MTLCreateSystemDefaultDevice() gets a reference to your Mac's GPU. This is the entry point for all Metal operations.
  • device.makeCommandQueue() creates a queue for sending batches of rendering commands to the GPU each frame.
  • colorPixelFormat and depthStencilPixelFormat tell the view what data format to use for color and depth.
  • clearColor sets the background fill color. The dark blue-black (0.05, 0.05, 0.1) gives the scene a moody baseline.
  • Setting metalView.delegate = self tells MetalKit to call draw(in:) on this Renderer every frame.
  • Save Renderer.swift.
  • Confirm the file compiles without errors. You won't see a visual change yet because the Renderer isn't connected to the app's UI.

Seeing a compile error?

  • Make sure import MetalKit is at the top of the file. Without it, Xcode doesn't know about MTKView or MTKViewDelegate.
  • Check that the class declaration includes both NSObject and MTKViewDelegate. The MTKViewDelegate protocol requires NSObject as the base class.
  • Help me fix a compile error in my Renderer class
Create the MetalKit view wrapper for SwiftUI

SwiftUI doesn't have a built-in Metal view. To display Metal content, you need to wrap MetalKit's MTKView (an AppKit view) in an NSViewRepresentable struct.

This bridge lets SwiftUI manage the view's lifecycle while Metal handles the rendering.

  • Create another new Swift file named MetalView.swift.
  • Replace the contents of the file with this code:
import SwiftUI
import MetalKit

struct MetalView: NSViewRepresentable {
    class Coordinator {
        // Keeps the Renderer alive as long as the view exists
        var renderer: Renderer?
    }

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func makeNSView(context: Context) -> MTKView {
        // Create a MetalKit view and hand it to the Renderer
        let view = MTKView()
        let renderer = Renderer(metalView: view)
        context.coordinator.renderer = renderer
        return view
    }

    func updateNSView(_ nsView: MTKView, context: Context) {}
}

What does this code do?

  • NSViewRepresentable is SwiftUI's protocol for wrapping AppKit views so they can appear inside SwiftUI layouts.
  • The Coordinator keeps a strong reference to the Renderer. Without this, the Renderer would be deallocated and the render loop would stop.
  • makeNSView creates the MTKView and hands it to the Renderer's init, which configures it and sets itself as the delegate.
  • Save MetalView.swift.
  • Confirm the file compiles without errors. The view still isn't connected to the app yet.

Seeing 'cannot find Renderer in scope'?

  • Make sure Renderer.swift is saved and belongs to the same target as MetalView.swift. Click the file in the project navigator and check the Target Membership checkbox in the File Inspector on the right.
  • Help me fix this scope error in MetalView.swift
Wire up the app and verify the render loop

Now connect the MetalView to your SwiftUI app and implement the actual rendering code inside draw(in:). The draw method follows Metal's frame rendering pattern: create a command buffer, get a render pass descriptor, encode commands, and commit.

  • Open ContentView.swift in the project navigator.
  • Replace the entire contents of the file with this code:
import SwiftUI

struct ContentView: View {
    var body: some View {
        MetalView()
            .frame(minWidth: 800, minHeight: 600)
    }
}
  • Open MetalSceneApp.swift in the project navigator.
  • Replace the entire contents of the file with this code:
import SwiftUI

@main
struct MetalSceneApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .defaultSize(width: 1024, height: 768)
    }
}

What did these two files change?

  • ContentView.swift now displays your MetalView as the entire window body, with a minimum size of 800x600.
  • MetalSceneApp.swift sets the window's default size to 1024x768 so you have a comfortable viewport for your 3D scene.
  • Save both ContentView.swift and MetalSceneApp.swift.

The app is now wired up, but the draw(in:) method in your Renderer is still empty. Time to fill it in with Metal's rendering pattern.

  • Open Renderer.swift.
  • Find the empty draw(in:) method at the bottom of the file:
    // Called every frame - we'll add rendering code here next
    func draw(in view: MTKView) {}
  • Replace that entire method with this implementation:
    func draw(in view: MTKView) {
        // Get the render pass descriptor (contains the clear color and attachments)
        guard let descriptor = view.currentRenderPassDescriptor,
              let commandBuffer = commandQueue.makeCommandBuffer(),
              let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
            return
        }

        // No draw calls yet - just clear the screen
        renderEncoder.endEncoding()

        // Present the rendered frame to the screen
        if let drawable = view.currentDrawable {
            commandBuffer.present(drawable)
        }
        commandBuffer.commit()
    }

What does this rendering code do?

This is the minimum Metal rendering pattern. Every frame follows the same three-phase flow:

  • commandQueue.makeCommandBuffer() creates a container for GPU instructions. Think of it as a sealed envelope you fill with commands and then hand to the GPU.
  • makeRenderCommandEncoder(descriptor:) gives you a render encoder. The encoder is how you record drawing commands into the command buffer. The descriptor carries the clear color and attachment settings.
  • endEncoding() seals the encoder. present(drawable) tells Metal to show the result on screen. commit() sends the command buffer to the GPU for execution.

Right now there are no draw calls between the encoder and endEncoding(). The GPU only fills the screen with the clear color. You'll add actual geometry in the next step.

  • Save Renderer.swift.
  • Run the app by pressing Cmd+R.

You should see a window filled with a dark blue-black color. That solid color is Metal rendering. Every frame, your Renderer clears the screen and presents it.

Seeing a blank white window or a crash?

  • A white window means Metal isn't rendering. Check that metalView.delegate = self is in the Renderer's init. Without the delegate, MetalKit never calls draw(in:).
  • If you see a Metal is not supported crash, make sure you're running on a real Mac and not the iOS Simulator. Metal requires an actual GPU.
  • Verify that ContentView.swift uses MetalView() as its body, not the default SwiftUI template content.
  • Help me debug why my Metal window isn't showing the dark background

Awesome, I've got everything!

Double check that you've saved all four files: Renderer.swift, MetalView.swift, ContentView.swift, and MetalSceneApp.swift.

I'd like to double check the full code

Here is the full code for each file at the end of this step.

import MetalKit
import simd

class Renderer: NSObject, MTKViewDelegate {
    // The GPU device and command queue for submitting render work
    let device: MTLDevice
    let commandQueue: MTLCommandQueue

    init(metalView: MTKView) {
        // Get the default Metal device (your Mac's GPU)
        guard let device = MTLCreateSystemDefaultDevice(),
              let commandQueue = device.makeCommandQueue() else {
            fatalError("Metal is not supported on this device")
        }
        self.device = device
        self.commandQueue = commandQueue

        super.init()

        // Configure the view for Metal rendering
        metalView.device = device
        metalView.colorPixelFormat = .bgra8Unorm_srgb
        metalView.depthStencilPixelFormat = .depth32Float
        metalView.clearColor = MTLClearColorMake(0.05, 0.05, 0.1, 1.0)
        metalView.delegate = self
    }

    // Called when the view resizes
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}

    func draw(in view: MTKView) {
        // Get the render pass descriptor (contains the clear color and attachments)
        guard let descriptor = view.currentRenderPassDescriptor,
              let commandBuffer = commandQueue.makeCommandBuffer(),
              let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
            return
        }

        // No draw calls yet - just clear the screen
        renderEncoder.endEncoding()

        // Present the rendered frame to the screen
        if let drawable = view.currentDrawable {
            commandBuffer.present(drawable)
        }
        commandBuffer.commit()
    }
}
import SwiftUI
import MetalKit

struct MetalView: NSViewRepresentable {
    class Coordinator {
        // Keeps the Renderer alive as long as the view exists
        var renderer: Renderer?
    }

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func makeNSView(context: Context) -> MTKView {
        // Create a MetalKit view and hand it to the Renderer
        let view = MTKView()
        let renderer = Renderer(metalView: view)
        context.coordinator.renderer = renderer
        return view
    }

    func updateNSView(_ nsView: MTKView, context: Context) {}
}
import SwiftUI

struct ContentView: View {
    var body: some View {
        MetalView()
            .frame(minWidth: 800, minHeight: 600)
    }
}
import SwiftUI

@main
struct MetalSceneApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .defaultSize(width: 1024, height: 768)
    }
}

Your Metal render loop is alive. Every frame, the CPU prepares a command buffer, sends it to the GPU, and the GPU fills the screen with your clear color. Next up, you'll write your first vertex and fragment shaders and render actual geometry on screen.

Write Your First Shaders and Render a Triangle

Your Metal render loop is working. The solid-color window you see proves that your app is communicating with the GPU every frame.

A clear color is just the GPU's default behavior when you give it nothing to draw. To render actual geometry, the GPU needs two things: a program that tells it how to process each vertex, and a program that tells it what color each pixel should be. These programs are called shaders. In this step, you will write your first vertex and fragment shaders, wire them into a render pipeline, and see a colored triangle on screen.

In this step, get ready to:
  • Write vertex and fragment shaders in Metal Shading Language.
  • Build a render pipeline state that connects your shaders to the GPU.
  • Render a colored triangle to confirm everything works end-to-end.
Write the vertex and fragment shaders

Shaders are small programs that run on the GPU. A vertex shader runs once per vertex and transforms its 3D position into the 2D screen position the GPU needs for drawing. A fragment shader runs once per pixel and decides its final color.

You write both shaders in Metal Shading Language (MSL), which is based on C++14 with special GPU-specific keywords.

  • Open Shaders.metal in Xcode.
  • Replace the file's contents with the VertexOut struct and the vertexShader function by copying this code:
#include <metal_stdlib>
using namespace metal;
#include "ShaderTypes.h"

// Output struct passed from vertex shader to fragment shader
struct VertexOut {
    float4 position [[position]]; // Clip-space position used by the rasterizer
    float3 worldNormal;           // Surface normal in world space (for lighting later)
    float3 worldPosition;         // Fragment position in world space
    float3 color;                 // Vertex color, interpolated across the triangle
};

vertex VertexOut vertexShader(uint vertexID [[vertex_id]],
                              constant Vertex *vertices [[buffer(0)]],
                              constant Uniforms &uniforms [[buffer(1)]]) {
    VertexOut out;

    // Transform vertex position from model space to world space
    float4 worldPos = uniforms.modelMatrix * float4(vertices[vertexID].position, 1.0);

    // Transform from world space to clip space (what the GPU uses to draw)
    out.position = uniforms.projectionMatrix * uniforms.viewMatrix * worldPos;

    // Pass world-space values through for use in the fragment shader
    out.worldPosition = worldPos.xyz;
    out.worldNormal = uniforms.normalMatrix * vertices[vertexID].normal;
    out.color = vertices[vertexID].color;

    return out;
}

What does this code do?

  • VertexOut defines the data the vertex shader passes to the fragment shader. The [[position]] attribute tells Metal which field holds the clip-space position the GPU uses for rasterization.
  • vertexShader receives each vertex from a buffer ([[buffer(0)]]) and a Uniforms struct ([[buffer(1)]]) containing transformation matrices.
  • The shader multiplies each vertex position by the model, view, and projection matrices to convert from 3D world coordinates to the 2D clip-space coordinates the GPU draws on screen.
  • Add the fragmentShader function below the vertex shader by copying this code:
fragment float4 fragmentShader(VertexOut in [[stage_in]],
                                constant LightUniforms &light [[buffer(0)]],
                                constant Uniforms &uniforms [[buffer(1)]]) {
    // For now, just return the interpolated vertex color
    return float4(in.color, 1.0);
}

What does the fragment shader do?

The [[stage_in]] attribute means this function receives the interpolated output from the vertex shader. The GPU automatically blends the VertexOut values across each triangle's surface.

Right now this shader ignores the light and uniform inputs. It just passes the vertex color through. You will add Phong lighting calculations here in a later step.

  • Save Shaders.metal.
  • Press Cmd+B to build the project. Xcode should compile without errors or warnings.

Seeing a compile error in Shaders.metal?

  • If you see Use of undeclared identifier 'Vertex' or Use of undeclared identifier 'Uniforms', make sure the line #include "ShaderTypes.h" is at the top of your Metal file. The shader needs this header to see the shared struct definitions.
  • Check that ShaderTypes.h contains the Vertex, Uniforms, and LightUniforms structs you defined in Step 1.
  • Still stuck? Help me fix my Metal shader compile error.

Awesome, I've got everything!

Great. Double check you have saved Shaders.metal before moving on.

I'd like to double check the full code

#include <metal_stdlib>
using namespace metal;
#include "ShaderTypes.h"

struct VertexOut {
    float4 position [[position]];
    float3 worldNormal;
    float3 worldPosition;
    float3 color;
};

vertex VertexOut vertexShader(uint vertexID [[vertex_id]],
                              constant Vertex *vertices [[buffer(0)]],
                              constant Uniforms &uniforms [[buffer(1)]]) {
    VertexOut out;
    float4 worldPos = uniforms.modelMatrix * float4(vertices[vertexID].position, 1.0);
    out.position = uniforms.projectionMatrix * uniforms.viewMatrix * worldPos;
    out.worldPosition = worldPos.xyz;
    out.worldNormal = uniforms.normalMatrix * vertices[vertexID].normal;
    out.color = vertices[vertexID].color;
    return out;
}

fragment float4 fragmentShader(VertexOut in [[stage_in]],
                                constant LightUniforms &light [[buffer(0)]],
                                constant Uniforms &uniforms [[buffer(1)]]) {
    // For now, just return the interpolated vertex color
    return float4(in.color, 1.0);
}
Build the render pipeline state

The GPU does not automatically know which shaders to use. You need to create a render pipeline state object that packages your vertex and fragment shaders together with the pixel formats your view expects. Metal compiles this configuration once so the GPU can reuse it efficiently every frame.

  • Open Renderer.swift.
  • Add a pipelineState property to the Renderer class, right below the commandQueue property:
var pipelineState: MTLRenderPipelineState!
  • Add the buildPipeline() method to your Renderer class by copying this code:
func buildPipeline() {
    // Load the compiled .metal shaders from the app bundle
    guard let library = device.makeDefaultLibrary() else {
        fatalError("Could not load default Metal library")
    }

    // Get references to the shader functions by name
    let vertexFunction = library.makeFunction(name: "vertexShader")
    let fragmentFunction = library.makeFunction(name: "fragmentShader")

    // Configure the pipeline with shaders and pixel formats
    let descriptor = MTLRenderPipelineDescriptor()
    descriptor.vertexFunction = vertexFunction
    descriptor.fragmentFunction = fragmentFunction
    descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm_srgb
    descriptor.depthAttachmentPixelFormat = .depth32Float

    // Compile the pipeline state (Metal validates everything here)
    pipelineState = try! device.makeRenderPipelineState(descriptor: descriptor)
}

What does this code do?

  • device.makeDefaultLibrary() loads all compiled .metal files from your app bundle. Xcode automatically compiles your Metal shaders at build time.
  • library.makeFunction(name:) retrieves a specific shader function by name. The names must match your function names in Shaders.metal exactly.
  • The pixel formats on the descriptor must match the formats you set on the MTKView in the previous step. A mismatch causes a runtime crash.
  • In the init(metalView:) method, add a call to buildPipeline() after the line where you set metalView.delegate = self.
  • Save Renderer.swift and press Cmd+B to build. The project should compile without errors.

Build failing after adding buildPipeline?

  • If you see a crash at runtime with makeRenderPipelineState, the function names in makeFunction(name:) must match your shader function names character-for-character. Verify that you used "vertexShader" and "fragmentShader".
  • Make sure the pixel formats match between the pipeline descriptor and the view: .bgra8Unorm_srgb for color and .depth32Float for depth.
  • Still stuck? Help me debug my Metal pipeline state creation.
Define triangle vertex data and draw

The pipeline is ready, but the GPU still has no geometry to draw. You need to give it vertex data. Each vertex has a position, a normal, and a color, matching the Vertex struct you defined in ShaderTypes.h.

  • In Renderer.swift, add a triangleVertices property to the Renderer class, below your pipelineState property:
// Temporary triangle for testing the pipeline
let triangleVertices: [Vertex] = [
    Vertex(position: SIMD3<Float>( 0.0,  0.5, 0.0), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(1, 0, 0)),
    Vertex(position: SIMD3<Float>(-0.5, -0.5, 0.0), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0, 1, 0)),
    Vertex(position: SIMD3<Float>( 0.5, -0.5, 0.0), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0, 0, 1)),
]

What does this vertex data define?

Each Vertex carries a position, a normal direction, and an RGB color. The three positions form a triangle in clip space, where coordinates range from -1 to 1. Each vertex has a different color (red, green, blue), and the GPU will smoothly interpolate between them across the triangle's surface.

Now you need to update the draw(in:) method to use your pipeline, pass the vertex data and uniforms to the GPU, and issue a draw call.

  • Replace your existing draw(in:) method with this updated version:
func draw(in view: MTKView) {
    guard let descriptor = view.currentRenderPassDescriptor,
          let commandBuffer = commandQueue.makeCommandBuffer(),
          let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
        return
    }

    // Tell the GPU which pipeline (shaders) to use
    renderEncoder.setRenderPipelineState(pipelineState)

    // Pass triangle vertex data to the vertex shader at buffer index 0
    var vertices = triangleVertices
    renderEncoder.setVertexBytes(&vertices,
                                  length: MemoryLayout<Vertex>.stride * vertices.count,
                                  index: 0)

    // Pass identity matrices so positions pass through unchanged
    var uniforms = Uniforms(
        modelMatrix: matrix_identity_float4x4,
        viewMatrix: matrix_identity_float4x4,
        projectionMatrix: matrix_identity_float4x4,
        normalMatrix: matrix_identity_float3x3,
        cameraPosition: SIMD3<Float>(0, 0, 0)
    )
    renderEncoder.setVertexBytes(&uniforms,
                                  length: MemoryLayout<Uniforms>.size,
                                  index: 1)

    // Pass basic light uniforms to the fragment shader
    var lightUniforms = LightUniforms(
        direction: SIMD3<Float>(0, -1, 0),
        ambientColor: SIMD3<Float>(0.1, 0.1, 0.1),
        diffuseColor: SIMD3<Float>(1, 1, 1),
        specularColor: SIMD3<Float>(1, 1, 1),
        shininess: 32.0
    )
    renderEncoder.setFragmentBytes(&lightUniforms,
                                    length: MemoryLayout<LightUniforms>.size,
                                    index: 0)
    renderEncoder.setFragmentBytes(&uniforms,
                                    length: MemoryLayout<Uniforms>.size,
                                    index: 1)

    // Draw the triangle (3 vertices, starting at index 0)
    renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)

    renderEncoder.endEncoding()

    if let drawable = view.currentDrawable {
        commandBuffer.present(drawable)
    }
    commandBuffer.commit()
}

What is happening in the draw method?

  • setVertexBytes copies small data directly to the GPU without creating a buffer object. The vertex data goes to buffer index 0 and the uniforms go to buffer index 1, matching the [[buffer(0)]] and [[buffer(1)]] attributes in your shader.
  • All matrices are set to identity, which means positions pass through without any transformation. The triangle's clip-space coordinates map directly to the screen.
  • setFragmentBytes sends data to the fragment shader. It receives both the light uniforms (at index 0) and the same uniforms struct (at index 1) so the shader signature is satisfied.
  • drawPrimitives(type: .triangle, ...) tells the GPU to assemble every 3 vertices into a triangle and run the shaders.
  • Save Renderer.swift.
  • Press Cmd+R to build and run the app.

You should see a triangle in the center of the window. The top vertex is red, the bottom-left is green, and the bottom-right is blue, with the colors smoothly blending across the surface.

Don't see a triangle?

  • If the window is still a solid color, make sure you added renderEncoder.setRenderPipelineState(pipelineState) before the draw call. Without this, the GPU does not know which shaders to use.
  • Verify that buildPipeline() is being called in your init(metalView:) method. If you forgot this call, pipelineState will be nil and the app will crash.
  • Check that the buffer indices match between Swift and Metal. The vertex data uses index 0 and uniforms use index 1 on both sides.
  • Still stuck? Help me debug my Metal triangle rendering.

What is the CPU-GPU data flow here?

Every frame, the CPU records a list of commands (set this pipeline, use this data, draw these primitives) into a command buffer. When you call commandBuffer.commit(), that entire batch of commands gets sent to the GPU for execution.

The CPU never draws pixels directly. It only describes what the GPU should do. This separation is what makes GPU rendering so fast.

Awesome, I've got everything!

Great. Double check you have saved both Shaders.metal and Renderer.swift before moving on.

I'd like to double check the full code

Here is Renderer.swift as it should look after this step. Your property names and init structure from Step 2 may differ slightly, but the key additions are pipelineState, buildPipeline(), triangleVertices, and the updated draw(in:).

import MetalKit
import simd

class Renderer: NSObject, MTKViewDelegate {
    let device: MTLDevice
    let commandQueue: MTLCommandQueue
    var pipelineState: MTLRenderPipelineState!

    // Temporary triangle for testing the pipeline
    let triangleVertices: [Vertex] = [
        Vertex(position: SIMD3<Float>( 0.0,  0.5, 0.0), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(1, 0, 0)),
        Vertex(position: SIMD3<Float>(-0.5, -0.5, 0.0), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0, 1, 0)),
        Vertex(position: SIMD3<Float>( 0.5, -0.5, 0.0), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0, 0, 1)),
    ]

    init(metalView: MTKView) {
        guard let device = MTLCreateSystemDefaultDevice(),
              let commandQueue = device.makeCommandQueue() else {
            fatalError("Metal is not supported on this device")
        }
        self.device = device
        self.commandQueue = commandQueue

        super.init()

        metalView.device = device
        metalView.colorPixelFormat = .bgra8Unorm_srgb
        metalView.depthStencilPixelFormat = .depth32Float
        metalView.clearColor = MTLClearColorMake(0.05, 0.05, 0.1, 1.0)
        metalView.delegate = self

        buildPipeline()
    }

    func buildPipeline() {
        guard let library = device.makeDefaultLibrary() else {
            fatalError("Could not load default Metal library")
        }
        let vertexFunction = library.makeFunction(name: "vertexShader")
        let fragmentFunction = library.makeFunction(name: "fragmentShader")

        let descriptor = MTLRenderPipelineDescriptor()
        descriptor.vertexFunction = vertexFunction
        descriptor.fragmentFunction = fragmentFunction
        descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm_srgb
        descriptor.depthAttachmentPixelFormat = .depth32Float

        pipelineState = try! device.makeRenderPipelineState(descriptor: descriptor)
    }

    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}

    func draw(in view: MTKView) {
        guard let descriptor = view.currentRenderPassDescriptor,
              let commandBuffer = commandQueue.makeCommandBuffer(),
              let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
            return
        }

        renderEncoder.setRenderPipelineState(pipelineState)

        var vertices = triangleVertices
        renderEncoder.setVertexBytes(&vertices,
                                      length: MemoryLayout<Vertex>.stride * vertices.count,
                                      index: 0)

        var uniforms = Uniforms(
            modelMatrix: matrix_identity_float4x4,
            viewMatrix: matrix_identity_float4x4,
            projectionMatrix: matrix_identity_float4x4,
            normalMatrix: matrix_identity_float3x3,
            cameraPosition: SIMD3<Float>(0, 0, 0)
        )
        renderEncoder.setVertexBytes(&uniforms,
                                      length: MemoryLayout<Uniforms>.size,
                                      index: 1)

        var lightUniforms = LightUniforms(
            direction: SIMD3<Float>(0, -1, 0),
            ambientColor: SIMD3<Float>(0.1, 0.1, 0.1),
            diffuseColor: SIMD3<Float>(1, 1, 1),
            specularColor: SIMD3<Float>(1, 1, 1),
            shininess: 32.0
        )
        renderEncoder.setFragmentBytes(&lightUniforms,
                                        length: MemoryLayout<LightUniforms>.size,
                                        index: 0)
        renderEncoder.setFragmentBytes(&uniforms,
                                        length: MemoryLayout<Uniforms>.size,
                                        index: 1)

        renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)

        renderEncoder.endEncoding()

        if let drawable = view.currentDrawable {
            commandBuffer.present(drawable)
        }
        commandBuffer.commit()
    }
}

Your first GPU-rendered geometry is on screen. That triangle proves the entire data pipeline works: CPU prepares vertex data, encodes commands, and the GPU executes your custom shaders. Next up, you will replace that flat triangle with a full 3D cube and build the matrix math that makes 3D rendering possible.

Go 3D with a Cube and Transformation Matrices

In the last step, you proved Metal can draw shapes to the screen. But a flat triangle is a long way from a 3D scene.

To make the jump to 3D, you need two things. First, a cube defined by vertices and indices. Second, transformation matrices that tell the GPU where every vertex sits in 3D space and how to project it onto your 2D screen.

In this step, get ready to:
  • Build matrix math functions for translation, rotation, scale, perspective, and look-at transforms.
  • Define a 3D cube mesh with per-face normals and colors.
  • Enable depth testing and render a spinning cube using your new matrices.
Build the matrix math utilities

Every vertex in a 3D scene passes through three matrix transformations before it appears on screen. The model matrix positions and rotates an object in the world. The view matrix simulates a camera. The projection matrix squashes 3D space into the 2D image you see.

You are going to build all of these by hand. This is the linear algebra that powers every 3D engine.

  • Create a new Swift file in your project. In Xcode, click File in the top menu bar.
  • Click New, then File.
  • Select Swift File and click Next.
  • Name it MathUtilities and click Create.
  • Replace the contents of MathUtilities.swift with the translation and scale matrix functions by pasting this code:
import simd

func makeTranslationMatrix(_ t: SIMD3<Float>) -> simd_float4x4 {
    // Start with identity (no change) and set the translation column
    var matrix = matrix_identity_float4x4
    matrix.columns.3 = SIMD4<Float>(t.x, t.y, t.z, 1)
    return matrix
}

func makeScaleMatrix(_ s: SIMD3<Float>) -> simd_float4x4 {
    // Scale each axis independently by modifying the diagonal
    var matrix = matrix_identity_float4x4
    matrix.columns.0.x = s.x
    matrix.columns.1.y = s.y
    matrix.columns.2.z = s.z
    return matrix
}

What does this code do?

  • Both functions start from matrix_identity_float4x4, which is a 4x4 matrix that changes nothing. It is the "do nothing" starting point.
  • Translation works by setting the fourth column (columns.3) to the desired x, y, z offset. When the GPU multiplies a vertex by this matrix, the vertex slides to that position.
  • Scale works by changing the diagonal values. A scale of (2, 1, 1) would stretch an object to twice its width.

Why column-major layout?

Metal's simd_float4x4 stores matrices in column-major order. That means columns.0 is the first column, not the first row. This is a common source of bugs when following row-major tutorials, so pay attention to which index you are setting.

  • Save MathUtilities.swift.

You will not see a change yet. The next function makes the math more interesting.

  • Add the rotation matrix function below makeScaleMatrix in MathUtilities.swift:
func makeRotationMatrix(angle: Float, axis: SIMD3<Float>) -> simd_float4x4 {
    // Rodrigues' rotation formula: rotate around any arbitrary axis
    let a = normalize(axis)
    let c = cos(angle)
    let s = sin(angle)
    let t = 1 - c

    let col0 = SIMD4<Float>(t * a.x * a.x + c,
                             t * a.x * a.y + s * a.z,
                             t * a.x * a.z - s * a.y,
                             0)
    let col1 = SIMD4<Float>(t * a.x * a.y - s * a.z,
                             t * a.y * a.y + c,
                             t * a.y * a.z + s * a.x,
                             0)
    let col2 = SIMD4<Float>(t * a.x * a.z + s * a.y,
                             t * a.y * a.z - s * a.x,
                             t * a.z * a.z + c,
                             0)
    let col3 = SIMD4<Float>(0, 0, 0, 1)

    return simd_float4x4(col0, col1, col2, col3)
}

What does this code do?

  • This implements Rodrigues' rotation formula. Given an angle and an arbitrary axis (like the Y-axis), it builds a matrix that spins vertices around that axis.
  • normalize(axis) ensures the axis is a unit vector. Without normalization, the rotation would distort the shape.
  • The formula uses cos and sin of the angle combined with the axis components to fill all 9 entries of the upper-left 3x3 portion of the matrix.
  • Save MathUtilities.swift.

Still no visible change. The last three functions handle the camera and projection.

  • Add the perspective projection, look-at, and normal matrix functions below makeRotationMatrix in MathUtilities.swift:
func makePerspectiveMatrix(fovyRadians: Float, aspect: Float, near: Float, far: Float) -> simd_float4x4 {
    // Maps 3D frustum to Metal's clip space with depth range [0, 1]
    let y = 1 / tan(fovyRadians * 0.5)
    let x = y / aspect
    let z = far / (near - far)

    let col0 = SIMD4<Float>(x, 0, 0,  0)
    let col1 = SIMD4<Float>(0, y, 0,  0)
    let col2 = SIMD4<Float>(0, 0, z, -1)
    let col3 = SIMD4<Float>(0, 0, z * near, 0)

    return simd_float4x4(col0, col1, col2, col3)
}

func makeLookAtMatrix(eye: SIMD3<Float>, center: SIMD3<Float>, up: SIMD3<Float>) -> simd_float4x4 {
    // Builds a view matrix that positions the camera at 'eye' looking toward 'center'
    let zAxis = normalize(center - eye)
    let xAxis = normalize(cross(up, zAxis))
    let yAxis = cross(zAxis, xAxis)

    // Dot products encode the camera's translation relative to each axis
    let tx = -dot(xAxis, eye)
    let ty = -dot(yAxis, eye)
    let tz = -dot(zAxis, eye)

    let col0 = SIMD4<Float>(xAxis.x, yAxis.x, zAxis.x, 0)
    let col1 = SIMD4<Float>(xAxis.y, yAxis.y, zAxis.y, 0)
    let col2 = SIMD4<Float>(xAxis.z, yAxis.z, zAxis.z, 0)
    let col3 = SIMD4<Float>(tx, ty, tz, 1)

    return simd_float4x4(col0, col1, col2, col3)
}

func makeNormalMatrix(from modelMatrix: simd_float4x4) -> simd_float3x3 {
    // Extract upper-left 3x3 and invert-transpose to keep normals correct under non-uniform scale
    let upperLeft = simd_float3x3(
        SIMD3<Float>(modelMatrix.columns.0.x, modelMatrix.columns.0.y, modelMatrix.columns.0.z),
        SIMD3<Float>(modelMatrix.columns.1.x, modelMatrix.columns.1.y, modelMatrix.columns.1.z),
        SIMD3<Float>(modelMatrix.columns.2.x, modelMatrix.columns.2.y, modelMatrix.columns.2.z)
    )
    return upperLeft.inverse.transpose
}

What does this code do?

  • makePerspectiveMatrix creates a frustum that maps 3D coordinates to Metal's clip space. Metal uses a depth range of [0, 1] (unlike OpenGL's [-1, 1]), so the Z column values are specifically tuned for Metal.
  • makeLookAtMatrix builds a view matrix from three vectors. The eye is where the camera sits. The center is what it looks at. The up vector keeps the camera from rolling sideways.
  • makeNormalMatrix extracts the upper-left 3x3 of the model matrix and returns its inverse transpose. If you scale an object non-uniformly (e.g. stretch it only along X), the surface normals would get distorted without this correction.
  • Save MathUtilities.swift.
  • Press Cmd+B to build the project. You should see Build Succeeded in the Xcode status bar, confirming the math functions compile.

Seeing a build error?

  • Check that the first line of the file is import simd. Without it, types like simd_float4x4 and SIMD3<Float> are undefined.
  • Make sure the function signatures match exactly. A typo in a parameter name or return type will cause a compile error.
  • My MathUtilities.swift won't compile. Help me debug.

Awesome, I've got everything!

Double check you have saved MathUtilities.swift.

I'd like to double check the full code

import simd

func makeTranslationMatrix(_ t: SIMD3<Float>) -> simd_float4x4 {
    var matrix = matrix_identity_float4x4
    matrix.columns.3 = SIMD4<Float>(t.x, t.y, t.z, 1)
    return matrix
}

func makeScaleMatrix(_ s: SIMD3<Float>) -> simd_float4x4 {
    var matrix = matrix_identity_float4x4
    matrix.columns.0.x = s.x
    matrix.columns.1.y = s.y
    matrix.columns.2.z = s.z
    return matrix
}

func makeRotationMatrix(angle: Float, axis: SIMD3<Float>) -> simd_float4x4 {
    let a = normalize(axis)
    let c = cos(angle)
    let s = sin(angle)
    let t = 1 - c

    let col0 = SIMD4<Float>(t * a.x * a.x + c,
                             t * a.x * a.y + s * a.z,
                             t * a.x * a.z - s * a.y,
                             0)
    let col1 = SIMD4<Float>(t * a.x * a.y - s * a.z,
                             t * a.y * a.y + c,
                             t * a.y * a.z + s * a.x,
                             0)
    let col2 = SIMD4<Float>(t * a.x * a.z + s * a.y,
                             t * a.y * a.z - s * a.x,
                             t * a.z * a.z + c,
                             0)
    let col3 = SIMD4<Float>(0, 0, 0, 1)

    return simd_float4x4(col0, col1, col2, col3)
}

func makePerspectiveMatrix(fovyRadians: Float, aspect: Float, near: Float, far: Float) -> simd_float4x4 {
    let y = 1 / tan(fovyRadians * 0.5)
    let x = y / aspect
    let z = far / (near - far)

    let col0 = SIMD4<Float>(x, 0, 0,  0)
    let col1 = SIMD4<Float>(0, y, 0,  0)
    let col2 = SIMD4<Float>(0, 0, z, -1)
    let col3 = SIMD4<Float>(0, 0, z * near, 0)

    return simd_float4x4(col0, col1, col2, col3)
}

func makeLookAtMatrix(eye: SIMD3<Float>, center: SIMD3<Float>, up: SIMD3<Float>) -> simd_float4x4 {
    let zAxis = normalize(center - eye)
    let xAxis = normalize(cross(up, zAxis))
    let yAxis = cross(zAxis, xAxis)

    let tx = -dot(xAxis, eye)
    let ty = -dot(yAxis, eye)
    let tz = -dot(zAxis, eye)

    let col0 = SIMD4<Float>(xAxis.x, yAxis.x, zAxis.x, 0)
    let col1 = SIMD4<Float>(xAxis.y, yAxis.y, zAxis.y, 0)
    let col2 = SIMD4<Float>(xAxis.z, yAxis.z, zAxis.z, 0)
    let col3 = SIMD4<Float>(tx, ty, tz, 1)

    return simd_float4x4(col0, col1, col2, col3)
}

func makeNormalMatrix(from modelMatrix: simd_float4x4) -> simd_float3x3 {
    let upperLeft = simd_float3x3(
        SIMD3<Float>(modelMatrix.columns.0.x, modelMatrix.columns.0.y, modelMatrix.columns.0.z),
        SIMD3<Float>(modelMatrix.columns.1.x, modelMatrix.columns.1.y, modelMatrix.columns.1.z),
        SIMD3<Float>(modelMatrix.columns.2.x, modelMatrix.columns.2.y, modelMatrix.columns.2.z)
    )
    return upperLeft.inverse.transpose
}
Define the 3D cube mesh data

A cube has 6 faces, but GPUs only understand triangles. Each face needs 4 vertices (one per corner) and 2 triangles to fill it. That gives you 24 vertices and 36 indices total.

Each vertex also carries an outward-facing normal vector. Normals tell the lighting system which direction the surface points, so the shader knows which faces are bright and which are in shadow.

  • Create a new Swift file named MeshData using the same process as before (File > New > File > Swift File).
  • Replace the contents of MeshData.swift with the cube vertex data by pasting this code:
import Metal
import simd

struct MeshData {
    static func cubeVertices() -> [Vertex] {
        // 24 vertices: 4 per face, each with position, outward normal, and face color
        let p: Float = 0.5
        let n: Float = -0.5

        return [
            // Front face (z = +0.5), red
            Vertex(position: SIMD3<Float>(n, n, p), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0.9, 0.2, 0.2)),
            Vertex(position: SIMD3<Float>(p, n, p), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0.9, 0.2, 0.2)),
            Vertex(position: SIMD3<Float>(p, p, p), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0.9, 0.2, 0.2)),
            Vertex(position: SIMD3<Float>(n, p, p), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0.9, 0.2, 0.2)),
            // Back face (z = -0.5), green
            Vertex(position: SIMD3<Float>(p, n, n), normal: SIMD3<Float>(0, 0, -1), color: SIMD3<Float>(0.2, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(n, n, n), normal: SIMD3<Float>(0, 0, -1), color: SIMD3<Float>(0.2, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(n, p, n), normal: SIMD3<Float>(0, 0, -1), color: SIMD3<Float>(0.2, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(p, p, n), normal: SIMD3<Float>(0, 0, -1), color: SIMD3<Float>(0.2, 0.9, 0.2)),
            // Top face (y = +0.5), blue
            Vertex(position: SIMD3<Float>(n, p, p), normal: SIMD3<Float>(0, 1, 0), color: SIMD3<Float>(0.2, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(p, p, p), normal: SIMD3<Float>(0, 1, 0), color: SIMD3<Float>(0.2, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(p, p, n), normal: SIMD3<Float>(0, 1, 0), color: SIMD3<Float>(0.2, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(n, p, n), normal: SIMD3<Float>(0, 1, 0), color: SIMD3<Float>(0.2, 0.2, 0.9)),
            // Bottom face (y = -0.5), yellow
            Vertex(position: SIMD3<Float>(n, n, n), normal: SIMD3<Float>(0, -1, 0), color: SIMD3<Float>(0.9, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(p, n, n), normal: SIMD3<Float>(0, -1, 0), color: SIMD3<Float>(0.9, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(p, n, p), normal: SIMD3<Float>(0, -1, 0), color: SIMD3<Float>(0.9, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(n, n, p), normal: SIMD3<Float>(0, -1, 0), color: SIMD3<Float>(0.9, 0.9, 0.2)),
            // Right face (x = +0.5), cyan
            Vertex(position: SIMD3<Float>(p, n, p), normal: SIMD3<Float>(1, 0, 0), color: SIMD3<Float>(0.2, 0.9, 0.9)),
            Vertex(position: SIMD3<Float>(p, n, n), normal: SIMD3<Float>(1, 0, 0), color: SIMD3<Float>(0.2, 0.9, 0.9)),
            Vertex(position: SIMD3<Float>(p, p, n), normal: SIMD3<Float>(1, 0, 0), color: SIMD3<Float>(0.2, 0.9, 0.9)),
            Vertex(position: SIMD3<Float>(p, p, p), normal: SIMD3<Float>(1, 0, 0), color: SIMD3<Float>(0.2, 0.9, 0.9)),
            // Left face (x = -0.5), magenta
            Vertex(position: SIMD3<Float>(n, n, n), normal: SIMD3<Float>(-1, 0, 0), color: SIMD3<Float>(0.9, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(n, n, p), normal: SIMD3<Float>(-1, 0, 0), color: SIMD3<Float>(0.9, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(n, p, p), normal: SIMD3<Float>(-1, 0, 0), color: SIMD3<Float>(0.9, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(n, p, n), normal: SIMD3<Float>(-1, 0, 0), color: SIMD3<Float>(0.9, 0.2, 0.9)),
        ]
    }
}

What does this code do?

  • p and n are shorthand for +0.5 and -0.5. The cube is 1 unit wide, centered at the origin.
  • Each face has 4 vertices with the same outward-facing normal. The front face normal is (0, 0, 1) because it points toward the viewer along the +Z axis.
  • Each face also has a unique color (red, green, blue, yellow, cyan, magenta) so you can visually distinguish them as the cube spins.
  • We need 4 separate vertices per face (24 total) instead of sharing the 8 cube corners because each face needs its own normal direction. A shared corner vertex cannot point in three different directions at once.
  • Save MeshData.swift.

The vertex data is ready, but the GPU does not know which three vertices form each triangle. That is what the index data provides.

  • Add the cubeIndices() function inside the MeshData struct, below the closing bracket of cubeVertices():
    static func cubeIndices() -> [UInt16] {
        // 36 indices: 2 triangles per face, 6 faces
        var indices: [UInt16] = []
        for face in 0..<6 {
            let base = UInt16(face * 4)
            indices.append(contentsOf: [base, base + 1, base + 2,
                                        base, base + 2, base + 3])
        }
        return indices
    }

What does this code do?

  • Each face has 4 vertices at indices base through base + 3. Two triangles (0-1-2 and 0-2-3) fill the quad, giving 6 indices per face and 36 total.
  • Using UInt16 indices instead of duplicating vertex data saves GPU memory. The GPU reuses the same vertex positions by looking them up via index.
  • Save MeshData.swift.
  • Press Cmd+B to build. You should see Build Succeeded.

Build error in MeshData.swift?

  • If you see "Cannot find type 'Vertex' in scope", make sure your bridging header (MetalScene-Bridging-Header.h) imports ShaderTypes.h. The Vertex struct is defined there.
  • Make sure cubeIndices() is inside the MeshData struct's closing brace, not outside it.
  • My MeshData.swift has a build error. Help me fix it.

Awesome, I've got everything!

Double check you have saved MeshData.swift.

I'd like to double check the full code

import Metal
import simd

struct MeshData {
    static func cubeVertices() -> [Vertex] {
        // 24 vertices: 4 per face, each with position, outward normal, and face color
        let p: Float = 0.5
        let n: Float = -0.5

        return [
            // Front face (z = +0.5), red
            Vertex(position: SIMD3<Float>(n, n, p), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0.9, 0.2, 0.2)),
            Vertex(position: SIMD3<Float>(p, n, p), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0.9, 0.2, 0.2)),
            Vertex(position: SIMD3<Float>(p, p, p), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0.9, 0.2, 0.2)),
            Vertex(position: SIMD3<Float>(n, p, p), normal: SIMD3<Float>(0, 0, 1), color: SIMD3<Float>(0.9, 0.2, 0.2)),
            // Back face (z = -0.5), green
            Vertex(position: SIMD3<Float>(p, n, n), normal: SIMD3<Float>(0, 0, -1), color: SIMD3<Float>(0.2, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(n, n, n), normal: SIMD3<Float>(0, 0, -1), color: SIMD3<Float>(0.2, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(n, p, n), normal: SIMD3<Float>(0, 0, -1), color: SIMD3<Float>(0.2, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(p, p, n), normal: SIMD3<Float>(0, 0, -1), color: SIMD3<Float>(0.2, 0.9, 0.2)),
            // Top face (y = +0.5), blue
            Vertex(position: SIMD3<Float>(n, p, p), normal: SIMD3<Float>(0, 1, 0), color: SIMD3<Float>(0.2, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(p, p, p), normal: SIMD3<Float>(0, 1, 0), color: SIMD3<Float>(0.2, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(p, p, n), normal: SIMD3<Float>(0, 1, 0), color: SIMD3<Float>(0.2, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(n, p, n), normal: SIMD3<Float>(0, 1, 0), color: SIMD3<Float>(0.2, 0.2, 0.9)),
            // Bottom face (y = -0.5), yellow
            Vertex(position: SIMD3<Float>(n, n, n), normal: SIMD3<Float>(0, -1, 0), color: SIMD3<Float>(0.9, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(p, n, n), normal: SIMD3<Float>(0, -1, 0), color: SIMD3<Float>(0.9, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(p, n, p), normal: SIMD3<Float>(0, -1, 0), color: SIMD3<Float>(0.9, 0.9, 0.2)),
            Vertex(position: SIMD3<Float>(n, n, p), normal: SIMD3<Float>(0, -1, 0), color: SIMD3<Float>(0.9, 0.9, 0.2)),
            // Right face (x = +0.5), cyan
            Vertex(position: SIMD3<Float>(p, n, p), normal: SIMD3<Float>(1, 0, 0), color: SIMD3<Float>(0.2, 0.9, 0.9)),
            Vertex(position: SIMD3<Float>(p, n, n), normal: SIMD3<Float>(1, 0, 0), color: SIMD3<Float>(0.2, 0.9, 0.9)),
            Vertex(position: SIMD3<Float>(p, p, n), normal: SIMD3<Float>(1, 0, 0), color: SIMD3<Float>(0.2, 0.9, 0.9)),
            Vertex(position: SIMD3<Float>(p, p, p), normal: SIMD3<Float>(1, 0, 0), color: SIMD3<Float>(0.2, 0.9, 0.9)),
            // Left face (x = -0.5), magenta
            Vertex(position: SIMD3<Float>(n, n, n), normal: SIMD3<Float>(-1, 0, 0), color: SIMD3<Float>(0.9, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(n, n, p), normal: SIMD3<Float>(-1, 0, 0), color: SIMD3<Float>(0.9, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(n, p, p), normal: SIMD3<Float>(-1, 0, 0), color: SIMD3<Float>(0.9, 0.2, 0.9)),
            Vertex(position: SIMD3<Float>(n, p, n), normal: SIMD3<Float>(-1, 0, 0), color: SIMD3<Float>(0.9, 0.2, 0.9)),
        ]
    }

    static func cubeIndices() -> [UInt16] {
        var indices: [UInt16] = []
        for face in 0..<6 {
            let base = UInt16(face * 4)
            indices.append(contentsOf: [base, base + 1, base + 2,
                                        base, base + 2, base + 3])
        }
        return indices
    }
}
Enable depth testing and render the spinning cube

Without depth testing, the GPU draws triangles in whatever order they arrive. A back face could paint over a front face, making the cube look inside-out. A depth buffer fixes this by tracking the closest fragment at each pixel.

  • Open Renderer.swift. Add these new properties below the existing pipelineState property, then add the buildDepthStencil() and buildBuffers() methods below buildPipeline():
    // Add these properties below pipelineState
    var depthStencilState: MTLDepthStencilState!

    var vertexBuffer: MTLBuffer!
    var indexBuffer: MTLBuffer!
    var indexCount: Int = 0

    var time: Float = 0
    // Add these methods below buildPipeline()
    func buildDepthStencil() {
        // Only keep the fragment closest to the camera at each pixel
        let descriptor = MTLDepthStencilDescriptor()
        descriptor.depthCompareFunction = .less
        descriptor.isDepthWriteEnabled = true
        depthStencilState = device.makeDepthStencilState(descriptor: descriptor)
    }

    func buildBuffers() {
        // Upload cube vertex and index data to GPU-accessible buffers
        let vertices = MeshData.cubeVertices()
        vertexBuffer = device.makeBuffer(bytes: vertices,
                                          length: MemoryLayout<Vertex>.stride * vertices.count,
                                          options: .storageModeShared)

        let indices = MeshData.cubeIndices()
        indexCount = indices.count
        indexBuffer = device.makeBuffer(bytes: indices,
                                         length: MemoryLayout<UInt16>.stride * indices.count,
                                         options: .storageModeShared)
    }

What does this code do?

  • depthCompareFunction = .less tells the GPU to only keep fragments that are closer to the camera than what is already drawn at that pixel. Combined with isDepthWriteEnabled = true, this ensures correct front-to-back rendering.
  • buildBuffers() creates persistent MTLBuffer objects for the vertex and index data. Unlike uniforms which change every frame, mesh data stays the same, so a persistent buffer is more efficient than setVertexBytes.
  • .storageModeShared means both the CPU and GPU can access this memory. On Apple Silicon, they share the same unified memory, so there is no performance penalty.
  • In the init(metalView:) method, find the line that says:
        buildPipeline()
  • Add two new lines directly below it, so the three calls look like this:
        buildPipeline()
        buildDepthStencil()
        buildBuffers()
  • Save Renderer.swift.

The buffers and depth state are ready. Now you need to update the draw(in:) method to use your new matrices and draw the cube with indexed primitives.

  • Replace the entire draw(in:) method in Renderer.swift with this updated version:
    func draw(in view: MTKView) {
        let deltaTime: Float = 1.0 / Float(view.preferredFramesPerSecond)
        time += deltaTime

        guard let descriptor = view.currentRenderPassDescriptor,
              let commandBuffer = commandQueue.makeCommandBuffer(),
              let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
            return
        }

        renderEncoder.setRenderPipelineState(pipelineState)
        renderEncoder.setDepthStencilState(depthStencilState)
        renderEncoder.setFrontFacing(.counterClockwise)
        renderEncoder.setCullMode(.back)

        // Build the three matrices that transform vertices from model space to screen
        let aspect = Float(view.drawableSize.width / view.drawableSize.height)
        let projectionMatrix = makePerspectiveMatrix(fovyRadians: Float.pi / 3,
                                                      aspect: aspect,
                                                      near: 0.1,
                                                      far: 100.0)

        // Fixed camera looking at the origin from slightly above
        let viewMatrix = makeLookAtMatrix(eye: SIMD3<Float>(0, 1.5, 5),
                                           center: SIMD3<Float>(0, 0, 0),
                                           up: SIMD3<Float>(0, 1, 0))

        // Spin the cube around the Y-axis over time
        let modelMatrix = makeRotationMatrix(angle: time, axis: SIMD3<Float>(0, 1, 0))

        var uniforms = Uniforms(
            modelMatrix: modelMatrix,
            viewMatrix: viewMatrix,
            projectionMatrix: projectionMatrix,
            normalMatrix: makeNormalMatrix(from: modelMatrix),
            cameraPosition: SIMD3<Float>(0, 1.5, 5)
        )

        var lightUniforms = LightUniforms(
            direction: normalize(SIMD3<Float>(0.3, -1.0, -0.5)),
            ambientColor: SIMD3<Float>(0.1, 0.1, 0.15),
            diffuseColor: SIMD3<Float>(0.9, 0.9, 0.85),
            specularColor: SIMD3<Float>(1.0, 1.0, 1.0),
            shininess: 32.0
        )

        // Bind vertex buffer and pass uniforms to both shaders
        renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
        renderEncoder.setVertexBytes(&uniforms,
                                      length: MemoryLayout<Uniforms>.size,
                                      index: 1)
        renderEncoder.setFragmentBytes(&lightUniforms,
                                        length: MemoryLayout<LightUniforms>.size,
                                        index: 0)
        renderEncoder.setFragmentBytes(&uniforms,
                                        length: MemoryLayout<Uniforms>.size,
                                        index: 1)

        // Draw using the index buffer instead of raw vertex order
        renderEncoder.drawIndexedPrimitives(type: .triangle,
                                             indexCount: indexCount,
                                             indexType: .uint16,
                                             indexBuffer: indexBuffer,
                                             indexBufferOffset: 0)

        renderEncoder.endEncoding()

        if let drawable = view.currentDrawable {
            commandBuffer.present(drawable)
        }
        commandBuffer.commit()
    }

What does this code do?

  • time increments each frame and feeds into makeRotationMatrix as the angle. This makes the cube spin continuously.
  • setFrontFacing(.counterClockwise) and setCullMode(.back) tell the GPU to skip triangles facing away from the camera. This is called back-face culling and it halves the number of fragments to process.
  • The three matrices (model, view, projection) are packed into a Uniforms struct and sent to the vertex shader via setVertexBytes. The vertex shader multiplies each vertex position by all three matrices to get the final screen position.
  • drawIndexedPrimitives replaces the old drawPrimitives call. Instead of reading vertices in order, the GPU uses the index buffer to look up which three vertices form each triangle.
  • Save Renderer.swift.
  • Press Cmd+R to build and run the app.

You should see a spinning 3D cube with six different colored faces. Each face is a distinct color (red, green, blue, yellow, cyan, magenta) and the cube rotates smoothly around the Y-axis.

Don't see a spinning cube?

  • If you see a blank window, make sure you deleted the old triangle vertex data and the old drawPrimitives call from Step 3. The entire draw(in:) method should be replaced.
  • If the cube looks flat or inside-out, check that setDepthStencilState(depthStencilState) is called on the render encoder before the draw call.
  • If you see a crash mentioning nil, make sure buildDepthStencil() and buildBuffers() are both called in init after buildPipeline().
  • My cube is not rendering correctly. Help me debug.

Awesome, I've got everything!

Double check you have saved Renderer.swift.

I'd like to double check the full code

import MetalKit
import simd

class Renderer: NSObject, MTKViewDelegate {
    let device: MTLDevice
    let commandQueue: MTLCommandQueue
    var pipelineState: MTLRenderPipelineState!
    var depthStencilState: MTLDepthStencilState!

    var vertexBuffer: MTLBuffer!
    var indexBuffer: MTLBuffer!
    var indexCount: Int = 0

    var time: Float = 0

    init(metalView: MTKView) {
        guard let device = MTLCreateSystemDefaultDevice(),
              let commandQueue = device.makeCommandQueue() else {
            fatalError("Metal is not supported on this device")
        }
        self.device = device
        self.commandQueue = commandQueue

        super.init()

        metalView.device = device
        metalView.colorPixelFormat = .bgra8Unorm_srgb
        metalView.depthStencilPixelFormat = .depth32Float
        metalView.clearColor = MTLClearColorMake(0.05, 0.05, 0.1, 1.0)
        metalView.delegate = self

        buildPipeline()
        buildDepthStencil()
        buildBuffers()
    }

    func buildPipeline() {
        guard let library = device.makeDefaultLibrary() else {
            fatalError("Could not load default Metal library")
        }
        let vertexFunction = library.makeFunction(name: "vertexShader")
        let fragmentFunction = library.makeFunction(name: "fragmentShader")

        let descriptor = MTLRenderPipelineDescriptor()
        descriptor.vertexFunction = vertexFunction
        descriptor.fragmentFunction = fragmentFunction
        descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm_srgb
        descriptor.depthAttachmentPixelFormat = .depth32Float

        pipelineState = try! device.makeRenderPipelineState(descriptor: descriptor)
    }

    func buildDepthStencil() {
        let descriptor = MTLDepthStencilDescriptor()
        descriptor.depthCompareFunction = .less
        descriptor.isDepthWriteEnabled = true
        depthStencilState = device.makeDepthStencilState(descriptor: descriptor)
    }

    func buildBuffers() {
        let vertices = MeshData.cubeVertices()
        vertexBuffer = device.makeBuffer(bytes: vertices,
                                          length: MemoryLayout<Vertex>.stride * vertices.count,
                                          options: .storageModeShared)

        let indices = MeshData.cubeIndices()
        indexCount = indices.count
        indexBuffer = device.makeBuffer(bytes: indices,
                                         length: MemoryLayout<UInt16>.stride * indices.count,
                                         options: .storageModeShared)
    }

    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}

    func draw(in view: MTKView) {
        let deltaTime: Float = 1.0 / Float(view.preferredFramesPerSecond)
        time += deltaTime

        guard let descriptor = view.currentRenderPassDescriptor,
              let commandBuffer = commandQueue.makeCommandBuffer(),
              let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
            return
        }

        renderEncoder.setRenderPipelineState(pipelineState)
        renderEncoder.setDepthStencilState(depthStencilState)
        renderEncoder.setFrontFacing(.counterClockwise)
        renderEncoder.setCullMode(.back)

        let aspect = Float(view.drawableSize.width / view.drawableSize.height)
        let projectionMatrix = makePerspectiveMatrix(fovyRadians: Float.pi / 3,
                                                      aspect: aspect,
                                                      near: 0.1,
                                                      far: 100.0)
        let viewMatrix = makeLookAtMatrix(eye: SIMD3<Float>(0, 1.5, 5),
                                           center: SIMD3<Float>(0, 0, 0),
                                           up: SIMD3<Float>(0, 1, 0))

        let modelMatrix = makeRotationMatrix(angle: time, axis: SIMD3<Float>(0, 1, 0))

        var uniforms = Uniforms(
            modelMatrix: modelMatrix,
            viewMatrix: viewMatrix,
            projectionMatrix: projectionMatrix,
            normalMatrix: makeNormalMatrix(from: modelMatrix),
            cameraPosition: SIMD3<Float>(0, 1.5, 5)
        )

        var lightUniforms = LightUniforms(
            direction: normalize(SIMD3<Float>(0.3, -1.0, -0.5)),
            ambientColor: SIMD3<Float>(0.1, 0.1, 0.15),
            diffuseColor: SIMD3<Float>(0.9, 0.9, 0.85),
            specularColor: SIMD3<Float>(1.0, 1.0, 1.0),
            shininess: 32.0
        )

        renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
        renderEncoder.setVertexBytes(&uniforms,
                                      length: MemoryLayout<Uniforms>.size,
                                      index: 1)
        renderEncoder.setFragmentBytes(&lightUniforms,
                                        length: MemoryLayout<LightUniforms>.size,
                                        index: 0)
        renderEncoder.setFragmentBytes(&uniforms,
                                        length: MemoryLayout<Uniforms>.size,
                                        index: 1)

        renderEncoder.drawIndexedPrimitives(type: .triangle,
                                             indexCount: indexCount,
                                             indexType: .uint16,
                                             indexBuffer: indexBuffer,
                                             indexBufferOffset: 0)

        renderEncoder.endEncoding()

        if let drawable = view.currentDrawable {
            commandBuffer.present(drawable)
        }
        commandBuffer.commit()
    }
}

Your flat triangle is now a fully 3D spinning cube, powered by matrix math you wrote from scratch. Next up, you will add the Phong lighting model to give those flat colors realistic shading.

Add Phong Lighting

Your cube spins with correct depth, but every face is a flat, uniform color. Real objects look different depending on how light hits them.

In this step, you will implement the Phong lighting model in your fragment shader. Phong lighting simulates how light interacts with surfaces by combining three components: ambient, diffuse, and specular light.

In this step, get ready to:
  • Update the fragment shader with the Phong lighting model.
  • Pass light data from the CPU to the GPU.
  • Verify realistic lighting on the spinning cube.
Update the fragment shader with Phong lighting

Right now, your fragment shader returns the vertex color directly. Every pixel on a face is the same flat color.

What is the Phong lighting model?

Phong lighting combines three components to approximate how real surfaces reflect light.

  • Ambient is a constant base illumination that ensures nothing is completely black.
  • Diffuse depends on the angle between the surface normal and the light direction. Surfaces facing the light are brighter.
  • Specular creates shiny highlights where the light reflects directly toward the camera.
  • In Shaders.metal, find the fragmentShader function.
  • Replace the entire function with this updated version that implements Phong lighting:
fragment float4 fragmentShader(VertexOut in [[stage_in]],
                                constant LightUniforms &light [[buffer(0)]],
                                constant Uniforms &uniforms [[buffer(1)]]) {
    float3 normal = normalize(in.worldNormal);
    float3 lightDir = normalize(-light.direction);

    // Ambient
    float3 ambient = light.ambientColor * in.color;

    // Diffuse
    float diff = max(dot(normal, lightDir), 0.0);
    float3 diffuse = diff * light.diffuseColor * in.color;

    // Specular
    float3 viewDir = normalize(uniforms.cameraPosition - in.worldPosition);
    float3 reflectDir = reflect(-lightDir, normal);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), light.shininess);
    float3 specular = spec * light.specularColor;

    float3 result = ambient + diffuse + specular;
    return float4(result, 1.0);
}

What does this code do?

  • normalize(in.worldNormal) ensures the interpolated normal has a length of 1. This is essential for accurate dot product calculations.
  • normalize(-light.direction) flips the light direction to point toward the light source. Dot products need both vectors pointing the same way to give a positive result when surfaces face the light.
  • The ambient term multiplies the ambient color by the surface color to produce a dim base color that is always present.
  • The diffuse term uses dot(normal, lightDir) to measure how directly the surface faces the light. max(..., 0.0) prevents negative values on surfaces facing away.
  • The specular term uses reflect to find where light bounces off the surface. pow(...) with shininess controls how tight and focused the highlight is.

You won't see the lighting change yet. The CPU still needs to send the right light data to the GPU.

Pass light uniforms from the CPU

Your updated fragment shader expects two buffer inputs. Buffer index 0 holds the light properties. Buffer index 1 holds the camera position for specular calculations.

  • In Renderer.swift, find where you create the LightUniforms value inside your draw(in:) method.
  • Replace it with this version that defines a proper directional light:
var lightUniforms = LightUniforms(
    direction: normalize(SIMD3<Float>(0.3, -1.0, -0.5)),
    ambientColor: SIMD3<Float>(0.1, 0.1, 0.15),
    diffuseColor: SIMD3<Float>(0.9, 0.9, 0.85),
    specularColor: SIMD3<Float>(1.0, 1.0, 1.0),
    shininess: 32.0
)

What do these values mean?

  • direction is (0.3, -1.0, -0.5) normalized. This points the light downward and slightly to the side, like afternoon sunlight.
  • ambientColor is a dim (0.1, 0.1, 0.15) so shadowed faces still have a faint cool tint instead of pure black.
  • diffuseColor is a warm white (0.9, 0.9, 0.85) that simulates natural daylight.
  • shininess at 32.0 creates medium-sized specular highlights. Higher values make the highlight smaller and sharper.

The fragment shader also needs the camera position to calculate specular reflections. You already pass a Uniforms struct to the vertex shader. Now you need to pass it to the fragment shader too.

  • Find the setFragmentBytes call that passes lightUniforms to the fragment shader.
  • Below it, add this line to also pass the Uniforms struct to the fragment shader at buffer index 1:
renderEncoder.setFragmentBytes(&uniforms,
                                length: MemoryLayout<Uniforms>.size,
                                index: 1)

Why pass uniforms to the fragment shader?

The vertex shader uses uniforms for matrix transformations. The fragment shader needs the same struct for a different reason: cameraPosition. Without knowing where the camera is, the shader cannot calculate the view direction needed for specular highlights.

  • Save both Shaders.metal and Renderer.swift.

Awesome, I've got everything!

Great. Double check you have saved both files before running.

I'd like to double check the full code

Here is the complete Shaders.metal file with the updated fragment shader.

#include <metal_stdlib>
using namespace metal;
#include "ShaderTypes.h"

struct VertexOut {
    float4 position [[position]];
    float3 worldNormal;
    float3 worldPosition;
    float3 color;
};

vertex VertexOut vertexShader(uint vertexID [[vertex_id]],
                              constant Vertex *vertices [[buffer(0)]],
                              constant Uniforms &uniforms [[buffer(1)]]) {
    VertexOut out;
    float4 worldPos = uniforms.modelMatrix * float4(vertices[vertexID].position, 1.0);
    out.position = uniforms.projectionMatrix * uniforms.viewMatrix * worldPos;
    out.worldPosition = worldPos.xyz;
    out.worldNormal = uniforms.normalMatrix * vertices[vertexID].normal;
    out.color = vertices[vertexID].color;
    return out;
}

fragment float4 fragmentShader(VertexOut in [[stage_in]],
                                constant LightUniforms &light [[buffer(0)]],
                                constant Uniforms &uniforms [[buffer(1)]]) {
    float3 normal = normalize(in.worldNormal);
    float3 lightDir = normalize(-light.direction);

    // Ambient
    float3 ambient = light.ambientColor * in.color;

    // Diffuse
    float diff = max(dot(normal, lightDir), 0.0);
    float3 diffuse = diff * light.diffuseColor * in.color;

    // Specular
    float3 viewDir = normalize(uniforms.cameraPosition - in.worldPosition);
    float3 reflectDir = reflect(-lightDir, normal);
    float spec = pow(max(dot(viewDir, reflectDir), 0.0), light.shininess);
    float3 specular = spec * light.specularColor;

    float3 result = ambient + diffuse + specular;
    return float4(result, 1.0);
}
Verify lighting on the cube
  • Run the app by pressing Cmd+R.

Your spinning cube should now show clearly lit and shaded faces. Faces pointing toward the light appear bright. Faces pointing away appear dark with only ambient light.

You should also see specular highlights as bright spots that move across the surface as the cube rotates.

Want to experiment?

  • Change shininess from 32.0 to 128.0 to see a tighter, more focused specular highlight.
  • Change the light direction to (0.0, -1.0, 0.0) to see what straight-down lighting looks like.
  • Increase the ambientColor values to see how brighter ambient light reduces the contrast between lit and shadowed faces.

Remember to change values back to the originals before moving on.

Cube looks completely black?

  • Check that you are passing lightUniforms at fragment buffer index 0 and uniforms at fragment buffer index 1. Swapped indices will cause the shader to read garbage data.
  • Make sure the LightUniforms values are not all zeros. Double check the ambientColor and diffuseColor have non-zero values.
  • Verify that your fragment shader signature includes constant Uniforms &uniforms [[buffer(1)]]. A missing parameter causes a mismatch between what the CPU sends and what the GPU expects.

Still stuck? Help me debug my Phong lighting. My cube is completely black even though I set up ambient, diffuse, and specular values.

No specular highlights visible?

  • Check that cameraPosition in your Uniforms struct has the correct camera position. If it is (0, 0, 0), the view direction calculation will be wrong.
  • Make sure the fragment shader uses uniforms.cameraPosition (not a hardcoded value) when computing viewDir.

Need a hand? Help me figure out why my specular highlights are missing in my Phong lighting shader.

Your cube is lit and looks three-dimensional for the first time. Next up, you will populate the scene with multiple objects, each with its own position, color, and rotation speed.

Render Multiple Objects

Your cube looks great with Phong lighting. Bright faces toward the light, dark faces away, specular highlights sliding across the surface as it spins.

A single spinning cube is not much of a scene, though. Real 3D applications render dozens or hundreds of objects, each with its own position, size, and rotation. In this step, you will define a scene data structure and draw multiple objects in a single frame, all sharing the same lighting and camera but each with its own independent transform.

In this step, get ready to:
  • Define a scene object data structure with per-object transform properties.
  • Render a floor plane and multiple cubes with independent transforms.
  • Animate each object at its own rotation speed and axis.
Create a scene with multiple objects

Right now your Renderer draws a single cube. To render multiple objects, you need a way to store each object's position, size, rotation axis, rotation speed, and color. A small struct makes this clean.

  • In Renderer.swift, add this SceneObject struct and objects array inside the Renderer class, below your existing property declarations:
struct SceneObject {
    var position: SIMD3<Float>
    var scale: SIMD3<Float>
    var rotationAxis: SIMD3<Float>
    var rotationSpeed: Float
    var color: SIMD3<Float>
}

var objects: [SceneObject] = []

What does this code do?

  • SceneObject holds everything needed to position and animate a single object: where it sits, how big it is, which axis it spins around, how fast it spins, and its color.
  • objects is an array that holds your entire scene. Each frame, the renderer loops through it and draws every object.

You won't see a change yet. The next chunk populates this array with actual scene objects.

Now you need a method to fill the objects array with a floor and several cubes. The floor is just a cube that has been heavily scaled along X and Z and flattened along Y.

  • Add this buildScene() method inside the Renderer class, below your buildBuffers() method:
func buildScene() {
    // Floor
    objects.append(SceneObject(
        position: SIMD3<Float>(0, -0.5, 0),
        scale: SIMD3<Float>(10, 0.1, 10),
        rotationAxis: SIMD3<Float>(0, 1, 0),
        rotationSpeed: 0,
        color: SIMD3<Float>(0.4, 0.4, 0.4)
    ))
    // Cubes
    objects.append(SceneObject(
        position: SIMD3<Float>(0, 0.5, 0),
        scale: SIMD3<Float>(1, 1, 1),
        rotationAxis: SIMD3<Float>(0, 1, 0),
        rotationSpeed: 1.0,
        color: SIMD3<Float>(0.9, 0.2, 0.2)
    ))
    objects.append(SceneObject(
        position: SIMD3<Float>(3, 0.5, -1),
        scale: SIMD3<Float>(0.8, 0.8, 0.8),
        rotationAxis: SIMD3<Float>(1, 0, 0),
        rotationSpeed: 1.5,
        color: SIMD3<Float>(0.2, 0.9, 0.3)
    ))
    objects.append(SceneObject(
        position: SIMD3<Float>(-2, 0.5, 2),
        scale: SIMD3<Float>(1.2, 1.2, 1.2),
        rotationAxis: SIMD3<Float>(0, 0, 1),
        rotationSpeed: 0.7,
        color: SIMD3<Float>(0.2, 0.3, 0.9)
    ))
}

What does this code do?

  • The floor is a cube scaled to (10, 0.1, 10). It becomes a wide, thin plane. Its rotationSpeed is 0 so it stays still.
  • Three cubes are placed at different positions with different sizes, colors, rotation axes, and speeds. One spins around Y, another around X, and the third around Z.
  • All objects reuse the same cube mesh. The only difference between a floor and a cube is the scale and position stored in its SceneObject.
  • In your init method, find the line that calls buildBuffers().
  • Find this line:
buildBuffers()
  • Add a call to buildScene() directly below it, so the two lines look like this:
buildBuffers()
buildScene()
  • Save Renderer.swift.

You won't see a change yet. The draw method still draws a single cube. The next substep connects the scene array to the render loop.

Draw each object with its own transform

The GPU does not know about your scene array. It draws whatever the render encoder tells it to draw, one draw call at a time. To render multiple objects, you loop over your objects array and issue a separate draw call for each one, passing unique uniforms each time.

  • In your draw(in:) method, find the code where you compute the single cube's model matrix, create the Uniforms struct, set vertex/fragment bytes, and call drawIndexedPrimitives.
  • Replace all of that single-cube drawing code with this loop. Make sure it sits after setVertexBuffer(vertexBuffer, offset: 0, index: 0) and before renderEncoder.endEncoding():
for obj in objects {
    let angle = time * obj.rotationSpeed
    let t = makeTranslationMatrix(obj.position)
    let r = obj.rotationSpeed != 0
        ? makeRotationMatrix(angle: angle, axis: obj.rotationAxis)
        : matrix_identity_float4x4
    let s = makeScaleMatrix(obj.scale)
    let modelMatrix = t * r * s

    var uniforms = Uniforms(
        modelMatrix: modelMatrix,
        viewMatrix: viewMatrix,
        projectionMatrix: projectionMatrix,
        normalMatrix: makeNormalMatrix(from: modelMatrix),
        cameraPosition: cameraPosition
    )

    renderEncoder.setVertexBytes(&uniforms,
                                  length: MemoryLayout<Uniforms>.size,
                                  index: 1)
    renderEncoder.setFragmentBytes(&lightUniforms,
                                    length: MemoryLayout<LightUniforms>.size,
                                    index: 0)
    renderEncoder.setFragmentBytes(&uniforms,
                                    length: MemoryLayout<Uniforms>.size,
                                    index: 1)

    renderEncoder.drawIndexedPrimitives(type: .triangle,
                                         indexCount: indexCount,
                                         indexType: .uint16,
                                         indexBuffer: indexBuffer,
                                         indexBufferOffset: 0)
}

What does this code do?

  • For each object, the loop computes a unique model matrix by multiplying translation, rotation, and scale together. The order matters: translate first, then rotate, then scale (t * r * s).
  • The floor has rotationSpeed == 0, so the ternary skips rotation entirely and uses the identity matrix instead.
  • Each draw call sends its own Uniforms to both the vertex and fragment shaders. The view and projection matrices stay the same for every object. Only the model matrix and normal matrix change.
  • setVertexBytes and setFragmentBytes pass data directly to the GPU without creating a buffer object. This works because the uniforms struct is well under Metal's 4KB limit for inline data.

Why does each object need its own normal matrix?

The normal matrix is the inverse transpose of the model matrix's upper-left 3x3. If you skip it and transform normals with the model matrix directly, non-uniform scaling (like the floor's (10, 0.1, 10) scale) distorts the normals. The lighting on the floor would look completely wrong.

  • Save Renderer.swift.
  • Run the app with Cmd+R. You should see a gray floor plane with colored cubes sitting on top of it, all lit by the same directional light.

Only seeing one object or a black screen?

  • Make sure you removed your old single-cube drawing code. If both the old code and the new loop exist, you could be drawing the old cube on top of everything.
  • Check that buildScene() is called in init. If the objects array is empty, the loop runs zero times and nothing draws.
  • Verify that setVertexBuffer(vertexBuffer, offset: 0, index: 0) is called once before the loop, not inside it.
  • Still stuck? Help me debug why my multi-object draw loop is not rendering all objects.
Verify independent animation

Each object's rotation comes from time * obj.rotationSpeed in the draw loop. Because each SceneObject has its own rotationSpeed and rotationAxis, they all spin independently.

  • Watch the running app closely. The red cube should spin around Y at speed 1.0. The green cube should spin around X faster at speed 1.5. The blue cube should spin around Z slower at speed 0.7. The gray floor should stay completely still.

How does the Phong lighting work across multiple objects?

The same lightUniforms struct is passed to the fragment shader for every draw call. Each object gets the same directional light. The visual difference comes from each object's unique normal matrix. A face on the floor points straight up, so it catches the light differently than a face on a spinning cube pointing sideways.

Pro tip: experiment with the scene

Try changing values in buildScene() to see how they affect the scene. Increase a cube's scale, move it further away, or set its rotationSpeed to a negative value to reverse its spin. This is your scene to play with.

Awesome, I've got everything!

Make sure you have saved Renderer.swift before moving on.

I'd like to double check the full code

Here is the SceneObject struct, buildScene() method, and the draw loop you added in this step. Compare these against your code in Renderer.swift.

// Inside the Renderer class, below your existing properties:

struct SceneObject {
    var position: SIMD3<Float>
    var scale: SIMD3<Float>
    var rotationAxis: SIMD3<Float>
    var rotationSpeed: Float
    var color: SIMD3<Float>
}

var objects: [SceneObject] = []

// -------------------------------------------------------
// New method, called from init after buildBuffers():

func buildScene() {
    // Floor
    objects.append(SceneObject(
        position: SIMD3<Float>(0, -0.5, 0),
        scale: SIMD3<Float>(10, 0.1, 10),
        rotationAxis: SIMD3<Float>(0, 1, 0),
        rotationSpeed: 0,
        color: SIMD3<Float>(0.4, 0.4, 0.4)
    ))
    // Cubes
    objects.append(SceneObject(
        position: SIMD3<Float>(0, 0.5, 0),
        scale: SIMD3<Float>(1, 1, 1),
        rotationAxis: SIMD3<Float>(0, 1, 0),
        rotationSpeed: 1.0,
        color: SIMD3<Float>(0.9, 0.2, 0.2)
    ))
    objects.append(SceneObject(
        position: SIMD3<Float>(3, 0.5, -1),
        scale: SIMD3<Float>(0.8, 0.8, 0.8),
        rotationAxis: SIMD3<Float>(1, 0, 0),
        rotationSpeed: 1.5,
        color: SIMD3<Float>(0.2, 0.9, 0.3)
    ))
    objects.append(SceneObject(
        position: SIMD3<Float>(-2, 0.5, 2),
        scale: SIMD3<Float>(1.2, 1.2, 1.2),
        rotationAxis: SIMD3<Float>(0, 0, 1),
        rotationSpeed: 0.7,
        color: SIMD3<Float>(0.2, 0.3, 0.9)
    ))
}

// -------------------------------------------------------
// In init, after buildBuffers():

buildBuffers()
buildScene()

// -------------------------------------------------------
// In draw(in:), replace the single-cube drawing code with this loop.
// Place it after setVertexBuffer and before endEncoding:

for obj in objects {
    let angle = time * obj.rotationSpeed
    let t = makeTranslationMatrix(obj.position)
    let r = obj.rotationSpeed != 0
        ? makeRotationMatrix(angle: angle, axis: obj.rotationAxis)
        : matrix_identity_float4x4
    let s = makeScaleMatrix(obj.scale)
    let modelMatrix = t * r * s

    var uniforms = Uniforms(
        modelMatrix: modelMatrix,
        viewMatrix: viewMatrix,
        projectionMatrix: projectionMatrix,
        normalMatrix: makeNormalMatrix(from: modelMatrix),
        cameraPosition: cameraPosition
    )

    renderEncoder.setVertexBytes(&uniforms,
                                  length: MemoryLayout<Uniforms>.size,
                                  index: 1)
    renderEncoder.setFragmentBytes(&lightUniforms,
                                    length: MemoryLayout<LightUniforms>.size,
                                    index: 0)
    renderEncoder.setFragmentBytes(&uniforms,
                                    length: MemoryLayout<Uniforms>.size,
                                    index: 1)

    renderEncoder.drawIndexedPrimitives(type: .triangle,
                                         indexCount: indexCount,
                                         indexType: .uint16,
                                         indexBuffer: indexBuffer,
                                         indexBufferOffset: 0)
}

Your scene is coming alive. Multiple objects, independent animations, correct lighting across every surface. There is one big piece missing though. You are stuck staring at the scene from a single fixed camera position. Next up, you will add interactive first-person camera controls so you can walk through your scene with WASD and look around with the mouse.

Add Interactive Camera Controls

Your scene is alive with multiple spinning cubes on a lit floor, all shaded by the Phong lighting model. But there is a problem. The camera is frozen in place, so you can only ever see the scene from one fixed angle.

In this step, you will build a first-person camera controlled by the keyboard and mouse. You will subclass MTKView to capture input events, then use yaw and pitch angles to compute a dynamic view matrix every frame.

In this step, get ready to:
  • Create a custom MTKView subclass that captures keyboard and mouse input.
  • Implement first-person camera movement with WASD and mouse look.
  • Wire up the new view so the app receives input events.
Create a custom MTKView subclass for input

MTKView does not handle keyboard or mouse input by default. On macOS, a view must explicitly opt in by overriding acceptsFirstResponder and implementing event handler methods.

  • Create a new Swift file by selecting File > New > File > Swift File.
  • Name it GameView.swift and click Create.
  • Replace the contents of GameView.swift with the following code:
import MetalKit
import AppKit

class GameView: MTKView {
    // Track which keys are currently held down
    var keysPressed: Set<UInt16> = []
    // Accumulate mouse movement between frames
    var mouseDeltaX: Float = 0
    var mouseDeltaY: Float = 0

    // Required for the view to receive keyboard events
    override var acceptsFirstResponder: Bool { true }

    override func keyDown(with event: NSEvent) {
        keysPressed.insert(event.keyCode)
    }

    override func keyUp(with event: NSEvent) {
        keysPressed.remove(event.keyCode)
    }

    override func mouseDragged(with event: NSEvent) {
        mouseDeltaX += Float(event.deltaX)
        mouseDeltaY += Float(event.deltaY)
    }

    // Returns accumulated mouse delta and resets it to zero
    func consumeMouseDelta() -> (Float, Float) {
        let dx = mouseDeltaX
        let dy = mouseDeltaY
        mouseDeltaX = 0
        mouseDeltaY = 0
        return (dx, dy)
    }
}

What does this code do?

  • keysPressed is a Set of hardware key codes. When a key goes down, its code is inserted. When it comes back up, it is removed. This lets the renderer check which keys are held at any moment.
  • mouseDeltaX and mouseDeltaY accumulate mouse movement between frames. The consumeMouseDelta() method returns the accumulated delta and resets it to zero, so each frame only sees new movement.
  • acceptsFirstResponder must return true for macOS to send keyboard events to this view at all.

What are those key code numbers?

macOS keyboard events use hardware-specific key codes, not characters. The codes for WASD and Q/E are: W=13, A=0, S=1, D=2, Q=12, E=14. You will see these numbers used in the renderer's camera logic shortly.

  • Save GameView.swift.

You will not see a change yet. The next substep connects this view to the renderer.

Implement first-person camera movement

A first-person camera needs two things: a position in world space, and an orientation defined by yaw (horizontal rotation) and pitch (vertical tilt). Each frame, the renderer reads input from the GameView, updates the camera angles and position, and builds a new view matrix from them.

  • Open Renderer.swift.
  • Find the existing property declarations near the top of the Renderer class (after var time: Float = 0). Add these camera state properties below it:
var cameraPosition: SIMD3<Float> = SIMD3<Float>(0, 1.5, 5)
var cameraYaw: Float = 0
var cameraPitch: Float = 0

weak var gameView: GameView?

What does this code do?

  • cameraPosition starts at (0, 1.5, 5), the same position the fixed camera used in previous steps.
  • cameraYaw controls left/right rotation. cameraPitch controls up/down tilt. Both start at zero.
  • gameView is a weak reference to the GameView so the renderer can read its input state each frame.
  • Now add the updateCamera method to the Renderer class. Place it after the buildScene() method:
func updateCamera(deltaTime: Float) {
    guard let view = gameView else { return }
    let (dx, dy) = view.consumeMouseDelta()

    // Apply mouse movement to yaw and pitch
    let sensitivity: Float = 0.005
    cameraYaw -= dx * sensitivity
    cameraPitch -= dy * sensitivity
    // Clamp pitch to prevent flipping upside down
    cameraPitch = max(-.pi / 2 + 0.01, min(.pi / 2 - 0.01, cameraPitch))

    // Compute forward and right vectors from yaw and pitch
    let forward = SIMD3<Float>(
        cos(cameraPitch) * sin(cameraYaw),
        sin(cameraPitch),
        cos(cameraPitch) * cos(cameraYaw)
    )
    let worldUp = SIMD3<Float>(0, 1, 0)
    let right = normalize(cross(forward, worldUp))

    // Move camera based on pressed keys
    let moveSpeed: Float = 5.0 * deltaTime
    let keys = view.keysPressed

    if keys.contains(13) { cameraPosition += forward * moveSpeed }   // W
    if keys.contains(1)  { cameraPosition -= forward * moveSpeed }   // S
    if keys.contains(0)  { cameraPosition -= right * moveSpeed }     // A
    if keys.contains(2)  { cameraPosition += right * moveSpeed }     // D
    if keys.contains(12) { cameraPosition += worldUp * moveSpeed }   // Q
    if keys.contains(14) { cameraPosition -= worldUp * moveSpeed }   // E
}

What does this code do?

  • consumeMouseDelta() grabs the accumulated mouse movement since the last frame and resets it. The delta is multiplied by a sensitivity factor to control how fast the camera turns.
  • The pitch is clamped just short of +/- 90 degrees. Without clamping, looking straight up or down causes the forward and up vectors to align, which breaks the cross product and flips the camera.
  • The forward vector is derived from yaw and pitch using spherical coordinate conversion. The right vector is computed as the cross product of forward and world up.
  • Movement speed is multiplied by deltaTime so movement stays consistent regardless of frame rate.

Next, the renderer's initializer and draw loop need to be updated to use GameView and call the new camera method.

  • In Renderer.swift, find the init method. Find this line:
init(metalView: MTKView) {
  • Replace MTKView with GameView, so the line now looks like this:
init(metalView: GameView) {
  • Inside the same init method, find the line super.init(). Add this line directly below it:
self.gameView = metalView

This stores a reference to the view so the renderer can read its key and mouse state during each frame.

  • Now scroll down to the draw(in:) method. Find the line time += deltaTime. Add this line directly below it:
updateCamera(deltaTime: deltaTime)
  • Still inside draw(in:), find the existing view matrix code. It currently uses a fixed camera position and looks at the origin:
let viewMatrix = makeLookAtMatrix(eye: SIMD3<Float>(0, 1.5, 5),
                                   center: SIMD3<Float>(0, 0, 0),
                                   up: SIMD3<Float>(0, 1, 0))
  • Replace those lines with the following code, which computes the view direction from the camera's yaw and pitch:
// Compute camera look direction from yaw and pitch
let forward = SIMD3<Float>(
    cos(cameraPitch) * sin(cameraYaw),
    sin(cameraPitch),
    cos(cameraPitch) * cos(cameraYaw)
)
let viewMatrix = makeLookAtMatrix(eye: cameraPosition,
                                   center: cameraPosition + forward,
                                   up: SIMD3<Float>(0, 1, 0))

Why compute forward twice?

The forward vector appears in both updateCamera (for movement) and draw(in:) (for the view matrix). Each function needs a fresh calculation because updateCamera may change cameraYaw and cameraPitch before the draw call builds the matrix.

  • Save Renderer.swift.

The project will not compile yet because MetalView.swift still creates a plain MTKView. The next substep fixes that.

Awesome, I've got everything!

Great. Double check you have saved Renderer.swift before moving on.

I'd like to double check the full code

import MetalKit
import simd

class Renderer: NSObject, MTKViewDelegate {
    let device: MTLDevice
    let commandQueue: MTLCommandQueue
    var pipelineState: MTLRenderPipelineState!
    var depthStencilState: MTLDepthStencilState!

    var vertexBuffer: MTLBuffer!
    var indexBuffer: MTLBuffer!
    var indexCount: Int = 0

    var time: Float = 0
    var cameraPosition: SIMD3<Float> = SIMD3<Float>(0, 1.5, 5)
    var cameraYaw: Float = 0
    var cameraPitch: Float = 0

    weak var gameView: GameView?

    struct SceneObject {
        var position: SIMD3<Float>
        var scale: SIMD3<Float>
        var rotationAxis: SIMD3<Float>
        var rotationSpeed: Float
        var color: SIMD3<Float>
    }

    var objects: [SceneObject] = []

    init(metalView: GameView) {
        guard let device = MTLCreateSystemDefaultDevice(),
              let commandQueue = device.makeCommandQueue() else {
            fatalError("Metal is not supported on this device")
        }
        self.device = device
        self.commandQueue = commandQueue
        self.gameView = metalView

        super.init()

        metalView.device = device
        metalView.colorPixelFormat = .bgra8Unorm_srgb
        metalView.depthStencilPixelFormat = .depth32Float
        metalView.clearColor = MTLClearColorMake(0.05, 0.05, 0.1, 1.0)
        metalView.delegate = self

        buildPipeline()
        buildDepthStencil()
        buildBuffers()
        buildScene()
    }

    func buildPipeline() {
        guard let library = device.makeDefaultLibrary() else {
            fatalError("Could not load default Metal library")
        }
        let vertexFunction = library.makeFunction(name: "vertexShader")
        let fragmentFunction = library.makeFunction(name: "fragmentShader")

        let descriptor = MTLRenderPipelineDescriptor()
        descriptor.vertexFunction = vertexFunction
        descriptor.fragmentFunction = fragmentFunction
        descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm_srgb
        descriptor.depthAttachmentPixelFormat = .depth32Float

        pipelineState = try! device.makeRenderPipelineState(descriptor: descriptor)
    }

    func buildDepthStencil() {
        let descriptor = MTLDepthStencilDescriptor()
        descriptor.depthCompareFunction = .less
        descriptor.isDepthWriteEnabled = true
        depthStencilState = device.makeDepthStencilState(descriptor: descriptor)
    }

    func buildBuffers() {
        let vertices = MeshData.cubeVertices()
        vertexBuffer = device.makeBuffer(bytes: vertices,
                                          length: MemoryLayout<Vertex>.stride * vertices.count,
                                          options: .storageModeShared)

        let indices = MeshData.cubeIndices()
        indexCount = indices.count
        indexBuffer = device.makeBuffer(bytes: indices,
                                         length: MemoryLayout<UInt16>.stride * indices.count,
                                         options: .storageModeShared)
    }

    func buildScene() {
        // Floor
        objects.append(SceneObject(
            position: SIMD3<Float>(0, -0.5, 0),
            scale: SIMD3<Float>(10, 0.1, 10),
            rotationAxis: SIMD3<Float>(0, 1, 0),
            rotationSpeed: 0,
            color: SIMD3<Float>(0.4, 0.4, 0.4)
        ))
        // Cubes
        objects.append(SceneObject(
            position: SIMD3<Float>(0, 0.5, 0),
            scale: SIMD3<Float>(1, 1, 1),
            rotationAxis: SIMD3<Float>(0, 1, 0),
            rotationSpeed: 1.0,
            color: SIMD3<Float>(0.9, 0.2, 0.2)
        ))
        objects.append(SceneObject(
            position: SIMD3<Float>(3, 0.5, -1),
            scale: SIMD3<Float>(0.8, 0.8, 0.8),
            rotationAxis: SIMD3<Float>(1, 0, 0),
            rotationSpeed: 1.5,
            color: SIMD3<Float>(0.2, 0.9, 0.3)
        ))
        objects.append(SceneObject(
            position: SIMD3<Float>(-2, 0.5, 2),
            scale: SIMD3<Float>(1.2, 1.2, 1.2),
            rotationAxis: SIMD3<Float>(0, 0, 1),
            rotationSpeed: 0.7,
            color: SIMD3<Float>(0.2, 0.3, 0.9)
        ))
    }

    func updateCamera(deltaTime: Float) {
        guard let view = gameView else { return }
        let (dx, dy) = view.consumeMouseDelta()
        let sensitivity: Float = 0.005
        cameraYaw -= dx * sensitivity
        cameraPitch -= dy * sensitivity
        cameraPitch = max(-.pi / 2 + 0.01, min(.pi / 2 - 0.01, cameraPitch))

        let forward = SIMD3<Float>(
            cos(cameraPitch) * sin(cameraYaw),
            sin(cameraPitch),
            cos(cameraPitch) * cos(cameraYaw)
        )
        let worldUp = SIMD3<Float>(0, 1, 0)
        let right = normalize(cross(forward, worldUp))

        let moveSpeed: Float = 5.0 * deltaTime
        let keys = view.keysPressed

        if keys.contains(13) { cameraPosition += forward * moveSpeed }   // W
        if keys.contains(1)  { cameraPosition -= forward * moveSpeed }   // S
        if keys.contains(0)  { cameraPosition -= right * moveSpeed }     // A
        if keys.contains(2)  { cameraPosition += right * moveSpeed }     // D
        if keys.contains(12) { cameraPosition += worldUp * moveSpeed }   // Q
        if keys.contains(14) { cameraPosition -= worldUp * moveSpeed }   // E
    }

    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}

    func draw(in view: MTKView) {
        let deltaTime: Float = 1.0 / Float(view.preferredFramesPerSecond)
        time += deltaTime
        updateCamera(deltaTime: deltaTime)

        guard let descriptor = view.currentRenderPassDescriptor,
              let commandBuffer = commandQueue.makeCommandBuffer(),
              let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
            return
        }

        renderEncoder.setRenderPipelineState(pipelineState)
        renderEncoder.setDepthStencilState(depthStencilState)
        renderEncoder.setFrontFacing(.counterClockwise)
        renderEncoder.setCullMode(.back)

        let aspect = Float(view.drawableSize.width / view.drawableSize.height)
        let projectionMatrix = makePerspectiveMatrix(fovyRadians: Float.pi / 3,
                                                      aspect: aspect,
                                                      near: 0.1,
                                                      far: 100.0)
        let forward = SIMD3<Float>(
            cos(cameraPitch) * sin(cameraYaw),
            sin(cameraPitch),
            cos(cameraPitch) * cos(cameraYaw)
        )
        let viewMatrix = makeLookAtMatrix(eye: cameraPosition,
                                           center: cameraPosition + forward,
                                           up: SIMD3<Float>(0, 1, 0))

        var lightUniforms = LightUniforms(
            direction: normalize(SIMD3<Float>(0.3, -1.0, -0.5)),
            ambientColor: SIMD3<Float>(0.1, 0.1, 0.15),
            diffuseColor: SIMD3<Float>(0.9, 0.9, 0.85),
            specularColor: SIMD3<Float>(1.0, 1.0, 1.0),
            shininess: 32.0
        )

        renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)

        for obj in objects {
            let angle = time * obj.rotationSpeed
            let t = makeTranslationMatrix(obj.position)
            let r = obj.rotationSpeed != 0
                ? makeRotationMatrix(angle: angle, axis: obj.rotationAxis)
                : matrix_identity_float4x4
            let s = makeScaleMatrix(obj.scale)
            let modelMatrix = t * r * s

            var uniforms = Uniforms(
                modelMatrix: modelMatrix,
                viewMatrix: viewMatrix,
                projectionMatrix: projectionMatrix,
                normalMatrix: makeNormalMatrix(from: modelMatrix),
                cameraPosition: cameraPosition
            )

            renderEncoder.setVertexBytes(&uniforms,
                                          length: MemoryLayout<Uniforms>.size,
                                          index: 1)
            renderEncoder.setFragmentBytes(&lightUniforms,
                                            length: MemoryLayout<LightUniforms>.size,
                                            index: 0)
            renderEncoder.setFragmentBytes(&uniforms,
                                            length: MemoryLayout<Uniforms>.size,
                                            index: 1)

            renderEncoder.drawIndexedPrimitives(type: .triangle,
                                                 indexCount: indexCount,
                                                 indexType: .uint16,
                                                 indexBuffer: indexBuffer,
                                                 indexBufferOffset: 0)
        }

        renderEncoder.endEncoding()

        if let drawable = view.currentDrawable {
            commandBuffer.present(drawable)
        }
        commandBuffer.commit()
    }
}
Update MetalView.swift to use GameView

The SwiftUI wrapper still creates a plain MTKView. It needs to create a GameView instead, and also make the view the first responder so it receives keyboard focus.

  • Open MetalView.swift and replace its entire contents with the following code:
import SwiftUI
import MetalKit

struct MetalView: NSViewRepresentable {
    class Coordinator {
        var renderer: Renderer?
    }

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    // Create a GameView instead of a plain MTKView
    func makeNSView(context: Context) -> GameView {
        let view = GameView()
        let renderer = Renderer(metalView: view)
        context.coordinator.renderer = renderer
        // Ensure the view receives keyboard focus after the window loads
        DispatchQueue.main.async {
            view.window?.makeFirstResponder(view)
        }
        return view
    }

    func updateNSView(_ nsView: GameView, context: Context) {}
}

What does this code do?

  • makeNSView(context:) now returns a GameView instead of a plain MTKView. This is why the renderer can now read keyboard and mouse input.
  • window?.makeFirstResponder(view) tells macOS to send keyboard events to this view. It is wrapped in DispatchQueue.main.async because the window is not available yet when makeNSView first runs.
  • Save MetalView.swift.
  • Run the app by pressing Cmd+R.
  • Press the W key. Your camera should move forward toward the cubes.
  • Press A and D to strafe left and right.
  • Click and drag the mouse to look around the scene.
  • Press Q to move up and E to move down.

You should be able to walk around the entire scene. The cubes continue spinning and the lighting updates correctly from every angle.

Camera not responding to keyboard?

  • Make sure you have the DispatchQueue.main.async { view.window?.makeFirstResponder(view) } call in makeNSView. Without it, the view never becomes the keyboard target.
  • Verify that acceptsFirstResponder returns true in GameView.swift. If it returns false or is missing, macOS ignores all keyboard events for that view.
  • Check that the init(metalView:) parameter type is GameView, not MTKView. If the type is wrong, self.gameView stays nil and updateCamera returns immediately.
  • If you see a system beep when pressing keys, you may have accidentally called super.keyDown(with:) inside your override. Remove the super call.

Still stuck? Help me debug why my GameView is not receiving keyboard input in my Metal app.

Mouse look not working?

  • Mouse look only works when you click and drag. A plain mouse move (without holding the button) does not trigger mouseDragged.
  • Confirm that consumeMouseDelta() resets both mouseDeltaX and mouseDeltaY to zero. If it does not reset, old deltas accumulate and the camera spins uncontrollably.

Need more help? Help me debug why mouse look is not working in my Metal app.

Awesome, I've got everything!

Great. Make sure all three files are saved: GameView.swift, Renderer.swift, and MetalView.swift.

I'd like to double check the full code

Here is the complete GameView.swift:

import MetalKit
import AppKit

class GameView: MTKView {
    var keysPressed: Set<UInt16> = []
    var mouseDeltaX: Float = 0
    var mouseDeltaY: Float = 0

    override var acceptsFirstResponder: Bool { true }

    override func keyDown(with event: NSEvent) {
        keysPressed.insert(event.keyCode)
    }

    override func keyUp(with event: NSEvent) {
        keysPressed.remove(event.keyCode)
    }

    override func mouseDragged(with event: NSEvent) {
        mouseDeltaX += Float(event.deltaX)
        mouseDeltaY += Float(event.deltaY)
    }

    func consumeMouseDelta() -> (Float, Float) {
        let dx = mouseDeltaX
        let dy = mouseDeltaY
        mouseDeltaX = 0
        mouseDeltaY = 0
        return (dx, dy)
    }
}

Here is the complete MetalView.swift:

import SwiftUI
import MetalKit

struct MetalView: NSViewRepresentable {
    class Coordinator {
        var renderer: Renderer?
    }

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func makeNSView(context: Context) -> GameView {
        let view = GameView()
        let renderer = Renderer(metalView: view)
        context.coordinator.renderer = renderer
        DispatchQueue.main.async {
            view.window?.makeFirstResponder(view)
        }
        return view
    }

    func updateNSView(_ nsView: GameView, context: Context) {}
}

Here is the complete Renderer.swift:

import MetalKit
import simd

class Renderer: NSObject, MTKViewDelegate {
    let device: MTLDevice
    let commandQueue: MTLCommandQueue
    var pipelineState: MTLRenderPipelineState!
    var depthStencilState: MTLDepthStencilState!

    var vertexBuffer: MTLBuffer!
    var indexBuffer: MTLBuffer!
    var indexCount: Int = 0

    var time: Float = 0
    var cameraPosition: SIMD3<Float> = SIMD3<Float>(0, 1.5, 5)
    var cameraYaw: Float = 0
    var cameraPitch: Float = 0

    weak var gameView: GameView?

    struct SceneObject {
        var position: SIMD3<Float>
        var scale: SIMD3<Float>
        var rotationAxis: SIMD3<Float>
        var rotationSpeed: Float
        var color: SIMD3<Float>
    }

    var objects: [SceneObject] = []

    init(metalView: GameView) {
        guard let device = MTLCreateSystemDefaultDevice(),
              let commandQueue = device.makeCommandQueue() else {
            fatalError("Metal is not supported on this device")
        }
        self.device = device
        self.commandQueue = commandQueue
        self.gameView = metalView

        super.init()

        metalView.device = device
        metalView.colorPixelFormat = .bgra8Unorm_srgb
        metalView.depthStencilPixelFormat = .depth32Float
        metalView.clearColor = MTLClearColorMake(0.05, 0.05, 0.1, 1.0)
        metalView.delegate = self

        buildPipeline()
        buildDepthStencil()
        buildBuffers()
        buildScene()
    }

    func buildPipeline() {
        guard let library = device.makeDefaultLibrary() else {
            fatalError("Could not load default Metal library")
        }
        let vertexFunction = library.makeFunction(name: "vertexShader")
        let fragmentFunction = library.makeFunction(name: "fragmentShader")

        let descriptor = MTLRenderPipelineDescriptor()
        descriptor.vertexFunction = vertexFunction
        descriptor.fragmentFunction = fragmentFunction
        descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm_srgb
        descriptor.depthAttachmentPixelFormat = .depth32Float

        pipelineState = try! device.makeRenderPipelineState(descriptor: descriptor)
    }

    func buildDepthStencil() {
        let descriptor = MTLDepthStencilDescriptor()
        descriptor.depthCompareFunction = .less
        descriptor.isDepthWriteEnabled = true
        depthStencilState = device.makeDepthStencilState(descriptor: descriptor)
    }

    func buildBuffers() {
        let vertices = MeshData.cubeVertices()
        vertexBuffer = device.makeBuffer(bytes: vertices,
                                          length: MemoryLayout<Vertex>.stride * vertices.count,
                                          options: .storageModeShared)

        let indices = MeshData.cubeIndices()
        indexCount = indices.count
        indexBuffer = device.makeBuffer(bytes: indices,
                                         length: MemoryLayout<UInt16>.stride * indices.count,
                                         options: .storageModeShared)
    }

    func buildScene() {
        // Floor
        objects.append(SceneObject(
            position: SIMD3<Float>(0, -0.5, 0),
            scale: SIMD3<Float>(10, 0.1, 10),
            rotationAxis: SIMD3<Float>(0, 1, 0),
            rotationSpeed: 0,
            color: SIMD3<Float>(0.4, 0.4, 0.4)
        ))
        // Cubes
        objects.append(SceneObject(
            position: SIMD3<Float>(0, 0.5, 0),
            scale: SIMD3<Float>(1, 1, 1),
            rotationAxis: SIMD3<Float>(0, 1, 0),
            rotationSpeed: 1.0,
            color: SIMD3<Float>(0.9, 0.2, 0.2)
        ))
        objects.append(SceneObject(
            position: SIMD3<Float>(3, 0.5, -1),
            scale: SIMD3<Float>(0.8, 0.8, 0.8),
            rotationAxis: SIMD3<Float>(1, 0, 0),
            rotationSpeed: 1.5,
            color: SIMD3<Float>(0.2, 0.9, 0.3)
        ))
        objects.append(SceneObject(
            position: SIMD3<Float>(-2, 0.5, 2),
            scale: SIMD3<Float>(1.2, 1.2, 1.2),
            rotationAxis: SIMD3<Float>(0, 0, 1),
            rotationSpeed: 0.7,
            color: SIMD3<Float>(0.2, 0.3, 0.9)
        ))
    }

    func updateCamera(deltaTime: Float) {
        guard let view = gameView else { return }
        let (dx, dy) = view.consumeMouseDelta()
        let sensitivity: Float = 0.005
        cameraYaw -= dx * sensitivity
        cameraPitch -= dy * sensitivity
        cameraPitch = max(-.pi / 2 + 0.01, min(.pi / 2 - 0.01, cameraPitch))

        let forward = SIMD3<Float>(
            cos(cameraPitch) * sin(cameraYaw),
            sin(cameraPitch),
            cos(cameraPitch) * cos(cameraYaw)
        )
        let worldUp = SIMD3<Float>(0, 1, 0)
        let right = normalize(cross(forward, worldUp))

        let moveSpeed: Float = 5.0 * deltaTime
        let keys = view.keysPressed

        if keys.contains(13) { cameraPosition += forward * moveSpeed }   // W
        if keys.contains(1)  { cameraPosition -= forward * moveSpeed }   // S
        if keys.contains(0)  { cameraPosition -= right * moveSpeed }     // A
        if keys.contains(2)  { cameraPosition += right * moveSpeed }     // D
        if keys.contains(12) { cameraPosition += worldUp * moveSpeed }   // Q
        if keys.contains(14) { cameraPosition -= worldUp * moveSpeed }   // E
    }

    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}

    func draw(in view: MTKView) {
        let deltaTime: Float = 1.0 / Float(view.preferredFramesPerSecond)
        time += deltaTime
        updateCamera(deltaTime: deltaTime)

        guard let descriptor = view.currentRenderPassDescriptor,
              let commandBuffer = commandQueue.makeCommandBuffer(),
              let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
            return
        }

        renderEncoder.setRenderPipelineState(pipelineState)
        renderEncoder.setDepthStencilState(depthStencilState)
        renderEncoder.setFrontFacing(.counterClockwise)
        renderEncoder.setCullMode(.back)

        let aspect = Float(view.drawableSize.width / view.drawableSize.height)
        let projectionMatrix = makePerspectiveMatrix(fovyRadians: Float.pi / 3,
                                                      aspect: aspect,
                                                      near: 0.1,
                                                      far: 100.0)
        let forward = SIMD3<Float>(
            cos(cameraPitch) * sin(cameraYaw),
            sin(cameraPitch),
            cos(cameraPitch) * cos(cameraYaw)
        )
        let viewMatrix = makeLookAtMatrix(eye: cameraPosition,
                                           center: cameraPosition + forward,
                                           up: SIMD3<Float>(0, 1, 0))

        var lightUniforms = LightUniforms(
            direction: normalize(SIMD3<Float>(0.3, -1.0, -0.5)),
            ambientColor: SIMD3<Float>(0.1, 0.1, 0.15),
            diffuseColor: SIMD3<Float>(0.9, 0.9, 0.85),
            specularColor: SIMD3<Float>(1.0, 1.0, 1.0),
            shininess: 32.0
        )

        renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)

        for obj in objects {
            let angle = time * obj.rotationSpeed
            let t = makeTranslationMatrix(obj.position)
            let r = obj.rotationSpeed != 0
                ? makeRotationMatrix(angle: angle, axis: obj.rotationAxis)
                : matrix_identity_float4x4
            let s = makeScaleMatrix(obj.scale)
            let modelMatrix = t * r * s

            var uniforms = Uniforms(
                modelMatrix: modelMatrix,
                viewMatrix: viewMatrix,
                projectionMatrix: projectionMatrix,
                normalMatrix: makeNormalMatrix(from: modelMatrix),
                cameraPosition: cameraPosition
            )

            renderEncoder.setVertexBytes(&uniforms,
                                          length: MemoryLayout<Uniforms>.size,
                                          index: 1)
            renderEncoder.setFragmentBytes(&lightUniforms,
                                            length: MemoryLayout<LightUniforms>.size,
                                            index: 0)
            renderEncoder.setFragmentBytes(&uniforms,
                                            length: MemoryLayout<Uniforms>.size,
                                            index: 1)

            renderEncoder.drawIndexedPrimitives(type: .triangle,
                                                 indexCount: indexCount,
                                                 indexType: .uint16,
                                                 indexBuffer: indexBuffer,
                                                 indexBufferOffset: 0)
        }

        renderEncoder.endEncoding()

        if let drawable = view.currentDrawable {
            commandBuffer.present(drawable)
        }
        commandBuffer.commit()
    }
}

Secret mission

Add an Orbiting Point Light

Your scene has a static directional light that never changes. Every face is always lit the same way, no matter what. Can you replace it with a point light that orbits the scene in a circle, casting dynamic highlights and shadows that shift in real time as it moves?

Before you go

Clean Up Your Resources

This project runs entirely on your Mac using Xcode. There are no cloud services, subscriptions, or ongoing costs. Decide whether to keep your project files, or delete them entirely.

Resources you used:

  • MetalScene Xcode project (local folder with all Swift, Metal, and header files).
  • Xcode (pre-installed, used as your IDE and build tool).

Keep everything running

No action needed. Choose this if you want to keep experimenting with your Metal scene, tweaking shaders, adding new objects, or extending the camera controls.

  • Your MetalScene project stays in whatever folder you created it in.
  • You can reopen the project anytime by double-clicking the .xcodeproj file.
  • There are no background processes or running services to worry about.

Pause - I'll come back to this later

Since this project is entirely local, there is nothing to pause. Your files sit on disk and use no resources when Xcode is closed.

  • Close the MetalScene project in Xcode.
  • Quit Xcode to free up memory.
  • Your project files remain on disk and can be reopened anytime.

Delete - I don't want to use this again

Remove all project files to start fresh.

  • Close the MetalScene project in Xcode.
  • Find the MetalScene folder in Finder and move it to the Trash.
  • Empty the Trash to permanently remove the files.
  • Xcode itself does not need to be uninstalled unless you want to free up disk space.

Mission accomplished

That's a Wrap!

You just built a real-time 3D renderer from scratch using Metal and Swift. That is not a small thing. You now have hands-on proof that you understand the GPU rendering pipeline, 3D math, shader programming, and interactive input handling.

Here's what you accomplished:

  • Built a complete Metal rendering pipeline with custom vertex and fragment shaders written in Metal Shading Language, passing data from the CPU to the GPU via uniform buffers and render encoders.
  • Wrote 3D matrix math by hand using Apple's SIMD types, including translation, rotation, scale, perspective projection, look-at, and normal matrices.
  • Implemented the Phong lighting model with ambient, diffuse, and specular components, rendered multiple independently animated objects, and built an interactive first-person camera with WASD and mouse look.
  • Added a dynamically orbiting point light with distance-based attenuation that casts dynamic lighting across the entire scene.

Ready to quiz yourself?