当前位置:   article > 正文

Unity默认Standard Shader BRDF函数计算详解

safenormalize函数

e73dd0aacb8060af4c0b58eb93e27df8.png

经历三篇文章的写作。终于写到最后一篇,也是精华之最精华部分。

这个系列的文章主要目的在于了解Unity默认 Standard Shader和相关内置的函数方法。需要看本系列文章前三篇的客官可以看这里:

雨轩:Unity PBR StandardShader 实现详解(一)PBR的简单介绍及美术原理​zhuanlan.zhihu.com
b04f5544c8cb42260705070db8cece5b.png
雨轩:Unity PBR StandardShader 实现详解(二)Shader框架和数据准备​zhuanlan.zhihu.com
b04f5544c8cb42260705070db8cece5b.png
雨轩:Unity PBR StandardShader 实现详解 (三)全局光照函数计算​zhuanlan.zhihu.com
b04f5544c8cb42260705070db8cece5b.png

其中

  1. 第一篇讲解了PBR渲染所需要的最简单的原理,以及一些美术角度的看法和观点。
  2. 第二篇给出了所需的shader框架,并且给出可以实现和默认Standard材质球相同效果的手撸Shader。
  3. 第三篇逐行讲解关键方法之全局光照函数的用法。以及相关的原理。

今天这篇文章,我们会讲完最后一部分,也是最重要的一部分:BRDF函数。

在本系列文章的第二篇:框架和数据准备中,我们给出了完整的standard shader简化代码。在shader的最后,我们讲到了两个重要方法:

  1. //基于PBS的全局光照(gi变量)的计算函数。计算结果是gi的参数(Light参数和Indirect参数)。注意这一步还没有做真的光照计算。
  2. LightingStandard_GI(o, giInput, gi);
  3. fixed4 c = 0;
  4. // realtime lighting: call lighting function
  5. //PBS计算
  6. c += LightingStandard(o, worldViewDir, gi);

其中第一个方法LightingStandard_GI是全局光照计算方法,在本系列文章的第三篇已有阐述。

本片文章将聚焦于最后一个方法LightingStandard,也是最终要的BRDF计算方法。

我给出本文的框架结构概览,读者可以先留存,便于在文章中找到内容在函数流程里具体存在的位置。

b45ff20c7deda5a368133e392b363823.png

另我给出可以用于测试验证的shader和cginc文件,需要的同学可以放在同一目录下使用

myPBR.shader
12.4K
·
百度网盘
myPBR.cginc
9.1K
·
百度网盘

1.什么是BRDF?

BRDF的英文原意是双向反射分布函数,说人话就是基于某种数学算法的光照衰减模型。何谓双向,即观察方向viewDir和光照方向lightDir。BRDF并不是一个确定的公式,而是一类光照模型算法的集合。不同的渲染器和引擎,可以根据自己的目标和需求来搭配修改不同的光照模型,组成自己的BRDF。

有了一个BRDF,我们就可以通过注入一些数据,例如法线,光强,光照方向,视线方向,金属度粗糙度等,经过BRDF输出一个最终的片段颜色。完成光照渲染。

而现在常说的的PBR,是基于真实的物理原理的BRDF模型。此方法上世纪90年代以来已有之,经由迪士尼优化和确认方向后,于2012年开始在业内广泛运用开来。具体的在浅墨的系列文里有更好的介绍:

毛星云:【基于物理的渲染(PBR)白皮书】(三)迪士尼原则的BRDF与BSDF相关总结​zhuanlan.zhihu.com
6305ff91269a804cbf8b69bc2aaff4ac.png

2.0 LightingStandard方法

Unity默认的BRDF的算法都在LightingStandard方法里,具体我们边看代码边讲解:

话不多说,我们看它的主干代码

  1. inline half4 LightingStandard (SurfaceOutputStandard s, float3 viewDir, UnityGI gi)
  2. {
  3. s.Normal = normalize(s.Normal);// 法线归一化
  4. half oneMinusReflectivity;// 漫反射系数(Albedo中参与漫反射的比例)
  5. half3 specColor;//反射(高光反射)
  6. s.Albedo = DiffuseAndSpecularFromMetallic (s.Albedo, s.Metallic, /*out*/ specColor, /*out*/ oneMinusReflectivity);
  7. // shader relies on pre-multiply alpha-blend (_SrcBlend = One, _DstBlend = OneMinusSrcAlpha)
  8. // this is necessary to handle transparency in physically correct way - only diffuse component gets affected by alpha
  9. // 计算只影响Diffuse的alpha值
  10. half outputAlpha;
  11. s.Albedo = PreMultiplyAlpha (s.Albedo, s.Alpha, oneMinusReflectivity, /*out*/ outputAlpha);
  12. half4 c = UNITY_BRDF_PBS (s.Albedo, specColor, oneMinusReflectivity, s.Smoothness, s.Normal, viewDir, gi.light, gi.indirect);
  13. c.a = outputAlpha;
  14. return c;
  15. }

这个方法输入了

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

