Raw Metal

Alain Galvan
8 min readApr 11, 2020

--

Apple Metal is Apple’s primary computer graphics API, and after depreciating OpenGL, the only graphics API supported and maintained by Apple.

Metal is limited to only Apple operating systems:

  • 🍎 Mac OS
  • 📱 iOS / iPad OS

Metal is also limited to only a few languages, with the rest having to opt for external calls to Objective C/C++ or Swift.

  • Objective C
  • Objective C++
  • Swift

Though despite these limitations, Apple Metal is an extremely elegant, concise, and robust API with decent support on all Apple supported platforms and driver engineers happy to help with any issues that may arise during development. You’ll also find that writing the same code in Apple Metal tends to take significantly fewer lines of code than other graphics APIs like Vulkan or DirectX 12.

I’ve prepared a Github Repo with everything we need to get started. We’re going to walk through a Hello Triangle app in modern C++, a program that creates a triangle and renders it onto the screen.

Setup

First install:

Then type the following in your terminal.

# 🐑 Clone the repo
git clone https://github.com/alaingalvan/metal-seed --recurse-submodules
# 💿 go inside the folder
cd metal-seed
# 👯 If you forget to `recurse-submodules` you can always run:
git submodule update --init
# 👷 Make a build folder
mkdir build
cd build
# 🖼️ To build your Visual Studio solution on Windows x64
cmake .. -A x64
# 🍎 To build your XCode project on Mac OS
cmake .. -G Xcode
# 🍎📱 To build your XCode project targeting iOS / iPad OS
cmake .. -G Xcode -DCMAKE_SYSTEM_NAME=iOS
# 🐧 To build your .make file on Linux
cmake ..
# 🔨 Build on any platform:
cmake --build .

Overview

The following will explain snippets that can be found in the Github repo, with certain parts omitted, and member variables (mMemberVariable) declared inline without the m prefix so their type is easier to see and the examples here can work on their own.

Window Creation

We’re using CrossWindow to handle cross platform window creation, so creating a window and updating it is very easy:

#include "CrossWindow/CrossWindow.h"
#include "Renderer.h"
#include <iostream>void xmain(int argc, const char** argv)
{
// 🖼 Create Window
xwin::WindowDesc wdesc;
wdesc.title = "Metal Seed";
wdesc.name = "MainWindow";
wdesc.visible = true;
wdesc.width = 640;
wdesc.height = 640;
wdesc.fullscreen = false;
xwin::Window window;
xwin::EventQueue eventQueue;
if (!window.create(wdesc, eventQueue))
{ return; };
// 🌋 Create a renderer
Renderer renderer(window);
// 🏁 Engine loop
bool isRunning = true;
while (isRunning)
{
bool shouldRender = true;
// ♻️ Update the event queue
eventQueue.update();
// 🎈 Iterate through that queue:
while (!eventQueue.empty())
{
//Update Events
const xwin::Event& event = eventQueue.front();
// 💗 On Resize:
if (event.type == xwin::EventType::Resize)
{
const xwin::ResizeData data = event.data.resize;
renderer.resize(data.width, data.height);
shouldRender = false;
}
// ❌ On Close:
if (event.type == xwin::EventType::Close)
{
window.close();
shouldRender = false;
isRunning = false;
}
eventQueue.pop();
}
// ✨ Update Visuals
if (shouldRender)
{
renderer.render();
}
}
}

Just note that on iOS, iPad OS, tvOS, and watchOS, the backend API will be **UIKit**, with MacOS using the **Cocoa** windowing API.

Initialize API

Metal Layer

Every Apple Window can have layers attached to it that handle things like OpenGL or Metal.

// ☕ Use CrossWindow-Graphics to create Metal Layer
xgfx::createMetalLayer(&window);
xwin::WindowDelegate& del = window.getDelegate();
CAMetalLayer* layer = (CAMetalLayer*)del.layer;

Device

A Device is the entry point to the Metal API.

// 👋 Declare handles
MTLCommandBuffer* device;
// 🎮 Create device
layer.device = MTLCreateSystemDefaultDevice();
device = layer.device;

Command Queue

A Command Queue functions similarly to other modern graphics APIs, a queue from which you can send graphics function calls to the GPU.

// 👋 Declare handles
MTLCommandQueue* commandQueue;
// 📦 Create the command queue
commandQueue = [device newCommandQueue];

Initialize Resources

Vertex Buffer

Vertex Buffers are blocks of data stored in the GPU that are used to create triangles.

You could describe this data with one big buffer containing everything or with independent arrays for each element in your vertex layout, whichever best fits your use case and performance requirements.

Having them split can be easier to update if you’re changing your vertex buffer data often, which may be useful for CPU animations or procedurally generated geometry.

