Metal Tutorial 1 β Hello Triangle
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()
}
}
}

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.
Metal β MT1HelloShaders.metal
The shader file lives in the MT1 namespace and defines two stages.
RasterizerData β the link between vertex and fragment shader
/// 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. UseMTLBufferfor 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.