方法内部的具体操作

  1. 首先将法线归一化。
  2. 声明变量oneMinusReflectivity和specColor,并在之后的DiffuseAndSpecularFromMetallic方法中进行inout处理。
  3. 声明outputAlpha,并在PreMultiplyAlpha中处理Alpha所影响的通道。
  4. 使用UNITY_BRDF_PBS进行具体的BRDF计算。
  5. 加入计算好的Alpha并返回。

接下来我们看一下内置的两个小方法DiffuseAndSpecularFromMetallic及PreMultiplyAlpha

1.1DiffuseAndSpecularFromMetallic方法

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

雨轩:Unity PBR StandardShader 实现详解(一)PBR的简单介绍及美术原理​zhuanlan.zhihu.com
b04f5544c8cb42260705070db8cece5b.png

关于Albedo-Metallic到Diffuse-Specular的过程,我把图再贴上来以便读者观察:

dd0d9aa980b9b9beed03ced54b09d906.png

那么我们来看一下DiffuseAndSpecularFromMetallic方法的代码:

  1. inline half3 DiffuseAndSpecularFromMetallic (half3 albedo, half metallic, out half3 specColor, out half oneMinusReflectivity)
  2. {
  3. //unity_ColorSpaceDielectricSpec是Unity内置的非金属specular颜色值fixed3(0.04,0.04,0.04)。线性和非线性空间定义有不同。
  4. //求反射值
  5. specColor = lerp (unity_ColorSpaceDielectricSpec.rgb, albedo, metallic);
  6. //求漫反射系数(lerp(0.96,0,metallic));
  7. oneMinusReflectivity = OneMinusReflectivityFromMetallic(metallic);
  8. //求漫反射值并且返回
  9. return albedo * oneMinusReflectivity;
  10. }

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之间:

1d6029fdd2ffd00cf8b9a2620c07097c.png

我们再看看实际在引擎内返回的specColor的值:

(这里的试验方法参考了宋开心的方法,中间一排的材质球即我们的测试值)

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

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

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

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

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

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

1.2 PreMultiplyAlpha方法

在DiffuseAndSpecularFromMetallic方法后,LightingStandard函数执行了PreMultiplyAlpha方法,这个方法不是特别重要,我们看一下它调用的代码:

  1. //根据不同的情况,对Diffuse进行处理,并对alpha进行处理
  2. inline half3 PreMultiplyAlpha (half3 diffColor, half alpha, half oneMinusReflectivity, out half outModifiedAlpha)
  3. {
  4. #if defined(_ALPHAPREMULTIPLY_ON)//如果开启了AlphaBlend
  5. // NOTE: shader relies on pre-multiply alpha-blend (_SrcBlend = One, _DstBlend = OneMinusSrcAlpha)
  6. // Transparency 'removes' from Diffuse component
  7. diffColor *= alpha;
  8. //这里准确说应该是Albedo(包含Diffuse和SpecColor,但因为透明物体都是非金属,所以基本是Diffuse)需要因为透明的原因,Diffuse减弱。
  9. #if (SHADER_TARGET < 30)//如果是低版本的平台
  10. // SM2.0: instruction count limitation
  11. // Instead will sacrifice part of physically based transparency where amount Reflectivity is affecting Transparency
  12. // SM2.0: uses unmodified alpha
  13. outModifiedAlpha = alpha;//则Alpha受平台限制不能处理。
  14. #else
  15. // Reflectivity 'removes' from the rest of components, including Transparency
  16. // outAlpha = 1-(1-alpha)*(1-reflectivity) = 1-(oneMinusReflectivity - alpha*oneMinusReflectivity) =
  17. // = 1-oneMinusReflectivity + alpha*oneMinusReflectivity
  18. outModifiedAlpha = 1-oneMinusReflectivity + alpha*oneMinusReflectivity;
  19. //3.0以上平台,Alpha要处理一下。当为金属时,Alpha=1,非金属时,Alpha = Alpha;
  20. #endif
  21. #else
  22. outModifiedAlpha = alpha;
  23. #endif
  24. return diffColor;
  25. }

这个方法里面有一些分支,我们总结一下它们的作用:

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

所以PreMultiplyAlpha是一个Alpha预处理的方法。

2.0 UNITY_BRDF_PBS 函数原理阐释

2.1最简化的数学公式理解

在进行完DiffuseAndSpecularFromMetallic和PreMultiplyAlpha后,LightStandard函数终于把我们的数据代入了UNITY_BRDF_PBS 函数,这个也是我们最终的BRDF部分的计算。

在进入这个函数的具体说明之前,我们先了解一下这个方法的算法来源。

首先我们看一个闪瞎眼的方程

defe7cf51b985d25441b8b00e28ffc3d.png

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

7688e22548aa1d91f292a47be1cf173c.png


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

270da765a522bcfd8ffe83f5ec6174fe.png

emmmm好像能看懂一点了,此时我们简化一下它:

输出颜色 = 漫反射比例*漫反射颜色 + 镜面反射比例*(DGF/神秘的系数)*光源颜色。

有强迫症的我再给它简化一下:

输出颜色 = 正确的漫反射+正确的镜面反射。

