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

Metal #8 材质

#ComputerGraphics#GraphicsEngine#Metal

这篇笔记主要写一下材质:怎么把上一篇的光照参数组织成一套美术能用的系统,以及为什么业界最后都收敛到了 PBR。

材质是一组参数加一组贴图

一个 Phong 材质大概是这样:

Ctypedef struct {
    vector_float3 diffuse;
    vector_float3 specular;
    float         shininess;
} PhongMaterial;

这套参数的问题是它们不对应任何可测量的物理量specular 该填多少?shininess 是 32 还是 64?美术只能靠试。更糟的是,同一个材质在不同光照环境下需要重新调参,因为参数里隐含了对光照强度的假设。

PBR(基于物理的渲染)解决的正是这个问题:用可测量的物理属性描述材质,让同一份材质在任何光照下都表现正确。

metallic-roughness 工作流

现在的事实标准是四个通道:

Ctypedef struct {
    vector_float3 baseColor;   // 反照率,线性空间
    float         metallic;    // 0 = 绝缘体, 1 = 金属
    float         roughness;   // 0 = 镜面, 1 = 完全漫反射
    float         ao;          // 环境光遮蔽
} PBRMaterial;

baseColor 对绝缘体是漫反射颜色,对金属是反射率颜色(金属没有漫反射)。这一点很反直觉但很重要:金色不是"黄色的漫反射",而是"对红绿光反射多、对蓝光反射少的镜面反射"。

metallic 在物理上是二值的:一个材质要么是金属要么不是。中间值只用于过渡区域——生锈的铁、剥落的漆——而不是"半金属"这种不存在的东西。如果你在调 0.5 的 metallic 来得到想要的效果,基本上说明别的地方错了。

roughness 描述微表面的统计分布。这是艺术家最常用的通道,因为它直接对应"看起来是新的还是旧的、干的还是湿的"。

对应地,贴图通常打包成:albedo(sRGB)+ normal(线性)+ ORM(AO / Roughness / Metallic 三通道打包进一张 RGB,线性)。ORM 打包是为了省一次纹理采样。

法线贴图与 TBN

法线贴图存的是切线空间下的法线扰动。之所以用切线空间而不是世界空间,是因为这样同一张贴图可以贴在任何朝向的模型上,而且模型可以变形、动画。

要把切线空间的法线转到世界空间,需要一组基:切线 T、副切线 B、法线 N

MSLstruct VertexOut {
    float4 position [[position]];
    float3 normalWS;
    float3 tangentWS;
    float3 bitangentWS;
    float2 uv;
};

// 顶点函数里
out.normalWS    = normalize(uniforms.normalMatrix * in.normal);
out.tangentWS   = normalize(uniforms.normalMatrix * in.tangent.xyz);
out.bitangentWS = cross(out.normalWS, out.tangentWS) * in.tangent.w;

那个 in.tangent.w手性(handedness),只会是 +1 或 -1。它记录 UV 是否被镜像。角色模型几乎总是左右共用一半 UV,镜像那一侧的副切线方向是反的,不乘这个符号,镜像半边的法线贴图效果就会反过来——通常表现为一边凹一边凸。

片元函数里:

MSLfloat3 tangentNormal = normalTex.sample(s, in.uv).xyz * 2.0 - 1.0;
float3x3 TBN = float3x3(normalize(in.tangentWS),
                        normalize(in.bitangentWS),
                        normalize(in.normalWS));
float3 n = normalize(TBN * tangentNormal);

* 2.0 - 1.0 是因为贴图存的是 [0,1],而法线分量范围是 [-1,1]。这也是法线贴图绝不能用 sRGB 格式的原因——那条 gamma 曲线会把这个映射彻底破坏。

Model I/O 可以在加载时生成切线:

SwiftmdlMesh.addTangentBasis(forTextureCoordinateAttributeNamed: MDLVertexAttributeTextureCoordinate,
                        tangentAttributeNamed: MDLVertexAttributeTangent,
                        bitangentAttributeNamed: nil)

但要注意,引擎生成的切线和 DCC 里烘焙法线贴图时用的切线必须一致,否则会出现细微的着色错误。业界通常统一用 MikkTSpace。

Cook-Torrance BRDF

PBR 的镜面项通常用 Cook-Torrance 微表面模型:

Plain Textf_spec = D * F * G / (4 * NdotL * NdotV)

三项各有分工:

**D(法线分布函数)**描述有多少比例的微表面朝向恰好能把光反射到眼睛。它决定高光的形状。现在通用 GGX / Trowbridge-Reitz,因为它的长尾和真实测量吻合得最好——高光中心之外那一圈柔和的光晕,就是长尾贡献的。

MSLfloat D_GGX(float NdotH, float roughness) {
    float a  = roughness * roughness;
    float a2 = a * a;
    float d  = NdotH * NdotH * (a2 - 1.0) + 1.0;
    return a2 / (M_PI_F * d * d);
}

注意 roughness * roughness:美术调的是感知粗糙度,GGX 用的是 alpha。平方这一步让滑块在中间段的变化更符合直觉。

**F(菲涅尔项)**描述反射率随视角的变化。所有材质在掠射角下都接近全反射——这就是为什么你能在湿地面上看到远处的倒影,却看不到脚下的。Schlick 近似:

MSLfloat3 F_Schlick(float VdotH, float3 F0) {
    return F0 + (1.0 - F0) * pow(1.0 - VdotH, 5.0);
}

F0 是垂直入射时的反射率。绝缘体几乎都在 0.04 左右(这是一个很好用的常数),金属的 F0 就是它的 baseColor:

MSLfloat3 F0 = mix(float3(0.04), baseColor, metallic);

**G(几何遮蔽项)**描述微表面之间互相遮挡的比例。粗糙表面在掠射角下,凸起会挡住凹陷,反射的能量比 D 单独预测的少。

最后把漫反射和镜面按能量守恒组合起来。金属没有漫反射,所以要乘 (1 - metallic)

MSLfloat3 kD = (1.0 - F) * (1.0 - metallic);
float3 color = (kD * baseColor / M_PI_F + specular) * lightColor * NdotL;

/ M_PI_F 常被省略,但漏掉它会让漫反射比镜面亮 π 倍,破坏两者的平衡。

用函数常量做材质变体

不是每个材质都有法线贴图。为每种组合写一个 shader 会爆炸,运行时用 if 判断又浪费性能。Metal 的函数常量正好解决这个:

MSLconstant bool hasNormalMap  [[function_constant(0)]];
constant bool hasMetallicMap [[function_constant(1)]];

fragment float4 pbr_fragment(...) {
    float3 n = in.normalWS;
    if (hasNormalMap) {          // 编译期消除,不是运行时分支
        n = sampleNormalMap(...);
    }
    ...
}

创建 pipeline 时传入常量值,Metal 会特化出一个没有这个分支的版本。这比 #define 组合好管理得多,也比运行时分支快。

下一篇讲怎么把这些渲染工作组织成多个 pass。

本系列文章

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