A "Hello Triangle" is the first exercise a graphics dev does when learning a new API, analogous to the "Hello World" exercise with a new language.
They vary a lot in terms of complexity and length, from the brutal 1k lines of Vulkan to the mere 100 of Metal.
I had heard really good things about both Metal and Swift, so I decided to give them a try. And so far I'm impressed. Well done Apple!
Other APIs, like OpenGL, Vulkan or DirectX have really nice documentation and tutorials all over the web, however, when I decided to start with Metal I couldn't find much. Almost every tutorial was meant for iOS apps, and Apple's docs are still on Objective C.
So I decided to document my journey, so it can help others facing similar issues.
π€·πΌββοΈ Why not using Xcode?
I don't really have an answer other than laziness. I just didn't want to rely on/learn all the Swift UI framework for this, since I don't want to write apps, just a very simple toy engine.
I wanted to focus on learning Metal.
That meant no xcodeproj, no storyboards, manually managing windows, compile shaders, etc.
On top of that, I come from Linux, so I'm pretty much used to work from the terminal (and Swift's CLI package manager is quite good).
SourceKit LSP gives a very good development experience too, so I can use my tightly customised VSCode/Vim setups and be productive from the get-go without needing to learn how to use a new IDE.
However, dealing with the window management is so messy that I might reconsider this part in the future.
After this "disclaimer", buckle up and letβs get started!
π Creating the project
Thanks to Swift's package manager's CLI, this is super simple and straightforward, simply open your terminal of choice and run this:
$ mkdir MetalHelloTriangle
$ cd MetalHelloTriangle
$ swift package init --type executable
This will create a Hello World executable project with everything you need.
Test it with:
$ swift run
Next, since Metal is only available for Apple devices, we'll need to add a restriction to the platforms this will run on.
Add this to Package.swift after the name field (auto-generated, it should already exist on the root directory of the project)
// Package.swift
let package = Package(
name: "MetalHelloTriangle",
platforms: [ .macOS(.v10_15) ], // NEW LINE
dependencies: [
...
I'm not planning to run this on any device besides my laptop, so I only set macOS as platform.
πͺ Opening a (barebones) Window
Here started the first problem with not using Xcode. It was a mess. Easily, 90% of the development time was wasted on this.
I wish I could find something similar to GLFW but I couldn't.
So I ended up relying on the AppDelegate/ViewController framework, following this blogpost, and the first part of this other.
First we will create a new file AppDelegate.swift
, it'll contain, you guessed it, the class AppDelegate, which will create and own the window.
We'll start by importing Cocoa and creating the class
// AppDelegate.swift
import Cocoa
let WIDTH = 800
let HEIGHT = 600
class AppDelegate: NSObject, NSApplicationDelegate
{
private var mWindow: NSWindow?
func applicationDidFinishLaunching(_ aNotification: Notification)
{
// This will be called once when we run the engine
}
}
Meanwhile, in main.swift, we set the app delegate:
// main.swift
import Cocoa
let delegate = AppDelegate()
NSApplication.shared.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
Time to create the window itself
// AppDelegate.swift
func applicationDidFinishLaunching(_ aNotification: Notification)
{
let screenSize = NSScreen.main?.frame.size ?? .zero
let rect = NSMakeRect((screenSize.width - CGFloat(WIDTH)) * 0.5,
(screenSize.height - CGFloat(HEIGHT)) * 0.5,
CGFloat(WIDTH),
CGFloat(HEIGHT))
mWindow = NSWindow(contentRect: rect,
styleMask: [.miniaturizable,
.closable,
.resizable,
.titled],
backing: .buffered,
defer: false)
mWindow?.title = "Hello Triangle"
mWindow?.makeKeyAndOrderFront(nil)
}
Ok, now we have a blank window, but we need to be able to draw stuff into it.
Next, we add the window's content view controller.
Create a new ViewController class and add an instance of it to mWindow before setting it as key:
// AppDelegate.swift
class ViewController : NSViewController
{
override func loadView()
{
let rect = NSRect(x: 0, y: 0, width: WIDTH, height: HEIGHT)
view = NSView(frame: rect)
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.red.cgColor
}
}
// AppDelegate.applicationDidFinishLaunching
window?.title = "Hello Triangle!"
window?.contentViewController = ViewController() // NEW LINE
window?.makeKeyAndOrderFront(nil)
Running this will give us a window with an nice red background.
We'll let Metal handle the refresh loop, and for that we'll need to replace the view controller's view for a MTKView.
To do that, we need to get the appropriate GPU.
Add a new member mDevice to AppDelegate
// AppDelegate.swift
import MetalKit // NEW LINE
class AppDelegate: NSObject, NSApplicationDelegate
{
private var window: NSWindow?
private var device: MTLDevice? // NEW LINE
func applicationDidFinishLaunching(_ aNotification: Notification)
{
...
window?.makeKeyAndOrderFront(nil)
mDevice = MTLCreateSystemDefaultDevice() // NEW LINE
if mDevice == nil { fatalError("NO GPU") } // NEW LINE
}
To handle the MTKView we'll create a new class that extends MTKViewDelegate. This will be our Renderer.swift
// Renderer.swift
import MetalKit
class Renderer : NSObject
{
public var mView: MTKView
public init(view: MTKView)
{
mView = view
super.init()
mView.delegate = self
}
private func update()
{
// Uncomment this to check it's working
// print("Hello frame!")
}
}
extension Renderer: MTKViewDelegate
{
public func draw(in view: MTKView)
{
// Called every frame
self.update()
}
public func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize)
{
// This will be called on resize
}
}
Now we create the MTKView and replace the ViewController's with it.
// AppDelegate.swift
...
private var mRenderer: Renderer? // NEW LINE
func applicationDidFinishLaunching(_ aNotification: Notification)
{
...
mDevice = MTLCreateSystemDefaultDevice()
if mDevice == nil { fatalError("NO GPU") }
// NEW BLOCK START
let view = MTKView(frame: rect, device: mDevice)
mRenderer = Renderer(view: view)
mWindow?.contentViewController?.view = view
// NEW BLOCK END
}
And we're done!
As a side note, this last step will remove the red background because we're no longer using the ViewController's original view. This could probably be done better, but I don't want to dwell on it.
This implementation is really brittle (for some reason it doesn't get on top of other windows, and closing it doesnβt stop the application), but it gets the job done. I'm here for the graphics API after all, nothing else.
I'd appreciate any help/feedback in the comments though.
πΊ Rendering a plain triangle
I don't want this article to be too long, so I'll assume you already know how the general graphics pipeline works.
In case you want to get a detailed explanation, you can go to the Apple docs.
This other article was really useful for me too.
Let's start by adding the triangle's vertex data and putting that into a vertex buffer:
// Renderer.swift
// NEW BLOCK START
let VERTEX_DATA: [SIMD3<Float>] =
[
[ 0.0, 1.0, 0.0],
[-1.0, -1.0, 0.0],
[ 1.0, -1.0, 0.0]
]
// NEW BLOCK END
class Renderer : NSObject
{
...
private func update()
{
// NEW BLOCK START
let dataSize = VERTEX_DATA.count * MemoryLayout.size(ofValue: VERTEX_DATA[0])
let vertexBuffer = mView.device?.makeBuffer(bytes: VERTEX_DATA,
length: dataSize,
options: [])
// NEW BLOCK END
}
}
Render Pipeline
Now we have to create a Render Pipeline to process said data.
// Renderer.swift
class Renderer : NSObject
{
private var mPipeline: MTLRenderPipelineState
...
public init(view: MTKView)
{
mView = view
// NEW BLOCK START
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.colorAttachments[0].pixelFormat = mView.colorPixelFormat
// TODO: pipelineDescriptor.vertexFunction =
// TODO: pipelineDescriptor.fragmentFunction = fragmentFunction
// TODO: pipelineDescriptor.vertexDescriptor = vertDesc
guard let ps = try! mView.device?.makeRenderPipelineState(descriptor: pipelineDescriptor) else
{
fatalError("Couldn't create pipeline state")
}
mPipeline = ps
// NEW BLOCK END
super.init()
mView.delegate = self
}
}
As you can see, we still need a couple more pieces to create the pipeline, namely the shader functions and the vertex descriptor (this last one is optional and will be discussed later).
Let's start with the shaders.
Shaders
Metal uses it's own shading language called MLSL, with the file extension .metal
.
For this example, we'll keep both vertex and fragment functions in the same file and we will just paint everything red.
Create a new directory Sources/Shaders
and a HelloTriangle.metal
file in it.
// Sources/Shaders/HelloTriangle.metal
#include <metal_stdlib>
using namespace metal;
struct VertexIn
{
float3 position [[ attribute(0) ]];
};
vertex
VertexOut vertex_main(VertexIn vert [[ stage_in ]])
{
return float4(vert.position, 1.0f);
}
fragment
float4 fragment_main()
{
return float4(1,0,0,1);
}
Metal uses precompiled shaders in what it's called libraries.
To compile them without Xcode, we'll need to run a couple of commands.
First, compile the .metal
into .air
files.
xcrun metal -c HelloTriangle.metal -o HelloTriangle.air
Second, pack the .air
files into a single .metallib
xcrun metal HelloTriangle.air -o HelloTriangle.metallib
More info about the manual compilation of Metal shaders and Libraries here.
Now that we have the library, we just need to load it.
// Renderer.swift
import MetalKit
let SHADERS_DIR_LOCAL_PATH = "/Sources/Shaders" // NEW LINE
let DEFAULT_SHADER_LIB_LOCAL_PATH = SHADERS_DIR_LOCAL_PATH + "/HelloTriangle.metallib" // NEW LINE
...
public init(view: MTKView)
{
mView = view
// NEW BLOCK START
let shaderLibPath = FileManager.default
.currentDirectoryPath +
DEFAULT_SHADER_LIB_LOCAL_PATH
guard let library = try! mView.device?.makeLibrary(filepath: shaderLibPath) else
{
fatalError("No shader library!")
}
let vertexFunction = library.makeFunction(name: "vertex_main")
let fragmentFunction = library.makeFunction(name: "fragment_main")
// NEW BLOCK END
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.colorAttachments[0].pixelFormat = mView.colorPixelFormat
pipelineDescriptor.vertexFunction = vertexFunction // NEW LINE
pipelineDescriptor.fragmentFunction = fragmentFunction // NEW LINE
}
Make sure the name in library.makeFunction
matches the one you want to call for that stage in the shader.
Vertex descriptors
You can technically pass the vertex data raw through buffer pointers and vertex indices, but it's much better to use vertex descriptors.
They, as their name implies, describe how the vertex data is arranged in memory, giving us a lot of flexibility and opportunities for optimisation.
Again, I want to keep this tutorial short, so I'll leave this article in case you want to learn the specifics.
Creating a vertex descriptor is really simple, we just need to tell it how many attributes each vertex has, their formats, the index of the buffer they belong to and the offset and stride.
In this case, we're only passing the position, so that's 1 argument of format float3
.
// Renderer.swift
...
let vertexFunction = library.makeFunction(name: "vertex_main")
let fragmentFunction = library.makeFunction(name: "fragment_main")
// NEW BLOCK START
let vertDesc = MTLVertexDescriptor()
vertDesc.attributes[0].format = .float3
vertDesc.attributes[0].bufferIndex = 0
vertDesc.attributes[0].offset = 0
vertDesc.layouts[0].stride = MemoryLayout<SIMD3<Float>>.stride
// NEW BLOCK END
...
Command Queue
Finally, last step, we need to tell the GPU the commands we want it to perform. We do that through CommandEncoders, grouped into CommandBuffers, lined up into a Command Queue.
We have to setup a command buffer each frame, but we can reuse a single Command Queue, so we'll have it as a class member.
// Renderer.swift
...
public class Renderer : NSObject
{
public var mView: MTKView
private let mPipeline: MTLRenderPipelineStat
private let mCommandQueue: MTLCommandQueue // NEW LINE
public init(view: MTKView)
{
mView = view
// NEW BLOCK START
guard let cq = mView.device?.makeCommandQueue() else
{
fatalError("Could not create command queue")
}
mCommandQueue = cq
// NEW BLOCK END
...
}
Next, in the update method, we setup the commands.
// Renderer.swift
...
private func update()
{
let dataSize = VERTEX_DATA.count * MemoryLayout.size(ofValue: VERTEX_DATA[0])
let vertexBuffer = mView.device?.makeBuffer(bytes: VERTEX_DATA,
length: dataSize,
options: [])
// NEW BLOCK START
let commandBuffer = mCommandQueue.makeCommandBuffer()!
let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: mView.currentRenderPassDescriptor!)
commandEncoder?.setRenderPipelineState(mPipeline)
commandEncoder?.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
commandEncoder?.drawPrimitives(type: .triangle,
vertexStart: 0,
vertexCount: 3,
instanceCount: 1)
commandEncoder?.endEncoding()
commandBuffer.present(mView.currentDrawable!)
commandBuffer.commit()
// NEW BLOCK END
}
π¨ Adding some color
Ok, now we have a nice red triangle. Let's give it some color.
First, add the color to the vertex data:
// Renderer.swift
let VERTEX_DATA: [SIMD3<Float>] =
[
// v0
[ 0.0, 1.0, 0.0 ], // position
[ 1.0, 0.0, 0.0 ], // color
// v1
[-1.0, -1.0, 0.0 ],
[ 0.0, 1.0, 0.0 ],
// v2
[ 1.0, -1.0, 0.0 ],
[ 0.0, 0.0, 1.0 ]
]
Now is when the Vertex Descriptors come in handy. Let them know about the changes in the data structure.
// Renderer.swift
let vertDesc = MTLVertexDescriptor()
vertDesc.attributes[0].format = .float3
vertDesc.attributes[0].bufferIndex = 0
vertDesc.attributes[0].offset = 0
vertDesc.attributes[1].format = .float3 // NEW LINE
vertDesc.attributes[1].bufferIndex = 0 // NEW LINE
vertDesc.attributes[1].offset = MemoryLayout<SIMD3<Float>>.stride // NEW LINE
vertDesc.layouts[0].stride = MemoryLayout<SIMD3<Float>>.stride * 2 // LINE MODIFIED!
The layout stride is now twice the SIMD3<Float>
because each vertex has 2 float3 of data.
And finally, the shader needs some updates.
Add a new struct that will serve as the output of the vertex stage and as the input of the fragment stage.
Also add a new attribute color to the pre-existent VertexIn.
// HelloTriangle.metal
#include <metal_stdlib>
using namespace metal;
struct VertexIn
{
float3 position [[ attribute(0) ]];
float3 color [[ attribute(1) ]];
};
struct VertexOut
{
float4 position [[ position ]];
float3 color;
};
vertex
VertexOut vertex_main(VertexIn vert [[ stage_in ]])
{
VertexOut out;
out.position = float4(vert.position, 1.0f);
out.color = vert.color;
return out;
}
fragment
float4 fragment_main(VertexOut frag [[ stage_in ]])
{
return sqrt(float4(frag.color, 1.0));
}
We do a square root of the color, to perform a simple Gamma correction, but that's totally optional.
From here, the sky is the limit. Add movement, projections, textures, lights, render passes, etc, etc.
Let me know any issues/improvements/feedback in the comments, and happy coding! :D
Top comments (1)
I stopped at "Running this will give us a window with an nice red background." I get an error:
AppDelegate.swift:25:3: error: cannot find 'window' in scope
window?.contentViewController = ViewController()
Any ideas? Unfortunately I'm a beginner.
Here are my files:
cat Package.swift
// swift-tools-version:5.5
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "MetalHelloTriangle",
platforms: [ .macOS(.v12) ],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.executableTarget(
name: "MetalHelloTriangle",
dependencies: []),
.testTarget(
name: "MetalHelloTriangleTests",
dependencies: ["MetalHelloTriangle"]),
]
)
cat Sources/MetalHelloTriangle/main.swift
import Cocoa
let delegate = AppDelegate()
NSApplication.shared.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
cat Sources/MetalHelloTriangle/AppDelegate.swift
import Cocoa
let WIDTH = 800
let HEIGHT = 600
class AppDelegate: NSObject, NSApplicationDelegate
{
private var mWindow: NSWindow?
}
cat Sources/MetalHelloTriangle/ViewController.swift
import Cocoa
class ViewController : NSViewController
{
override func loadView()
{
let rect = NSRect(x: 0, y: 0, width: WIDTH, height: HEIGHT)
view = NSView(frame: rect)
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.red.cgColor
}
}%