所以其实BRDF就是使用物理正确的方式计算 漫反射+镜面反射。

简化到这里,我自己找到了爱因斯坦写质能方程式的感觉:)

但上面的简化方式有个东西我们没算进去,就是这个

,它代表的是入射方向半球的积分。说人话:各个入射光的和。

在实时渲染领域里面,所有的光照其实来源于两个部分:直接光源和IBL(基于图像的渲染)。所以各个入射光的和其实就等于 直接光源+IBL。

再结合我们上面所述的最简化公式,其实我们在代码内主要计算的就是四个部分

输出颜色 = 直接光源漫反射+直接光源镜面反射+IBL漫反射+IBL镜面反射。

在Unity里面,IBL的漫反射其实在gi.indirect.diffuse分量已经计算过了(此部分在本系列文章的第三篇已经详细讲解),所以在UNITY_BRDF_PBS 函数里,只具体计算了直接光源漫反射,直接光源镜面反射以及IBL镜面反射三个部分。

2.2 DGF三兄弟

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

cf4e8e9495a99243c1b1e46cc6c8fae5.png
公式翻译来自:宋开心

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

77ed3f58ef550c317549bbafe126609e.png
我的天怎么这么大。。。

这里面涉及到了几个部分,我们还是需要理解的。这里简单的解释一下。

法线分布函数(D)和几何函数(G)都是基于微表面原理

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

其实就是把宏观的表面看做是由微观的复杂的微小平面的集合。这些微平面虽然不能被肉眼看到,但会实际地影响光线在物体表面的反射效果。

其中:

法线分布函数(F)是描述的可以将入射光线反射到人眼里的微平面比例:当微平面法线和半角向量相等时,入射光就刚好可以反射到观察视角上,而其他微平面是没有这个反射贡献的。

976ffd4861c5dbb3147b25a3cb6b8b36.png
图片来自《Real-Time Rendering 4th》

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

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

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

dcc864f160c53fa789b4ff0a48212e90.png
图片来自 www.dorian-iten.com

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

c1d1beccab3009a1dfc58acd740805fa.png
图片来自:www.scratchapixel.com

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

我们回到公式

69842590886dda50441c13a39d2f3a88.png

刚解析完DGF,分母是计算后的一个配平系数,我们暂不在此阐述来源。

在Unity中,Unity把几何函数和配平分母中的两个点积结合在了一起,变成了V项(可见性项),也就是说这个部分公式在Unity的方法中长这样:DVF 。之后会体现在我们的代码中。

3.0 UNITY_BRDF_PBS 函数代码详解:

3.1UNITY_BRDF_PBS BRDF分支

在LightingStandard函数的最后,数据全部导入了UNITY_BRDF_PBS函数中,在cginc文件中搜索,我们可以得到这些代码分支:

  1. //Unity提供三个质量级别级别的BRDF供选择,在quality菜单里可以设置,见右图
  2. //质量从High到Low分别对应BRDF1到BRDF3
  3. // Default BRDF to use:
  4. #if !defined (UNITY_BRDF_PBS) // allow to explicitly override BRDF in custom shader//允许在自定义Shader内强制定义使用哪个BRDF
  5. // still add safe net for low shader models, otherwise we might end up with shaders failing to compile
  6. #if SHADER_TARGET < 30 || defined(SHADER_TARGET_SURFACE_ANALYSIS) // only need "something" for surface shader analysis pass; pick the cheap one
  7. #define UNITY_BRDF_PBS BRDF3_Unity_PBS//3.0以下平台直接使用最低级别PBS
  8. #elif defined(UNITY_PBS_USE_BRDF3)
  9. #define UNITY_BRDF_PBS BRDF3_Unity_PBS
  10. #elif defined(UNITY_PBS_USE_BRDF2)
  11. #define UNITY_BRDF_PBS BRDF2_Unity_PBS
  12. #elif defined(UNITY_PBS_USE_BRDF1)
  13. #define UNITY_BRDF_PBS BRDF1_Unity_PBS
  14. #else
  15. #error something broke in auto-choosing BRDF
  16. // 调试方法,假如走到这一条会在console里报error后的文字错误。
  17. #endif
  18. #endif

这里其实是对ProjectSettings中的一个Graphics图形选项的设置,具体在这个位置

491e77b8dfd6667574af3ba1bdfab5c6.png

在这个选项中根据高中低三个选项,Unity将从上面的相应分支进行编译。

不同的分支,所选取的BRDF的算法是不同的,这里,我们选用最高质量的BRDF1_Unity_PBS函数进行逐行分析。

3.2BRDF1_Unity_PBS函数 之 数据准备

BRDF1_Unity_PBS函数很长,分支也比较多,我们一步步分开来分析,完整的代码及注释,我将放到这段内容的最后。

首先我们看输入项:

  1. half4 BRDF1_Unity_PBS (half3 diffColor, half3 specColor, half oneMinusReflectivity,
  2. half smoothness,float3 normal, float3 viewDir,UnityLight light, UnityIndirect gi)

