从零开始在Unity中写一个PBR着色器

几个月前,偶然接触了PBR(Physically Based Rendering),找了很多博客看是怎么回事,并照着公式写了个shader,感觉还可以。现在回头来整理下,本来我是想写些关于PBR的理论的,不过逛知乎发现大神毛星云已经对PBR的相关理论写了好几篇博客,非常具有体系性,所以我就在GitHub上fork了他的文章基于物理的渲染(PBR)白皮书 | PBR White Paper,我自己就不献丑了,本文就来谈谈如何把理论变成可执行的代码吧。

首先把PBR的渲染公式贴一下,以下所有内容都将围绕此公式展开

我知道这个公式不在说人话,所以把它翻译一下是这样的


翻译

是不是好理解多了?

其中DFG三项我是用了Epic他们于2013年发表的论文Real Shading in Unreal Engine 4,里面的公式和最初Disney在2012年发表的论文Physically-Based Shading at Disney有点不一样,比如说Epic他们觉得Disney用的F项有点消耗过大,所以拟合了一个公式来代替Disney用的F项。当然最近几年PBR大火,有更多更好的公式被发现,大家如果在实践中发现公式不一样,也无需纠结。

D项为


这一项代表法线的分布函数,什么意思呢?我们在传统的光照模型中需要一个表面的法线方向来进行一系列的计算,从宏观上来说这就是一条法线。然而,我们都知道PBR是基于微表面理论的,所以宏观上的一条法线在微表面上其实代表的是有许许多多的微表面法线都朝着某个方向,组成了宏观上我们计算的那条法线。这个D项即是在说明,在微表面上,有多少微表面的法线可是正确的朝向(正确意味着光线l可以被反射到视线方向v),这些能被观察到的法线会对最终的计算结果产生影响。所以,这个函数的输出是一个统计分布,表明根据现在的表面粗糙度等输入,计算得到有多少法线会对最终结果产生实际影响。

G项为



这一项代表着自阴影这一属性。其实上面的D项虽然可以算出所有有用的微表面的法线,然而这些法线并不一定都能被观察方向所看见。由于表面的几何结构,也许存在一些表面被其他表面挡住,所以这一项实际上是在对D项的再过滤,把真正有用的法线提取出来参与到最后的计算。

F项为

这项为菲涅尔项。根据物理研究,万物皆有菲涅尔,菲涅尔项在表达所见光的反射率与视角相关的现象。具体来说,从掠射角(与法线呈接近90度)下观察,光的反射率会增加。举个例子,我们在海滩边,看着脚下的水会觉得很清澈,地下的沙看的很清楚,而远处却是浮光掠金,看不清底下到底有什么,这就是菲涅尔所在表达的现象。


菲涅尔效果示意

这里要注意的是,宏观上我们看见的菲涅尔其实是在微观上所有菲涅尔的平均值,也就是说微平面上每道光的入射角和法线都在影响着最终宏观上菲涅尔的结果。
不同材质的菲涅尔是不同的(好像是句废话。。。)。一般金属的菲涅尔会很弱,因为金属的反射本身就很强了。拿铝做个例子,其反射率在所有角度几乎都保持在86%以上,随角度变化很小。而绝缘体则相反(这里想科普一下,水不导电,水能导电是因为水中的其他物质在导电,纯净的水是不导电的),比如玻璃,在法线方向的反射率仅为4%,到掠射角度的时候可以接近100%。



如果大家看见代码里涉及菲涅尔时有变量名叫F0,F90的时候,不要奇怪,F0代表从法线方向观察材质的反射率,而F90就是与法线垂直方向观察材质的反射率。但代码里不会直接使用反射率,而是在此反射率下材质应该是什么颜色,毛星云已经总结了一张F0处的表,方便大家快速查找。

从表上来看,金属的F0值在0.5-1.0之间,电介质(Dielectric)或者叫绝缘体大都在0.02-0.05之间,半导体在0.3-0.5之间。

