DEV Community

Cover image for 🀘🏻 TUTORIAL: Metal HelloTriangle using Swift 5 and no Xcode
Javier Salcedo
Javier Salcedo

Posted on

🀘🏻 TUTORIAL: Metal HelloTriangle using Swift 5 and no Xcode

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
Enter fullscreen mode Exit fullscreen mode

This will create a Hello World executable project with everything you need.
Test it with:

$ swift run
Enter fullscreen mode Exit fullscreen mode

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: [
...
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode
// AppDelegate.applicationDidFinishLaunching
window?.title = "Hello Triangle!"
window?.contentViewController = ViewController() // NEW LINE
window?.makeKeyAndOrderFront(nil)
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Second, pack the .air files into a single .metallib

xcrun metal HelloTriangle.air -o HelloTriangle.metallib
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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.
Command Structure 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
        ...
    }
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

🎨 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 ]
]
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

We do a square root of the color, to perform a simple Gamma correction, but that's totally optional.

Et voilΓ !
Colourful 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


πŸ“š References

Discussion (0)