找回密码
 立即注册
查看: 792|回复: 16

[笔记] Unity PBR Standard Shader 实现详解 (四)BRDF函数计算 ...

[复制链接]
发表于 2021-12-11 07:44 | 显示全部楼层 |阅读模式
经历三篇文章的写作。终于写到最后一篇,也是精华之最精华部分。
这个系列的文章主要目的在于了解Unity默认 Standard Shader和相关内置的函数方法。需要看本系列文章前三篇的客官可以看这里:
其中

  • 第一篇讲解了PBR渲染所需要的最简单的原理,以及一些美术角度的看法和观点。
  • 第二篇给出了所需的shader框架,并且给出可以实现和默认Standard材质球相同效果的手撸Shader。
  • 第三篇逐行讲解关键方法之全局光照函数的用法。以及相关的原理。
今天这篇文章,我们会讲完最后一部分,也是最重要的一部分:BRDF函数。
在本系列文章的第二篇:框架和数据准备中,我们给出了完整的standard shader简化代码。在shader的最后,我们讲到了两个重要方法:
//基于PBS的全局光照(gi变量)的计算函数。计算结果是gi的参数(Light参数和Indirect参数)。注意这一步还没有做真的光照计算。
LightingStandard_GI(o, giInput, gi);
fixed4 c = 0;
// realtime lighting: call lighting function
//PBS计算
c += LightingStandard(o, worldViewDir, gi);其中第一个方法LightingStandard_GI是全局光照计算方法,在本系列文章的第三篇已有阐述。
本片文章将聚焦于最后一个方法LightingStandard,也是最终要的BRDF计算方法。
我给出本文的框架结构概览,读者可以先留存,便于在文章中找到内容在函数流程里具体存在的位置。


另我给出可以用于测试验证的shader和cginc文件,需要的同学可以放在同一目录下使用
myPBR.shader
12.4K
· 百度网盘


myPBR.cginc
9.1K
· 百度网盘


1.什么是BRDF?
BRDF的英文原意是双向反射分布函数,说人话就是基于某种数学算法的光照衰减模型。何谓双向,即观察方向viewDir和光照方向lightDir。BRDF并不是一个确定的公式,而是一类光照模型算法的集合。不同的渲染器和引擎,可以根据自己的目标和需求来搭配修改不同的光照模型,组成自己的BRDF。
有了一个BRDF,我们就可以通过注入一些数据,例如法线,光强,光照方向,视线方向,金属度粗糙度等,经过BRDF输出一个最终的片段颜色。完成光照渲染。
而现在常说的的PBR,是基于真实的物理原理的BRDF模型。此方法上世纪90年代以来已有之,经由迪士尼优化和确认方向后,于2012年开始在业内广泛运用开来。具体的在浅墨的系列文里有更好的介绍:
2.0 LightingStandard方法

Unity默认的BRDF的算法都在LightingStandard方法里,具体我们边看代码边讲解:
话不多说,我们看它的主干代码
inline half4 LightingStandard (SurfaceOutputStandard s, float3 viewDir, UnityGI gi)
{
    s.Normal = normalize(s.Normal);// 法线归一化
    half oneMinusReflectivity;// 漫反射系数(Albedo中参与漫反射的比例)
    half3 specColor;//反射(高光反射)
    s.Albedo = DiffuseAndSpecularFromMetallic (s.Albedo, s.Metallic, /*out*/ specColor, /*out*/ oneMinusReflectivity);
    // shader relies on pre-multiply alpha-blend (_SrcBlend = One, _DstBlend = OneMinusSrcAlpha)
    // this is necessary to handle transparency in physically correct way - only diffuse component gets affected by alpha
    // 计算只影响Diffuse的alpha值
    half outputAlpha;
    s.Albedo = PreMultiplyAlpha (s.Albedo, s.Alpha, oneMinusReflectivity, /*out*/ outputAlpha);
   
    half4 c = UNITY_BRDF_PBS (s.Albedo, specColor, oneMinusReflectivity, s.Smoothness, s.Normal, viewDir, gi.light, gi.indirect);
    c.a = outputAlpha;
    return c;
}这个方法输入了

  • SurfaceOutputStandard s ,即材质表面信息,如颜色,金属度,粗糙度,法线等,具体在本系列第二篇有详细讲解。
  • viewDir即视线方向
  • UnityGI gi,即全局光照信息,这部分内容在本系列文章第二篇和第三篇有详细讲解。
方法内部的具体操作

  • 首先将法线归一化。
  • 声明变量oneMinusReflectivity和specColor,并在之后的DiffuseAndSpecularFromMetallic方法中进行inout处理。
  • 声明outputAlpha,并在PreMultiplyAlpha中处理Alpha所影响的通道。
  • 使用UNITY_BRDF_PBS进行具体的BRDF计算。
  • 加入计算好的Alpha并返回。
接下来我们看一下内置的两个小方法DiffuseAndSpecularFromMetallic及PreMultiplyAlpha
1.1DiffuseAndSpecularFromMetallic方法

这个方法的主要目的是将Albedo-Metalness贴图转换为Diffuse-Specular贴图。这个部分有很多的文章和讲解不清楚。在工作流程中,为了美术的直观方便,shader一般暴露Albedo和Metallic属性给美术制作。而实际的光照计算中,往往是分成Diffuse漫反射和Specular直接反射两部分计算。那么在进入BRDF计算之前,要得到Diffuse和Specular两部分的内容。这个观点具体我在本系列的第一篇文章有详细讲解:
关于Albedo-Metallic到Diffuse-Specular的过程,我把图再贴上来以便读者观察:


