MetalKit + Swift Series

During my time as a developer I have primarily focused on iOS and macOS apps using Swift. Recently I have been actively learning different frameworks provided by Apple, these frameworks include SceneKit, SpriteKit, ARKit, and CoreML. SceneKit is Apple’s 3D graphics framework and is similar to Unity or Unreal. While learning SceneKit, I started realizing how much goes on under the hood of the framework that the developers, like myself, don’t have to worry about. For example in SceneKit you can add simple shaders to objects, that allow you to manipulate vertices or fragments of the SceneKit object. Curious about what else I could do with 3D graphics and shaders; I started researching into Metal, since it is more focused on performance of 3D rendering. Metal is also said to be up-to 10 times faster than OpenGL ES, reducing the time spent using CPU resources. MetalKit allows developers to communicate with the GPU using a Command Queue containing command buffers, which gives you the power of Metal, but removes unneeded boilerplate.

While I am diving into MetalKit, I want to share what I learn along the way! From 2D to 3D I will show you how to build a simple graphical metal application. A great reason to start learning Metal is simply because of shaders. Shaders allow you to communicate near directly with the graphics processing unit or GPU. There are many different ways you can use Metal shaders, for example you can attach shaders to a 3D SceneKit object or even apply a shader in ARKit. This article is for people comfortable with Swift and some knowledge of C++.

MetalKit Introduction

If you are looking for better performance or more draw calls, Metal allows you to gain low-level and low-overhead access to the graphics processing unit or GPU. This will allow you to potentially maximize the graphics and compute potential of your software. MetalKit simplifies the code needed to render graphics, load textures, and work with I/O. MetalKit uses a MTKView to display its output to, we can think of this just like any other view.

In this post we will go over the basic setup of a MetalKit project and drawing a Hello World Triangle.

Start a new macOS Project in Xcode. When creating the project, you do not have to change any settings. We will create a Cocoa App and make sure that your selected language is Swift.

macOS Cocoa App

MetalView.swift

After you create the basic project, delete ViewController.swift and create a new Cocoa Class called MetalView.swift. Make it subclass from MTKView.

Create MetalView

To fix the error simply change Cocoa to MetalKit.

"Import

 

"Import

 

Next go into the main.storyboard and select the view. Give the view the class of MetalView

Note the view is inside the ViewController

The view is inside the View Controller

Inside the identity inspector

Make the view's class MetalView

Now go back to the MetalView.swift and we are going to add an init to the class.

required init(coder: NSCoder) {
    super.init(coder: coder)

    // Make sure we are on a device that can run metal!
    guard let defaultDevice = MTLCreateSystemDefaultDevice() else {
        fatalError("Device loading error")
    }
    device = defaultDevice
    colorPixelFormat = .bgra8Unorm
    // Our clear color, can be set to any color
    clearColor = MTLClearColor(red: 0.1, green: 0.57, blue: 0.25, alpha: 1)
}

Renderer.swift

Now it is time to create the Renderer. Create a new NSObject class called Renderer. Again reminder to replace Cocoa with MetalKit for the import statement! We are going to treat the Renderer as the MTKViewDelegate.

Objects that inherit from MTKViewDelegate are allowed to provide a drawing method to a MTKView and respond to rendering events without subclassing MTKView.

Create the Renderer Class

Subclass from NSObject

We will start the Renderer class with some variables we need. The Vertex struct will be used to while passing data to the Metal Shader Functions and GPU.

struct Vertex {
    var position: float3
    var color: float4
}