这里输入了

  1. 材质表面贴图采样值:diffuse,specColor等等
  2. 所需要的向量:法线,viewDir等等
  3. 在shader内准备的gi.light,gi.indirect

然后进入函数

  1. float perceptualRoughness = SmoothnessToPerceptualRoughness (smoothness);
  2. //=1-smoothness,光滑度转粗糙度。
  3. float3 halfDir = Unity_SafeNormalize (float3(light.dir) + viewDir);
  4. //求半角向量。Unity_SafeNormalize函数用于避免出现除零及负数的情况。

perceptualRoughness指的是感性粗糙度,Unity官方在cginc里多次强调这个不是粗糙度而是感性粗糙度。是因为在数学公式中,科学的粗糙度(一般写作α)是这个感性粗糙度的平方。假如直接暴露α给用户(美术)调节,那么调节起来的效果是非线性的,不便于用户直观的调节。于是暴露α的平方根--->感性粗糙度给用户,这样用户在DCC软件中调节起来,感受就很线性了。

半角向量halfDir是一个常用的光照计算的值,其实就是光线方向和视线方向的平均夹角。所以直接将两者相加然后归一化就可以得到。Unity_SafeNormalize是一个特殊的归一化操作,主要是避免出现0。因为这个向量计算有的时候会用在式子的分母中。

接下来计算法线和视线方向的点积:

  1. // 1、要避免dot(normal,viewDir)为负。但透视视角和法线贴图映射时有可能出现这种为负情况。
  2. // 2、解决这个问题提供了两种方案。
  3. // 1>把法线扭到偏向摄影机方向再做点积计算(准确但耗性能)
  4. // 2>直接对点积取绝对值(不完全准确,但效果可接受,省性能)
  5. #define UNITY_HANDLE_CORRECTLY_NEGATIVE_NDOTV 0//默认情况下走方法2,假如要走方法1需要注释此行
  6. #if UNITY_HANDLE_CORRECTLY_NEGATIVE_NDOTV//方法1
  7. // The amount we shift the normal toward the view vector is defined by the dot product.
  8. half shiftAmount = dot(normal, viewDir);
  9. normal = shiftAmount < 0.0f ? normal + viewDir * (-shiftAmount + 1e-5f) : normal;
  10. // A re-normalization should be applied here but as the shift is small we don't do it to save ALU.
  11. //normal = normalize(normal);
  12. float nv = saturate(dot(normal, viewDir)); // TODO: this saturate should no be necessary here
  13. #else//方法2,默认走此方法
  14. half nv = abs(dot(normal, viewDir)); // This abs allow to limit artifact
  15. #endif

这里出现了一些分支,是因为法线和视线方向的点积,在使用法线贴图和透视摄像机的情况下,会出现一些负值的情况,在这里为了物理的准确,并不是把这个负值clamp到0,而是让他偏转回正值。这里的第一个分支NITY_HANDLE_CORRECTLY_NEGATIVE_NDOTV是科学的计算方式,但涉及到的计算量很大,默认被注释掉了。实际默认走的是第二个分支,也就是使用abs()绝对值的方式进行近似处理。

接下来计算点积大家庭

  1. //剩余的点积计算 saturate限制非负范围。
  2. float nl = saturate(dot(normal, light.dir));
  3. float nh = saturate(dot(normal, halfDir));
  4. half lv = saturate(dot(light.dir, viewDir));
  5. 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函数中计算的第一个部分是直接光照的漫反射。这部分具体要计算的内容是

24c35a6e521fec137687ad50acfbaea9.png

这个部分,漫反射比例已经在LightStandard函数里的DiffuseAndSpecularFromMetallic计算后被放入了s.Albedo里。在BRDF1_Unity_PBS函数里以diffColor项输入。

这里的表面颜色实际由两个部分相乘获得,第一部分是物体的漫反射贴图颜色,第二部分是BRDF函数计算出来的漫反射颜色(包含物理光照的衰减)。

直接光照的漫反射计算具体是这样:

diffColor * light.color * diffuseTerm

那么漫反射比例*表面颜色1被放到了diffColor里,light.color就是光源颜色,dot(lightDir,normal)*表面颜色2被放到了diffuseTerm里。

那么问题来了。公式里的除以π这个步骤放哪里去了。。。

其实傲娇的Unity没有除以π,在代码内有这些解释:

  1. // HACK: theoretically we should divide diffuseTerm by Pi and not multiply specularTerm!
  2. // BUT 1) that will make shader look significantly darker than Legacy ones
  3. // and 2) on engine side "Non-important" lights have to be divided by Pi too in cases when they are injected into ambient SH
  4. //在Unity中,因为和旧效果适配和非重要灯光的一些原因,所以在Diffuse层面没有根据迪士尼BRDF的Diffuse部分公式一样除以Pi;

Unity为了保证Standard效果和引擎的旧版本的光照类似,diffuse部分就没有除以π。那么在后面的Specular项,为了保证光照的diffuse和specular的物理平衡,需要额外的再乘一个π。这个之后到了相关代码部分我们再提。

到了这里代码和公式就同步了,diffColor和light.color都是输入值,那么只需要计算diffuseTerm。