那么我们来看一下DiffuseAndSpecularFromMetallic方法的代码:
inline half3 DiffuseAndSpecularFromMetallic (half3 albedo, half metallic, out half3 specColor, out half oneMinusReflectivity)
{
    //unity_ColorSpaceDielectricSpec是Unity内置的非金属specular颜色值fixed3(0.04,0.04,0.04)。线性和非线性空间定义有不同。
    //求反射值
    specColor = lerp (unity_ColorSpaceDielectricSpec.rgb, albedo, metallic);
    //求漫反射系数(lerp(0.96,0,metallic));
    oneMinusReflectivity = OneMinusReflectivityFromMetallic(metallic);
    //求漫反射值并且返回
    return albedo * oneMinusReflectivity;
}specColor的值在Metallic=1时,返回Albedo,Metallic=0时,返回0.04(非金属默认反射颜色)
其中常量unity_ColorSpaceDielectricSpec是绝缘体的specular颜色(F0角度)。线性空间下默认值是fixed4(0.04,0.04,0.04,0.96)。在自然界中,绝缘体的反射是有一定变化的,但都在一定的范围内。在Albedo-Metallic流程里,统一这个参数为一个固定值0.04。以下是试验测定的反射度值,这些取值是在sRGB下的,非金属Specular转为线性空间就是在0.02-0.06之间:


我们再看看实际在引擎内返回的specColor的值:
(这里的试验方法参考了宋开心的方法,中间一排的材质球即我们的测试值)



specColor变量计算值,可以看到和Diffuse-Specular流程的Specular贴图很相似

说完specColor,下一个求得的值是oneMinusReflectivity。背后的算法其实就是lerp(0.96,0,metallic)。我们也来看一下引擎内的返回效果:



可以看到,返回颜色就像是Metallic的相反值,只不过clamp到0.96

最终整个函数还有一个返回值,返回的是albedo * oneMinusReflectivity,我们来看一下引擎内的结果:



DiffuseAndSpecularFromMetallic函数返回值,可以看到和Diffuse-Specular流程的Diffuse图十分相似

所以我们可以得出结论DiffuseAndSpecularFromMetallic方法的作用,就是将Albedo-Metallic贴图转换为Diffuse-Specular贴图。捎带计算了一个金属度的相反值oneMinusReflectivity。
1.2 PreMultiplyAlpha方法

在DiffuseAndSpecularFromMetallic方法后,LightingStandard函数执行了PreMultiplyAlpha方法,这个方法不是特别重要,我们看一下它调用的代码:
//根据不同的情况,对Diffuse进行处理,并对alpha进行处理
inline half3 PreMultiplyAlpha (half3 diffColor, half alpha, half oneMinusReflectivity, out half outModifiedAlpha)
{
    #if defined(_ALPHAPREMULTIPLY_ON)//如果开启了AlphaBlend
        // NOTE: shader relies on pre-multiply alpha-blend (_SrcBlend = One, _DstBlend = OneMinusSrcAlpha)
        // Transparency 'removes' from Diffuse component
        diffColor *= alpha;
        //这里准确说应该是Albedo(包含Diffuse和SpecColor,但因为透明物体都是非金属,所以基本是Diffuse)需要因为透明的原因,Diffuse减弱。
        #if (SHADER_TARGET < 30)//如果是低版本的平台
            // SM2.0: instruction count limitation
            // Instead will sacrifice part of physically based transparency where amount Reflectivity is affecting Transparency
            // SM2.0: uses unmodified alpha
            outModifiedAlpha = alpha;//则Alpha受平台限制不能处理。
        #else
            // Reflectivity 'removes' from the rest of components, including Transparency
            // outAlpha = 1-(1-alpha)*(1-reflectivity) = 1-(oneMinusReflectivity - alpha*oneMinusReflectivity) =
            //          = 1-oneMinusReflectivity + alpha*oneMinusReflectivity
            outModifiedAlpha = 1-oneMinusReflectivity + alpha*oneMinusReflectivity;
            //3.0以上平台,Alpha要处理一下。当为金属时,Alpha=1,非金属时,Alpha = Alpha;
        #endif
    #else
        outModifiedAlpha = alpha;
    #endif
    return diffColor;
}这个方法里面有一些分支,我们总结一下它们的作用:

  • 当使用_ALPHAPREMULTIPLY_ON时,也就是使用默认的透明度通道叠加方式时,Alpha应该只影响Diffuse(实例:玻璃球的漫反射基本消失,但是高光反射还是存在的)。
  • 在SM2.0及以下平台,因为硬件限制,所以牺牲了物理真实性,把Alpha直接使用。
  • 在SM3.0以上平台,金属度为1时Alpha返回1,金属度为0时,返回原始的Alpha。这是因为金属是不透明的(试想,你见过透明的金属吗?金属的自由电子会吸收所有折射入金属表面的光线,所以物理上不可能有光线能穿过有宏观厚度的金属。)
所以PreMultiplyAlpha是一个Alpha预处理的方法。
2.0 UNITY_BRDF_PBS 函数原理阐释

2.1最简化的数学公式理解

在进行完DiffuseAndSpecularFromMetallic和PreMultiplyAlpha后,LightStandard函数终于把我们的数据代入了UNITY_BRDF_PBS 函数,这个也是我们最终的BRDF部分的计算。
在进入这个函数的具体说明之前,我们先了解一下这个方法的算法来源。
首先我们看一个闪瞎眼的方程


怕了么,但其实它还有个更闪耀的版本



然而小学毕业的我也看不懂它们,万幸 宋开心 同学给出了这个式子的翻译


