Tutorial 1 β€” Hello Triangle

Prerequisites

Make sure you have completed Tutorial 0 β€” Preparation and can build and run the project.

Setting up the project

In MTMetalTutorialsApp.swift set MT1ContentView() and run:

@main
struct MetalTutorialsApp: App {
    var body: some Scene {
        WindowGroup {
            // substitute here to choose the tutorial
            MT1ContentView()
        }
    }
}

Triangle result

Files

File Purpose
MT1ContentView.swift SwiftUI view that initializes and displays the Metal view
MT1Simple2DTriangleMetalView.swift UIViewRepresentable wrapping MTKView; also the MTKViewDelegate
MT1HelloShaders.metal Vertex and fragment shaders for the triangle
MT1Vertex.h Shared vertex struct between Swift and Metal

Code

In this tutorial we render a single colored 2D triangle β€” the β€œHello World” of graphics programming. The goal is to get familiar with the core Metal objects you will use in every subsequent tutorial.

GPU render pipeline

flowchart LR
    A["Vertex Buffer\n(triangleVertices)"] --> B["Vertex Shader\nMT1::VertexShader"]
    B --> C["Rasterization"]
    C --> D["Fragment Shader\nMT1::FragmentShader"]
    D --> E["Color Attachment\n(MTKView drawable)"]

The shared vertex struct β€” MT1Vertex.h

Both Swift and the Metal shader need to agree on what a vertex looks like. We define it in a C header included on both sides via the bridging header:

typedef struct
{
    vector_float2 position;
    vector_float4 color;
    
} MT1Vertex;

Swift β€” MT1Simple2DTriangleMetalView.swift

MT1Simple2DTriangleMetalView is a UIViewRepresentable that embeds an MTKView into SwiftUI. The coordinator is both the renderer and the MTKViewDelegate:

/// the coordinator is our renderer that manages drawing on the metalview
func makeCoordinator() -> MTRenderer {
    return MTRenderer(metalView: mtkView)
}

