šŸ¤˜šŸ» TUTORIAL: Metal HelloTriangle using Swift 5 and no Xcode

Javier Salcedo
10 min readApr 6, 2021

Take a look at the code

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.

Metalā€™s command system visualised as shown in Appleā€™s docs

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.

Et voilĆ !

Hello Triangle šŸ¤˜šŸ»šŸ”ŗ

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

Originally published at https://dev.to on April 6, 2021.

--

--