emmmm好像能看懂一点了,此时我们简化一下它:
输出颜色 = 漫反射比例*漫反射颜色 + 镜面反射比例*(DGF/神秘的系数)*光源颜色。
有强迫症的我再给它简化一下:
输出颜色 = 正确的漫反射+正确的镜面反射。
所以其实BRDF就是使用物理正确的方式计算 漫反射+镜面反射。
简化到这里,我自己找到了爱因斯坦写质能方程式的感觉:)
但上面的简化方式有个东西我们没算进去,就是这个 ,它代表的是入射方向半球的积分。说人话:各个入射光的和。
在实时渲染领域里面,所有的光照其实来源于两个部分:直接光源和IBL(基于图像的渲染)。所以各个入射光的和其实就等于 直接光源+IBL。
再结合我们上面所述的最简化公式,其实我们在代码内主要计算的就是四个部分
输出颜色 = 直接光源漫反射+直接光源镜面反射+IBL漫反射+IBL镜面反射。
在Unity里面,IBL的漫反射其实在gi.indirect.diffuse分量已经计算过了(此部分在本系列文章的第三篇已经详细讲解),所以在UNITY_BRDF_PBS 函数里,只具体计算了直接光源漫反射,直接光源镜面反射以及IBL镜面反射三个部分。
2.2 DGF三兄弟

在上面的篇幅里,我们看到了这个公式



公式翻译来自:宋开心

其中漫反射部分比较简单。具体在镜面反射部分有一个比较复杂的部分



我的天怎么这么大。。。

这里面涉及到了几个部分,我们还是需要理解的。这里简单的解释一下。
法线分布函数(D)和几何函数(G)都是基于微表面原理



微平面理论(图片来自The PBR Guide by allegorithmic- Vol. 1)

其实就是把宏观的表面看做是由微观的复杂的微小平面的集合。这些微平面虽然不能被肉眼看到,但会实际地影响光线在物体表面的反射效果。
其中:
法线分布函数(F)是描述的可以将入射光线反射到人眼里的微平面比例:当微平面法线和半角向量相等时,入射光就刚好可以反射到观察视角上,而其他微平面是没有这个反射贡献的。



图片来自《Real-Time Rendering 4th》

几何分布函数(G)是描述的,在以上法线分布原理符合要求的微平面里,有一部分的微平面因为互相之间的遮挡,同样不能反射到观察视角里。所以需要计算去除:



图片来自Naty Hoffman, Recent Advances in Physically Based Shading, SIGGRAPH 2016

菲涅尔系数(F)并不是基于微表面原理,而是一种反射物理现象:当观察视角越接近于掠射角,反射效果越强,视角越垂直于表面,反射越弱。



图片来自 www.dorian-iten.com

在自然界中也轻易地可以观察到这个现象,比如在观察一个水体时,近处的水面透明见底,而远处的水面有如镜面一般。就是典型的菲涅尔现象。



图片来自:www.scratchapixel.com

关于DFG三个函数,在网上还有很多很好的阐述,读者可以自行搜索,在这里就不讲的太过于深入。
我们回到公式


刚解析完DGF,分母是计算后的一个配平系数,我们暂不在此阐述来源。
在Unity中,Unity把几何函数和配平分母中的两个点积结合在了一起,变成了V项(可见性项),也就是说这个部分公式在Unity的方法中长这样:DVF 。之后会体现在我们的代码中。
3.0 UNITY_BRDF_PBS 函数代码详解:

3.1UNITY_BRDF_PBS BRDF分支

在LightingStandard函数的最后,数据全部导入了UNITY_BRDF_PBS函数中,在cginc文件中搜索,我们可以得到这些代码分支:
//Unity提供三个质量级别级别的BRDF供选择,在quality菜单里可以设置,见右图
//质量从High到Low分别对应BRDF1到BRDF3
// Default BRDF to use:
#if !defined (UNITY_BRDF_PBS) // allow to explicitly override BRDF in custom shader//允许在自定义Shader内强制定义使用哪个BRDF
    // still add safe net for low shader models, otherwise we might end up with shaders failing to compile
    #if SHADER_TARGET < 30 || defined(SHADER_TARGET_SURFACE_ANALYSIS) // only need "something" for surface shader analysis pass; pick the cheap one
        #define UNITY_BRDF_PBS BRDF3_Unity_PBS//3.0以下平台直接使用最低级别PBS
    #elif defined(UNITY_PBS_USE_BRDF3)
        #define UNITY_BRDF_PBS BRDF3_Unity_PBS
    #elif defined(UNITY_PBS_USE_BRDF2)
        #define UNITY_BRDF_PBS BRDF2_Unity_PBS
    #elif defined(UNITY_PBS_USE_BRDF1)
        #define UNITY_BRDF_PBS BRDF1_Unity_PBS
    #else
        #error something broke in auto-choosing BRDF
        // 调试方法,假如走到这一条会在console里报error后的文字错误。
    #endif
#endif这里其实是对ProjectSettings中的一个Graphics图形选项的设置,具体在这个位置


在这个选项中根据高中低三个选项,Unity将从上面的相应分支进行编译。
不同的分支,所选取的BRDF的算法是不同的,这里,我们选用最高质量的BRDF1_Unity_PBS函数进行逐行分析。
3.2BRDF1_Unity_PBS函数 之 数据准备

BRDF1_Unity_PBS函数很长,分支也比较多,我们一步步分开来分析,完整的代码及注释,我将放到这段内容的最后。
首先我们看输入项:
half4 BRDF1_Unity_PBS (half3 diffColor, half3 specColor, half oneMinusReflectivity,
half smoothness,float3 normal, float3 viewDir,UnityLight light, UnityIndirect gi)这里输入了

  • 材质表面贴图采样值:diffuse,specColor等等
  • 所需要的向量:法线,viewDir等等
  • 在shader内准备的gi.light,gi.indirect
