这篇笔记主要写一下曲面细分(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 ¶ms [[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 ¶ms) {
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)
置换贴图与法线
置换贴图真正改变了几何,这和法线贴图有本质区别:它有正确的轮廓、正确的自阴影、正确的视差。
但有一个陷阱:置换之后原来的法线就不对了。有三种做法:
- 从高度图算法线。 采样相邻的高度值做有限差分。简单,但需要额外三次采样。
- 预烘焙法线贴图。 在 DCC 里从高模烘焙,运行时直接采样。最快,最常用。
- 在片元阶段用屏幕空间导数。
normalize(cross(dfdx(posWS), dfdy(posWS)))。零额外采样,但得到的是面法线,看起来是硬边的。
什么时候用,什么时候不用
细分在地形上是明确的胜利:一张高度图加一个粗网格,就能得到近处精细、远处粗糙的自适应几何,而且数据量极小。
但要清楚它的代价。细分阶段在很多 GPU(尤其是移动 GPU)上吞吐有限,细分因子开太大很容易让它成为瓶颈。经验规则是让最终三角形保持在屏幕上 8 像素以上——比这更小的三角形会被 quad 开销吃掉大部分性能,还不如直接用更精细的静态网格。
另外值得知道的是,行业的方向正在改变。Mesh shader(Metal 里叫 object shader + mesh shader)提供了一套更通用的几何生成管线,可以做细分能做的一切,还能做 meshlet 剔除这类细分做不到的事。在新项目里,如果目标设备支持 mesh shader,它通常是更好的选择。细分仍然值得了解,因为它在已有引擎里还广泛存在,而且概念上更简单。
下一篇讲后处理。