MetalKit Introduction

This is part 2 of a MetalKit and Swift multi-part series, it may help to read part 1. For part 2 we will continue development with the code from part 1. Our goal is to create a rotating cube. In the next part, we will introduce lighting and input detection. To do this we will need to create some new classes to act as objects and modify some existing code from part 1. If you are interested about diving into other MetalKit and Swift development, check out my friend’s channel.

Node.swift

First, we shall create a file called Node which will act as our base object class. Nodes can have children nodes and will be able to render all of them.

import MetalKit

class Node {
    var childern: [Node] = []
    
    func add(child: Node) {
        childern.append(child)
    }
    
    func render(commandEncoder: MTLRenderCommandEncoder, deltaTime: Float) {
        childern.forEach{ $0.render(commandEncoder: commandEncoder, deltaTime: deltaTime) }
    }
}

Primitive.swift

Second, we will need to create a file named Primitive which will handle MTLBuffers and MTLStates. Primitives will be used to create objects inside the Scene. We will create a Cube object later.

import MetalKit

class Primitive: Node {
    // Buffers
    var vertexBuffer: MTLBuffer!
    var indexBuffer: MTLBuffer!
    // BufferData
    var vertices: [Vertex]!
    var indices: [UInt16]!
    // States
    var renderPipelineState: MTLRenderPipelineState!
    var depthStencilState: MTLDepthStencilState!
    // Constraints
    var modelConstraints = ModelConstraints()
    // ...

var depthStencilState: MTLDepthStencilState

A depth and stencil state object that specifies the depth and stencil configuration and operations used in a render pass. The MTLRenderCommandEncoder uses a MTLDepthStencilState object to set the depth and stencil state for a rendering pass.

Now we will create the init method of the Primitive. Similar to the Renderer in part 1, we will use the device to help us create most of the objects we need.

init(withDevice device: MTLDevice) {
        super.init()
        buildVertices()
        buildBuffers(device: device)
        buildPipelineState(device: device)
        buildDepthStencil(device: device)
}

Build Vertices will create the vertices and indices array for the object. It will be overridden from objects that inherit from Primitive.

public func buildVertices() {
    
}

Build Buffers we will use the method makeBuffer. This will take in three parameters, bytes, length, and options.

makeBuffer(bytes:length:options:)

Allocates a new buffer of a given length and initializes its contents by copying existing data into it.

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

Build Pipeline State will set up the vertex and fragment shader functions. We will also define a VertextDescriptor, which will help the Metal vertex shader function, to better understand what we want to pass to it.

private func buildPipelineState(device: MTLDevice) {
        let library = device.makeDefaultLibrary()
        // Retrieve the shader functions
        let vertexFunction = library?.makeFunction(name: "basic_vertex_function")
        let fragmentFunction = library?.makeFunction(name: "basic_fragment_function")
        
        // Create the renderPiplineDescriptor
        let renderPipelineDescriptor = MTLRenderPipelineDescriptor()
        renderPipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
        renderPipelineDescriptor.vertexFunction = vertexFunction
        renderPipelineDescriptor.fragmentFunction = fragmentFunction
        renderPipelineDescriptor.depthAttachmentPixelFormat = .depth32Float
        
        // Create the VertexDescriptor
        let vertexDescriptor = MTLVertexDescriptor()
        vertexDescriptor.attributes[0].bufferIndex = 0
        vertexDescriptor.attributes[0].format = .float3
        vertexDescriptor.attributes[0].offset = 0
        
        vertexDescriptor.attributes[1].bufferIndex = 0
        vertexDescriptor.attributes[1].format = .float4
        vertexDescriptor.attributes[1].offset = MemoryLayout<float3>.size
        
        vertexDescriptor.layouts[0].stride = MemoryLayout<Vertex>.stride
        
        // Create the PipelineState
        renderPipelineDescriptor.vertexDescriptor = vertexDescriptor
        
        do {
            renderPipelineState = try device.makeRenderPipelineState(descriptor: renderPipelineDescriptor)
        } catch {
            print(error.localizedDescription)
        }
}

MTLVertexDescriptor

An object that describes how vertex data is organized and mapped to a vertex function.

Build Depth Stencil will create an MTLDepthStencilDescriptor. Which we will use to create the Depth Stencil.

private func buildDepthStencil(device: MTLDevice) {
        let depthStencilDescriptor = MTLDepthStencilDescriptor()
        depthStencilDescriptor.isDepthWriteEnabled = true
        depthStencilDescriptor.depthCompareFunction = .less
        depthStencilState = device.makeDepthStencilState(descriptor: depthStencilDescriptor)
}

Finally, we have the Render function which deals with the Command Encoder. This is used inside the Renderer’s draw function and will be called for every Node in the scene. We will pass most of the values we built into the Command Encoder.

override func render(commandEncoder: MTLRenderCommandEncoder, deltaTime: Float) {
        commandEncoder.setRenderPipelineState(renderPipelineState)
        super.render(commandEncoder: commandEncoder, deltaTime: deltaTime)
        commandEncoder.setVertexBuffer(vertexBuffer,
                                       offset: 0,
                                       index: 0)
        commandEncoder.setDepthStencilState(depthStencilState)
        commandEncoder.setVertexBytes(&modelConstraints, length: MemoryLayout<ModelConstraints>.stride, index: 1)
        commandEncoder.drawIndexedPrimitives(type: .triangle,
                                             indexCount: indices.count,
                                             indexType: .uint16,
                                             indexBuffer: indexBuffer,
                                             indexBufferOffset: 0)
}

Types.swift

This is where we will create our ModelConstraints, SceneConstraints, and Constraintable. Also, we will move the Vertex struct we made from part 1.

import MetalKit

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

struct ModelConstraints {
    var modelMatrix = matrix_identity_float4x4
}

struct SceneConstraints {
    var projectionMatrix = matrix_identity_float4x4
}

protocol Constraintable {
    func scale(axis: float3)
    
