PBR基础介绍(二)

本文将在unity中尝试复现上文提及的内容,主要参考了宋开心大佬的这篇

根据BRDF的渲染方程需要实现的主要部分:

  1. 直接光
    1. 直接光漫反射
    2. 直接光高光
      1. 法线分布函数 D
      2. 几何函数 G
      3. 菲涅尔函数 F
  2. 间接光
    1. IBL
    2. SH

实现之前

Unity的linear空间与Gamma空间

1
2
3
4
5
6
7
// Shader中涉及到的参数如下
_MainTex("Texture", 2D) = "white" {}
_Tint("Tint", Color) = (1 ,1 ,1 ,1)
// 金属度
_Metallic("Metallic", Range(0, 1)) = 0
// 粗糙度
_Smoothness("Smoothness", Range(0, 1)) = 0.5

需要注意的是设置金属度/光滑度贴图的时候,如果在linear空间下贴图设定勾选了SRGB的选项(这个勾选本质上就是对颜色做了一次pow(color,2.2)的操作),那么需要在开头添加[Gamma]。一般来说PBR需要配合HDR进行开发。

1
2
3
4
// 金属度
[Gamma]_Metallic("Metallic", Range(0, 1)) = 0
// 粗糙度
[Gamma]_Smoothness("Smoothness", Range(0, 1)) = 0.5

这里是两个空间下进行的步骤

linear颜色空间:

1)unity对输入颜色做逆gamma校正

2)shader对颜色进行计算并返回

3)unity对返回颜色做gamma校正

4)显示器对显卡输出的颜色做逆校正

5)人眼对显示器显示的图像做gamma校正

gamma颜色空间:

1)shader对输入颜色进行计算并返回

2)显示器对显卡输出的颜色做逆gamma校正

3)人眼对显示器显示的图像做gamma校正

Roughness和perceptualRoughness的关系

由Disney提出,在diffuse和specular中roughness使用的是不同的值,如下图所示,个人觉得并没有很清晰的解释另一个roughness是怎么来的,只是知道了roughness需要更明显的表现对表面的凸起的平滑。在Unity中计算关系如下

1
2
perceptualRoughness = 1.0 - _Smoothness;
roughness = perceptualRoughness * perceptualRoughness;

直接光

直接光漫反射

由渲染方程可得 Kd * Color / PI

1
half3 diffuseColor = albedo * lightColor * NdotL / UNITY_PI;

得到的结果如下,上方是从Unity官方扒下来的直接光部分,下方是除了PI之后的表现效果。

可以看到得到的结果相当暗,但是在UnityStandardBRDF.cginc的注释中得到了答案。我理解的是这里因为是经验性的设置,为了统一和旧版本shader效果,保证整体不会太暗,漫反射和高光项同时乘了PI。

因此漫反射改为如下

1
half3 diffuseColor = albedo * lightColor * NdotL;

在Unity中主要使用的是Disney Diffuse

1
2
3
4
5
6
7
8
9
10
// Disney Diffuse
half DisneyDiffuse(half NdotV, half NdotL, half LdotH, half perceptualRoughness)
{
half fd90 = 0.5 + 2 * LdotH * LdotH * perceptualRoughness;
// Two schlick fresnel term
half lightScatter = (1 + (fd90 - 1) * Pow5(1 - NdotL));
half viewScatter = (1 + (fd90 - 1) * Pow5(1 - NdotV));

return lightScatter * viewScatter;
}

这里采用的是渲染方程中的实现方式,得到的效果如下第四排

直接光镜面反射

这边儿是重点,整个的形式如下,Unity中采用的是cook-torrance的公式

法线分布函数 D

按上一篇理论来说,这个函数本质上输出的是一个比值,统计学上是一个正态分布函数,计算的是微平面上的半程向量H和宏观平面半程向量一致的数量有多少。在Unity中采用的是Trowbridge-Reitz GGX。

1
2
3
4
5
6
7
8
// NDF
inline float TrowbridgeReitzGGX(float NdotH, float roughness)
{
float r = max(roughness, 0.003);
float r2 = r * r;
float d = (NdotH * NdotH) * (r2 - 1.0) + 1.0;
return UNITY_INV_PI * r2 / (d * d);
}

将实现的值输出,得到结果如下图第四排,当_Smoothness为0的时候没有高光的显示,当_Smoothness为1的时候球体上呈现出的是很小很亮的光斑。

几何函数 G

这个函数描述的是遮挡的比率。需要注意的是几何函数内是计算两个方向的,一个是光线入射方向,一个是光线出射方向。

写入函数如下:

