本文继续对《UnityShader入门精要》——冯乐乐 第十八章 基于物理的渲染 进行学习
在https://github.com/candycat1992/Unity_Shaders_Book中,冯乐乐给出了2019年改版后的第18章,本文基于此版本进行学习。
一、实践
我们已经介绍了足够多的理论内容了,现在是时候动手自己实现一个基于物理渲染的 Shader了!在本节中,我们将在 Unity Shader 中实现 18.1.7 节ᨀ到的 BRDF 模型。读者可以发现,把 PBS应用到自己的材质中并不是一件非常困难的事情。
我们回顾使用了精确光源简化后的渲染方程:
其中,Le(v)是自发光部分,f(lic, v)是最为关键的 BRDF 模型部分。BRDF 的高光反射项则可以用下面的通用形式来表示:
在本例中,我们会使用 Disney BRDF 中的漫反射项、Schlick 菲涅耳近似等式、基于 GGX 模型的法线分布函数和 Smith-Joint 阴影-遮掩函数作为 BRDF 光照模型的实现。在学习完本节后,我们会得到类似图 18.7 中的效果:
① https://www.assetstore.unity3d.com/en/#!/content/58870
1.
(1)在 Unity 中新建一个场景。在本书资源中,该场景名为 Scene_18_2。我们使用本书资源中的天空盒材质 EveningSkyboxHDR,在 Window -> Lighting -> Skybox 中代替场景默认的天空盒。
(2)新建两个材质。在本书资源中,这两个材质分别名为 CustomPBSCubeMat 和CustomPBSSphereMat。
(3)新建一个 Unity Shader。在本书资源中,该 Unity Shader 名为 Chapter18-CustomPBR。把新的 Unity Shader 赋给第 2 步中创建的材质。
(4)在场景中放置一个球体和立方体,并把第 2 步中的两个材质分别赋给两个物体。
(5)保存场景。
2.
打开新建的 Chapter18-CustomPBR,删除所有已有代码,并进行如下修改。
(1)首先,我们需要为这个 Unity Shader 起一个名字:Shader "Unity Shaders Book v2/Chapter 18/Custom PBR"
(2)然后,我们需要在 Properties 语义块中声明 PBR 中需要的所有材质属性:
Properties {
_Color ("Color", Color) = (1, 1, 1, 1)
_MainTex ("Albedo", 2D) = "white" {}
_Glossiness ("Smoothness", Range(0.0, 1.0)) = 0.5
_SpecColor ("Specular", Color) = (0.2, 0.2, 0.2)
_SpecGlossMap ("Specular (RGB) Smoothness (A)", 2D) = "white" {}
_BumpScale ("Bump Scale", Float) = 1.0
_BumpMap ("Normal Map", 2D) = "bump" {}
_EmissionColor ("Color", Color) = (0, 0, 0)
_EmissionMap ("Emission", 2D) = "white" {}
}
其中, _MainTex 和 _Color 用于控制漫反射项中的材质纹 理和颜色, _SpecColor 和_SpecGlossMap 的 RGB 通道值用于控制材质的高光反射颜色。_SpecGlossMap 的 A 通道值和_Glossiness 用于共同控制材质的粗糙度。_BumpMap 则是材质的法线纹理,它的凹凸程度可以依靠_BumpScale 属性来控制。最后,_EmissionColor 和_EmissionMap 用于控制材质的自发光颜色。
3)定义 Forwad Base Pass:
SubShader {
Tags { "RenderType"="Opaque" }
LOD 300
Pass {
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma target 3.0
#pragma multi_compile_fwdbase
#pragma multi_compile_fog
注意,在上面的代码中我们通过使用#pragma target 3.0 来指明使用 Shader Target 3.0,这是因为基于物理渲染涉及了较多的公式,因此需要较多的数学指令来进行计算,这可能会超过 Shader Target 2.0 对指令数目的规定,因此我们选择使用更高的 Shader Target 3.0。
4)接下来,我们来定义顶点着色器:
struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 TtoW0 : TEXCOORD1;
float4 TtoW1 : TEXCOORD2;
float4 TtoW2 : TEXCOORD3;
SHADOW_COORDS(4) // Defined in AutoLight.cginc
UNITY_FOG_COORDS(5) // Defined in UnityCG.cginc
};
v2f vert(a2v v) {
v2f o;
UNITY_INITIALIZE_OUTPUT(v2f, o); // Defined in HLSLSupport.cginc
o.pos = UnityObjectToClipPos(v.vertex); // Defined in UnityCG.cginc
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex); // Defined in UnityCG.cginc
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
//We need this for shadow receving
TRANSFER_SHADOW(o); // Defined in AutoLight.cginc
//We need this for fog rendering
UNITY_TRANSFER_FOG(o, o.pos); // Defined in UnityCG.cginc
return o;
}
顶点着色器中的计算比较简单,我们在之前的章节中也进行过类似的计算。为了在片元着色器中把采样得到的切线空间下的法线方向转换到世界空间下,我们把变换矩阵的相关数据存储在了 o.TtoW0、o.TtoW1 和 o.TtoW2 中。除此之外,我们还使用内置宏 SHADOW_COORDS、UNITY_FOG_COORDS、TRANSFER_SHADOW 和 UNITY_TRANSFER_FOG 等声明和计算了阴影和雾效所需要的一些纹理坐标参数。
5)片元着色器是我们的重点,我们来一步步看它是怎样实现的:
half4 frag(v2f i) : SV_Target {
///// Prepare all the inputs
half4 specGloss = tex2D(_SpecGlossMap, i.uv);
specGloss.a *= _Glossiness;
half3 specColor = specGloss.rgb * _SpecColor.rgb;
half roughness = 1 - specGloss.a;
half oneMinusReflectivity = 1 - max(max(specColor.r, specColor.g), specColor.b);
half3 diffColor = _Color.rgb * tex2D(_MainTex, i.uv).rgb * oneMinusReflectivity;
half3 normalTangent = UnpackNormal(tex2D(_BumpMap, i.uv));
normalTangent.xy *= _BumpScale;
normalTangent.z = sqrt(1.0 - saturate(dot(normalTangent.xy, normalTangent.xy)));
half3 normalWorld = normalize(half3(dot(i.TtoW0.xyz, normalTangent),
dot(i.TtoW1.xyz, normalTangent), dot(i.TtoW2.xyz, normalTangent)));
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
half3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos)); // Defined in
UnityCG.cginc
half3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos)); // Defined in
UnityCG.cginc
half3 reflDir = reflect(-viewDir, normalWorld);
UNITY_LIGHT_ATTENUATION(atten, i, worldPos); // Defined in AutoLight.cginc
我们首先需要为后续计算准备好所有的输入数据,这些输入大多来源于材质面板中的各个属性,例如漫反射颜色 diffColor 和高光反射颜色 specColor、粗糙度 roughness、世界空间下的法线方向、光源方向、观察方向、反射方向等。我们还使用内置宏 UNITY_LIGHT_ATTENUATION 计算了阴影和光照衰减值 atten。除此之外,我们还计算了一个变量 oneMinusReflectivity,这个变量并不是我们之前提到的 BRDF 中需要的变量,它主要是为了计算掠射角的反射颜色,从而得到效果更好的菲涅耳反射效果。
接下来,我们开始计算最重要的 BRDF 光照模型。在此之前,我们先准备好各个角度的余弦值,即之前公式中的各个点乘项,如(n • v)、(n • l)等:
///// Compute BRDF terms
half3 halfDir = normalize(lightDir + viewDir);
half nv = saturate(dot(normalWorld, viewDir));
half nl = saturate(dot(normalWorld, lightDir));
half nh = saturate(dot(normalWorld, halfDir));
half lv = saturate(dot(lightDir, viewDir));
half lh = saturate(dot(lightDir, halfDir));
通过使用 Cg 的 saturate 函数,我们把这些点乘值的范围截取到了[0, 1]之间,来避免背光面的光照。然后,我们来计算 BRDF 中的漫反射项:
// Diffuse term
half3 diffuseTerm = CustomDisneyDiffuseTerm(nv, nl, lh, roughness, diffColor);
6)对于漫反射项,我们选择使用 Disney BRDF 中的漫反射项实现,CustomDisneyDiffuseTerm函数的实现(依照 Disney BRDF 中的漫反射项公式)如下:
inline half3 CustomDisneyDiffuseTerm(half NdotV, half NdotL, half LdotH, half
roughness, half3 baseColor) {
half fd90 = 0.5 + 2 * LdotH * LdotH * roughness;
// Two schlick fresnel term
half lightScatter = (1 + (fd90 - 1) * pow(1 - NdotL, 5));
half viewScatter = (1 + (fd90 - 1) * pow(1 - NdotV, 5));
return baseColor * UNITY_INV_PI * lightScatter * viewScatter;
}
这个函数非常简单,我们就是按照之前提到的公式实现相应代码而已。UNITY_INV_PI 是在UnityCG.cginc 文件中定义的宏变量,即圆周率π的倒数。在上面的实现中,我们还使用了 Cg 关键词 inline①来修饰函数声明,inline 的作用是用于告诉编译器应该尽可能使用内联调用的方式来调用该函数,减少函数调用的开销。
① http://http.developer.nvidia.com/Cg/Cg_language.html
7)下面,我们来实现高光反射项:
// Specular term
half V = CustomSmithJointGGXVisibilityTerm(nl, nv, roughness);
half D = CustomGGXTerm(nh, roughness * roughness);
half3 F = CustomFresnelTerm(specColor, lh);
half3 specularTerm = F * V * D;
首先是可见性项 V,它计算的是阴影-遮掩函数除以高光反射项的分母部分后的结果。CustomSmithJointGGXVisibilityTerm 函数的实现(依照 Eric Heitz[12]提出的按 Height-Correlated Masking and Shadowing 方式组合的 Smith-Joint 阴影-遮掩函数)如下:
inline half CustomSmithJointGGXVisibilityTerm(half NdotL, half NdotV, half roughness)
{
// Original formulation:
// lambda_v = (-1 + sqrt(a2 * (1 - NdotL2) / NdotL2 + 1)) * 0.5f;
// lambda_l = (-1 + sqrt(a2 * (1 - NdotV2) / NdotV2 + 1)) * 0.5f;
// G = 1 / (1 + lambda_v + lambda_l);
// Approximation of the above formulation (simplify the sqrt, not mathematically
correct but close enough)
half a2 = roughness * roughness;
half lambdaV = NdotL * (NdotV * (1 - a2) + a2);
half lambdaL = NdotV * (NdotL * (1 - a2) + a2);
return 0.5f / (lambdaV + lambdaL + 1e-5f);
}
接下来是法线分布项 D,CustomGGXTerm 函数的实现(依照基于 GGX 模型的法线分布函数)如下:
inline half CustomGGXTerm(half NdotH, half roughness) {
half a2 = roughness * roughness;
half d = (NdotH * a2 - NdotH) * NdotH + 1.0f;
return UNITY_INV_PI * a2 / (d * d + 1e-7f);
}
最后是菲涅耳反射项 F,CustomFresnelTerm 函数(依照 Schlick 菲涅耳近似等式[7])的实现如下:
inline half3 CustomFresnelTerm(half3 c, half cosA) {
half t = pow(1 - cosA, 5);
return c + (1 - c) * t;
}
最后的高光反射项就是把 V、D 和 F 相乘后的结果。
8)接下来,我们还需要计算自发光项:
// Emission term
half3 emisstionTerm = tex2D(_EmissionMap, i.uv).rgb * _EmissionColor.rgb;
自发光项非常简单,我们只需要从自发光纹理中进行采样再乘以自发光颜色即可。
9)为了得到更加真实的光照,我们还需要计算基于图像的光照部分(IBL):
// IBL
half perceptualRoughness = roughness * (1.7 - 0.7 * roughness);
half mip = perceptualRoughness * 6;
half4 envMap = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, reflDir, mip); // Defined in
HLSLSupport.cginc
half grazingTerm = saturate((1 - roughness) + (1 - oneMinusReflectivity));
half surfaceReduction = 1.0 / (roughness * roughness + 1.0);
half3 indirectSpecular = surfaceReduction * envMap.rgb * CustomFresnelLerp(specColor,
grazingTerm, nv);
IBL 部分的主要思想是使用材质粗糙度对环境贴图进行 LOD(Level Of Detail)采样,这是因为粗糙度越大的材质,反射的环境光照应该越模糊,而这可以通过对环境贴图不同级数的多级渐远纹理(mipmaps)进行采样来模拟得到。级数越高,在多级渐远纹理中对应的纹理就越小,图像也就越模糊。
为了计算需要采样的多级渐远纹理的级数,我们将材质粗糙度乘以某个常数(在上述实现中该常数为 6),这个常数表明了整个粗糙度范围内多级渐远纹理的总级数。需要注意的是,这种由粗糙度计算级数的方法并不是唯一的,读者可以在 UnityImageBasedLighting.cginc 文件的perceptualRoughnessToMipmapLevel 函数中找到相关实现。
然后,我们使用该级数和反射方向来对环境贴图进行采样。其中,unity_SpecCube0 包含了该物体周围当前活跃的反射探针(Reflection Probe)中所包含的环境贴图。尽管我们没有在场景中手动放置任何反射探针,但 Unity 会根据Window -> Lighting -> Skybox 中的设置,在场景中生成一个默认的反射探针。由于在本节的准备工作中我们在 Window -> Lighting -> Skybox 中设置了自定义的天空盒,因此此时 unity_SpecCube0中包含的就是这个自定义天空盒的环境贴图。
如果我们在场景中放置了其他反射探针,Unity 则会根据相关设置和物体所在的位置自动把距离该物体最近的一个或几个反射探针数据传递给 Shader。尽管在之前的内容中,我们是使用 samplerCUBE 来声明一个立方体贴图并使用 texCUBE 来采样它,但是 Unity 内置反射探针的立方体贴图则是以一种特殊的方式声明的,这主要是为了在某些平台下可以节省 sampler slots。读者可以在 UnityShaderVariables.cginc 文件中找到 unity_SpecCube0的声明,Unity 主要是通过 HLSLSupport.cginc 文件中定义的内置宏 UNITY_DECLARE_TEXCUBE来实现的。 由于这样的特殊性, 在采样 unity_SpecCube0 时我们也应该使用内置宏如UNITY_SAMPLE_TEXCUBE(在 HLSLSupport.cginc 文件中被定义)来采样。由于在这里我们还需要对指定级数的多级渐远纹理采样,因此我们使用内置宏 UNITY_SAMPLE_TEXCUBE_LOD(在 HLSLSupport.cginc 文件中被定义)来实现。
至此,我们得到了采样后的环境光照颜色 envMap。然后,为了给 IBL 添加更加真实的菲涅耳反射,我们对高光反射颜色 specColor 和掠射颜色grazingTerm 进行菲涅耳插值。掠射颜色 grazingTerm 是由材质粗糙度和之前计算得到的oneMinusReflectivity 共同决定的。使用掠射角度进行菲涅耳插值的好处是,我们可以在掠射角得到更加真实的菲涅耳反射效果,同时还考虑了材质粗糙度的影响。除此之外,我们还使用了由粗糙度计算得到的 surfaceReduction 参数进一步对 IBL 的进行修正。CustomFresnelLerp 的函数实现如下:
inline half3 CustomFresnelLerp(half3 c0, half3 c1, half cosA) {
half t = pow(1 - cosA, 5);
return lerp (c0, c1, t);
}
它的实现和之前实现的 CustomFresnelTerm 函数很类似,不同的是这里使用参数 t 来混合两个颜色。尽管 grazingTerm 被声明为单一维数的 half 变量,在传递给 CustomFresnelLerp 时它会自动被转换成 half3 类型的变量,这在 Cg 中被称为是"Smearing" Of Scalars To Vectors①。
① http://http.developer.nvidia.com/Cg/Cg_language.html
10)最后,我们只需要按照渲染方程把所有项加起来即可:
// Combine all togather
half3 col = emisstionTerm + UNITY_PI * (diffuseTerm + specularTerm) * _LightColor0.rgb
* nl * atten + indirectSpecular;
UNITY_APPLY_FOG(i.fogCoord, c.rgb); // Defined in UnityCG.cginc
return half4(col, 1);
在返回最后的像素颜色前,我们还添加了雾效的影响。至此我们完成了 Forward Base Pass 中的所有实现。
11)由于场景中可能存在多个光源,我们还需要实现 Forward Add Pass。Forward Add Pass 的实现与 Forward Base Pass 基本一致,其中不同的是,Forward Add Pass 不需要计算雾效、自发光和 IBL 的部分,因为这些只需要在 Forward Base Pass 计算一遍即可。其他实现在此不再赘述。至此,我们就完成了一个较为完整的基于物理渲染的 Shader。
保存后返回场景,再调整相关参数即可得到类似图 18.7 中的效果。图 18.7 中物体所用的材质面板如图 18.8 所示,它们分别对应了一个金属类型的材质和一个塑料类型的材质。关于如何使用PBR 设置各种材质参数,读者可以参见 18.3.2 节中的内容。
需要注意的是,我们还需要保证 Player Settings → Other Settings → Rendering → Color Space 中的选项是 Linear,即线性空间,只有这样才能保证我们的计算是在线性空间下进行的,且输出的为线性颜色。与线性空间相关的是伽马校正,这部分内容读者可以参见本章的 18.4.2 节。
在上面的内容中,我们依靠自定义的函数实现了一个基于 GGX BRDF 模型的 Shader。实际上,Unity 已经帮我们实现了很多 BRDF 模型中的函数,并为我们提供了现成的基于物理着色的Shader,也就是 Standard Shader。