class Renderer: NSObject {
    var commandQueue: MTLCommandQueue!
    var renderPipelineState: MTLRenderPipelineState!
    var vertexBuffer: MTLBuffer!
    var vertices: [Vertex] = [
        Vertex(position: float3(0,1,0), color: float4(1,0,0,1)),
        Vertex(position: float3(-1,-1,0), color: float4(0,1,0,1)),
        Vertex(position: float3(1,-1,0), color: float4(0,0,1,1))
    ]
//...

var commandQueue: MTLCommandQueue

The is the object that handles queuing an ordered list of command buffers for the Metal device to execute. The MTLCommandQueue is thread safe and allows multiple outstanding command buffer to be encoded simultaneously.

var renderPipelineState: MTLRenderPipelineState

An object that contains the graphics functions and configuration state used in a render pass. The MTLRenderPipelineState protocol defines the interface for a lightweight object used to encode the state for a configured graphics rendering pipeline.

var vertexBuffer: MTLBuffer

A memory allocation for storing unformatted data that is accessible to the GPU.
The MTLBuffer protocol defines the interface for objects that represent an allocation of unformatted, device-accessible memory that can contain any type of data. Your app does not define classes that implement this protocol.

var vertices: [Vertex]

These are the points of the object we are going to draw the the screen. Every point has a color value attached to it. Currently, it is hardcoded to be a triangle.

Now we will start on the init method of the Renderer. The device will help create most of the variables we need.

init(device: MTLDevice) {
    super.init()
    createCommandQueue(device: device)
    createPipelineState(device: device)
    createBuffers(device: device)
}

Create Command Queue is trivial, since the device passed in can make a command queue.

func createCommandQueue(device: MTLDevice) {
    commandQueue = device.makeCommandQueue()
}

Create Pipeline State is more complex, because we need to pass in the shader functions and update the render pipeline descriptor and state.

func createPipelineState(device: MTLDevice) {
    // The device will make a library for us
    let library = device.makeDefaultLibrary()
    // Our vertex function name
    let vertexFunction = library?.makeFunction(name: "basic_vertex_function")
    // Our fragment function name
    let fragmentFunction = library?.makeFunction(name: "basic_fragment_function")
    // Create basic descriptor
    let renderPipelineDescriptor = MTLRenderPipelineDescriptor()
    // Attach the pixel format that is the same as the MetalView
    renderPipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
    // Attach the shader functions
    renderPipelineDescriptor.vertexFunction = vertexFunction
    renderPipelineDescriptor.fragmentFunction = fragmentFunction
    // Try to update the state of the renderPipeline
    do {
        renderPipelineState = try device.makeRenderPipelineState(descriptor: renderPipelineDescriptor)
    } catch {
        print(error.localizedDescription)
    }
}

Now we can create the buffers with the device. Finding the length is simple when using MemoryLayout.

func createBuffers(device: MTLDevice) {
    vertexBuffer = device.makeBuffer(bytes: vertices,
length: MemoryLayout<Vertex>.stride * vertices.count,
options: [])
}

Finally we just need to extend Renderer. Make an extension for Renderer of MTKViewDelegate.

extension Renderer: MTKViewDelegate {
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}

    func draw(in view: MTKView) {
         // Get the current drawable and descriptor
         guard let drawable = view.currentDrawable,
         let renderPassDescriptor = view.currentRenderPassDescriptor else {return}
         // Create a buffer from the commandQueue
         let commandBuffer = commandQueue.makeCommandBuffer()
         let commandEncoder = commandBuffer?.makeRenderCommandEncoder(descriptor: renderPassDescriptor)
         commandEncoder?.setRenderPipelineState(renderPipelineState)
         // Pass in the vertexBuffer into index 0
         commandEncoder?.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
         // Draw primitive at vertexStart 0
         commandEncoder?.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: vertices.count)
         commandEncoder?.endEncoding()
         commandBuffer?.present(drawable)
         commandBuffer?.commit()
    }
}

MetalView + Renderer

After you have done this, we can go back to MetalView and add a Renderer now!

 

class MetalView: MTKView {
    var renderer: Renderer!
    // ...
    init() {
        // ...
        createRenderer(device: defaultDevice)
    }
    func createRenderer(device: MTLDevice){
        renderer = Renderer(device: device)
        delegate = renderer
    }
}

Metal

Metal is a C++ based programming language used primarily for GPU based graphics or general data-parallel computation. Metal allows developers to write a graphics and compute program with a single language.

Helpful Links

Shaders.metal

Now it is time to add the basic shader functions! Create a new file and this time make it a Metal file. Name it Shaders and add in these lines.

#include <metal_stdlib>
using namespace metal;

// Basic Struct to match our Swift type
// This is what is passed into the Vertex Shader
struct VertexIn {
    float3 position;
    float4 color;
};
// What is returned by the Vertex Shader
// This is what is passed into the Fragment Shader
struct VertexOut {
    float4 position [[ position ]];
    float4 color;
};

After you have added in those basic structs, we can implement the basic functions. For the Vertex function it will simply take a VertexIn and a VertexID. It will return a VertexOut with the color and position set.

vertex VertexOut basic_vertex_function(const device VertexIn *vertices [[ buffer(0) ]],
uint vertexID [[ vertex_id ]]) {
    VertexOut vOut;
    vOut.position = float4(vertices[vertexID].position,1);
    vOut.color = vertices[vertexID].color;
    return vOut;
}

For the Fragment Shader we simply will use the color that the Vertex Shader set.

fragment float4 basic_fragment_function(VertexOut vIn [[ stage_in ]]) {
    return vIn.color;
}

And hopefully after that you should be able to run it and see a triangle! You can clone the project on Github.

Hello World Triangle

Hello World Triangle

Swift Developer with a passion to turn coffee into code.

Contact