然后进入函数
float perceptualRoughness = SmoothnessToPerceptualRoughness (smoothness);
//=1-smoothness,光滑度转粗糙度。
float3 halfDir = Unity_SafeNormalize (float3(light.dir) + viewDir);
//求半角向量。Unity_SafeNormalize函数用于避免出现除零及负数的情况。perceptualRoughness指的是感性粗糙度,Unity官方在cginc里多次强调这个不是粗糙度而是感性粗糙度。是因为在数学公式中,科学的粗糙度(一般写作α)是这个感性粗糙度的平方。假如直接暴露α给用户(美术)调节,那么调节起来的效果是非线性的,不便于用户直观的调节。于是暴露α的平方根--->感性粗糙度给用户,这样用户在DCC软件中调节起来,感受就很线性了。
半角向量halfDir是一个常用的光照计算的值,其实就是光线方向和视线方向的平均夹角。所以直接将两者相加然后归一化就可以得到。Unity_SafeNormalize是一个特殊的归一化操作,主要是避免出现0。因为这个向量计算有的时候会用在式子的分母中。
接下来计算法线和视线方向的点积:
// 1、要避免dot(normal,viewDir)为负。但透视视角和法线贴图映射时有可能出现这种为负情况。
// 2、解决这个问题提供了两种方案。
//    1>把法线扭到偏向摄影机方向再做点积计算(准确但耗性能)
//    2>直接对点积取绝对值(不完全准确,但效果可接受,省性能)
#define UNITY_HANDLE_CORRECTLY_NEGATIVE_NDOTV 0//默认情况下走方法2,假如要走方法1需要注释此行
#if UNITY_HANDLE_CORRECTLY_NEGATIVE_NDOTV//方法1
    // The amount we shift the normal toward the view vector is defined by the dot product.
    half shiftAmount = dot(normal, viewDir);
    normal = shiftAmount < 0.0f ? normal + viewDir * (-shiftAmount + 1e-5f) : normal;
    // A re-normalization should be applied here but as the shift is small we don't do it to save ALU.
    //normal = normalize(normal);
    float nv = saturate(dot(normal, viewDir)); // TODO: this saturate should no be necessary here
#else//方法2,默认走此方法
    half nv = abs(dot(normal, viewDir));    // This abs allow to limit artifact
#endif这里出现了一些分支,是因为法线和视线方向的点积,在使用法线贴图和透视摄像机的情况下,会出现一些负值的情况,在这里为了物理的准确,并不是把这个负值clamp到0,而是让他偏转回正值。这里的第一个分支NITY_HANDLE_CORRECTLY_NEGATIVE_NDOTV是科学的计算方式,但涉及到的计算量很大,默认被注释掉了。实际默认走的是第二个分支,也就是使用abs()绝对值的方式进行近似处理。
接下来计算点积大家庭
//剩余的点积计算 saturate限制非负范围。
float nl = saturate(dot(normal, light.dir));
float nh = saturate(dot(normal, halfDir));
half lv = saturate(dot(light.dir, viewDir));
half lh = saturate(dot(light.dir, halfDir));这部分没什么很多好说的,就是对光照公式中会用到的各类点积进行计算。然后使用saturate方法使计算值限制到(0,1)范围内。
这里对各个字母指代简单介绍一下:

  • n:normal 世界法线方向
  • l :lightDir 世界灯光方向
  • h:halfVector 半角向量
  • v:viewDir 视线方向
准备好以上所有的数据之后 BRDF1_Unity_PBS准备开始进行BRDF计算了。
3.2 BRDF1_Unity_PBS函数 之 直接光照漫反射部分

BRDF1_Unity_PBS函数中计算的第一个部分是直接光照的漫反射。这部分具体要计算的内容是


这个部分,漫反射比例已经在LightStandard函数里的DiffuseAndSpecularFromMetallic计算后被放入了s.Albedo里。在BRDF1_Unity_PBS函数里以diffColor项输入。
这里的表面颜色实际由两个部分相乘获得,第一部分是物体的漫反射贴图颜色,第二部分是BRDF函数计算出来的漫反射颜色(包含物理光照的衰减)。
直接光照的漫反射计算具体是这样:
diffColor * light.color * diffuseTerm那么漫反射比例*表面颜色1被放到了diffColor里,light.color就是光源颜色,dot(lightDir,normal)*表面颜色2被放到了diffuseTerm里。
那么问题来了。公式里的除以π这个步骤放哪里去了。。。
其实傲娇的Unity没有除以π,在代码内有这些解释:
// HACK: theoretically we should divide diffuseTerm by Pi and not multiply specularTerm!
// BUT 1) that will make shader look significantly darker than Legacy ones
// and 2) on engine side "Non-important" lights have to be divided by Pi too in cases when they are injected into ambient SH
//在Unity中,因为和旧效果适配和非重要灯光的一些原因,所以在Diffuse层面没有根据迪士尼BRDF的Diffuse部分公式一样除以Pi;Unity为了保证Standard效果和引擎的旧版本的光照类似,diffuse部分就没有除以π。那么在后面的Specular项,为了保证光照的diffuse和specular的物理平衡,需要额外的再乘一个π。这个之后到了相关代码部分我们再提。
到了这里代码和公式就同步了,diffColor和light.color都是输入值,那么只需要计算diffuseTerm。
Unity使用的Diffuse BRDF使用的是迪士尼的公式。那么对于这些公式,我们没必要逐项地理解他们的原理,这些式子都是科研人员和前辈们根据实际效果的结论。对于应用部分来说,了解即可。下面我们看看公式:



迪士尼diffuse公式 由Disney2012的BRDF paper提出

公式内的各项:

  • = 入射方向和半角向量的夹角 --->cos  = dot(lightDir,halfVector)
  • = 入射方向和法线的夹角 --->cos  = dot(lightDir,normal)
  • =出射射方向和法线的夹角 --->cos  = dot(viewDir,normal)
  • F90 (猜)应该是掠射角的反射系数,也就是最大反射值
  • (猜)因为入射和出射均受菲涅尔影响,所以括号内的部分根据入射角度和观察角度乘了两遍。
