Swift x Metal for 3D Graphics Rendering Part 4: Drawing 3D Shapes And The Matrix
Special Shoutout to @Caroline Begbie. As I was doing research for how to implement 3D Shapes in Metal with Swift Matrices. There was one name that showed up in most YouTube videos, Articles, Support Forums and Books. That being Caroline. So I’m without a doubt certain, that without her contribution to the Metal community. This article wouldn’t be made. Moreover, Caroline has a book and course on the topic that both seem super helpful for any learners. Moreover, the source code for the book is publicly available if you’re only interested in reading it.
- Kodeco’s (Ray Wenderlich) Metal Course Taught by Caroline
- Old but FREE Kodeco Course on YouTube Taught by Caroline
- Metal by Tutorials book Source Code on GitHub
- Metal By Tutorials Book (Kodeco)
Show Me The Code
Rendering in 3D isn’t that much different than rendering in 2D. Essentially, we need to modify our initial project in two ways to achieve it. Our set of vertices need to represent a 3D Obeject and we need a set of matrices telling our GPU how to make a 3D Object on a 2D Screen.
1. Vertices
We can get the vertices needed to draw a 3D object in a handful of ways. But for now, I will add a “Make Cube” function in our Mesh Builder.
func makeCube() -> Mesh {
let s = Float(0.5)
let vertices: [Vertex] = [
Vertex(position: SIMD4<Float>(-s, -s, s, 1), color: SIMD3<Float>(1, 0, 0)),
Vertex(position: SIMD4<Float>( s, -s, s, 1), color: SIMD3<Float>(0, 1, 0)),
Vertex(position: SIMD4<Float>( s, s, s, 1), color: SIMD3<Float>(0, 0, 1)),
Vertex(position: SIMD4<Float>(-s, s, s, 1), color: SIMD3<Float>(1, 1, 0)),
Vertex(position: SIMD4<Float>(-s, -s, -s, 1), color: SIMD3<Float>(1, 0, 1)),
Vertex(position: SIMD4<Float>( s, -s, -s, 1), color: SIMD3<Float>(0, 1, 1)),
Vertex(position: SIMD4<Float>( s, s, -s, 1), color: SIMD3<Float>(1, 1, 1)),
Vertex(position: SIMD4<Float>(-s, s, -s, 1), color: SIMD3<Float>(0, 0, 0))
]
let indices: [UInt16] = [
0,1,2,2,3,0,
1,5,6,6,2,1,
5,4,7,7,6,5,
4,0,3,3,7,4,
3,2,6,6,7,3,
4,5,1,1,0,4
]
let vb = device.makeBuffer(bytes: vertices, length: vertices.count * MemoryLayout<Vertex>.stride, options: [])!
let ib = device.makeBuffer(bytes: indices, length: indices.count * MemoryLayout<UInt16>.stride, options: [])!
return Mesh(vertexBuffer: vb, indexBuffer: ib, indexCount: indices.count)
}
To draw this, it will be quite literally identical to drawing our quad or triangle from the last part but with a different Mesh. In your Renderer’s Draw call, we’ll have:
func draw(in view: MTKView) {
...
renderEncoder.setVertexBuffer(cube.vertexBuffer, offset: 0, index: 0)
renderEncoder.drawIndexedPrimitives(
type: .triangle,
indexCount: cube.indexCount,
indexType: .uint16,
indexBuffer: cube.indexBuffer,
indexBufferOffset: 0)
...
renderEncoder.endEncoding()
}
However, if you run this, you’ll surely be disappointed and see your screen filled with the cube’s colors or nothing at all. But why? There’s a short and long answer to both of this question but essentially, its a lot of math. @GetIntoGameDev, @Caroline Begbie and others have very detailed explanations of this. But for now, just now we need to create some matrices.
2. The Matrix
Firstly, let’s add Struct to our bridging header. We’ll call it “Uniforms.” This is how we will send our matrices to the GPU and create them on the CPU side.
//In your .h file
#include <simd/simd.h>
struct Uniforms{
matrix_float4x4 modelMatrix;
matrix_float4x4 viewMatrix;
matrix_float4x4 projectionMatrix;
};
In the Renderer class. We want to add a property called Uniforms and then initialize it in the class’s init. Afterwards, we will create the projection matrix once we know the View’s size but before passing it to the GPU. At this point, we transition to the draw function and send the Uniforms.
class Renderer : NSObject, MTKViewDelegate {
//Other properties
var uniforms : Uniforms
...
init(_ parent : ContentView) {
...
uniforms = setUpUniforms()
...
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
//Create the Projection Matrix Using the CGSize from the View
let aspect = Float(view.bounds.width) / Float(view.bounds.height)
let projectionMatrix =
createFloat4x4Projection(
projectionFov: Float(70).degreesToRadians,
near: 0.1,
far: 100,
aspect: aspect)
uniforms.projectionMatrix = projectionMatrix
}
func draw(in view: MTKView) {
....
// Send the Uniforms to the Shaders
renderEncoder.setVertexBytes(&uniforms,
length: MemoryLayout<Uniforms>.stride,
index: 1)
...
}
//Basic and Hardcoded Math Functions
func createFloat4x4Projection(projectionFov fov: Float, near: Float, far: Float, aspect: Float) -> float4x4{
let y = 1 / tan(fov * 0.5)
let x = y / aspect
let z = far / (far - near)
let X = SIMD4<Float>( x, 0, 0, 0)
let Y = SIMD4<Float>( 0, y, 0, 0)
let Z = SIMD4<Float>( 0, 0, z, 1)
let W = SIMD4<Float>( 0, 0, z * -near, 0)
return float4x4(columns: (X, Y, Z, W))
}
func setUpUniforms() -> Uniforms{
var uniforms = Uniforms()
let translation = float4x4(
SIMD4<Float>(1, 0, 0, 0),
SIMD4<Float>(0, 1, 0, 0),
SIMD4<Float>(0, 0, 1, 0),
SIMD4<Float>(0, 0, 0, 1)
)
let angle = Float.pi / 6
let rotation = float4x4(
SIMD4<Float>(cos(angle), 0, sin(angle), 0),
SIMD4<Float>(0, 1, 0, 0),
SIMD4<Float>(-sin(angle), 0, cos(angle), 0),
SIMD4<Float>(0, 0, 0, 1)
)
let modelMatrix = matrix_multiply(translation, rotation)
let viewTranslation = float4x4(
SIMD4<Float>(1, 0, 0, 0),
SIMD4<Float>(0, 1, 0, 0),
SIMD4<Float>(0, 0, 1, 0),
SIMD4<Float>(0, 0, -2, 1)
)
let viewMatrix = viewTranslation.inverse
uniforms.modelMatrix = modelMatrix
uniforms.viewMatrix = viewMatrix
return uniforms
}
For convenience purposes, I will use a simple function called Create Uniforms which is very basic and has values hardcoded. However, in production, you’d likely create a set of functions to do common matrix graphics math for you. Both Caroline and GetIntoGameDev have publicly availably Swift functions showcasing this. I’d honestly opt for this if you’re making a personal project. If you want an deep understanding of what’s going on. I’d say write your own. You’ll most likely use Apple’s SIMD Library.
Here’s the updated Vertex Shader:
VertexOutput vertex vertexMain(const device Vertex* vertices [[buffer(0)]],
uint vertexID [[vertex_id]],
constant Uniforms &uniforms [[buffer(1)]])
{
Vertex v = vertices[vertexID];
VertexOutput data;
float4 position = uniforms.projectionMatrix * uniforms.viewMatrix * uniforms.modelMatrix * v.position;
data.position = position;
data.color = half3(v.color);
return data;
}
At this point. We should have a cube but it looks kinda weird.
To fix this we need to enable back culling (and possibly depth testing)
func draw(in view: MTKView) {{
....
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
//After creating the render encoder, we set Cull Mode to back
renderEncoder.setCullMode(.back)
....
}
Congratulations! You now have a 3D Cube. If you wanted to have a 3D Model. We’d use a very similar process. But instead of using the 3D Cube we created. The vertices we load would come from a .obj file or some other source.