// 📈 Describe Position Vertex Buffer Data
float positions[3*3] = { 1.0f, 1.0f, 0.0f,
-1.0f, 1.0f, 0.0f,
0.0f, -1.0f, 0.0f };
// 🎨 Describe Color Vertex Buffer Data
float colors[3*3] = { 1.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f,
0.0f, 0.0f, 1.0f };
// 👋 Declare handles
MTLBuffer* positionBuffer;
MTLBuffer* colorBuffer;
// ⚪ Create VBO
positionBuffer = [device newBufferWithLength:sizeof(Vertex) * 3
options:MTLResourceOptionCPUCacheModeDefault];
// 💬 Label VBO
[positionBuffer setLabel:@"PositionBuffer"];
// 💾 Push data to VBO
memcpy(positionBuffer.contents, positions, sizeof(float) * 3 * 3);
// ⚪ Create VBO
colorBuffer = [device newBufferWithLength:sizeof(Vertex) * 3
options:MTLResourceOptionCPUCacheModeDefault];
// 💬 Label VBO
[colorBuffer setLabel:@"ColorBuffer"];
// 💾 Push data to VBO
memcpy(colorBuffer.contents, colors, sizeof(float) * 3 * 3);

Index Buffer

Index Buffers are blocks of data stored in the GPU used to describe triangles, each 3 numbers corresponds ot the index of that triangle.

If you’re making triangles, there should be 3 points per triangle in the index buffer, for lines there should be 2, and points only need 1.

// 🗄️ Describe Index Buffer Data
unsigned indexBufferData[3] = { 0, 1, 2 };
// ✋ Declare Index Buffer Handle
MTLBuffer* indexBuffer;
// 🃏 Index Data
indexBuffer = [device newBufferWithLength:sizeof(unsigned) * 3
options:MTLResourceOptionCPUCacheModeDefault];
[indexBuffer setLabel:@"IBO"];
memcpy(indexBuffer.contents, indexBufferData, sizeof(unsigned) * 3);

Uniform Buffer

Uniform Buffers are blocks of memory describing data that’s meant to be sent to your shader during rendering, such values to control effects, positional matrices, etc.

// 👋 Declare handles
MTLBuffer* uniformBuffer;
// 🗄️ Describe Uniform Data
struct UniformData
{
mat4 projectionMatrix;
mat4 modelMatrix;
mat4 viewMatrix;
} uboVS;
// 🎛️ Create Uniform Buffer
uniformBuffer = [device newBufferWithLength:(sizeof(UniformData) + 255) & ~255
options:MTLResourceOptionCPUCacheModeDefault];
[uniformBuffer setLabel:@"UBO"];
// Update Uniforms...

Shader Libraries

Shader Libraries are unique to Metal, they function as intermediary objects that can perform reflection on those shaders. As the name suggests, you can create uber-shaders, large shaders with different main functions.

Shader Functions are handles to a particular shader function in a shader library.

// Load all the shader files with a .msl file extension in the projectNSError* err = nil;// 📂 Load shader files, add null terminator to the end.
std::vector<char> vertSource = readFile("triangle.vert.msl");
vertSource.emplace_back(0);
std::vector<char> fragSource = readFile("triangle.frag.msl");
fragSource.emplace_back(0);
NSString* vertPath = [NSString stringWithCString:vertSource.data() encoding:[NSString defaultCStringEncoding]];
MTLLibrary* vertLibrary = [device newLibraryWithSource:vertPath options:nil error:&err];
[vertPath dealloc];
NSString* fragPath = [NSString stringWithCString:fragSource.data() encoding:[NSString defaultCStringEncoding]];
MTLLibrary* fragLibrary = [device newLibraryWithSource:fragPath options:nil error:&err];
[fragPath dealloc];
// Load the vertex function from the library
MTLFunction* vertexFunction = [vertLibrary newFunctionWithName:@"main0"];
// Load the fragment function from the library
MTLFunction* fragmentFunction = [fragLibrary newFunctionWithName:@"main0"];

Pipeline State

Pipeline State describes all the data that’s to be fed into the execution of a raster based graphics pipeline.