我们来看一下相关代码:
//迪士尼的漫反射计算
// Note: Disney diffuse must be multiply by diffuseAlbedo / PI. This is done outside of this function.
//备注:baseColor/Pi的部分需要在方法外进行处理
half DisneyDiffuse(half NdotV, half NdotL, half LdotH, half perceptualRoughness)
{
    half fd90 = 0.5 + 2 * LdotH * LdotH * perceptualRoughness;//先计算F90
    // Two schlick fresnel term
    half lightScatter   = (1 + (fd90 - 1) * Pow5(1 - NdotL));//入射方向菲涅尔
    half viewScatter    = (1 + (fd90 - 1) * Pow5(1 - NdotV));//出射方向菲涅尔
    return lightScatter * viewScatter;
}
// Diffuse term//迪士尼BRDF漫反射系数计算
half diffuseTerm = DisneyDiffuse(nv, nl, lh, perceptualRoughness) * nl;这部分先是用一个DisneyDiffuse方法完整的写完公式,最后乘以了漫反射部分最终需要乘的dot(lightDir,normal)。
至此,直射光部分的漫反射计算就搞定了。我们返回一下直射光漫反射部分,也就是diffColor * light.color * diffuseTerm这三项的乘积,看看引擎效果:



PBS之直接光漫反射部分。第一行材质球是直接光部分PBS,第二行是我们的测试值,第三行是StandardShader的效果。

看这个图片,我们可以清楚的了解直接光漫反射部分是什么:当Metal值等于0时,我们的测试值和直接光部分的值几乎是一样的(但是没有高光点)。其中Metal值等于1时,Smooth等于1的情况下,也是只有高光点的区别。但smooth等于0时却大有不同,这是因为smooth等于0时,材质球表面的变化就是高光(即镜面反射)变化。这个我们之后会看到。
3.3 BRDF1_Unity_PBS函数 之 直接光照镜面反射部分

计算完直接光照漫反射部分后,我们计算 BRDF1_Unity_PBS函数的直接光照镜面反射部分,首先我们先看看镜面反射部分公式:


之前我们也有提到,因为Unity将几何部分和配平系数放到了一起组成V项(visibility term),所以这个公式将变为:
镜面反射 = 镜面反射比例*法线分布函数(D)*可见性项(v)*菲涅尔系数*光源颜色*dot(lightDir,normal)
了解了公式意义后,我们来看直接光照高光部分代码
// Specular term
//获得1-smooth(即Roughness)的平方,即科学意义上的roughness
float roughness = PerceptualRoughnessToRoughness(perceptualRoughness);
#if UNITY_BRDF_GGX//默认会直接定义这个关键字
    // GGX with roughtness to 0 would mean no specular at all, using max(roughness, 0.002) here to match HDrenderloop roughtness remapping.
    roughness = max(roughness, 0.002);//限制roughness不为0(避免高光反射完全消失)。
    float V = SmithJointGGXVisibilityTerm (nl, nv, roughness);
    float D = GGXTerm (nh, roughness);
#else
    // Legacy//旧版本保留而已,不会被用到,除非注释掉UnityStandardConfig.cginc中关于UNITY_BRDF_GGX的定义
    half V = SmithBeckmannVisibilityTerm (nl, nv, roughness);
    half D = NDFBlinnPhongNormalizedTerm (nh, PerceptualRoughnessToSpecPower(perceptualRoughness));
#endif
float specularTerm = V*D * UNITY_PI; // Torrance-Sparrow model, Fresnel is applied later
//Cook-Torrance的反射部分。菲涅尔项后面再处理。因为Diffuse项没有除Pi,所以这里乘Pi以保证比例相等(配平)。
#   ifdef UNITY_COLORSPACE_GAMMA//Gamma空间需要开方的原理是?
specularTerm = sqrt(max(1e-4h, specularTerm));
#   endif
// specularTerm * nl can be NaN on Metal in some cases, use max() to make sure it's a sane value
specularTerm = max(0, specularTerm * nl);// 在渲染方程中,高光项最终要乘以dot(n,l)。使用max方法避免负数。
#if defined(_SPECULARHIGHLIGHTS_OFF)
    specularTerm = 0.0;// 材质面板中的specular Highlight开关
#endif
//渲染方程计算
fixed3 directSpecular = specularTerm * light.color * FresnelTerm (specColor, lh);这个部分有两个地方有分支,第一个是保留了一个Legacy旧版本的V项和D项计算(为了版本稳定性)。第二个部分是在gamma空间下,对高光部分计算结果进行了开方操作(为什么高光项gamma空间要开方我没整明白,希望有大佬能指教)。
那么去掉分支,我们整理一下以上代码
// Specular term
//获得1-smooth(即Roughness)的平方,即科学意义上的roughness
float roughness = PerceptualRoughnessToRoughness(perceptualRoughness);
// GGX with roughtness to 0 would mean no specular at all, using max(roughness, 0.002) here to match HDrenderloop roughtness remapping.
roughness = max(roughness, 0.002);//限制roughness不为0(避免高光反射完全消失)。
float V = SmithJointGGXVisibilityTerm (nl, nv, roughness);
float D = GGXTerm (nh, roughness);
float specularTerm = V*D * UNITY_PI; // Torrance-Sparrow model, Fresnel is applied later
//Cook-Torrance的反射部分。菲涅尔项后面再处理。因为Diffuse项没有除Pi,所以这里乘Pi以保证比例相等(配平)。
// specularTerm * nl can be NaN on Metal in some cases, use max() to make sure it's a sane value
specularTerm = max(0, specularTerm * nl);// 在渲染方程中,高光项最终要乘以dot(n,l)。使用max方法避免负数。
#if defined(_SPECULARHIGHLIGHTS_OFF)
    specularTerm = 0.0;// 材质面板中的specular Highlight开关