    func translate(direction: float3)
    
    func rotate(angle: Float, axis: float3)
}

We will use the Constraints to modify the values of the object in the Scene. We will need to create the math functions for scale, translate, and rotate.

Math.swift

import MetalKit

extension Float {
    var rads: Float {
        return (self / 180) * Float.pi
    }
}

extension matrix_float4x4 {
    
    init(degreesFov: Float, aspectRatio: Float, nearZ: Float, farZ: Float) {
        let fov = degreesFov.rads
        
        let y = 1 / tan(fov * 0.5)
        let x = y / aspectRatio
        let z1 = farZ / (nearZ - farZ)
        let w = (z1 * nearZ)
        
        columns = (
            float4(x,0,0,0),
            float4(0,y,0,0),
            float4(0,0,z1,-1),
            float4(0,0,w,0)
        )
    }
    
    mutating func scale(axis: float3) {
        var result = matrix_identity_float4x4
        
        let x,y,z :Float
        (x,y,z) = (axis.x,axis.y,axis.z)
        
        result.columns = (
            float4(x,0,0,0),
            float4(0,y,0,0),
            float4(0,0,z,0),
            float4(0,0,0,1)
        )
        
        self = matrix_multiply(self, result)
    }
    
    mutating func rotate(angle: Float, axis: float3) {
        var result = matrix_identity_float4x4
        
        let x,y,z :Float
        (x,y,z) = (axis.x,axis.y,axis.z)
        let c: Float = cos(angle)
        let s: Float = sin(angle)
        
        let mc: Float = (1 - c)
        
        let r1c1 = x * x * mc + c
        let r2c1 = x * y * mc + z * s
        let r3c1 = x * z * mc - y * s
        let r4c1: Float = 0.0
        
        let r1c2 = y * x * mc - z * s
        let r2c2 = y * y * mc + c
        let r3c2 = y * z * mc + x * s
        let r4c2: Float = 0.0
        
        let r1c3 = z * x * mc + y * s
        let r2c3 = z * y * mc - x * s
        let r3c3 = z * z * mc + c
        let r4c3: Float = 0.0
        
        let r1c4: Float = 0.0
        let r2c4: Float = 0.0
        let r3c4: Float = 0.0
        let r4c4: Float = 1.0
        
        result.columns = (
            float4(r1c1,r2c1,r3c1,r4c1),
            float4(r1c2,r2c2,r3c2,r4c2),
            float4(r1c3,r2c3,r3c3,r4c3),
            float4(r1c4,r2c4,r3c4,r4c4)
        )
        
        self = matrix_multiply(self, result)
    }
    
    mutating func translate(direction: float3) {
        var result = matrix_identity_float4x4
        
        let x,y,z :Float
        (x,y,z) = (direction.x,direction.y,direction.z)
        
        result.columns = (
            float4(1,0,0,0),
            float4(0,1,0,0),
            float4(0,0,1,0),
            float4(x,y,z,1)
        )
        
        self = matrix_multiply(self, result)
    }
}

Now that we have added the Types and implemented the Math functions. We can extend our Primitive object by adding the following lines.

extension Primitive: Constraintable {
    func scale(axis: float3) {
        modelConstraints.modelMatrix.scale(axis: axis)
    }
    
    func translate(direction: float3) {
        modelConstraints.modelMatrix.translate(direction: direction)
    }
    
    func rotate(angle: Float, axis: float3) {
        modelConstraints.modelMatrix.rotate(angle: angle, axis: axis)
    }
}

Scene.swift

Now we will create a Scene object that will contain Primitive objects.

import MetalKit

class Scene: Node {
    var device: MTLDevice!
    var sceneConstraints = SceneConstraints()
    var objects: [Primitive] = []
    
    init(device: MTLDevice) {
        self.device = device
        super.init()
        sceneConstraints.projectionMatrix = matrix_float4x4(degreesFov: 45, aspectRatio: 1, nearZ: 0.1, farZ: 100)
    }
    