Unity使用的Diffuse BRDF使用的是迪士尼的公式。那么对于这些公式,我们没必要逐项地理解他们的原理,这些式子都是科研人员和前辈们根据实际效果的结论。对于应用部分来说,了解即可。下面我们看看公式:

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

公式内的各项:

  • = 入射方向和半角向量的夹角 --->cos
    = dot(lightDir,halfVector)
  • = 入射方向和法线的夹角 --->cos
    = dot(lightDir,normal)
  • =出射射方向和法线的夹角 --->cos
    = dot(viewDir,normal)
  • F90 (猜)应该是掠射角的反射系数,也就是最大反射值
  • (猜)因为入射和出射均受菲涅尔影响,所以括号内的部分根据入射角度和观察角度乘了两遍。

我们来看一下相关代码:

  1. //迪士尼的漫反射计算
  2. // Note: Disney diffuse must be multiply by diffuseAlbedo / PI. This is done outside of this function.
  3. //备注:baseColor/Pi的部分需要在方法外进行处理
  4. half DisneyDiffuse(half NdotV, half NdotL, half LdotH, half perceptualRoughness)
  5. {
  6. half fd90 = 0.5 + 2 * LdotH * LdotH * perceptualRoughness;//先计算F90
  7. // Two schlick fresnel term
  8. half lightScatter = (1 + (fd90 - 1) * Pow5(1 - NdotL));//入射方向菲涅尔
  9. half viewScatter = (1 + (fd90 - 1) * Pow5(1 - NdotV));//出射方向菲涅尔
  10. return lightScatter * viewScatter;
  11. }
  12. // Diffuse term//迪士尼BRDF漫反射系数计算
  13. half diffuseTerm = DisneyDiffuse(nv, nl, lh, perceptualRoughness) * nl;

这部分先是用一个DisneyDiffuse方法完整的写完公式,最后乘以了漫反射部分最终需要乘的dot(lightDir,normal)。

至此,直射光部分的漫反射计算就搞定了。我们返回一下直射光漫反射部分,也就是diffColor * light.color * diffuseTerm这三项的乘积,看看引擎效果:

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

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

3.3 BRDF1_Unity_PBS函数 之 直接光照镜面反射部分

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

9b1aca042804091bf2a4142222005dfb.png

之前我们也有提到,因为Unity将几何部分和配平系数放到了一起组成V项(visibility term),所以这个公式将变为:

镜面反射 = 镜面反射比例*法线分布函数(D)*可见性项(v)*菲涅尔系数*光源颜色*dot(lightDir,normal)

了解了公式意义后,我们来看直接光照高光部分代码

  1. // Specular term
  2. //获得1-smooth(即Roughness)的平方,即科学意义上的roughness
  3. float roughness = PerceptualRoughnessToRoughness(perceptualRoughness);
  4. #if UNITY_BRDF_GGX//默认会直接定义这个关键字
  5. // GGX with roughtness to 0 would mean no specular at all, using max(roughness, 0.002) here to match HDrenderloop roughtness remapping.
  6. roughness = max(roughness, 0.002);//限制roughness不为0(避免高光反射完全消失)。
  7. float V = SmithJointGGXVisibilityTerm (nl, nv, roughness);
  8. float D = GGXTerm (nh, roughness);
  9. #else
  10. // Legacy//旧版本保留而已,不会被用到,除非注释掉UnityStandardConfig.cginc中关于UNITY_BRDF_GGX的定义
  11. half V = SmithBeckmannVisibilityTerm (nl, nv, roughness);
  12. half D = NDFBlinnPhongNormalizedTerm (nh, PerceptualRoughnessToSpecPower(perceptualRoughness));
  13. #endif
  14. float specularTerm = V*D * UNITY_PI; // Torrance-Sparrow model, Fresnel is applied later
  15. //Cook-Torrance的反射部分。菲涅尔项后面再处理。因为Diffuse项没有除Pi,所以这里乘Pi以保证比例相等(配平)。
  16. # ifdef UNITY_COLORSPACE_GAMMA//Gamma空间需要开方的原理是?
  17. specularTerm = sqrt(max(1e-4h, specularTerm));
  18. # endif
  19. // specularTerm * nl can be NaN on Metal in some cases, use max() to make sure it's a sane value
  20. specularTerm = max(0, specularTerm * nl);// 在渲染方程中,高光项最终要乘以dot(n,l)。使用max方法避免负数。
  21. #if defined(_SPECULARHIGHLIGHTS_OFF)
  22. specularTerm = 0.0;// 材质面板中的specular Highlight开关
  23. #endif
  24. //渲染方程计算
  25. fixed3 directSpecular = specularTerm * light.color * FresnelTerm (specColor, lh);

这个部分有两个地方有分支,第一个是保留了一个Legacy旧版本的V项和D项计算(为了版本稳定性)。第二个部分是在gamma空间下,对高光部分计算结果进行了开方操作(为什么高光项gamma空间要开方我没整明白,希望有大佬能指教)。