func makeUIView(context: UIViewRepresentableContext<MT1Simple2DTriangleMetalView>) -> MTKView {
    mtkView.delegate = context.coordinator

1. Device and pipeline setup (inside MTRenderer.init)

guard let device = MTLCreateSystemDefaultDevice() else {
    fatalError( "Failed to get the system's default Metal device." )
}
mtkView.device = device

We load the shader functions from the default Metal library and bind them to a pipeline descriptor:

let library = _device.makeDefaultLibrary()!
                
//create the vertex and fragment shaders
let vertexFunction = library.makeFunction(name: "MT1::VertexShader")
let fragmentFunction = library.makeFunction(name: "MT1::FragmentShader")
//create the pipeline we will run during draw
let rndPipStatDescriptor = MTLRenderPipelineDescriptor()
rndPipStatDescriptor.label = "Tutorial1 Simple Pipeline"
rndPipStatDescriptor.vertexFunction = vertexFunction
rndPipStatDescriptor.fragmentFunction = fragmentFunction
rndPipStatDescriptor.colorAttachments[0].pixelFormat = _metalView.colorPixelFormat
do {
    _pipelineState = try _device.makeRenderPipelineState(descriptor: rndPipStatDescriptor)
}
catch
{
    _pipelineState = nil
    print(error)
}

The render pipeline state (MTLRenderPipelineState) is an immutable snapshot of how the GPU executes a draw call. It is expensive to create and is reused every frame.

_commandQueue = _device.makeCommandQueue()

The command queue is the channel through which command buffers are submitted to the GPU.

2. Drawing (func draw(in view: MTKView))

Every frame, MTKView calls draw(in:) on the delegate. The sequence is always the same:

sequenceDiagram
    participant MTKView
    participant CommandQueue as MTLCommandQueue
    participant CommandBuffer as MTLCommandBuffer
    participant Encoder as MTLRenderCommandEncoder
    participant GPU

    MTKView->>CommandQueue: makeCommandBuffer()
    CommandQueue->>CommandBuffer: (new buffer)
    CommandBuffer->>Encoder: makeRenderCommandEncoder(descriptor:)
    Encoder->>Encoder: setRenderPipelineState
    Encoder->>Encoder: setViewport
    Encoder->>Encoder: setVertexBytes (vertices, viewport)
    Encoder->>Encoder: drawPrimitives(triangle, 3)
    Encoder->>CommandBuffer: endEncoding()
    CommandBuffer->>MTKView: present(drawable)
    CommandBuffer->>GPU: commit()

The triangle vertices are defined in pixel space (origin at viewport center):

/// triangle definition 2D
let triangleVertices:[MT1Vertex] = [
        // 2D positions,                                     RGBA colors
    MT1Vertex(position:  vector_float2(250,  -250), color: vector_float4(1, 0, 0, 1 )),
    MT1Vertex(position: vector_float2(-250,  -250), color: vector_float4(0, 1, 0, 1 )),
    MT1Vertex(position: vector_float2(   0,   250), color: vector_float4(0, 0, 1, 1 ))
    ]

Then we create a command buffer and a render command encoder for the current pass:

/// create the new command buffer for this pass
let commandBuffer = _commandQueue.makeCommandBuffer()!
commandBuffer.label = "Tutorial1Commands"

if let passDesc = view.currentRenderPassDescriptor {
    
    let commandEncoder:MTLRenderCommandEncoder! = commandBuffer.makeRenderCommandEncoder(descriptor: passDesc)
    commandEncoder.label = "Tutorial1RenderCommandEncoder"

currentRenderPassDescriptor provides the render target (the current drawable texture) and its load/store actions.

// init the MTLViewport from the metal library
let viewport = MTLViewport(originX: 0.0, originY: 0.0, width: Double(_viewportSize.x), height: Double(_viewportSize.y), znear: 0.0, zfar: 1.0)
commandEncoder.setViewport(viewport)

commandEncoder.setRenderPipelineState(_pipelineState!)
                
commandEncoder.setVertexBytes(triangleVertices, length: MemoryLayout<MT1Vertex>.size*3 , index: 0 )

commandEncoder.setVertexBytes(&_viewportSize, length: MemoryLayout<vector_uint2>.size, index: 1)

// encode the draw call
commandEncoder.drawPrimitives(type: MTLPrimitiveType.triangle, vertexStart: 0, vertexCount: 3)

commandEncoder.endEncoding()

setVertexBytes is for small, one-shot data (< 4 KB). For larger or reused data, use MTLBuffer.

let drawable:MTLDrawable! = view.currentDrawable
commandBuffer.present(drawable)
commandBuffer.commit()

present schedules the drawable to appear on screen once the GPU finishes. commit enqueues the buffer for execution.

Pixel Space β†’ Clip Space: live coordinate transform

The vertex shader converts pixel coordinates to clip space with one division: clip.xy = pixel.xy / (viewportSize / 2). Drag any vertex β€” the right canvas shows the clip-space result instantly. Use the slider to shrink the viewport and watch the same pixel coordinates exceed the [-1, 1] NDC range: Metal clips the triangle to the viewport boundary.

Viewport: 500 Γ— 500 px
Pixel Space β€” origin = viewport center, y↑ β€” drag vertices

Clip Space β€” Metal NDC [-1, 1] β€” βœ— = clipped

Metal β€” MT1HelloShaders.metal

The shader file lives in the MT1 namespace and defines two stages.

/// Vertex shader outputs and fragment shader inputs
struct RasterizerData
{
    float4 position [[position]];
    float4 color;
};

Vertex shader

Takes each vertex by vertex_id, reads it from the buffer, converts its 2D pixel-space position to clip space, and passes the color through to the rasterizer:

vertex RasterizerData
VertexShader(uint vertexID [[vertex_id]],
                constant MT1Vertex *vertices [[buffer(0)]],
                constant vector_uint2 *viewportSizePointer [[buffer(1)]])
{
    RasterizerData out;
    
    float2 pixelSpacePosition = vertices[vertexID].position.xy;
    
    vector_float2 viewportSize = vector_float2(*viewportSizePointer);
    
    out.position = vector_float4(0.0, 0.0, 0.0, 1.0);
    out.position.xy = pixelSpacePosition / (viewportSize / 2.0);
    
    out.color = vertices[vertexID].color;
    
    return out;
}

The [[buffer(n)]] indices match exactly what we set on the command encoder:

commandEncoder.setVertexBytes(triangleVertices, length: …, index: 0)
commandEncoder.setVertexBytes(&_viewportSize,   length: …, index: 1)

Fragment shader

Metal interpolates RasterizerData across the triangle surface. The fragment shader just returns the interpolated color:

fragment float4 FragmentShader(RasterizerData in [[stage_in]])
{
    return in.color;
}

[[stage_in]] tells Metal to automatically supply the interpolated output from the vertex shader β€” no manual buffer index needed.

Key concepts recap

  • MTLRenderPipelineState β€” immutable GPU program (shaders + pixel format). Create once, reuse every frame.
  • MTLCommandQueue / MTLCommandBuffer β€” the submission channel. One buffer per frame.
  • setVertexBytes β€” inline path for small (< 4 KB) one-shot data. Use MTLBuffer for anything larger or reused.
  • Pixel space β†’ clip space β€” the vertex shader divides pixel coordinates by half the viewport size to get Metal’s [-1, 1] clip space.
  • [[stage_in]] β€” Metal interpolates vertex shader outputs across the triangle; the fragment shader receives the result automatically.

πŸŽ‰ Congrats!

Tutorial 2 β€” Sample Object