Metal Rendering Pipeline Tutorial
Take a deep dive through the rendering pipeline and create a Metal app that renders primitives on screen, in this excerpt from our book, Metal by Tutorials! By Marius Horga.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Metal Rendering Pipeline Tutorial
40 mins
Initialization
First, you need to set up the Metal environment.
Metal has a major advantage over OpenGL in that you’re able to instantiate some objects up-front rather than create them during each frame. The following diagram indicates some of the objects you can create at the start of the app.
-
MTLDevice
: The software reference to the GPU hardware device. -
MTLCommandQueue
: Responsible for creating and organizingMTLCommandBuffer
s each frame. -
MTLLibrary
: Contains the source code from your vertex and fragment shader functions. -
MTLRenderPipelineState
: Sets the information for the draw, like which shader functions to use, what depth and color settings to use and how to read the vertex data. -
MTLBuffer
: Holds data, such as vertex information, in a form that you can send to the GPU.
Typically, you’ll have one MTLDevice
, one MTLCommandQueue
and one MTLLibrary
object in your app. You’ll also have several MTLRenderPipelineState
objects that will define the various pipeline states, as well as several MTLBuffer
s to hold the data.
Before you can use these objects, however, you need to initialize them. Add these properties to Renderer
:
static var device: MTLDevice! static var commandQueue: MTLCommandQueue! var mesh: MTKMesh! var vertexBuffer: MTLBuffer! var pipelineState: MTLRenderPipelineState!
These are the properties you need to keep references to the different objects. They are currently all implicitly unwrapped optionals for convenience, but you can change this after you’ve completed the initialization. Also, you won’t need to keep a reference to the MTLLibrary
, so there’s no need to create it.
Next, add this code to init(metalView:)
before super.init()
:
guard let device = MTLCreateSystemDefaultDevice() else { fatalError("GPU not available") } metalView.device = device Renderer.commandQueue = device.makeCommandQueue()!
This initializes the GPU and creates the command queue. You’re using class properties for the device and the command queue to ensure that only one of each exists. In rare cases, you may require more than one — but in most apps, one will be plenty.
Finally, after super.init()
, add this code:
metalView.clearColor = MTLClearColor(red: 1.0, green: 1.0, blue: 0.8, alpha: 1.0) metalView.delegate = self
This sets metalView.clearColor
to a cream color. It also sets Renderer
as the delegate for metalView
so that it calls the MTKViewDelegate
drawing methods.
Build and run the app to make sure everything’s set up and working. If all’s well, you should see a plain gray window. In the debug console, you’ll see the word “draw” repeatedly. Use this to verify that your app is calling draw(in:)
for every frame.
metalView
’s cream color because you’re not asking the GPU to do any drawing yet.
Set Up the Data
A class to build 3D primitive meshes is always useful. In this tutorial, you’ll set up a class for creating 3D shape primitives, and you’ll add a cube to it.
Create a new Swift file named Primitive.swift and replace the default code with this:
import MetalKit class Primitive { class func makeCube(device: MTLDevice, size: Float) -> MDLMesh { let allocator = MTKMeshBufferAllocator(device: device) let mesh = MDLMesh(boxWithExtent: [size, size, size], segments: [1, 1, 1], inwardNormals: false, geometryType: .triangles, allocator: allocator) return mesh } }
This class method returns a cube.
In Renderer.swift, in init(metalView:)
, before calling super.init()
, set up the mesh:
let mdlMesh = Primitive.makeCube(device: device, size: 1) do { mesh = try MTKMesh(mesh: mdlMesh, device: device) } catch let error { print(error.localizedDescription) }
Then, set up the MTLBuffer
that contains the vertex data you’ll send to the GPU.
vertexBuffer = mesh.vertexBuffers[0].buffer
This puts the data in an MTLBuffer
. Now, you need to set up the pipeline state so that the GPU will know how to render the data.
First, set up the MTLLibrary
and ensure that the vertex and fragment shader functions are present.
Continue adding code before super.init()
:
let library = device.makeDefaultLibrary() let vertexFunction = library?.makeFunction(name: "vertex_main") let fragmentFunction = library?.makeFunction(name: "fragment_main")
You’ll create these shader functions later in this tutorial. Unlike OpenGL shaders, these are compiled when you compile your project which is more efficient than compiling on the fly. The result is stored in the library.
Now, create the pipeline state:
let pipelineDescriptor = MTLRenderPipelineDescriptor() pipelineDescriptor.vertexFunction = vertexFunction pipelineDescriptor.fragmentFunction = fragmentFunction pipelineDescriptor.vertexDescriptor = MTKMetalVertexDescriptorFromModelIO(mdlMesh.vertexDescriptor) pipelineDescriptor.colorAttachments[0].pixelFormat = metalView.colorPixelFormat do { pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor) } catch let error { fatalError(error.localizedDescription) }
This sets up a potential state for the GPU. The GPU needs to know its complete state before it can start managing vertices. You set the two shader functions the GPU will call, and you also set the pixel format for the texture to which the GPU will write.
You also set the pipeline’s vertex descriptor. This is how the GPU will know how to interpret the vertex data that you’ll present in the mesh data MTLBuffer
.
If you need to call different vertex or fragment functions, or use a different data layout, then you’ll need more pipeline states. Creating pipeline states is relatively time-consuming which is why you do it up-front, but switching pipeline states during frames is fast and efficient.
The initialization is complete and your project will compile. However, if you try to run it, you’ll get an error because you haven’t yet set up the shader functions.
Render Frames
In Renderer.swift, replace the print
statement in draw(in:)
with this code:
guard let descriptor = view.currentRenderPassDescriptor, let commandBuffer = Renderer.commandQueue.makeCommandBuffer(), let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { return } // drawing code goes here renderEncoder.endEncoding() guard let drawable = view.currentDrawable else { return } commandBuffer.present(drawable) commandBuffer.commit()
This sets up the render command encoder and presents the view’s drawable texture to the GPU.
Drawing
On the CPU side, to prepare the GPU, you need to give it the data and the pipeline state. Then, you need to issue the draw call.
Still in draw(in:)
, replace the comment:
// drawing code goes here
with:
renderEncoder.setRenderPipelineState(pipelineState) renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) for submesh in mesh.submeshes { renderEncoder.drawIndexedPrimitives(type: .triangle, indexCount: submesh.indexCount, indexType: submesh.indexType, indexBuffer: submesh.indexBuffer.buffer, indexBufferOffset: submesh.indexBuffer.offset) }
When you commit the command buffer at the end of draw(in:)
, this indicates to the GPU that the data and the pipeline are all set up and the GPU can take over.