T.TAO
返回博客
/8 min read/Graphics Engine

Metal #3 顶点函数

#ComputerGraphics#GraphicsEngine#Metal

这篇笔记主要写一下 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

这里最容易出错的是 stridesize 的区别。MemoryLayout&lt;T>.size 是实际占用的字节,stride 是加上对齐填充之后、数组中相邻元素的间距。顶点布局永远用 stride——用 size 会在有对齐填充的结构体上产生错位,而且症状是模型扭曲,不是崩溃,很难查。

一个具体的坑:SIMD3&lt;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,而不是在着色器里对每个顶点做一次求逆。

下一篇我们看插值之后的片元函数。

本系列文章

Metal
  1. 01Metal #0 Swift 回顾
  2. 02Metal #1 初始化
  3. 03Metal #2 渲染管线
  4. 04Metal #3 顶点函数
  5. 05Metal #4 片元函数
  6. 06Metal #5 纹理
  7. 07Metal #6 摄像机与交互
  8. 08Metal #7 光照
  9. 09Metal #8 材质
  10. 10Metal #9 渲染通道
  11. 11Metal #10 阴影
  12. 12Metal #11 延迟渲染
  13. 13Metal #12 粒子系统
  14. 14Metal #13 曲面细分
  15. 15Metal #14 后处理
  16. 16Metal #15 反射与折射
  17. 17Metal #16 动画
  18. 18Metal #17 光线追踪(一)渲染算法
  19. 19Metal #18 光线追踪(二)阴影与光照
  20. 20Metal #19 光线追踪(三)性能优化
  21. 21Metal #21 [附录] 计算着色器
  22. 22Metal #22 [附录] SwiftUI 中的 Metal