// 👋 Declare handles
MTLRenderPipelineState* pipelineState;
// ⚗️ Graphics Pipeline
MTLRenderPipelineDescriptor* pipelineStateDescriptor = [[MTLRenderPipelineDescriptor alloc] init];
pipelineStateDescriptor.label = @"Simple Pipeline";
pipelineStateDescriptor.vertexFunction = vertexFunction;
pipelineStateDescriptor.fragmentFunction = fragmentFunction;
pipelineStateDescriptor.colorAttachments[0].pixelFormat = layer.pixelFormat;
// 🔣 Input Assembly
MTLVertexDescriptor* vertexDesc = [MTLVertexDescriptor vertexDescriptor];
vertexDesc.attributes[0].format = MTLVertexFormatFloat3;
vertexDesc.attributes[0].offset = 0;
vertexDesc.attributes[0].bufferIndex = 0;
vertexDesc.attributes[1].format = MTLVertexFormatFloat3;
vertexDesc.attributes[1].offset = sizeof(float) * 3;
vertexDesc.attributes[1].bufferIndex = 0;
vertexDesc.layouts[0].stepFunction = MTLVertexStepFunctionPerVertex;
vertexDesc.layouts[0].stride = sizeof(Vertex);
pipelineStateDescriptor.vertexDescriptor = vertexDesc;
NSError* error = nil;
// 🌟 Create Pipeline State Object
pipelineState = [device
newRenderPipelineStateWithDescriptor:pipelineStateDescriptor
error:&error];
if (!pipelineState)
{
NSLog(@"Failed to created pipeline state, error %@", error);
}

Rendering

Render Pass

Render Passes are a combination of Frame Buffer Descriptions for a given set of render calls.

// 👋 Declare Handles
CAMetalLayer* layer;
// 🤵 Build renderPassDescriptor generated from the view's drawable textures
CAMetalDrawable* drawable = layer.nextDrawable;
MTLRenderPassDescriptor* renderPassDescriptor = [MTLRenderPassDescriptor renderPassDescriptor];
renderPassDescriptor.colorAttachments[0].texture = drawable.texture;
renderPassDescriptor.colorAttachments[0].loadAction = MTLLoadActionClear;
MTLClearColor clearCol;
clearCol.red = 0.2;
clearCol.green = 0.2;
clearCol.blue = 0.2;
clearCol.alpha = 1.0;
renderPassDescriptor.colorAttachments[0].clearColor = clearCol;

Command Buffer

Command Buffers encode all the draw commands you intend to execute, and once you’re done providing it with calls, can be submitted to the GPU. In that sense a command buffer is analogous to a callback that executes draw functions on the GPU once it’s submitted to the queue.

// 👋 Declare Metal Handle
MTLCommandBuffer* commandBuffer;
unsigned viewportSize[2];
if (commandBuffer != nil)
{ [commandBuffer release]; }
mCommandBuffer = [(commandQueue commandBuffer];
(commandBuffer).label = @"MyCommand";
if(renderPassDescriptor != nil)
{
// Create a render command encoder so we can render into something
id<MTLRenderCommandEncoder> renderEncoder =
[commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];
renderEncoder.label = @"MyRenderEncoder";
// Set the region of the drawable to which we'll draw.
[renderEncoder setViewport:(MTLViewport){0.0, 0.0, static_cast<float>(viewportSize[0]), static_cast<float>(viewportSize[1]), 0.1, 1000.0 }];
[renderEncoder setRenderPipelineState: pipelineState]; [renderEncoder setCullMode:MTLCullModeNone]; [renderEncoder setVertexBuffer:positionBuffer offset:0 atIndex:0]; [renderEncoder setVertexBuffer:colorBuffer offset:0 atIndex:1]; [renderEncoder setVertexBuffer:uniformBuffer offset:0 atIndex:2]; [renderEncoder drawIndexedPrimitives:MTLPrimitiveTypeTriangle indexCount:3 indexType:MTLIndexTypeUInt32 indexBuffer:indexBuffer indexBufferOffset:0]; [renderEncoder endEncoding]; [commandBuffer presentDrawable:drawable]; [commandBuffer commit];
}

Destroying Handles

Like most system level programming languages with managed memory models, in Objective C++ you must destroy any objects that you create. This however isn’t the case if automatic reference counting (or ARC) is enabled at some point in your code’s execution with @autoreleasepool.

void destroyAPI()
{
if (commandBuffer != nil)
{
[commandBuffer release];
}

[commandQueue release];

[device release];
}
void destroyResources()
{
[fragmentFunction release];
[vertexFunction release];

[vertLibrary release];
[fragLibrary release];
[positionBuffer release];
[colorBuffer release];
[indexBuffer release];
[uniformBuffer release];

[pipelineState release];
}

Conclusion

Metal is arguably the easiest modern computer graphics API to use, with smart defaults and an intuitive API that maps easily to modern GPUs.

Here’s a few other resources to further your understanding of the Metal API:

You’ll find all the source code described in this post in the Github repo here.

--

--

Alain Galvan

https://Alain.xyz | Graphics Software Engineer @ AMD, Previously @ Marmoset.co. Guest lecturer talking about 🛆 Computer Graphics, ✍ tech author.