公式有了,那就写成代码吧

//Specular D, normal distribution function, α = roughtness^2
float GGX(float NdotH, float r_2){
    float alpha_2 = pow2(r_2);
    float res = (alpha_2 * _GGX) / (UNITY_PI * pow2(pow2(NdotH) * (alpha_2 - 1) + 1) + MINNUM);//加个非常小的数以防是0
    return res;
}

//Specular G,Geometry Term
float SmithJoint(float NdotL, float NdotV,float r){
    float k = pow2(r+1) / 8;
    float g1 = NdotV / (NdotV * (1 - k) + k);
    float g2 = NdotL / (NdotL * (1 - k) + k);
    return g1 * g2;
}
//Specular F, Fresnel Term
float4 FresnelSchlick(float4 F0, float VdotH){
    return F0 + (1 - F0) * exp2((-5.55473 * VdotH - 6.98316) * VdotH);
}

float4 CookTorranceBRDF(float NdotH,float NdotL,float NdotV,float VdotH,float roughness,float4 specularColor){
    float D = GGX(NdotH,pow2(roughness));
    float G = SmithJoint(NdotL,NdotV,roughness);
    float4 F = FresnelSchlick(specularColor,VdotH);
    float4 res = (D * G * F) / (4 * NdotL * NdotV + MINNUM);
    return res;
}

写代码的时候注意除的时候分母要加个比较小的数,防止除0发生。

那么有了以上代码,我们要怎么调用呢?
我们先要得到NdotH,NdotL,NdotV,VdotH,由于一般材质会提供法线贴图,所以得到的法线是处于tangent space的,虽然可以把光照方向,观察方向转到tangent space,但我决定这次把法线转到world space处理,所以要得到这些变量,我们应该现在vertex shader里构建好一个变换矩阵。

float3 worldPos = mul(unity_ObjectToWorld,v.vertex).xyz;
float3 worldNoraml = UnityObjectToWorldNormal(v.normal);
float3 worldTangent = UnityObjectToWorldDir(v.tan.xyz);
float3 worldBinormal = cross(worldNoraml,worldTangent) * v.tan.w;
o.TtoW0 = float4(worldTangent.x,worldBinormal.x,worldNoraml.x,worldPos.x);
o.TtoW1 = float4(worldTangent.y,worldBinormal.y,worldNoraml.y,worldPos.y);
o.TtoW2 = float4(worldTangent.z,worldBinormal.z,worldNoraml.z,worldPos.z);

然后在fragment shader里把法线转换好。

float3 worldPos = float3(i.TtoW0.w,i.TtoW1.w,i.TtoW2.w);
float3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
float3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
float3 halfDir = normalize(viewDir + lightDir);
//normal in tangent space
float3 normal = UnpackNormal(tex2D(_NormalTex,i.uv));
normal.xy *= _NormalScale;
normal.z = sqrt(1 - saturate(dot(normal.xy,normal.xy)));
//normal in world space
float3 normalWorld = normalize(float3(dot(i.TtoW0.xyz,normal),dot(i.TtoW1.xyz,normal),dot(i.TtoW2.xyz,normal)));

有了world space的法线后,上述的变量就好解决了。

float NdotL = saturate(dot(normalWorld,lightDir));
float NdotV = saturate(dot(normalWorld,viewDir));
float VdotH = saturate(dot(viewDir,halfDir));
float NdotH = saturate(dot(normalWorld,halfDir));
float LdotH = saturate(dot(lightDir,halfDir));

现在可以开始调用公式的代码了。

//direct light part
float4 ambient = UNITY_LIGHTMODEL_AMBIENT * _MainCol * col * _LightFactor;
float4 diffuse = OneMinusReflectivityFromMetallic(Metalness.r) * _MainCol * col / UNITY_PI;
float3 F0 = lerp(unity_ColorSpaceDielectricSpec.rgb,col.rgb,Metalness.r);//区分金属非金属
float4 specular = CookTorranceBRDF(NdotH,NdotL,NdotV,VdotH,roughness,float4(F0,1) * _SpecularColor);                

