T.TAO
Back to Blog
/5 min read/Graphics Engine

Metal #3 Vertex Function

#ComputerGraphics#GraphicsEngine#Metal

This note covers the vertex function in Metal, and how vertex data actually travels from the CPU into a shader.

What the vertex function does

The vertex function runs once per vertex, and its one hard obligation is to output a clip-space position. Beyond that it may output any amount of data, which the rasterizer interpolates before handing it to the fragment function.

MSL#include <metal_stdlib>
using namespace metal;

struct VertexIn {
    float4 position [[attribute(0)]];
    float3 normal   [[attribute(1)]];
    float2 uv       [[attribute(2)]];
};

struct VertexOut {
    float4 position [[position]];   // required, and must carry this attribute
    float3 normalWS;
    float2 uv;
};

vertex VertexOut vertex_main(VertexIn in [[stage_in]],
                             constant Uniforms &uniforms [[buffer(11)]])
{
    VertexOut out;
    float4 posWS  = uniforms.modelMatrix * in.position;
    out.position  = uniforms.projectionMatrix * uniforms.viewMatrix * posWS;
    out.normalWS  = (uniforms.normalMatrix * in.normal);
    out.uv        = in.uv;
    return out;
}

A few Metal-specific qualifiers:

  • [[stage_in]] tells Metal this parameter is assembled by the vertex descriptor, rather than being read out of a buffer by hand.
  • [[attribute(n)]] corresponds to attribute n in that descriptor.
  • [[position]] marks which output is the clip-space position. Without it the shader does not compile.

The vertex descriptor

An MTLVertexDescriptor describes how the bytes in a buffer should be interpreted as struct fields. It has two halves: attributes (format, offset and source buffer for each) and layouts (stride and step function for each buffer).

Swiftlet descriptor = MTLVertexDescriptor()

// position: float3 at offset 0 of buffer 0
descriptor.attributes[0].format = .float3
descriptor.attributes[0].offset = 0
descriptor.attributes[0].bufferIndex = 0

// normal: float3, immediately after
descriptor.attributes[1].format = .float3
descriptor.attributes[1].offset = MemoryLayout<SIMD3<Float>>.stride
descriptor.attributes[1].bufferIndex = 0

// uv: float2
descriptor.attributes[2].format = .float2
descriptor.attributes[2].offset = MemoryLayout<SIMD3<Float>>.stride * 2
descriptor.attributes[2].bufferIndex = 0

descriptor.layouts[0].stride = MemoryLayout<Vertex>.stride
descriptor.layouts[0].stepFunction = .perVertex

pipelineDescriptor.vertexDescriptor = descriptor

The thing that most often goes wrong here is stride versus size. MemoryLayout&lt;T>.size is the bytes actually occupied; stride is the distance between adjacent elements in an array, including alignment padding. Always use stride for vertex layouts β€” using size misaligns any struct with padding, and the symptom is a distorted mesh rather than a crash, which makes it hard to find.

A concrete trap: SIMD3&lt;Float> has a size of 12 and a stride of 16, and Metal's float3 is also 16-byte aligned. If the CPU side uses three separate Floats while the shader declares a float3, the two layouts do not agree.

Interleaved vs separate

Putting position, normal and uv in one buffer, interleaved per vertex as above, is an interleaved layout. The alternative is one buffer per attribute β€” separate, or planar.

Interleaved keeps all of a vertex's data on the same cache line, which gives good locality during vertex fetch, and it is the sensible default. Separate lets you update one attribute alone (skinned positions, say) and lets a position-only pass β€” a shadow map or a depth prepass β€” bind just one buffer and read less bandwidth.

When loading a model with MDLMesh, use MTKModelIOVertexDescriptorFromMetal() to convert the Metal vertex descriptor into a Model I/O one, so the loaded buffers match what the pipeline expects. Skip that step and you get the classic "the model loads fine but renders as a tangle".

Three ways to pass uniforms

The vertex function needs matrices, time, and other per-frame constants. Metal offers three routes, chosen by data size:

1. setVertexBytes (under 4 KB)

Swiftvar uniforms = Uniforms(modelMatrix: model, viewMatrix: view, projectionMatrix: proj)
encoder.setVertexBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 11)

Metal copies the data straight into the command buffer, so you do not manage an MTLBuffer lifetime at all. This is the first choice for small uniforms.

2. setVertexBuffer (over 4 KB, or reused)

Swiftencoder.setVertexBuffer(uniformBuffer, offset: 0, index: 11)

Larger data β€” arrays of instance matrices, skeleton matrices β€” has to go this way. Note that the GPU may still be reading last frame's buffer, so you need triple buffering with a semaphore, or you will overwrite data that is in use.

3. Function constants (compile time)

MSLconstant bool hasNormalMap [[function_constant(0)]];

This is not runtime data; it is a constant specialized when the pipeline is created. It lets the compiler eliminate the branch entirely β€” the equivalent of shader variants in other APIs, without maintaining a combinatorial pile of #defines.

Assigning buffer indices

The n in [[buffer(n)]] is a binding slot. Vertex attributes usually occupy the low indices starting at 0, so uniforms go high β€” many projects settle on 11 and 12 β€” to avoid colliding with vertex buffers.

Define these indices in a header shared by CPU and GPU:

C// Common.h, included by both the Swift bridging header and the .metal files
typedef enum {
    BufferIndexVertices = 0,
    BufferIndexUniforms = 11,
    BufferIndexParams   = 12
} BufferIndices;

Then the two sides cannot drift apart. The same header can define the Uniforms struct itself, which is one of Metal's clear advantages over OpenGL: CPU and GPU share one C struct definition instead of being manually kept in sync.

Why the transform belongs in the vertex function

The MVP transform could be done on the CPU, but the vertex function is the better place:

  • Vertices are far fewer than fragments but far more numerous than objects. One matrix multiply per vertex is exactly the bulk work a GPU is good at.
  • Vertex data can stay resident on the GPU for a whole frame or many frames; the CPU only updates a few matrices.
  • The world-space position and normal computed here can be output as interpolated varyings, so the fragment stage does not recompute them.

One exception is worth flagging: the normal is not transformed by the model matrix. With non-uniform scale, normals must be transformed by the inverse transpose of the model matrix, or they stop being perpendicular to the surface. Compute that matrix on the CPU and put it in the uniforms, rather than inverting a matrix per vertex in the shader.

The next note looks at what happens after interpolation, in the fragment function.

In this series

Metal β†’
  1. 01Metal #0 Swift Review
  2. 02Metal #1 Initialization
  3. 03Metal #2 Rendering Pipeline
  4. 04Metal #3 Vertex Function
  5. 05Metal #4 Fragment Function
  6. 06Metal #5 Texture
  7. 07Metal #6 Navigation
  8. 08Metal #7 Lighting
  9. 09Metal #8 Materials
  10. 10Metal #9 Render Passes
  11. 11Metal #10 Shadow
  12. 12Metal #11 Deferred Rendering
  13. 13Metal #12 Particle System
  14. 14Metal #13 Tessellation
  15. 15Metal #14 Post-processing
  16. 16Metal #15 Reflection and Refraction
  17. 17Metal #16 Animation
  18. 18Metal #17 Ray Tracing (I) Rendering Algorithm
  19. 19Metal #18 Ray Tracing (II) Shadows and Lighting
  20. 20Metal #19 Ray Tracing (III) Performance Optimization
  21. 21Metal #21 [Appendix] Compute Shaders
  22. 22Metal #22 [Appendix] Metal in SwiftUI