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

Metal #13 曲面细分

#ComputerGraphics#GraphicsEngine#Metal

这篇笔记主要写一下曲面细分(tessellation):在 GPU 上把一个粗糙的面片切成很多小三角形,用来做地形、置换和自适应 LOD。

Metal 的细分管线与众不同

在 D3D11 和 OpenGL 4 里,细分是三个固定阶段:hull shader(外壳着色器)→ tessellator(固定功能)→ domain shader(域着色器)。

Metal 只保留了后两个。细分因子不是由一个着色器阶段计算,而是由一个普通的 compute kernel 写进一个 buffer。

Plain Textcompute kernel (你写的) → factor buffer → tessellator (固定功能) → post-tessellation vertex function (你写的)

这个设计一开始会让人困惑,但它其实更灵活:细分因子的计算变成了一次普通的 compute dispatch,可以和别的 GPU 工作放在一起,可以复用之前计算的可见性数据,也可以缓存下来跨帧使用。

计算细分因子

MSLkernel void tessellation_factors(
    device MTLQuadTessellationFactorsHalf *factors [[buffer(0)]],
    constant Patch *patches [[buffer(1)]],
    constant TessParams &params [[buffer(2)]],
    uint pid [[thread_position_in_grid]])
{
    float3 center = patches[pid].center;
    float  dist   = distance(center, params.cameraPosition);

    // 距离越近,细分越密
    float t = saturate(1.0 - (dist - params.nearDist) / (params.farDist - params.nearDist));
    float level = mix(1.0, params.maxTessellation, t);

    // 四条边 + 内部两个方向
    factors[pid].edgeTessellationFactor[0] = half(edgeLevel(patches[pid], 0, params));
    factors[pid].edgeTessellationFactor[1] = half(edgeLevel(patches[pid], 1, params));
    factors[pid].edgeTessellationFactor[2] = half(edgeLevel(patches[pid], 2, params));
    factors[pid].edgeTessellationFactor[3] = half(edgeLevel(patches[pid], 3, params));
    factors[pid].insideTessellationFactor[0] = half(level);
    factors[pid].insideTessellationFactor[1] = half(level);
}

这里最容易出错的是边的因子。 相邻两个面片共享一条边,如果它们给这条边算出了不同的细分因子,生成的顶点位置对不上,就会出现一条裂缝(crack)。

解法是让边的因子只依赖于这条边本身的属性,而不依赖面片:比如用边的两个端点的中点到相机的距离来算,这样无论从哪个面片看,同一条边算出的结果都相同。

MSLfloat edgeLevel(Patch p, uint edge, constant TessParams &params) {
    float3 mid = (p.corner[edge] + p.corner[(edge + 1) % 4]) * 0.5;
    float  d   = distance(mid, params.cameraPosition);
    return clamp(params.maxTessellation / max(d, 1.0), 1.0, params.maxTessellation);
}

因子为 0 表示剔除这个面片——这是一个很有用的特性,可以在这个 kernel 里同时做视锥剔除和背面剔除,被剔除的面片连细分都不会发生。

细分后的顶点函数

MSL[[patch(quad, 4)]]
vertex TessOut tessellation_vertex(
    patch_control_point<ControlPoint> control [[stage_in]],
    float2 uv [[position_in_patch]],
    texture2d<float> heightMap [[texture(0)]],
    constant Uniforms &u [[buffer(11)]])
{
    // 双线性插值得到面片内部的位置
    float3 top    = mix(control[0].position, control[1].position, uv.x);
    float3 bottom = mix(control[3].position, control[2].position, uv.x);
    float3 pos    = mix(top, bottom, uv.y);

    // 置换:按高度图抬升
    float2 texUV  = mix(mix(control[0].uv, control[1].uv, uv.x),
                        mix(control[3].uv, control[2].uv, uv.x), uv.y);
    float  height = heightMap.sample(heightSampler, texUV, level(0)).r;
    pos.y += height * u.displacementScale;

    TessOut out;
    out.position = u.viewProjection * float4(pos, 1);
    out.uv       = texUV;
    return out;
}

[[patch(quad, 4)]] 声明这是四边形面片、四个控制点。[[position_in_patch]] 是 tessellator 给出的面片内参数坐标(四边形是 uv,三角形是重心坐标)。

注意 level(0):在顶点阶段采样纹理必须指定显式 LOD,因为顶点阶段没有导数信息。

设置管线

SwiftpipelineDescriptor.tessellationFactorStepFunction      = .perPatch
pipelineDescriptor.tessellationPartitionMode           = .fractionalEven
pipelineDescriptor.tessellationOutputWindingOrder      = .clockwise
pipelineDescriptor.maxTessellationFactor               = 16

tessellationPartitionMode 决定非整数因子怎么处理:

  • .pow2 — 只取 2 的幂,最快,但 LOD 切换时会有明显跳变。
  • .integer — 取整,切换时仍有跳变。
  • .fractionalEven / .fractionalOdd允许小数因子,新顶点从已有边上平滑地"长出来",这是避免 LOD 弹跳(popping)的唯一办法。地形几乎总是用 fractional 模式。

绘制时用专门的 API:

Swiftencoder.setTessellationFactorBuffer(factorBuffer, offset: 0, instanceStride: 0)
encoder.drawPatches(numberOfPatchControlPoints: 4,
                    patchStart: 0, patchCount: patchCount,
                    patchIndexBuffer: nil, patchIndexBufferOffset: 0,
                    instanceCount: 1, baseInstance: 0)

置换贴图与法线

置换贴图真正改变了几何,这和法线贴图有本质区别:它有正确的轮廓、正确的自阴影、正确的视差。

但有一个陷阱:置换之后原来的法线就不对了。有三种做法:

  1. 从高度图算法线。 采样相邻的高度值做有限差分。简单,但需要额外三次采样。
  2. 预烘焙法线贴图。 在 DCC 里从高模烘焙,运行时直接采样。最快,最常用。
  3. 在片元阶段用屏幕空间导数。 normalize(cross(dfdx(posWS), dfdy(posWS)))。零额外采样,但得到的是面法线,看起来是硬边的。

什么时候用,什么时候不用

细分在地形上是明确的胜利:一张高度图加一个粗网格,就能得到近处精细、远处粗糙的自适应几何,而且数据量极小。

但要清楚它的代价。细分阶段在很多 GPU(尤其是移动 GPU)上吞吐有限,细分因子开太大很容易让它成为瓶颈。经验规则是让最终三角形保持在屏幕上 8 像素以上——比这更小的三角形会被 quad 开销吃掉大部分性能,还不如直接用更精细的静态网格。

另外值得知道的是,行业的方向正在改变。Mesh shader(Metal 里叫 object shader + mesh shader)提供了一套更通用的几何生成管线,可以做细分能做的一切,还能做 meshlet 剔除这类细分做不到的事。在新项目里,如果目标设备支持 mesh shader,它通常是更好的选择。细分仍然值得了解,因为它在已有引擎里还广泛存在,而且概念上更简单。

下一篇讲后处理。

本系列文章

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