#endif
//渲染方程计算
fixed3 directSpecular = specularTerm * light.color * FresnelTerm (specColor, lh);首先计算了roughness值,是PerceptualRoughness的平方。这个我们之前有提到,为了使粗糙度调整线性,我们最后暴露给用户的是roughness的平方根。这里讲这个开方操作计算回来。然后做了max操作使roughness不为0。
接下来使用两个外部函数计算V和D项,这个我们稍后讲。
为了和Diffuse中的没有除π配平,这里选择乘以π,这个我们在直接光漫反射部分也有提到。然后将V*D*π赋值给specularTerm变量,注意这里开始在套公式了。
将specularTerm乘以nl(之前计算的法线和灯光方向的点积),这是公式中需要乘的。然后再进行max的非负操作。
如果关掉了standard材质球面板的Specular Highlights开关,就使specularTerm为零。这个开关是暴露给用户的属性,可以直接关掉直接光照的高光。在面板中,这个开关长这样:


最后进行方程剩余项的计算,包含菲涅尔系数(F项)的计算。
我们再来看看刚没有说到V项,D项及F项计算。
3.4 直接光镜面反射的V项,D项及F项

D项 法线分布函数
因为我们这篇文章的主要讲api应用而非数学原理,所以我只会给出公式,具体的推导请读者自行搜索,网上有很多的相关讨论。而在实际工作中,了解公式结论和具体效果,其实是非常重要的。
先看D项法线分布函数,Unity使用的是最常见的GGX分布方法,公式如下:



GGX(Trowbridge-Reitz)法线分布函数(1975由Trowbridge-Reitz提出,2007由Walter再发现)

相关项及特点

  • 有最长的高光长尾(高光辉光),即具备形状不变性
  • α为科学意义的粗糙度,即用户设定粗糙度的平方
  • m代表微表面,向量m代表微表面法线。因为不可能获得每个微表面法线,所以使用半角向量作为替代。因为只有向量m=向量h时,才会产生反射到viewDir的光线。其他角度的微表面法向量会正负抵消(微观平面假设方向概率都是平均的)。
那么根据以上公式,我们来看看实际的代码:
//法线分布函数计算
inline half GGXTerm (half NdotH, half roughness)
{
    half a2 = roughness * roughness;
    half d = (NdotH * a2 - NdotH) * NdotH + 1.0f; // 2 mad//2mad啥意思(很疯狂?)。。。
    //这里是分母括号内的项,为了优化做了一下变换,和公式略有不同
    return UNITY_INV_PI * a2 / (d * d + 1e-7f);
    // This function is not intended to be running on Mobile,
    // therefore epsilon is smaller than what can be represented by half
    //直译:这个函数没有打算在移动端运行,所以(要让?)它的返回值精确度比half小。
    //最后的1e-7f是一个极小的小数,为了保证分母不为零。
    //UNITY_INV_PI即Unity自带的变量1/π。
}其实这里很简单,就是把公式里的计算给写成代码。其中变量d是计算公式内的分母(进行了一下数学上的变换,结果相同)。然后UNITY_INV_PI即Unity自带的变量1/π,Unity提供了这个常量避免出现不必要的除法操作。
我们看一下法线分布函数返回后的引擎效果:



法线分布函数测试值,中间一行材质球为测试效果

通过法线分布函数测试值,我们可以看到,粗糙度对于高光的聚拢效果的变化。这里我再做一张动图直观地表示这种变化,注意高光越扩散,其颜色就越浅这种能量守恒的效果:


D项解释完毕,我们看一下V项。
V项:几何函数(G)*配平系数
正如前文解释过的,V项即  几何函数(G)*配平系数。我们看一下V项的公式:



Respawn Entertainment的 GGX-Smith Joint近似方案,由EarlHammon提出


  • 这是一个近似的高效方案,计算后包含了配平系数。
  • 表示LightDir和ViewDir两个方向上的可见比例。
了解了公式后,我们看一下V项的实际调用代码:
//可见性项(包括几何函数和配平系数一起)的计算
// Ref: http://jcgt.org/published/0003/02/03/paper.pdf
inline half SmithJointGGXVisibilityTerm (half NdotL, half NdotV, half roughness)
{
    #if 0// 这部分默认关闭。
        // 备注,这里是 Frostbite的GGX-Smith Joint方案(精确,但是需要开方两次,很不经济)
        // 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);
        // Reorder code to be more optimal
        half a          = roughness;
        half a2         = a * a;
        half lambdaV    = NdotL * sqrt((-NdotV * a2 + NdotV) * NdotV + a2);
        half lambdaL    = NdotV * sqrt((-NdotL * a2 + NdotL) * NdotL + a2);
        // Simplify visibility term: (2.0f * NdotL * NdotV) /  ((4.0f * NdotL * NdotV) * (lambda_v + lambda_l + 1e-5f));
        return 0.5f / (lambdaV + lambdaL + 1e-5f);  // This function is not intended to be running on Mobile,
        // therefore epsilon is smaller than can be represented by half
    #else// 主要走这个部分
        // Approximation of the above formulation (simplify the sqrt, not mathematically correct but close enough)
        //这个部分是Respawn Entertainment的 GGX-Smith Joint近似方案
        half a = roughness;
        half lambdaV = NdotL * (NdotV * (1 - a) + a);
        half lambdaL = NdotV * (NdotL * (1 - a) + a);
        return 0.5f / (lambdaV + lambdaL + 1e-5f);
    #endif
}原始代码有两个分支其中第一个分支是精确的算法,但是涉及到两个开方操作,比较昂贵。所以默认关掉了。实际走的是第二个近似的分支。
那么我们精简掉不用的分支,代码如下
//可见性项(包括几何函数和配平系数一起)的计算
inline half SmithJointGGXVisibilityTerm (half NdotL, half NdotV, half roughness)
{
        // Approximation of the above formulation (simplify the sqrt, not mathematically correct but close enough)
        //这个部分是Respawn Entertainment的 GGX-Smith Joint近似方案
        half a = roughness;
        half lambdaV = NdotL * (NdotV * (1 - a) + a);
        half lambdaL = NdotV * (NdotL * (1 - a) + a);
        return 0.5f / (lambdaV + lambdaL + 1e-5f);
}其实和上面给出的公式是一样的算法,只不过将lerp的方法写成了式子。我们把V项输出测试一下效果:



V项直接返回值

这个V项返回值看测试不太明确它的意义,是因为背光面没有做处理,实际上我们的镜面反射最后会乘以dot(n,l)-->一个传统的兰伯特计算。我们看一下乘上后的效果。



V项乘以dot(normal,lightDir)的效果

可以看到,V项主要修正的是掠射角度的一个增强效果,这是通过实验去测定获得的。V项会和D项,dot(n,l)最后乘在一起,变成specularTerm。我们看一下specularTerm的返回值:



specularTerm的返回效果

注意这里的部分效果和D项返回值比较相似,除了加入了dot(n,l)进行了光照反应以外,注意V项对于掠射角度的影响:


F项:菲涅尔函数
在Unity中,菲涅尔函数是最后乘入的,我们看一下公式:


其中F0是基础反射系数,就是逆着法线方向看表面时的反射系数。对于非金属来说,这个值是0.04(Unity线性空间下),对于金属来说,就是高光工作流的SpecColor。
然后我们看一下实际的代码
//F菲涅尔项
inline half3 FresnelTerm (half3 F0, half cosA)
{
    half t = Pow5 (1 - cosA);   // ala Schlick interpoliation
    //公式中使用的是dot(v,h)。而Unity默认传入的是dot(l,h)
    //是因为BRDF大量的计算使用的是l,h的点积,而h是l和v的半角向量,所以lh和vh的夹角是一样的。不需要多来一个变量。
    return F0 + (1-F0) * t;
}代码也是公式的复现,这里F0传入的就是specColor,也就是Diffuse-Specular流程的specular贴图采样值。这个部分在本文较前的部分有讲解。
代码的cosA部分传入的是dot(l,h),而公式给的算法是dot(v,h)。这是因为在整个PBR计算中,少有dot(v,h)的计算,而因为光线的入射角和出射角是相等的,数值上来说dot(l,h) == dot(v,h)。故而代替运算,省掉了这部分的计算操作。
这里需要注意的是这里返回值是一个颜色值,而非单通道的比例系数。
我们看一下菲涅尔项直接返回的效果:


咦,这不和specColor直接返回的效果是一样的么。。。且慢,我们看一下当光线变化时的效果:


我们可以看到,当光线在掠射角度时,菲涅尔项影响了非金属的反射亮度变化,这个也是菲涅尔项的意义所在。
那么至此DVF项计算完毕,我们代入最后的一行代码
directSpecular = specularTerm * light.color * FresnelTerm (specColor, lh)得到直接光照的镜面反射效果如下:


可以看到实际的模型上有完美的高光变化及颜色变化。
3.5 BRDF1_Unity_PBS函数 之 IBL漫反射部分

在以上的文章内,我们已经完成了PBR光照的直接光照部分。那么接下来还有间接光照的部分。间接光照部分,我们是使用IBL技术,也就是基于图像的光照方式。那么根据渲染方程,IBL光照部分也是分为 漫反射和镜面反射两个部分 我们先看漫反射部分。

其实在PBR的关键方法lightingStandard之前,我们已经计算过了gi,即全局光照,不过还没有计算BRDF,即光照衰减。而在BRDF1_Unity_PBS函数里,就是需要加入这个光照衰减。
漫反射部分很简单,Unity直接将漫反射颜色和直接光照相乘获得,代码如下:
half3 IBLDiffuse = diffColor*gi.diffuse我们看一下代码计算后的返回值:


可以看到这是一个柔和的间接光照效果。且金属部分的反射是完全没有的。其中的光照部分,在之前的计算中,其实是通过球谐光照计算得到的。
3.5 BRDF1_Unity_PBS函数 之 IBL镜面反射部分

IBL的镜面反射部分,Unity的代码如下
half3 IBLSpecular = surfaceReduction * gi.specular * FresnelLerp (specColor, grazingTerm, nv);Unity的IBL镜面反射,在网上是找不到具体的公式计算的,但是我们可以一项一项去查看它的效果,来预估这些部分的实现。为什么Unity没有根据公式进行计算,这里假如有大佬了解可以斧正一下。
首先我们看输入的gi.specular值:


可以看到,gi.specular项是完整的没有任何光照的反射效果(模型的光照反应是Cubemap自带的反射特性)。这个部分已经完成了smoothness或roughness对cubemap的采样差值,粗糙度的不同,采样效果是不一样的。
然后我们再看surfaceReduction项,这部分Unity给出了具体的计算代码:
// surfaceReduction = Int D(NdotH) * NdotH * Id(NdotL>0) dH = 1/(roughness^2+1)
half surfaceReduction;
//可能是经过观测,在物体的粗糙度越来越强时,反射会相对减弱,所以在这里假如一个根据粗糙度减弱反射的变量。
//(roughness = 1,reflect = 0.5)
//因为roughnes是通过smoothness贴图采样得到,所以分线性和非线性进行不同的处理。
#   ifdef UNITY_COLORSPACE_GAMMA
surfaceReduction = 1.0-0.28*roughness*perceptualRoughness;     
// 1-0.28*x^3 as approximation for (1/(x^4+1))^(1/2.2) on the domain [0;1]
#   else
surfaceReduction = 1.0 / (roughness*roughness + 1.0);           
// fade \in [0.5;1]
#   endif可以根据代码推测,是Unity观察到在物体的粗糙度越来越强时,反射会相对减弱,所以在这里假如一个根据粗糙度减弱反射的系数,在roughness 等于1时reflect取值到0.5。我们看下surfaceReduction返回的具体的效果:



注:此图为了便于观察,进行了线性转换