1
2
3
4
5
6
7
8
9
// Geometric
inline float SchlickGeometricGGX(float NdotV, float NdotL, float roughness)
{
float k = (roughness + 1.0) * (roughness + 1.0) * 0.125;
float lightTerm = NdotL / lerp(NdotL, 1.0, k);
float viewTerm = NdotV / lerp(NdotV, 1.0, k);

return lightTerm * viewTerm;
}

按照公式进行计算得到的结果如下

菲涅尔 F

菲涅尔主要描述的是反射光线对比光线被折射的部分所占的比率,这个比率会随着我们观察的角度不同而不同。当光线碰撞到一个表面的时候,菲涅尔方程会根据观察角度告诉我们被反射的光线所占的百分比。利用这个反射比率和能量守恒,我们可以直接得出光线被折射的部分以及光线剩余的能量。一般当观察方向是垂直的时候获得的是对应物体最基本的反射性,基本没有菲涅尔相关的影响,当掠射角增加,菲涅尔现象会明显很多。

通常会采用Fresnel-Schlick的方法:

在经过Unreal拟合优化之后,通过exp2函数提高了计算效率,变成了这样:

1
2
3
4
5
6
7
8
half3 F0 = lerp(half3(0.04,0.04,0.04), albedo, _Metallic);

// Fresnel
inline float3 Fresnel(float3 F0, float VdotH)
{
float power = (-5.55473 * VdotH - 6.98316) * VdotH;
return F0 + (1 - F0) * exp2(power);
}

最终菲涅尔效果如下:

综合整体的直接光效果后,下边面两排分别是Unity自带的standard,和手写的shader对比

环境光

环境光的公式如下

第一部分为间接光漫反射,第二部分为间接光镜面反射。

间接光漫反射

根据Learnopengl提到的方法,通常情况下是对间接光进行预处理,将经过处理后的数据存到一张新的贴图,贴图计算主要是通过卷积完成。这张贴图又叫辐照度图,在每个入射方向取平均值的结果。Unity中已经自动完成了这一步,是通过球谐函数进行编码的全局光照,Unity整个的执行过程为:将环境贴图进行预积分得到辐照度图→将辐照度图进行球谐函数编码存储。

在Unity中通过直接调用UnityCG.cginc中的ShadeSH9函数获取相应的光照信息,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 获取辐照度
half3 ambientContribution = ShadeSH9(float4(normalWorld, 1));

half3 ambient = 0.03 * albedo;
//
half3 iblDiffuse = max(half3(0, 0, 0), ambient.rgb + ambientContribution);

// 和直接光的系数不同需要重新计算
float3 Flast = FresnelSchlickRoughness(max(NdotV, 0.0), F0, roughtness);
float kdLast = (1 - Flast) * (1 - _Metallic);

half3 iblDiffuseResult = iblDiffuse * kdLast * albedo;

// ***************

float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness)
{
return F0 + (max(float3(1.0 - roughness, 1.0 - roughness, 1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0);
}

加上间接光之后效果如下图第二排:

间接光镜面反射

Unreal给出的方法是使用近似算法将高光反射的方程简化成右边。

镜面反射部分,使用的是对预处理的环境贴图进行LOD操作之后会生成有多层的一张贴图,通过对这张贴图进行三线性插值,得到的就是对应的mip层级数据,最后在根据这个数据对HDR进行解码。

1
2
3
4
5
6
7
8
9
10
// 根据粗糙度获取mip的层级
half mipRoughness = perceptualRoughness * (1.7 - 0.7 * perceptualRoughness);
// 获取视线的反射
half3 reflectVec = reflect(-viewDir, normalWorld);
// 获取mip层级
half mip = mipRoughness * UNITY_SPECCUBE_LOD_STEPS;
// 三线性插值
half4 rgbm = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, reflectVec, mip);
// 从HDR下解码
half3 iblSpecular = DecodeHDR(rgbm, unity_SpecCube0_HDR);

这样就得到了简化方程的左半部分,而右半部分在Unity中并没有采用LUT的方法,而是通过surfaceReduction的系数,以及一个在F0(specColor就是我们的F0)和grazingTerm之间进行插值的菲涅尔系数。Unity中的计算如下:

1
2
3
4
5
half surfaceReduction = 1.0 / (roughtness * roughtness + 1.0);
half oneMinusReflectivity = unity_ColorSpaceDielectricSpec.a - _Metallic * unity_ColorSpaceDielectricSpec.a;
half grazingTerm = saturate(_Smoothness + (1.0 - oneMinusReflectivity));

half3 iblSpecularResult = iblSpecular * surfaceReduction * FresnelLerp(F0, grazingTerm, NdotV);

最后综合得到的效果如下:

参考:

  1. https://zhuanlan.zhihu.com/p/68025039
  2. https://zhuanlan.zhihu.com/p/141904960
  3. https://learnopengl-cn.github.io/07%20PBR/03%20IBL/01%20Diffuse%20irradiance/