这篇笔记主要写一下 Metal 的顶点函数(vertex function),以及顶点数据是怎么从 CPU 走到着色器里的。
顶点函数在做什么
顶点函数对每个顶点执行一次,唯一的硬性任务是输出一个裁剪空间坐标(position)。除此之外它可以输出任意多的数据,这些数据会在光栅化阶段被插值,再送给片元函数。
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]]; // 必须,且必须叫这个 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;
}
几个 Metal 特有的限定符:
[[stage_in]]:告诉 Metal 这个参数由顶点描述符按属性装配,而不是我们手动从 buffer 里读。[[attribute(n)]]:对应顶点描述符里 attribute n 的定义。[[position]]:标记哪一个输出是裁剪空间位置。少了它无法编译。
顶点描述符
MTLVertexDescriptor 描述的是"buffer 里的字节应该怎么被解释成结构体字段"。它有三部分:attributes(每个属性的格式、偏移、来自哪个 buffer)和 layouts(每个 buffer 的步长和步进方式)。
Swiftlet descriptor = MTLVertexDescriptor()
// position: float3,位于 buffer 0 的偏移 0
descriptor.attributes[0].format = .float3
descriptor.attributes[0].offset = 0
descriptor.attributes[0].bufferIndex = 0
// normal: float3,紧跟其后
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
这里最容易出错的是 stride 和 size 的区别。MemoryLayout<T>.size 是实际占用的字节,stride 是加上对齐填充之后、数组中相邻元素的间距。顶点布局永远用 stride——用 size 会在有对齐填充的结构体上产生错位,而且症状是模型扭曲,不是崩溃,很难查。
一个具体的坑:SIMD3<Float> 的 size 是 12,stride 是 16。Metal 的 float3 也是 16 字节对齐。如果 CPU 端用的是三个独立的 Float,而着色器端声明的是 float3,两边的布局就对不上了。
交错 vs 分离
上面这种把 position / normal / uv 放在同一个 buffer 里、按顶点交错排列的布局叫交错(interleaved)。另一种是每个属性一个 buffer,叫分离(separate 或 planar)。
交错的好处是一个顶点的全部数据在同一条 cache line 上,顶点读取的局部性好,是默认的选择。分离的好处是可以只更新其中一个属性(比如只更新蒙皮后的位置),以及在只需要位置的 pass(如 shadow map、深度预通道)里只绑定一个 buffer,带宽更省。
MDLMesh 从文件加载模型时,可以通过 MTKModelIOVertexDescriptorFromMetal() 把 Metal 的顶点描述符转成 Model I/O 的格式,让加载出来的 buffer 布局和 pipeline 期望的一致。这一步漏了,就会出现"模型加载成功但渲染出来是一团乱麻"。
传 uniform 的三种方式
顶点函数需要矩阵、时间这类每帧变化的常量。Metal 提供三种传法,取决于数据大小:
1. setVertexBytes(小于 4 KB)
Swiftvar uniforms = Uniforms(modelMatrix: model, viewMatrix: view, projectionMatrix: proj)
encoder.setVertexBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 11)
Metal 会把数据直接拷进命令缓冲区,不需要你管理 MTLBuffer 的生命周期。这是小 uniform 的首选。
2. setVertexBuffer(大于 4 KB,或需要复用)
Swiftencoder.setVertexBuffer(uniformBuffer, offset: 0, index: 11)
大的数据——实例矩阵数组、骨骼矩阵——必须走这条路。注意 GPU 可能还在读上一帧的 buffer,所以需要三缓冲(triple buffering)配合信号量,否则会改到正在被使用的数据。
3. 函数常量(编译期)
MSLconstant bool hasNormalMap [[function_constant(0)]];
这不是运行时数据,而是在创建 pipeline 时特化的常量。它能让编译器直接消除分支,相当于其它 API 里的 shader 变体,但不需要维护一堆 #define 组合。
buffer index 的分配
[[buffer(n)]] 的 n 是绑定槽位。顶点属性通常从 0 开始占用,所以 uniform 一般放到高位(很多项目约定 11、12),避免和顶点 buffer 冲突。
建议把这些索引定义在一个 CPU 和 GPU 共享的头文件里:
C// Common.h,同时被 Swift 的 bridging header 和 .metal 文件 include
typedef enum {
BufferIndexVertices = 0,
BufferIndexUniforms = 11,
BufferIndexParams = 12
} BufferIndices;
这样两边永远不会对不上。同一个头文件里也可以定义 Uniforms 结构体本身——这是 Metal 相对 OpenGL 的一个明显优势:CPU 和 GPU 可以共享同一份 C 结构体定义,不用手动同步。
为什么变换放在顶点函数里
MVP 变换完全可以在 CPU 上做,但顶点函数是更合适的位置:
- 顶点数量通常远少于片元数量,但远多于物体数量。放在顶点阶段,每个顶点算一次矩阵乘法,正好是 GPU 擅长的批量工作。
- 顶点数据可以留在 GPU 上一整帧甚至多帧不变,CPU 只需要更新几个矩阵。
- 顶点函数里算出的世界空间位置和法线,可以直接作为插值输出给片元函数使用,不用重复计算。
有一个例外值得注意:法线的变换矩阵不是 model 矩阵。当模型有非均匀缩放时,法线需要用 model 矩阵的逆转置来变换,否则法线不再垂直于表面。这个矩阵应该在 CPU 上算好放进 uniform,而不是在着色器里对每个顶点做一次求逆。
下一篇我们看插值之后的片元函数。