可以看到,取值的不同主要是和roughness相关。
再有一个参数是FresnelLerp方法,在讲这个方法之前,我们需要看这个方法所需要的一个参数F90,在Unity中是通过一个grazingTerm变量进行计算。我们看一下这个参数的具体计算代码:
half grazingTerm = saturate(smoothness + (1-oneMinusReflectivity));这里计算的是一个掠射角度的反射强度,还没有进行菲涅尔系数计算,所以是一个固定值,我们看一下这个值的具体效果:


可以看到这个参数对于金属是没有什么影响的,主要影响的是非金属。这和菲涅尔效果的特性是一样的:非金属才有强烈的菲涅尔效果。

有了这个参数之后,我们就可以看一下具体的FresnelLerp方法了,我们先看一下代码:
inline half3 FresnelLerp (half3 F0, half3 F90, half cosA)
{
    half t = Pow5 (1 - cosA);   // ala Schlick interpoliation
    return lerp (F0, F90, t);
}

  • F0传入specColor,F90传入grazingTerm。cosA为dot(l,h)
  • 在F0角度反射颜色到F90角度反射颜色使用菲涅尔系数进行线性差值。
  • 注意这里的掠射角颜色是一个灰度颜色。然后这个灰度颜色在IBLterm乘以gi.specular(cubemap采样颜色)后,返回的是cubemap颜色。
  • 也就是说,在IBL里,在F0角度,会有SpecColor参与到颜色倾向(尤其是金属)(以cubemapColor*materialSpecColor*Fresnel的形式)
  • 而在F90角度,材质高光贴图颜色不影响最终颜色倾向,只影响反射强度。
那么在这里,IBL的镜面反射的三个参数surfaceReduction * gi.specular * FresnelLerp 我们都获得了,那么我们直接相乘,就获得了IBL的镜面反射效果:



完整的IBL镜面反射部分。

3.6 BRDF1_Unity_PBS函数 最终的加和及返回

在之前的长篇内容中,我们已经计算了PBR所需要的四个光照部分:直接光漫反射,直接光镜面反射,IBL漫反射,IBL镜面反射。我们直接把这四项加和,可以得到最终的效果:



PBR光照四个部分分别的效果及最终效果

那么我们可以再回顾一下我们整张流程图


后记

历时一周多业余时间的写作,终于完成了这份5万余字的总结。其中翻阅无数资料,加入自己的整理,才获得了这份结果。真心感谢能分享自己广闻博识的人,这也是我写作本文的初衷。假如这篇文章对你有所帮助,希望得到你的赞同和关注,这将是对我最大的鼓励。
本系列完整的四篇文章地址:
在写作这篇文章时,我只是一个刚接触shader内容的模型师,文内可能有所疏漏,希望能有大佬斧正。我参考了如下资料,献出我的膝盖以示感谢:

  • taecg老师的系列教学《渲染管线与UnityShader编程》
  • 冯乐乐女神的书《UnityShader入门精要》
  • 毛星云(浅墨)的系列文章《基于物理的渲染(PBR)白皮书》
peace:)

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2021-12-11 07:50 | 显示全部楼层
感谢分享,我的PBR学习之路就从你的文章开始
发表于 2021-12-11 07:57 | 显示全部楼层
关于specularTerm为什么开方的问题,我的理解如下不知对不对:公式都是在在线性空间计算好颜色,如果要转换到gamma空间,那么需要开2.2次方,约等于开2次方。就应该是 sqrt(D*V*F)。 但由于UNITY_COLORSPACE_GAMMA宏已经开启,unity传入给shader的颜色值已经是gamma space的颜色,所以凡是含有颜色的项就不需要再开方了,只有第一项需要开方。但这样好像也无法解释FresnelTerm为什么这么写
发表于 2021-12-11 07:58 | 显示全部楼层
哈哈,我觉得肯定如你所说,是中间某个因子还没有转入线性空间计算。也如你所说,那假如只是颜色的问题,其实不仅是菲涅尔项,diffuse项也没看见开方操作呀。。。

不过我觉得假如是以学习画面效果心态的去看的话,这个问题可以先放一放。因为其实严格的要处理PBR的shader和效果的话,线性空间是必选项。

个人意见哈哈。
发表于 2021-12-11 08:05 | 显示全部楼层
为什么没有看到 IBL BRDF 的积分项
发表于 2021-12-11 08:14 | 显示全部楼层
如文章所述,在实时渲染中,光照的积分就是直接光照+IBL两项。其中IBL又分为漫反射和镜面反射两个部分。IBL漫反射,在数据准备阶段就在gi变量中使用球谐函数计算过了(系列文章前文有讲)。IBL镜面反射,各家引擎有不同的处理方式,在本文是解释了一下Unity使用的几个方法。UE也有一个查找表(LUT图)的方式。至于为什么不是使用标准的DGF三个函数去算镜面反射项,这个我没有研究深入,可以查一下是否有大佬有解释此问题。
发表于 2021-12-11 08:20 | 显示全部楼层
其实这是一种补偿而已,跟specular的结果乘以pi道理一样,本身是不对的。在gamma空间下,diffuse项的贴图属于sRGB处于gamma空间,为了补偿specular也做pow(1/2.2)。最终,在gamma空间下加上乘以pi的合并影响,unity的pbr比理论要亮。
发表于 2021-12-11 08:26 | 显示全部楼层
因为不好用标准的DGF来模拟,indirect光照在从渲染方程看是对法线所在半球的立体角做irradiance积分,本质上这个方程不可解,所以才有了蒙特卡洛积分和重要性采样来逼近这个解,而这是比较费的,传统实时渲染才用各种map或probe存储的IBL形式最后做采样。可以简单认为,直接光照方向是一个,而indirect光照方向来自四面八方,不好用几个函数模拟。
发表于 2021-12-11 08:26 | 显示全部楼层
解释的很棒[赞同]
发表于 2021-12-11 08:28 | 显示全部楼层
感谢思考指教,确实很多时候看引擎内置的做法会有一些临时的手段处理问题[赞同]。
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-5-10 17:14 , Processed in 0.111363 second(s), 27 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表