这篇笔记主要写一下 Metal 的片元函数(fragment function)以及它前后的固定管线阶段。
片元不是像素
一个片元(fragment)是"某个三角形覆盖了某个采样点"这件事产生的候选数据。一个像素最终的颜色可能来自多个片元(半透明叠加、MSAA 的多个采样点),也可能来自零个片元(被剔除或没有几何覆盖)。
区分这两个词很重要,因为片元函数执行的次数取决于三角形覆盖了多少采样点,而不是屏幕上有多少像素——这正是过绘制(overdraw)带来性能问题的原因。
MSLfragment float4 fragment_main(VertexOut in [[stage_in]],
constant Params ¶ms [[buffer(12)]])
{
float3 n = normalize(in.normalWS);
float ndotl = saturate(dot(n, params.lightDirection));
return float4(params.baseColor * ndotl, 1.0);
}
注意 normalize(in.normalWS):顶点函数输出的是单位法线,但线性插值不保持长度。三角形内部插值出来的法线一定比单位长度短,越靠近三角形中心偏得越多。不重新归一化,着色就会出现一片可见的暗块。
插值限定符
默认情况下所有 VertexOut 的成员都会被透视正确地插值。可以逐成员改变这个行为:
MSLstruct VertexOut {
float4 position [[position]];
float3 normalWS; // 默认:perspective-correct
float2 screenUV [[center_no_perspective]]; // 线性插值,屏幕空间用
uint materialID [[flat]]; // 不插值,取第一个顶点的值
};
[[flat]]:不插值。整数类型(材质 ID、实例索引)必须用它,因为整数没有合理的插值方式。[[center_no_perspective]]:屏幕空间线性插值。做全屏 quad 的 UV 时正确且更省。[[sample_perspective]]:MSAA 下按每个采样点求值,而不是每个片元一次。质量更好,代价是片元函数执行次数上升到采样数倍。
quad:真正的执行单位
GPU 不会单独执行一个片元。光栅化器以 2×2 的 quad 为单位派发,即使这四个片元里只有一个真的被三角形覆盖。
这样做是因为纹理采样需要导数。选择 mipmap 层级要知道相邻像素之间 UV 变化了多少,而这个差值只能通过比较同一个 quad 里相邻片元的 UV 得到。dfdx / dfdy 就是这么实现的。
这带来两个直接后果:
- 细长三角形非常昂贵。 一个只覆盖几个像素的三角形,仍然要为每个部分覆盖的 quad 执行 4 次片元函数,其中 3 次的结果被丢弃。这就是为什么密集网格在远处比想象中慢得多,也是 mesh LOD 存在的理由之一。
- 在有分支的代码里调用
sample()是危险的。 如果 quad 内的四个片元走了不同分支,导数就失去意义。Metal 要求纹理采样发生在非均匀控制流之外。需要在分支里采样时,用显式的 LOD:texture.sample(s, uv, level(0))。
深度测试与提前深度
深度测试属于固定功能,通过 MTLDepthStencilState 配置:
Swiftlet depthDescriptor = MTLDepthStencilDescriptor()
depthDescriptor.depthCompareFunction = .less
depthDescriptor.isDepthWriteEnabled = true
depthState = device.makeDepthStencilState(descriptor: depthDescriptor)
encoder.setDepthStencilState(depthState)
按规范,深度测试发生在片元函数之后——因为片元函数理论上可以修改深度。但只要着色器不写 [[depth]]、不使用 discard_fragment()、不开启 alpha to coverage,硬件就会做提前深度测试(early-Z),在执行片元函数之前就丢掉被遮挡的片元。
所以:一个用了 discard_fragment() 的 shader(比如 alpha test 的树叶)会让整个 draw call 失去 early-Z。渲染带 alpha test 的植被时,通常值得把它们单独分组,放在不透明物体之后绘制。
如果着色器确实需要写深度,但你能保证只会把深度写得更远,可以声明保守深度,让硬件保留部分优化:
MSLfragment float4 f(..., float d [[depth(greater)]]) { ... }
混合
半透明通过混合状态配置,属于 pipeline state 的一部分而不是 encoder 状态:
Swiftlet attachment = pipelineDescriptor.colorAttachments[0]!
attachment.isBlendingEnabled = true
attachment.rgbBlendOperation = .add
attachment.sourceRGBBlendFactor = .sourceAlpha
attachment.destinationRGBBlendFactor = .oneMinusSourceAlpha
常见组合:
| 效果 | source | destination |
|---|---|---|
| 常规透明 | .sourceAlpha | .oneMinusSourceAlpha |
| 预乘 alpha | .one | .oneMinusSourceAlpha |
| 加法(发光、火焰) | .one | .one |
| 乘法(阴影贴花) | .destinationColor | .zero |
混合的关键限制是顺序相关:a over b 不等于 b over a。所以半透明物体必须由远及近排序绘制,而且要关闭深度写入(isDepthWriteEnabled = false),否则近处的透明物体会挡住它后面本该透出来的东西。排序在物体之间穿插时会失效——这就是为什么半透明在任何引擎里都是麻烦事。
预乘 alpha 值得单独说一句:它让混合结果对滤波和 mipmap 更稳健,因为线性插值两个预乘颜色得到的仍然是正确的预乘颜色,而直通 alpha 会在边缘产生黑边。
多渲染目标
一个片元函数可以同时写多个附件,这是延迟渲染的基础:
MSLstruct GBufferOut {
float4 albedo [[color(0)]];
float4 normal [[color(1)]];
float4 position [[color(2)]];
};
fragment GBufferOut gbuffer_fragment(VertexOut in [[stage_in]]) {
GBufferOut out;
out.albedo = float4(baseColor, 1);
out.normal = float4(normalize(in.normalWS) * 0.5 + 0.5, 1);
out.position = float4(in.positionWS, 1);
return out;
}
CPU 端要在 render pass descriptor 和 pipeline descriptor 里配置对应数量的 color attachment,并且像素格式必须完全一致,否则创建 pipeline 时就会报错。
在 Apple Silicon 上,这里还有一个特别的机会:tile memory。多个附件可以完全留在片上内存里,不写回显存。我们在《Metal #11 延迟渲染》里会详细看这件事。
下一篇讲纹理。