那么去掉分支,我们整理一下以上代码

  1. // Specular term
  2. //获得1-smooth(即Roughness)的平方,即科学意义上的roughness
  3. float roughness = PerceptualRoughnessToRoughness(perceptualRoughness);
  4. // GGX with roughtness to 0 would mean no specular at all, using max(roughness, 0.002) here to match HDrenderloop roughtness remapping.
  5. roughness = max(roughness, 0.002);//限制roughness不为0(避免高光反射完全消失)。
  6. float V = SmithJointGGXVisibilityTerm (nl, nv, roughness);
  7. float D = GGXTerm (nh, roughness);
  8. float specularTerm = V*D * UNITY_PI; // Torrance-Sparrow model, Fresnel is applied later
  9. //Cook-Torrance的反射部分。菲涅尔项后面再处理。因为Diffuse项没有除Pi,所以这里乘Pi以保证比例相等(配平)。
  10. // specularTerm * nl can be NaN on Metal in some cases, use max() to make sure it's a sane value
  11. specularTerm = max(0, specularTerm * nl);// 在渲染方程中,高光项最终要乘以dot(n,l)。使用max方法避免负数。
  12. #if defined(_SPECULARHIGHLIGHTS_OFF)
  13. specularTerm = 0.0;// 材质面板中的specular Highlight开关
  14. #endif
  15. //渲染方程计算
  16. 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为零。这个开关是暴露给用户的属性,可以直接关掉直接光照的高光。在面板中,这个开关长这样:

b6a84f43ca932a3670614449ddb26103.png

最后进行方程剩余项的计算,包含菲涅尔系数(F项)的计算。

我们再来看看刚没有说到V项,D项及F项计算。

3.4 直接光镜面反射的V项,D项及F项

D项 法线分布函数

因为我们这篇文章的主要讲api应用而非数学原理,所以我只会给出公式,具体的推导请读者自行搜索,网上有很多的相关讨论。而在实际工作中,了解公式结论和具体效果,其实是非常重要的。

先看D项法线分布函数,Unity使用的是最常见的GGX分布方法,公式如下:

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

相关项及特点

  • 有最长的高光长尾(高光辉光),即具备形状不变性
  • α为科学意义的粗糙度,即用户设定粗糙度的平方
  • m代表微表面,向量m代表微表面法线。因为不可能获得每个微表面法线,所以使用半角向量作为替代。因为只有向量m=向量h时,才会产生反射到viewDir的光线。其他角度的微表面法向量会正负抵消(微观平面假设方向概率都是平均的)。

那么根据以上公式,我们来看看实际的代码:

  1. //法线分布函数计算
  2. inline half GGXTerm (half NdotH, half roughness)
  3. {
  4. half a2 = roughness * roughness;
  5. half d = (NdotH * a2 - NdotH) * NdotH + 1.0f; // 2 mad//2mad啥意思(很疯狂?)。。。
  6. //这里是分母括号内的项,为了优化做了一下变换,和公式略有不同
  7. return UNITY_INV_PI * a2 / (d * d + 1e-7f);
  8. // This function is not intended to be running on Mobile,
  9. // therefore epsilon is smaller than what can be represented by half
  10. //直译:这个函数没有打算在移动端运行,所以(要让?)它的返回值精确度比half小。
  11. //最后的1e-7f是一个极小的小数,为了保证分母不为零。
  12. //UNITY_INV_PI即Unity自带的变量1/π。
  13. }

其实这里很简单,就是把公式里的计算给写成代码。其中变量d是计算公式内的分母(进行了一下数学上的变换,结果相同)。然后UNITY_INV_PI即Unity自带的变量1/π,Unity提供了这个常量避免出现不必要的除法操作。

我们看一下法线分布函数返回后的引擎效果:

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

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

3e8cecccddd343f1986b62a1bac87223.gif

D项解释完毕,我们看一下V项。

V项:几何函数(G)*配平系数

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

26afe42f4b9e3dbc7bdf145241695481.png
Respawn Entertainment的 GGX-Smith Joint近似方案,由EarlHammon提出
  • 这是一个近似的高效方案,计算后包含了配平系数。
  • 表示LightDir和ViewDir两个方向上的可见比例。

