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

Metal #4 片元函数

#ComputerGraphics#GraphicsEngine#Metal

这篇笔记主要写一下 Metal 的片元函数(fragment function)以及它前后的固定管线阶段。

片元不是像素

一个片元(fragment)是"某个三角形覆盖了某个采样点"这件事产生的候选数据。一个像素最终的颜色可能来自多个片元(半透明叠加、MSAA 的多个采样点),也可能来自零个片元(被剔除或没有几何覆盖)。

区分这两个词很重要,因为片元函数执行的次数取决于三角形覆盖了多少采样点,而不是屏幕上有多少像素——这正是过绘制(overdraw)带来性能问题的原因。

MSLfragment float4 fragment_main(VertexOut in [[stage_in]],
                              constant Params &params [[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 就是这么实现的。

这带来两个直接后果:

  1. 细长三角形非常昂贵。 一个只覆盖几个像素的三角形,仍然要为每个部分覆盖的 quad 执行 4 次片元函数,其中 3 次的结果被丢弃。这就是为什么密集网格在远处比想象中慢得多,也是 mesh LOD 存在的理由之一。
  2. 在有分支的代码里调用 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

常见组合:

效果sourcedestination
常规透明.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 延迟渲染》里会详细看这件事。

下一篇讲纹理。

本系列文章

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