重点来看下diffuse和specular,diffuse我没有用Disney的公式,完全按照Epic论文中的公式

然后乘以了一个漫反射系数OneMinusReflectivityFromMetallic(Metalness.r),这个OneMinusReflectivityFromMetallic方法定义在UnityStandardUtils.cginc中,源码

inline half OneMinusReflectivityFromMetallic(half metallic)
{
    // We'll need oneMinusReflectivity, so
    //   1-reflectivity = 1-lerp(dielectricSpec, 1, metallic) = lerp(1-dielectricSpec, 0, metallic)
    // store (1-dielectricSpec) in unity_ColorSpaceDielectricSpec.a, then
    //   1-reflectivity = lerp(alpha, 0, metallic) = alpha + metallic*(0 - alpha) =
    //                  = alpha - metallic * alpha
    half oneMinusDielectricSpec = unity_ColorSpaceDielectricSpec.a;
    return oneMinusDielectricSpec - metallic * oneMinusDielectricSpec;
}

注释推导得很明白了,但我想说下从理论上来说,漫反射系数 = 1 - 金属度,金属度决定了镜面反射系数,所以漫反射是1 - 金属度,但非金属或多或少也有镜面反射,所以,简单的减法并不能满足,所以有了以上的源码来计算漫反射系数。在这里金属度是金属贴图的r通道提供的,这张贴图的a通道提供了光滑度,bg通道空置。Unity中有两种PBR的工作流,Metallic和Specular,现在我用的这种是Metallic,欲了解详细请前往探究PBR的两种流程以及Unity中的PBS。

spcular项中,unity中有个变量unity_ColorSpaceDielectricSpec,定义在UnityCG.cginc中,它的rgb存了介于金属与非金属之间的F0的颜色,这个颜色与金属贴图上的颜色通过金属度进行插值,并承以自定义的颜色,来决定最终传入F项公式的F0的颜色。
这里有人会问了,上面的代码里没有项啊?你写代码时写漏了?其实项已经被F项表达出来了,他们俩是重复的,之前的公式有那么一点瑕疵,所以在实现时这个就没有了。

由于这个公式中的积分项无法实时计算出来

所以我们通过一些手段让公式简化为

其中即,是光源的颜色

那么代码里就是

return  (diffuse + specular) * _LightColor0 * UNITY_PI * NdotL;

最后,为了得到更真实的光照,我们还需要计算IBL部分。说起IBL又得另外写一篇,所以这里你可以认为是对天空盒进行采样,并且在采样天空盒时,我们需要一个级数。这个级数呢是代表天空盒那张贴图(称作环境贴图)的级数,级数越高对应的纹理越小,图像越模糊。我们把这样一个技术叫做多级渐远纹理(mipmaps)
粗糙度越大,反射应该越模糊,那么采样的环境贴图的级数也应该越高。然而,粗糙度和级数的关系并不是一个线性关系,Unity内使用的转换公式为mip = r(1.7 - 0.7r),可在UnityImageBasedLighting.cginc内找到。有时我们还会再乘以一个常数,表明整个粗糙度范围内多级渐远纹理的总级数。
然后,我们会在F0和F90之间进行插值,来为IBL添加高质量的菲涅尔反射效果,再考虑一个由粗糙度计算得到的surfaceReduction进一步对IBL进行修正。

//indirect light part
float3 reflectDir = normalize(reflect(-viewDir,normalWorld));
float percetualRoughness = roughness * (1.7 - 0.7 * roughness);
float mip = percetualRoughness * 6;
float4 envMap = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0,reflectDir,mip);
float grazing = saturate((1 - roughness) + 1 - OneMinusReflectivityFromMetallic(Metalness.r));
float surfaceReduction = 1 / (pow2(roughness) + 1);
float4 indirectSpecualr = surfaceReduction * envMap * FresnelLerp(float4(F0,1) * _SpecularColor,grazing,NdotV);