了解了公式后,我们看一下V项的实际调用代码:

  1. //可见性项(包括几何函数和配平系数一起)的计算
  2. // Ref: http://jcgt.org/published/0003/02/03/paper.pdf
  3. inline half SmithJointGGXVisibilityTerm (half NdotL, half NdotV, half roughness)
  4. {
  5. #if 0// 这部分默认关闭。
  6. // 备注,这里是 Frostbite的GGX-Smith Joint方案(精确,但是需要开方两次,很不经济)
  7. // Original formulation:
  8. // lambda_v = (-1 + sqrt(a2 * (1 - NdotL2) / NdotL2 + 1)) * 0.5f;
  9. // lambda_l = (-1 + sqrt(a2 * (1 - NdotV2) / NdotV2 + 1)) * 0.5f;
  10. // G = 1 / (1 + lambda_v + lambda_l);
  11. // Reorder code to be more optimal
  12. half a = roughness;
  13. half a2 = a * a;
  14. half lambdaV = NdotL * sqrt((-NdotV * a2 + NdotV) * NdotV + a2);
  15. half lambdaL = NdotV * sqrt((-NdotL * a2 + NdotL) * NdotL + a2);
  16. // Simplify visibility term: (2.0f * NdotL * NdotV) / ((4.0f * NdotL * NdotV) * (lambda_v + lambda_l + 1e-5f));
  17. return 0.5f / (lambdaV + lambdaL + 1e-5f); // This function is not intended to be running on Mobile,
  18. // therefore epsilon is smaller than can be represented by half
  19. #else// 主要走这个部分
  20. // Approximation of the above formulation (simplify the sqrt, not mathematically correct but close enough)
  21. //这个部分是Respawn Entertainment的 GGX-Smith Joint近似方案
  22. half a = roughness;
  23. half lambdaV = NdotL * (NdotV * (1 - a) + a);
  24. half lambdaL = NdotV * (NdotL * (1 - a) + a);
  25. return 0.5f / (lambdaV + lambdaL + 1e-5f);
  26. #endif
  27. }

原始代码有两个分支其中第一个分支是精确的算法,但是涉及到两个开方操作,比较昂贵。所以默认关掉了。实际走的是第二个近似的分支。

那么我们精简掉不用的分支,代码如下

  1. //可见性项(包括几何函数和配平系数一起)的计算
  2. inline half SmithJointGGXVisibilityTerm (half NdotL, half NdotV, half roughness)
  3. {
  4. // Approximation of the above formulation (simplify the sqrt, not mathematically correct but close enough)
  5. //这个部分是Respawn Entertainment的 GGX-Smith Joint近似方案
  6. half a = roughness;
  7. half lambdaV = NdotL * (NdotV * (1 - a) + a);
  8. half lambdaL = NdotV * (NdotL * (1 - a) + a);
  9. return 0.5f / (lambdaV + lambdaL + 1e-5f);
  10. }

其实和上面给出的公式是一样的算法,只不过将lerp的方法写成了式子。我们把V项输出测试一下效果:

3ac11a1cfceae69cec69026fac53daeb.png
V项直接返回值

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

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

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

12b306c9ee2ab433e9b2acaf548ee7e7.png
specularTerm的返回效果

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

7c58473aab06909e4cd76aa2babc65df.png

F项:菲涅尔函数

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

cbdbfc821c9fdf4000d9064e9e99ea2d.png

其中F0是基础反射系数,就是逆着法线方向看表面时的反射系数。对于非金属来说,这个值是0.04(Unity线性空间下),对于金属来说,就是高光工作流的SpecColor。

然后我们看一下实际的代码

  1. //F菲涅尔项
  2. inline half3 FresnelTerm (half3 F0, half cosA)
  3. {
  4. half t = Pow5 (1 - cosA); // ala Schlick interpoliation
  5. //公式中使用的是dot(v,h)。而Unity默认传入的是dot(l,h)
  6. //是因为BRDF大量的计算使用的是l,h的点积,而h是l和v的半角向量,所以lh和vh的夹角是一样的。不需要多来一个变量。
  7. return F0 + (1-F0) * t;
  8. }

代码也是公式的复现,这里F0传入的就是specColor,也就是Diffuse-Specular流程的specular贴图采样值。这个部分在本文较前的部分有讲解。

代码的cosA部分传入的是dot(l,h),而公式给的算法是dot(v,h)。这是因为在整个PBR计算中,少有dot(v,h)的计算,而因为光线的入射角和出射角是相等的,数值上来说dot(l,h) == dot(v,h)。故而代替运算,省掉了这部分的计算操作。

这里需要注意的是这里返回值是一个颜色值,而非单通道的比例系数。

我们看一下菲涅尔项直接返回的效果:

d0df8972c3c69ee2c27cac5b9315a4c5.png

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

12eb458ee972f566889b9acc02ff32e5.gif

我们可以看到,当光线在掠射角度时,菲涅尔项影响了非金属的反射亮度变化,这个也是菲涅尔项的意义所在。

那么至此DVF项计算完毕,我们代入最后的一行代码

 directSpecular = specularTerm * light.color * FresnelTerm (specColor, lh)

得到直接光照的镜面反射效果如下:

5cbd6944f10a711915ff8b7c96edc73d.png

可以看到实际的模型上有完美的高光变化及颜色变化。

3.5 BRDF1_Unity_PBS函数 之 IBL漫反射部分

在以上的文章内,我们已经完成了PBR光照的直接光照部分。那么接下来还有间接光照的部分。间接光照部分,我们是使用IBL技术,也就是基于图像的光照方式。那么根据渲染方程,IBL光照部分也是分为 漫反射和镜面反射两个部分 我们先看漫反射部分。
其实在PBR的关键方法lightingStandard之前,我们已经计算过了gi,即全局光照,不过还没有计算BRDF,即光照衰减。而在BRDF1_Unity_PBS函数里,就是需要加入这个光照衰减。

漫反射部分很简单,Unity直接将漫反射颜色和直接光照相乘获得,代码如下:

half3 IBLDiffuse = diffColor*gi.diffuse

我们看一下代码计算后的返回值:

8d394fefdf4ac0109b3566be3f661cc0.png

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

3.5 BRDF1_Unity_PBS函数 之 IBL镜面反射部分

IBL的镜面反射部分,Unity的代码如下

half3 IBLSpecular = surfaceReduction * gi.specular * FresnelLerp (specColor, grazingTerm, nv);

Unity的IBL镜面反射,在网上是找不到具体的公式计算的,但是我们可以一项一项去查看它的效果,来预估这些部分的实现。为什么Unity没有根据公式进行计算,这里假如有大佬了解可以斧正一下。

首先我们看输入的gi.specular值:

60f783d90546460c0be50a9498eb5384.png

可以看到,gi.specular项是完整的没有任何光照的反射效果(模型的光照反应是Cubemap自带的反射特性)。这个部分已经完成了smoothness或roughness对cubemap的采样差值,粗糙度的不同,采样效果是不一样的。

然后我们再看surfaceReduction项,这部分Unity给出了具体的计算代码:

  1. // surfaceReduction = Int D(NdotH) * NdotH * Id(NdotL>0) dH = 1/(roughness^2+1)
  2. half surfaceReduction;
  3. //可能是经过观测,在物体的粗糙度越来越强时,反射会相对减弱,所以在这里假如一个根据粗糙度减弱反射的变量。
  4. //(roughness = 1,reflect = 0.5
  5. //因为roughnes是通过smoothness贴图采样得到,所以分线性和非线性进行不同的处理。
  6. # ifdef UNITY_COLORSPACE_GAMMA
  7. surfaceReduction = 1.0-0.28*roughness*perceptualRoughness;
  8. // 1-0.28*x^3 as approximation for (1/(x^4+1))^(1/2.2) on the domain [0;1]
  9. # else
  10. surfaceReduction = 1.0 / (roughness*roughness + 1.0);
  11. // fade in [0.5;1]
  12. # endif

可以根据代码推测,是Unity观察到在物体的粗糙度越来越强时,反射会相对减弱,所以在这里假如一个根据粗糙度减弱反射的系数,在roughness 等于1时reflect取值到0.5。我们看下surfaceReduction返回的具体的效果:

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

可以看到,取值的不同主要是和roughness相关。

再有一个参数是FresnelLerp方法,在讲这个方法之前,我们需要看这个方法所需要的一个参数F90,在Unity中是通过一个grazingTerm变量进行计算。我们看一下这个参数的具体计算代码:

half grazingTerm = saturate(smoothness + (1-oneMinusReflectivity));

这里计算的是一个掠射角度的反射强度,还没有进行菲涅尔系数计算,所以是一个固定值,我们看一下这个值的具体效果:

0d04c503c81f5606ffd206ce522ddd41.png

可以看到这个参数对于金属是没有什么影响的,主要影响的是非金属。这和菲涅尔效果的特性是一样的:非金属才有强烈的菲涅尔效果。
有了这个参数之后,我们就可以看一下具体的FresnelLerp方法了,我们先看一下代码:

  1. inline half3 FresnelLerp (half3 F0, half3 F90, half cosA)
  2. {
  3. half t = Pow5 (1 - cosA); // ala Schlick interpoliation
  4. return lerp (F0, F90, t);
  5. }
  • 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的镜面反射效果:

ddd821d9efcd0a19e5bc07f0e4f6ec41.png
完整的IBL镜面反射部分。

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

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

8165a2c6c7783c11ba8171ec37b3f03b.png
PBR光照四个部分分别的效果及最终效果

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

b45ff20c7deda5a368133e392b363823.png

后记

历时一周多业余时间的写作,终于完成了这份5万余字的总结。其中翻阅无数资料,加入自己的整理,才获得了这份结果。真心感谢能分享自己广闻博识的人,这也是我写作本文的初衷。假如这篇文章对你有所帮助,希望得到你的赞同和关注,这将是对我最大的鼓励。

本系列完整的四篇文章地址:

雨轩:Unity PBR StandardShader 实现详解(一)PBR的简单介绍及美术原理​zhuanlan.zhihu.com
b04f5544c8cb42260705070db8cece5b.png
雨轩:Unity PBR StandardShader 实现详解(二)Shader框架和数据准备​zhuanlan.zhihu.com
b04f5544c8cb42260705070db8cece5b.png
雨轩:Unity PBR StandardShader 实现详解 (三)全局光照函数计算​zhuanlan.zhihu.com
b04f5544c8cb42260705070db8cece5b.png
雨轩:Unity PBR StandardShader 实现详解 (四)BRDF函数计算​zhuanlan.zhihu.com
b04f5544c8cb42260705070db8cece5b.png

在写作这篇文章时,我只是一个刚接触shader内容的模型师,文内可能有所疏漏,希望能有大佬斧正。我参考了如下资料,献出我的膝盖以示感谢:

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

peace:)

1930b7c9aa5095b0f604c93a385a09c9.png
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/羊村懒王/article/detail/127747
推荐阅读
相关标签
  

闽ICP备14008679号