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.

Leave a rating/review
Download materials
Save for later
Share

This is an excerpt taken from Chapter 3, “The Rendering Pipeline”, of our book Metal by Tutorials. This book will introduce you to graphics programming in Metal — Apple’s framework for programming on the GPU. You’ll build your own game engine in Metal where you can create 3D scenes and build your own 3D games. Enjoy!

In this tutorial, you’ll take a deep dive through the rendering pipeline and create a Metal app that renders a red cube. Along the way, you’ll discover all of the hardware chips responsible for taking the 3D objects and turning them into the gorgeous pixels that you see on the screen.

The GPU and the CPU

All computers have a Central Processing Unit (CPU) that drives the operations and manages the resources on a computer. They also have a Graphics Processing Unit (GPU).

A GPU is a specialized hardware component that can process images, videos and massive amounts of data really fast. This is called throughput. The throughput is measured by the amount of data processed in a specific unit of time.

A CPU, on the other hand, can’t handle massive amounts of data really fast, but it can process many sequential tasks (one after another) really fast. The time necessary to process a task is called latency.

The ideal set up includes low latency and high throughput. Low latency allows for the serial execution of queued tasks so the CPU can execute the commands without the system becoming slow or unresponsive; and high throughput lets the GPU render videos and games asynchronously without stalling the CPU. Because the GPU has a highly parallelized architecture, specialized in doing the same task repeatedly, and with little or no data transfers, it’s able to process larger amounts of data.

The following diagram shows the major differences between the CPU and the GPU.

The CPU has a large cache memory and a few Arithmetic Logic Unit (ALU) cores. The low latency cache memory on the CPU is used for fast access to temporary resources. The GPU does not have much cache memory and there’s room for more ALU cores which only do calculations without saving partial results to memory.

Also, the CPU typically only has a handful of cores while the GPU has hundreds — even thousands of cores. With more cores, the GPU can split the problem into many smaller parts, each running on a separate core in parallel, thus hiding latency. At the end of processing, the partial results are combined and the final result returned to the CPU. But cores aren’t the only thing that matters!

Besides being slimmed down, GPU cores also have special circuitry for processing geometry and are often called shader cores. These shader cores are responsible for the beautiful colors you see on the screen. The GPU writes a whole frame at a time to fit the entire rendering window. It will then proceed to rendering the next frame as quickly as possible to maintain a good frame rate.

The CPU continues to issue commands to the GPU to keep it busy, but at some point, either the CPU will finish sending commands or the GPU will finish processing the commands it received. To avoid stalling, Metal on the CPU queues up multiple commands in command buffers and will issue new commands, sequentially, for the next frame without having to wait for the GPU to finish the first frame. This way, no matter who finishes the work first, there will be more work available to do.

The GPU part of the graphics pipeline starts once it’s received all of the commands and resources.

The Metal Project

You’ve been using Playgrounds to learn about Metal. Playgrounds are great for testing and learning new concepts. It’s important to understand how to set up a full Metal project. Because the iOS simulator doesn’t support Metal, you’ll use a macOS app.

Note: The project files for this tutorial’s challenge project also include an iOS target.

Create a new macOS app using the Cocoa App template.

Name your project Pipeline and check Use Storyboards. Leave the rest of the options unchecked.

Open Main.storyboard and select View under the View Controller Scene.

In the Identity inspector, change the view from NSView to MTKView.

This sets up the main view as a MetalKit View.

Open ViewController.swift. At the top of the file, import the MetalKit framework:

import MetalKit

Then, add this code to viewDidLoad():

guard let metalView = view as? MTKView else {
  fatalError("metal view not set up in storyboard")
}

You now have a choice. You can subclass MTKView and use this view in the storyboard. In that case, the subclass’s draw(_:) will be called every frame and you’d put your drawing code in that method. However, in this tutorial, you’ll set up a Renderer class that conforms to MTKViewDelegate and sets Renderer as a delegate of MTKView. MTKView calls a delegate method every frame, and this is where you’ll place the necessary drawing code.

Note: If you’re coming from a different API world, you might be looking for a game loop construct. You do have the option of extending CAMetalLayer instead of creating the MTKView. You can then use CADisplayLink for the timing; but Apple introduced MetalKit with its protocols to manage the game loop more easily.

The Renderer Class

Create a new Swift file named Renderer.swift and replace its contents with the following code:

import MetalKit

class Renderer: NSObject {
  init(metalView: MTKView) {
    super.init()
  }
}

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

Here, you create an initializer and make Renderer conform to MTKViewDelegate with the two MTKView delegate methods:

  • mtkView(_:drawableSizeWillChange:): Gets called every time the size of the window changes. This allows you to update the render coordinate system.
  • draw(in:): Gets called every frame.

In ViewController.swift, add a property to hold the renderer:

var renderer: Renderer?

At the end of viewDidLoad(), initialize the renderer:

renderer = Renderer(metalView: metalView)