Sitemap

GPU Programming using Metal 4: Part 2

Part 2: More Vertices

This story continues the previously published Part 1: Vertex Drawing in which we saw how to set up a complete rendering pass on the GPU using the Metal 4 API. The focus is on various ways of passing data from the CPU to the vertex shader.

Drawing Many Vertices

In Part 1 we drew a single vertex. Here, we will attempt something more ambitious and interesting: multiple vertices. Let’s say we want to to draw N points equally spaced along a circle centered at (0,0), that is, defining a regular polygon. We want to be able to control the drawing from the Swift code by passing to the shader the following:

  • the circle radius r, a number between zero and one in clip space,
  • the number of points, that is the vertices of the polygon N > 2 (at least three for obvious reasons),
  • the angular coordinate of each point as the angle between the positive x axis and the vector defined by this point, with the usual positive direction counter-clockwise; the points will be equally spaced at 2*pi/N radians.

What changes in the Swift code? To start, we need to draw N vertices so we’ll adjust the drawing code accordingly and let’s hardcode 5 vertices for now:

func draw(in view: MTKView) {
...
let count = 5
encoder.drawPrimitives(
primitiveType: .point, vertexStart: 0, vertexCount: count
)

Remember, this will cause the vertex shader to be called count times (here is the documentation if you want to take a look again). Without changing anything else in the program it will compile and run displaying the same single point in the center as before (it’s 5 points on top of each other).

Recall that the Metal vertex shader function can have a [[vertex_id]]-attributed parameter of type ushort or uint which the shader can use to identify which point it is working on (0 through count-1). With this parameter our vertex shader is shaping up to look like this:

// Shaders.metal
...
vertex Vertex vertex_f(uint id [[vertex_id]]) {
const float increment = 2 * M_PI_F / numVertices;
const float rads = initial + id * increment;
return {
.position = float4(radius * cos(rads), radius * sin(rads), 0, 1),
.pointSize = 30,
};
}
...

This will not work, of course, with a bunch of Use of undeclared identifier… compiler errors. Let’s analyze it a bit. The increment is the angle between the radii of any two adjacent vertices in radians because that’s what the trigonometric functions in the Metal library expect (note that M_PI_F is the value of pi, one of a handful of constants defined by the Metal library). rads is the angular coordinate of the vertex for which the call is made: vertex 0 will be at initial (specified by us) angle, vertex 1 at initial + increment and so on. The x and y coordinates of the vertex follow from basic trigonometry.

Passing Shader Parameters: Hardcoding

Wait a second, you’ll say, how will the vertex shader get numVertices, initial, and radius? For now let’s just test our concept end-to-end by hardcoding these values into the shader while keeping count = 5 (to match the Swift code above) and choosing initial so that the first and second vertices define a line parallel to the x axis and below it (that is, the polygon is resting on one of its sides):

...
constant const float numVertices = 5;
constant const float initial = - (M_PI_2_F + M_PI_F / numVertices);
constant const float radius = 0.8;

vertex Vertex vertex_f(uint id [[vertex_id]]) {
const float increment = 2 * M_PI_F / numVertices;
const float rads = initial + id * increment;
return {
.position = float4(radius * cos(rads), radius * sin(rads), 0, 1),
.pointSize = 30,
};
}

With the fragment shader for now unchanged this will give us the following pretty output, exactly as we ordered:

Vertices forming a regular pentagon.

While this works for illustration purposes in our simple example, it is clearly not a viable strategy for more complex applications. We’ll look at how to improve this situation next.

Passing Parameters to a Shader from CPU-land

The shader functions are just like other functions in the sense that they communicate with the world via their input parameters and return values. What makes them different is that we can not and do not call these functions directly from the Swift code. The GPU on which the shaders execute is essentially an entirely separate computer, running asynchronously side-by-side with the CPU executing the Swift code, each with their own individual memory space. Passing parameters to the GPU is possible, desirable, and necessary but we need to do some extra work to make it happen.

Built-in Variables

We’ve already seen and used one way of passing parameters into the shaders, the [[vertex_id]]-attributed parameter. You can read about these in the Attributes for Built-in Variables section (section 5.2.3 in the MSL Specification, Version 4 that I am looking at) but the most important takeaway is that the shader gets the parameter with the attribute automatically based on some setup we did in CPU or GPU land, for instance:

  • the parameters of a draw* call on the MTL4RenderCommandEncoder may affect the values of the [[base_instance]], [[base_vertex]], [[instance_id]], and[[vertex_id]]-attributed parameters,
  • the [[point_coord]]-attributed parameter of the fragment shader is automatically set for point primitives, and so on.

The documentation on these things is of slightly above mediocre quality but one can piece the information together with some degree of certainty from various sources (hopefully, articles like this help) and via trial and error.

Memory Buffers

For a serious application one typically needs to pass to the GPU a substantial amount of data, e.g., dozens, hundreds or even thousands of vertex coordinates. Hardcoding those in the vertex shader is clearly not an option. A more general approach to doing so is by using the MTLBuffer abstraction of a piece of memory accessible by both the CPU and the GPU.

Get Giik Web Developer’s stories in your inbox

Join Medium for free to get updates from this writer.

Right at the start, the documentation states that “…Each MTLBuffer instance represents a general purpose, typeless memory allocation that your app uses to send and retrieve data from a shader. Your app decides how to use and interpret the buffer’s underlying bytes.” That’s really all there is to it—a chunk of bytes that programs and devices can write to or read from.

While this is fairly low level—possibly lower than some folks are used to—it really is not that bad. To use a MTLBuffer to pass data from the CPU to the GPU, one typically needs to prepare the data on the side, create the buffer and copy the data into it, then use it on the GPU side (while passing data in the opposite direction is also possible, here we focus on the CPU => GPU path specifically).

The most important thing to understand is that the GPU must be able to interpret correctly data it reads from a MTLBuffer's memory. In our polygon of points, for example, the GPU may want to read the number of vertices from the buffer as an integer or a float/double, as these are numerical types in the MSL (that is, types that can represent a number such as 5 or 11).

Because we write the data in CPU land and read it in GPU land, the set of possible types that can be written on the CPU end and interpreted correctly on the GPU end other is quite narrow. The Swift documentation often refers to them as “trivial types” (the Manual Memory Management documentation emphasizes this a lot). Broadly speaking, these are the basic types such as boolean, integer, float, and double, and aggregates of them such as contiguous arrays of or structs containing only such types. While that may seem like a draconian restriction it really isn’t because the GPU mostly processes numbers and not strings, or instance of an Employee or Animal classes.

Binding Data to Buffers

To keep this less abstract let’s lead with a concrete example for our polygon of points. Let’s agree to pass all data to the vertex shader in three buffers: one each for the for the central angle, radius, and point size of the vertices. The vertex shader will look like this:

struct VertexOutBuffer {
float4 position [[position]];
float pointSize [[point_size]];
};

vertex VertexOutBuffer vertex_f(constant float* radii [[buffer(0)]],
constant float* angles [[buffer(1)]],
constant float* pointSizes [[buffer(2)]],
uint id [[vertex_id]]) {
const float radius = radii[id];
const float angle = angles[id];
return {
.position = float4(radius * cos(angle), radius * sin(angle), 0, 1),
.pointSize = pointSizes[id],
};
}

This is not terribly unfamiliar except for the [[buffer(N)]]-attributed parameters that look like standard C arrays. For example, float* radii is a pointer to any number of floats that can be indexed into to read the radius of the current vertex id. Similarly for angles and pointSizes. Note that there is no need to pass the length of the radii, or any of the other arrays. We simply have to get it right and—in some cases—there are rules about what happens when indexing is out of bounds.

The number N in the [[buffer(N)]] attribute is the buffer index which is chosen when the data is bound on the CPU side. More specifically, when a MTLBuffer in CPU land is created, is it bound to an index N via the argument table which is made known to the encoder. The entire code for our example is below:

// Per-vertex radius, angle and point size.
let count = 9. // or 5 as before, or 12, or whatever we want
var arrayRadius = [simd_float1](repeating: 0.8, count: count)
let PI = Float.pi, PI_2 = PI / 2, first = -(PI_2 + PI / Float(count))
var arrayAngle: [simd_float1] = (0 ..< count).map {
first + 2 * PI * Float($0) / Float(count)
}
var arrayPointSize = [simd_float1](repeating: 30, count: count)
// MTLBuffers holding the per-vertex data for radius, angle and point size.
guard
let bufRadius = device.makeBuffer(
bytes: &arrayRadius,
length: MemoryLayout<simd_float1>.stride * arrayRadius.count
),
let bufAngle = device.makeBuffer(
bytes: &arrayAngle,
length: MemoryLayout<simd_float1>.stride * arrayAngle.count
),
let bufPointSize = device.makeBuffer(
bytes: &arrayPointSize,
length: MemoryLayout<simd_float1>.stride * arrayPointSize.count
) else {
fatalError("cannot create metal buffers")
}
// An argument table binds the buffers to specific buffer indices.
let argumentTableDescriptor = MTL4ArgumentTableDescriptor()
argumentTableDescriptor.maxBufferBindCount = 3
argumentTable.setAddress(self.bufRadius.gpuAddress, index: 0)
argumentTable.setAddress(self.bufAngle.gpuAddress, index: 1)
argumentTable.setAddress(self.bufPointSize.gpuAddress, index: 2)
// Tell the encoder that the arguments from the table are for the vertex shader.
encoder.setArgumentTable(argumentTable, stages: .vertex)
// Draw calls...
encoder.drawPrimitives(primitiveType: .point,
vertexStart: 0, vertexCount: count)
...

This should be mostly self-explanatory but let’s unpack it a bit:

  • the arrays are of type simd_float1 which is type in the simd library representing a “…vector of one 32-bit floating-point element,”
  • we use the MTLDevice to create the MTLBuffers corresponding to and holding the memory for the radius, angle and point size arrays. The makeBuffer(bytes: &array… part was really mysterious to me and for a long time I just followed other examples without fully understanding why it works. To get to the bottom of it, note the signature of func makeBuffer(bytes pointer: UnsafeRawPointer... and read the Calling Functions with Pointer Parameters chapter of Manual Memory Management. It says that “…When you call a function that is declared as taking an UnsafePointer<Type> argument, you can pass any of the following: … An in-out expression that contains a mutable variable,… A [Type] value, which is passed as a pointer to the start of the array.” So there it is, and it actually made me realize that the calls do not need the & part, so device.MakeBuffer(bytes: arrayRadius... also works (go ahead and verify that).
  • the argument table is something we haven’t seen so far: it’s a Metal 4 abstraction that stores the bindings to resources on an encoder’s behalf. It’s pretty straightforward stuff so I’ll just let you read about it if you want all the details.

It is important to realize that the CPU-side instances of MTLBuffer have to hold on to the memory they allocated until the rendering is complete. That makes sense—we pass to the encoder (via the argument table) only the gpuAddress property of the buffer and not the instance. If you look at the type of the gpuAddress property it is an MTLGPUAddress which happens to be a UInt64 so really, the actual address of some memory somewhere.

Go ahead and play with this code (if you need a SwiftUI harness for the Metal rendering check out Part 1 or my Metal 4 View for SwiftUI story). In the upcoming parts, we’ll see how to leverage buffers incl. with vertex descriptors to make passing vertex data to the GPU a bit more sophisticated.

i write computer programs. to solve problems. or just for fun. as a form of intellectual exercise. so I can become better at it.