    override func render(commandEncoder: MTLRenderCommandEncoder, deltaTime: Float) {
        commandEncoder.setVertexBytes(&sceneConstraints, length: MemoryLayout<SceneConstraints>.stride, index: 2)
        super.render(commandEncoder: commandEncoder, deltaTime: deltaTime)
    }
}

Cube.swift

Now we will create our first Primitive object. It is very simple, due to the fact that we only need to define one function, buildVertices.

import MetalKit

class Cube: Primitive {
    
    override func buildVertices() {
        vertices = [
            Vertex(position: float3(-1,1,1), color: float4(1,0,0,1)),
            Vertex(position: float3(-1,-1,1), color: float4(0,1,0,1)),
            Vertex(position: float3(1,1,1), color: float4(0,0,1,1)),
            Vertex(position: float3(1,-1,1), color: float4(1,0,1,1)),
            Vertex(position: float3(-1,1,-1), color: float4(1,1,0,1)),
            Vertex(position: float3(1,1,-1), color: float4(0,1,1,1)),
            Vertex(position: float3(-1,-1,-1), color: float4(0.5,0.5,0,1)),
            Vertex(position: float3(1,-1,-1), color: float4(1,0,0.5,1))
        ]
        indices = [
            0,1,2,  2,1,3, //Front
            5,2,3,  5,3,7,
            0,2,4,  2,5,4,
            0,1,4,  4,1,6,
            5,4,6,  5,6,7,
            3,1,6,  3,6,7
            
        ]
    }
}

CubeScene.swift

import MetalKit

class CubeScene: Scene {
    override init(device: MTLDevice) {
        super.init(device: device)
        // Create the Cube
        let c = Cube(withDevice: device)
        objects.append(c)
        // Move the Cube away from the camera
        c.translate(direction: float3(0,0,-6))
        // Add the Cube to the Scene
        add(child: c)
    }
    
    override func render(commandEncoder: MTLRenderCommandEncoder, deltaTime: Float) {
        // Rotate the objects in the Scene
        objects.forEach{ $0.rotate(angle: deltaTime, axis: float3(1,1,0)) }
        super.render(commandEncoder: commandEncoder, deltaTime: deltaTime)
    }
}

Updating Renderer.swift

Now we can update the Renderer’s variables and remove some unneeded code!

class Renderer: NSObject {
    var commandQueue: MTLCommandQueue!
    var scenes: [Scene] = []
    // ...

Next, we will only need these methods for the init

init(device: MTLDevice) {
        super.init()
        createCommandQueue(device: device)
        scenes.append(CubeScene(device: device))
}
    
    func createCommandQueue(device: MTLDevice) {
        commandQueue = device.makeCommandQueue()
}

Finally, we will change the draw function by replacing the following lines

// ...
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)
// ...

With

// ...
let deltaTime = 1 / Float(view.preferredFramesPerSecond)
        
scenes.forEach{ $0.render(commandEncoder: commandEncoder!,
                               deltaTime: deltaTime) }
// ...

Updating MetalView.swift

Inside MetalView, we will simply add one line. depthStencilPixelFormat = .depth32Float

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
        depthStencilPixelFormat = .depth32Float // Add this line!
        colorPixelFormat = .bgra8Unorm
        // Our clear color, can be set to any color
        clearColor = MTLClearColor(red: 0.1, green: 0.57, blue: 0.25, alpha: 1)
        createRenderer(device: defaultDevice)
}

Updating Shaders.metal

The first update we will do to the Shaders is to add what is known as Vertex Attributes.

Vertex Attributes

A vertex function can read per-vertex inputs by indexing into a buffer(s) passed as arguments to
the vertex function using the vertex and instance IDs. In addition, per-vertex inputs can also be
passed as an argument to a vertex function by declaring them with the [[stage_in]] attribute.
(Page 66)
struct VertexIn {
    float3 position [[ attribute(0) ]];
    float4 color [[ attribute(1) ]];
};

Next we will add two simple structs for the constraints we made in Swift.

struct ModelConstraints {
    float4x4 modelMatrix;
};

struct SceneConstraints {
    float4x4 projectionMatrix;
};

Finally, we will update the basic_vertex_function to use the scene and model constraints.

vertex VertexOut basic_vertex_function(VertexIn vIn [[ stage_in ]],
                                       constant ModelConstraints &modelConstants [[ buffer(1) ]],
                                       constant SceneConstraints &sceneConstants [[ buffer(2) ]]) {
    VertexOut vOut;
    vOut.position = sceneConstants.projectionMatrix * modelConstants.modelMatrix * float4(vIn.position,1);
    vOut.color = vIn.color;
    return vOut;
}

Hopefuly when you run it you should see a rotating cube! If you had any issues or want to view the full source code check out this GitHub page!

Rotating Cube

Swift Developer with a passion to turn coffee into code. Currently diving into Objective-C and Swift using MetalKit, ARKit, and SceneKit. Feel free to follow me on GitLab or join my open source agile development team oneleif.

Contact