赞
踩
经历三篇文章的写作。终于写到最后一篇,也是精华之最精华部分。
这个系列的文章主要目的在于了解Unity默认 Standard Shader和相关内置的函数方法。需要看本系列文章前三篇的客官可以看这里:
雨轩:Unity PBR StandardShader 实现详解(一)PBR的简单介绍及美术原理zhuanlan.zhihu.com其中
今天这篇文章,我们会讲完最后一部分,也是最重要的一部分: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文件,需要的同学可以放在同一目录下使用
1.什么是BRDF?
BRDF的英文原意是双向反射分布函数,说人话就是基于某种数学算法的光照衰减模型。何谓双向,即观察方向viewDir和光照方向lightDir。BRDF并不是一个确定的公式,而是一类光照模型算法的集合。不同的渲染器和引擎,可以根据自己的目标和需求来搭配修改不同的光照模型,组成自己的BRDF。
有了一个BRDF,我们就可以通过注入一些数据,例如法线,光强,光照方向,视线方向,金属度粗糙度等,经过BRDF输出一个最终的片段颜色。完成光照渲染。
而现在常说的的PBR,是基于真实的物理原理的BRDF模型。此方法上世纪90年代以来已有之,经由迪士尼优化和确认方向后,于2012年开始在业内广泛运用开来。具体的在浅墨的系列文里有更好的介绍:
毛星云:【基于物理的渲染(PBR)白皮书】(三)迪士尼原则的BRDF与BSDF相关总结zhuanlan.zhihu.comUnity默认的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;
- }
这个方法输入了
方法内部的具体操作
接下来我们看一下内置的两个小方法DiffuseAndSpecularFromMetallic及PreMultiplyAlpha
这个方法的主要目的是将Albedo-Metalness贴图转换为Diffuse-Specular贴图。这个部分有很多的文章和讲解不清楚。在工作流程中,为了美术的直观方便,shader一般暴露Albedo和Metallic属性给美术制作。而实际的光照计算中,往往是分成Diffuse漫反射和Specular直接反射两部分计算。那么在进入BRDF计算之前,要得到Diffuse和Specular两部分的内容。这个观点具体我在本系列的第一篇文章有详细讲解:
雨轩:Unity PBR StandardShader 实现详解(一)PBR的简单介绍及美术原理zhuanlan.zhihu.com关于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,下一个求得的值是oneMinusReflectivity。背后的算法其实就是lerp(0.96,0,metallic)。我们也来看一下引擎内的返回效果:
最终整个函数还有一个返回值,返回的是albedo * oneMinusReflectivity,我们来看一下引擎内的结果:
所以我们可以得出结论DiffuseAndSpecularFromMetallic方法的作用,就是将Albedo-Metallic贴图转换为Diffuse-Specular贴图。捎带计算了一个金属度的相反值oneMinusReflectivity。
在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;
- }
这个方法里面有一些分支,我们总结一下它们的作用:
所以PreMultiplyAlpha是一个Alpha预处理的方法。
在进行完DiffuseAndSpecularFromMetallic和PreMultiplyAlpha后,LightStandard函数终于把我们的数据代入了UNITY_BRDF_PBS 函数,这个也是我们最终的BRDF部分的计算。
在进入这个函数的具体说明之前,我们先了解一下这个方法的算法来源。
首先我们看一个闪瞎眼的方程
怕了么,但其实它还有个更闪耀的版本
然而小学毕业的我也看不懂它们,万幸 宋开心 同学给出了这个式子的翻译
emmmm好像能看懂一点了,此时我们简化一下它:
输出颜色 = 漫反射比例*漫反射颜色 + 镜面反射比例*(DGF/神秘的系数)*光源颜色。
有强迫症的我再给它简化一下:
输出颜色 = 正确的漫反射+正确的镜面反射。
所以其实BRDF就是使用物理正确的方式计算 漫反射+镜面反射。
简化到这里,我自己找到了爱因斯坦写质能方程式的感觉:)
但上面的简化方式有个东西我们没算进去,就是这个
在实时渲染领域里面,所有的光照其实来源于两个部分:直接光源和IBL(基于图像的渲染)。所以各个入射光的和其实就等于 直接光源+IBL。
再结合我们上面所述的最简化公式,其实我们在代码内主要计算的就是四个部分
输出颜色 = 直接光源漫反射+直接光源镜面反射+IBL漫反射+IBL镜面反射。
在Unity里面,IBL的漫反射其实在gi.indirect.diffuse分量已经计算过了(此部分在本系列文章的第三篇已经详细讲解),所以在UNITY_BRDF_PBS 函数里,只具体计算了直接光源漫反射,直接光源镜面反射以及IBL镜面反射三个部分。
在上面的篇幅里,我们看到了这个公式
其中漫反射部分比较简单。具体在镜面反射部分有一个比较复杂的部分
这里面涉及到了几个部分,我们还是需要理解的。这里简单的解释一下。
法线分布函数(D)和几何函数(G)都是基于微表面原理:
其实就是把宏观的表面看做是由微观的复杂的微小平面的集合。这些微平面虽然不能被肉眼看到,但会实际地影响光线在物体表面的反射效果。
其中:
法线分布函数(F)是描述的可以将入射光线反射到人眼里的微平面比例:当微平面法线和半角向量相等时,入射光就刚好可以反射到观察视角上,而其他微平面是没有这个反射贡献的。
几何分布函数(G)是描述的,在以上法线分布原理符合要求的微平面里,有一部分的微平面因为互相之间的遮挡,同样不能反射到观察视角里。所以需要计算去除:
菲涅尔系数(F)并不是基于微表面原理,而是一种反射物理现象:当观察视角越接近于掠射角,反射效果越强,视角越垂直于表面,反射越弱。
在自然界中也轻易地可以观察到这个现象,比如在观察一个水体时,近处的水面透明见底,而远处的水面有如镜面一般。就是典型的菲涅尔现象。
关于DFG三个函数,在网上还有很多很好的阐述,读者可以自行搜索,在这里就不讲的太过于深入。
我们回到公式
刚解析完DGF,分母是计算后的一个配平系数,我们暂不在此阐述来源。
在Unity中,Unity把几何函数和配平分母中的两个点积结合在了一起,变成了V项(可见性项),也就是说这个部分公式在Unity的方法中长这样:DVF 。之后会体现在我们的代码中。
在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函数进行逐行分析。
BRDF1_Unity_PBS函数很长,分支也比较多,我们一步步分开来分析,完整的代码及注释,我将放到这段内容的最后。
首先我们看输入项:
- half4 BRDF1_Unity_PBS (half3 diffColor, half3 specColor, half oneMinusReflectivity,
- half smoothness,float3 normal, float3 viewDir,UnityLight light, UnityIndirect gi)
这里输入了
然后进入函数
- 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)范围内。
这里对各个字母指代简单介绍一下:
准备好以上所有的数据之后 BRDF1_Unity_PBS准备开始进行BRDF计算了。
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使用的是迪士尼的公式。那么对于这些公式,我们没必要逐项地理解他们的原理,这些式子都是科研人员和前辈们根据实际效果的结论。对于应用部分来说,了解即可。下面我们看看公式:
公式内的各项:
我们来看一下相关代码:
- //迪士尼的漫反射计算
- // 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这三项的乘积,看看引擎效果:
看这个图片,我们可以清楚的了解直接光漫反射部分是什么:当Metal值等于0时,我们的测试值和直接光部分的值几乎是一样的(但是没有高光点)。其中Metal值等于1时,Smooth等于1的情况下,也是只有高光点的区别。但smooth等于0时却大有不同,这是因为smooth等于0时,材质球表面的变化就是高光(即镜面反射)变化。这个我们之后会看到。
计算完直接光照漫反射部分后,我们计算 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项计算。
D项 法线分布函数
因为我们这篇文章的主要讲api应用而非数学原理,所以我只会给出公式,具体的推导请读者自行搜索,网上有很多的相关讨论。而在实际工作中,了解公式结论和具体效果,其实是非常重要的。
先看D项法线分布函数,Unity使用的是最常见的GGX分布方法,公式如下:
相关项及特点
那么根据以上公式,我们来看看实际的代码:
- //法线分布函数计算
- 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项的公式:
了解了公式后,我们看一下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项返回值看测试不太明确它的意义,是因为背光面没有做处理,实际上我们的镜面反射最后会乘以dot(n,l)-->一个传统的兰伯特计算。我们看一下乘上后的效果。
可以看到,V项主要修正的是掠射角度的一个增强效果,这是通过实验去测定获得的。V项会和D项,dot(n,l)最后乘在一起,变成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)
得到直接光照的镜面反射效果如下:
可以看到实际的模型上有完美的高光变化及颜色变化。
在以上的文章内,我们已经完成了PBR光照的直接光照部分。那么接下来还有间接光照的部分。间接光照部分,我们是使用IBL技术,也就是基于图像的光照方式。那么根据渲染方程,IBL光照部分也是分为 漫反射和镜面反射两个部分 我们先看漫反射部分。
其实在PBR的关键方法lightingStandard之前,我们已经计算过了gi,即全局光照,不过还没有计算BRDF,即光照衰减。而在BRDF1_Unity_PBS函数里,就是需要加入这个光照衰减。
漫反射部分很简单,Unity直接将漫反射颜色和直接光照相乘获得,代码如下:
half3 IBLDiffuse = diffColor*gi.diffuse
我们看一下代码计算后的返回值:
可以看到这是一个柔和的间接光照效果。且金属部分的反射是完全没有的。其中的光照部分,在之前的计算中,其实是通过球谐光照计算得到的。
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);
- }
那么在这里,IBL的镜面反射的三个参数surfaceReduction * gi.specular * FresnelLerp 我们都获得了,那么我们直接相乘,就获得了IBL的镜面反射效果:
在之前的长篇内容中,我们已经计算了PBR所需要的四个光照部分:直接光漫反射,直接光镜面反射,IBL漫反射,IBL镜面反射。我们直接把这四项加和,可以得到最终的效果:
那么我们可以再回顾一下我们整张流程图
历时一周多业余时间的写作,终于完成了这份5万余字的总结。其中翻阅无数资料,加入自己的整理,才获得了这份结果。真心感谢能分享自己广闻博识的人,这也是我写作本文的初衷。假如这篇文章对你有所帮助,希望得到你的赞同和关注,这将是对我最大的鼓励。
本系列完整的四篇文章地址:
雨轩:Unity PBR StandardShader 实现详解(一)PBR的简单介绍及美术原理zhuanlan.zhihu.com在写作这篇文章时,我只是一个刚接触shader内容的模型师,文内可能有所疏漏,希望能有大佬斧正。我参考了如下资料,献出我的膝盖以示感谢:
peace:)
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。