放上效果图,有两种球,铁锈的球和竹子材质的球。其中有两个球是官方自带的standard shader,另外两个球是我自己写的shader,看起来还不错。贴图出自https://freepbr.com/

项目地址

PS. PBR需要把color space调到linear space,原来的gamma space并不适合做PBR,为什么呢?因为gamma space本身是为了渲染出来的物体看起来更加真实而对我们眼睛所看到的颜色进行了修正,而PBR本身就是基于物理的渲染,所有涉及到的贴图都是在真实光照环境下设计出来的,不需要再进行修正了。至于有朋友对lienar space和gamma space感兴趣的话,可以看看【图形学】我理解的伽马校正(Gamma Correction),聊聊Unity的Gamma校正以及线性工作流以及Unite 2018 | 浅谈伽玛和线性颜色空间。

2020.09.17更新
之前对于IBL部分讲得太简单了,有些地方写的也不太满意,所以在此做一些补充。
首先IBL部分的代码我改成了这个样子:

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);
}

//indirect light part
//indirct diffuse
float3 sh = ShadeSH9(float4(normalWorld,1));
float3 iblDiffuse = max(float3(0,0,0),sh + (0.03 * ambient));
float3 Flast = fresnelSchlickRoughness(max(NdotV, 0.0), F0, roughness);
float kd = (1 - Flast) * OneMinusReflectivityFromMetallic(Metalness.r);
iblDiffuse = iblDiffuse * kd / UNITY_PI;
//indirect specular
float3 reflectDir = normalize(reflect(-viewDir,normalWorld));
float percetualRoughness = roughness * (1.7 - 0.7 * roughness);
float mip = percetualRoughness * 6;
float4 rgbm = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0,reflectDir,mip);
float4 iblSpecular = float4(DecodeHDR(rgbm,unity_SpecCube0_HDR),1);
//LUT part, use surfaceReduction instead
float grazing = saturate(smoothness + OneMinusReflectivityFromMetallic(Metalness.r));
float surfaceReduction = 1 / (pow2(roughness) + 1);
float4 indirectSpecualr = surfaceReduction * iblSpecular * FresnelLerp(float4(F0,1) * _SpecularColor,grazing,NdotV);

同样间接光也是由间接漫反射间接镜面反射组成的。在这里间接漫反射约等于球谐函数编码后的全局光照信息乘上漫反射比例,球谐部分unity中是有API可直接调用的,就是ShadeSH9,然后加上很小的环境光影响(所以环境光乘了0.03),而漫反射比例根据Adopting a physically based shading model这篇博文来看,我们需要用粗糙度来计算这个比例,得出后两部分乘起来就是间接漫反射项。而间接镜面反射被epic公司简化成了下面的形式:

左边括号内的东西是上文写的关于mipmap的采样天空盒的内容,然后天空盒可能是HDR格式存储的,所以要用DecodeHDR将HDR信息转换成正常信息。
右侧括号内的东西一般来说是个定值,最常见的做法是把值放到一张LUT中,根据nv和粗糙度采样即可。

LUT

然而采样必然会给带宽带来压力,带宽有了压力就发热,所以Unity内部并不用LUT的方式来实现右侧括号内的东西,而是用一个拟合函数来模拟,这个拟合函数就是上面代码中的surfaceReduction乘上一个菲涅尔系数(这个系数是在高亮颜色和grazing项之间插值所得)。

2021.06.18
我升级到了unity2021,然后我自己写的shader基本没变,效果却和以前天差地别,还是玩shadergraph保平安吧= =

参考
第 18 章 基于物理的渲染
如何在Unity中造一个PBR Shader轮子
【学习笔记】Unity PBR的实现
浅墨的游戏编程
基于物理着色:BRDF
PBR Step by Step(一)立体角
猴子都能看懂的PBR(才怪)

你可能感兴趣的:(从零开始在Unity中写一个PBR着色器)