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