← Metal

Metal

A Triangle Without a Vertex Buffer

The traditional first Metal program uploads three vertices to a buffer and draws them. This one does not, because most of the full-screen work in a real renderer — a sky, a post-process, a Mode 7 ground — needs no vertex data at all and it is worth seeing why on the first triangle rather than the fiftieth.

A pipeline is a compiled decision

Before anything draws, the GPU needs a MTLRenderPipelineState: a vertex function, a fragment function, the formats of the textures being drawn into, and how the result is blended. It is compiled once, at startup, and is expensive enough that building one mid-frame is a visible stall.

Swift
public static func render(device: MTLDevice, vertex: MTLFunction, fragment: MTLFunction?,
                          format: RenderTargetFormat, blend: Blend = .opaque,
                          label: String? = nil) throws -> MTLRenderPipelineState {
    let d = MTLRenderPipelineDescriptor()
    if let label { d.label = label }
    d.vertexFunction = vertex
    d.fragmentFunction = fragment
    d.colorAttachments[0].pixelFormat = format.colour
    blend.apply(to: d.colorAttachments[0])
    d.depthAttachmentPixelFormat = format.depth
    d.rasterSampleCount = format.sampleCount
    return try device.makeRenderPipelineState(descriptor: d)
}

The pixel formats in that descriptor must match the textures the pass actually draws into, exactly. A mismatch does not warn; it fails at draw time with a message about an incompatible attachment, and the cause is usually that the view's colorPixelFormat was changed after the pipelines were built. Build pipelines after configuring the view, from the view's own formats:

Swift
let format = RenderTargetFormat(colour: view.colorPixelFormat, depth: view.depthStencilPixelFormat,
                                sampleCount: view.sampleCount)

Set label on everything. It costs nothing, and it is the difference between a GPU capture full of "Pipeline 3" and one where every draw says what it is.

One small helper that pays for itself immediately:

Swift
/// A function from a library, or a thrown error naming it, where `makeFunction` would
/// return nil and leave the pipeline to fail later with less to go on.
public static func function(_ name: String, in library: MTLLibrary) throws -> MTLFunction {
    guard let f = library.makeFunction(name: name) else { throw PipelineError.missingFunction(name) }
    return f
}

A misspelled shader name otherwise surfaces as a nil unwrap three frames later, in a different file.

The triangle that needs no vertices

A vertex function's only required job is to return a clip-space position. Where it gets that position is its own business, and [[vertex_id]] — the index of the vertex being processed — is enough:

Metal
/// One triangle that covers the whole viewport: clip positions (-1,-1), (3,-1), (-1,3).
/// Draw three vertices. The depth is the caller's: one game draws its sky at 0, another
/// its ground at 1.
inline metal::float2 fullscreenTriangle(uint vertexID) {
    return metal::float2((vertexID << 1) & 2, vertexID & 2) * 2.0 - 1.0;
}

The bit-twiddling produces (0,0), (2,0), (0,2), scaled and shifted to (−1,−1), (3,−1), (−1,3). That triangle is twice the size of the screen, and the visible part of it is exactly the viewport. Two triangles forming a quad would also work, but they meet along the screen's diagonal, and pixels along that seam get rasterised by two triangles — which matters once the fragment shader uses derivatives, as everything in the shader posts later in this series does.

Using it is three lines:

Metal
vertex GroundOut vetaGroundVertex(uint id [[vertex_id]]) {
    // One triangle over the whole viewport, at the far depth; the fragment writes the ground's own.
    return { float4(veta::fullscreenTriangle(id), 1.0, 1.0) };
}

and on the Swift side, a draw call with no buffers bound at all:

Swift
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1)

Clip space, briefly

What a vertex function returns is a float4 in clip space, and the GPU divides xyz by w to get normalised device coordinates. In Metal those run from −1 to 1 in x and y, with y up, and from 0 to 1 in z, with 0 nearest the camera. The 0-to-1 depth range is a real difference from OpenGL, where it is −1 to 1, and it is where half of ported projection matrices go wrong.

For a full-screen triangle w is 1, so the position is already in NDC, and the only remaining choice is z. Draw a sky at 1 and everything else passes in front of it; draw a ground that computes its own depth per pixel and objects can be both in front of it and behind it. The Mode 7 ground does the second, which is the subject of post ten.

Fragments

The fragment function runs per pixel covered and returns a colour. The simplest useful one in Veta is four lines, and it is the one that draws every ink outline in the game:

Metal
fragment float4 vetaInkFragment(constant veta::ViewUniforms &U [[buffer(0)]]) {
    return float4(U.palette[veta::slot::ink].rgb, 1.0);
}

Note what it does not take: no [[stage_in]], no interpolated values from the vertex stage. A fragment function that needs nothing from its vertices declares nothing, and the GPU interpolates nothing.

The other thing a fragment can do is write depth, by returning a struct instead of a colour:

Metal
struct GroundFragment {
    float4 colour [[color(0)]];
    float depth [[depth(any)]];
};

This is powerful and not free: a shader that writes depth cannot be depth-tested before it runs, so the GPU loses early-z rejection for that pipeline. Use it where the geometry genuinely cannot express the depth — a ray-marched ground, an impostor — and not as a convenience.

Next

A triangle that needs no data is a good start and a short one. The next post is about getting data in: the three ways to bind a buffer, the 4 KB cliff, and the struct that is declared twice and agrees by luck until it does not.