写在前面
直接光昨天已经实现了:【Unity Shader】Build-in管线实现PBR:直接光部分,今天趁热打铁,补完剩下的间接光计算。
1 补一个法线纹理
突然法线直接光部分忽略了法线纹理应用的部分,这当然也是不可或缺的部分,之前学习入门精要的时候,就已经分别在法线空间和世界空间下实现了:
【Unity Shader】纹理实践3.0:切线空间下使用法线纹理
【Unity Shader】纹理实践5.0:世界空间下使用法线纹理
这里要使用Cubemap的话,就必须要用世界空间下的方法了,补充一下就好!
(顺便说明一点,暂时先不考虑必要时候使用half变量来优化整个shader,所以暂时所有变量都用的float,等所有工作都做完后再针对性的优化~)
1.1 UnpackNormalWithScale
我们既然引用了Unity的文件,那就最大可能的不自己算!实现什么的先找找有无对应的封装函数,简化计算!但是在用的过程中需要注意,
- 不同版本之间:Unity每个版本之间可能存在函数没更新、函数冲突等情况
- 不同管线之间:我这里是在Build-in固定管线下实现的PBR Shader,其他管线下(例如URP)函数一定会有冲突,需要进行很多的修改
好的,回到这个函数,这个函数会自动对法线贴图使用正确的解码,并缩放法线,意味着它同时具备Unpack和Scale缩放两个功能,之前的老办法是:
float3 normal = UnpackNormal(tex2D(_NormalMap, i.uv)); // 纹理采样+解码,得到法线方向
normal.xy *= _NormalScale; // 缩放
normal.z = sqrt(1.0 - saturate(dot(normal.xy, normal.xy)));
现在只需要一个函数就解决了:
float3 normal = UnpackNormalWithScale(tex2D(_NormalMap, i.uv), _NormalScale);
函数源码:
fixed3 UnpackNormalWithScale(fixed4 packednormal, float scale)
{
#ifndef UNITY_NO_DXT5nm
// Unpack normal as DXT5nm (1, y, 1, x) or BC5 (x, y, 0, 1)
// Note neutral texture like "bump" is (0, 0, 1, 1) to work with both plain RGB normal and DXT5nm/BC5
packednormal.x *= packednormal.w;
#endif
fixed3 normal;
normal.xy = (packednormal.xy * 2 - 1) * scale;
normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
return normal;
}
另外关于法线应用部分其他的点,我几乎在之前的两篇文章中都有涉及了,所以这里其他的函数、方法之类的就不再赘述。
1.2 部分代码展示
首先是vertex shader的输入输出结构体,主要是输出部分吧,有需要传给fragment shader的东西:
float2 uv : TEXCOORD0;
float4 pos : SV_POSITION;
// float4 worldPos : TEXCOORD1; // 存入变换矩阵中,节省空间
// float3 worldNormal : TEXCOORD2; // 跟着下面的变换矩阵一起传入,节省空间
float4 TtoW0 : TEXCOORD1;
float4 TtoW1 : TEXCOORD2;
float4 TtoW2 : TEXCOORD3; // xyz存入变换矩阵,w储存世界坐标
然后是vert shader部分,相对来说比较简单,就是加入计算变换矩阵的步骤:
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
接着是fragment shader中:
// 计算世界空间法线
float3 normal = UnpackNormalWithScale(tex2D(_NormalMap, i.uv), _NormalScale); // 采样+解码+缩放
// 原始方法
//float3 normal = UnpackNormal(tex2D(_NormalMap, i.uv)); // 纹理采样+解码,得到法线方向
// normal.xy *= _NormalScale; // 缩放
// normal.z = sqrt(1.0 - saturate(dot(normal.xy, normal.xy)));
float3 worldNormal = normalize(float3(dot(i.TtoW0.xyz, normal), dot(i.TtoW1.xyz, normal), dot(i.TtoW2.xyz, normal)));
1.3 测测效果
这样整个NormalMap部分就完成了!给个法线贴图试试:
芜湖,一切正常!那我们继续!
2 捋一捋Unity中间接光来源
回顾一下:【技术美术图形部分】PBR全局光照:理论知识补充
Unity中间接光照有3个来源:Light Probe、LightMap和实时GI,划分的话,这里放上一张简洁明了的图:
所以说我们写的Shader是否也要根据光照去分一分呢?伴随着这种想法,我在一众实现间接光只做简单的SH计算的文章中发现了它:【学习笔记】Unity PBR的实现,刚好跟我的想法拟合!那么就重点参考他,继续我们的实现之旅。
2.1 Unity的VertexGIForward
UnityStandardCore.cginc文件中,这个函数做工作大概就是上面那个图里面描述的,把光照方式分门别类,执行各自的计算工作,源码如下:
inline half4 VertexGIForward(VertexInput v, float3 posWorld, half3 normalWorld)
{
half4 ambientOrLightmapUV = 0;
// Static lightmaps
#ifdef LIGHTMAP_ON
ambientOrLightmapUV.xy = v.uv1.xy * unity_LightmapST.xy + unity_LightmapST.zw;
ambientOrLightmapUV.zw = 0;
// Sample light probe for Dynamic objects only (no static or dynamic lightmaps)
#elif UNITY_SHOULD_SAMPLE_SH
#ifdef VERTEXLIGHT_ON
// Approximated illumination from non-important point lights
ambientOrLightmapUV.rgb = Shade4PointLights (
unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
unity_LightColor[0].rgb, unity_LightColor[1].rgb, unity_LightColor[2].rgb, unity_LightColor[3].rgb,
unity_4LightAtten0, posWorld, normalWorld);
#endif
ambientOrLightmapUV.rgb = ShadeSHPerVertex (normalWorld, ambientOrLightmapUV.rgb);
#endif
#ifdef DYNAMICLIGHTMAP_ON
ambientOrLightmapUV.zw = v.uv2.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
#endif
return ambientOrLightmapUV;
}
其中:
VERTEXLIGHT_ON
这个关键字是需要Pass去自己定义的吧,有些情况下需要用顶点光照用以节省性能。
详细的话可以参考:翻译5 Unity Advanced Lighting - 带着红领巾 - 博客园 (cnblogs.com)
unity_lightmap
从烘焙好的lightmap贴图中获取光照颜色
UNITY_SHOULD_SAMPLE_SH
从light probe中读取光照颜色,有点类似于
ShadeSH9
这个其实不是上面源码中的,其实我也不是很明白ShadeSHPerVertex和ShadeSH9的区别,由于后面我实现这部分想用ShadeSH9,所以这里标出的是ShadeSH9,关于它的源码在后面会有所体现。
2.2 我的实现
// 间接光
inline half4 VertexGIForward(float2 uv1, float2 uv2, float3 posWorld, float3 normalWorld)
{
half4 ambientOrLightmapUV = 0;
// 静态物体
//勾选了Static
// 开启Lightmap,计算lightmap坐标
#ifdef LIGHTMAP_ON
ambientOrLightmapUV.xy = uv1.xy * unity_LightmapST.xy + unity_LightmapST.zw;
ambientOrLightmapUV.zw = 0;
// 动态物体
// 采样light probe
#elif UNITY_SHOULD_SAMPLE_SH
// 计算非重要光源
#ifdef VERTEXLIGHT_ON
// 选择不使用探针,计算4个顶点光照
ambientOrLightmapUV.rgb = Shade4PointLights (
unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
unity_LightColor[0].rgb, unity_LightColor[1].rgb, unity_LightColor[2].rgb, unity_LightColor[3].rgb,
unity_4LightAtten0, posWorld, normalWorld);
#endif
// 选择使用探针,计算球谐光照
ambientOrLightmapUV.rgb = ShadeSH9 (normalWorld);
#endif
// 开启动态lightmap
// 计算动态lightmap坐标
#ifdef DYNAMICLIGHTMAP_ON
ambientOrLightmapUV.zw = v.uv2.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
#endif
return ambientOrLightmapUV;
}
3 LightPrbe+SH
捋清楚了Unity间接光照分类,下面开始大概解释一下Light Probe和球谐函数把!
3.1 探针照明 Probe Lighting
光照探针Light Probe
一切还要从睡前有一天看到了这个教程说起:Light Probes 基本理论介绍
对于场景中的静态物体,我们可以预先烘焙好Lightmap.而对于动态物体,Unity采用的是往场景中放很多Light Probe(光照探针)来实现。
想象场景中有很多小球,每个小球保存小球在的这一点的环境光信息,这些信息可以是,
一张Cubemap
但是场景中可能会有很多小球欸!就像下图:
每个小球来一张Cubemap?
这有点类似于给场景中的静物每个都给个Cubemap的操作,反正都是一个缺点——开销太大。
或许可以降低Cubemap精度?
emmm,我们先一步一步来想吧,要么就尽可能把Cubemap缩小,例如256x256缩放成4x4(仅假设),原本需要给100个小球每个分配一张256x256,现在变成了100张4x4,确实节省了很多,但采样效果一定大打折扣!
球谐函数
不行了,继续在Cubemap上绕来绕去似乎必定行不通,需要请出我们的球谐函数——球谐光照。
还记得我们讨论过Cubemap和球谐函数的关联吗?球谐函数是搬出来的救兵,给Cubemap的缺点打补丁的,这里也差不多,过程大概就是:
先给光照信息编码 -> 编码后的信息存入一个大小为27的float数组中 -> 游戏运行起来时,重构光照信息(这个具体过程可以看看后面会体现的Unity中实现过程)
求解间接光漫反射只需要3阶球谐基函数就能模拟个大概,例如下图就是仅仅通过前几项基函数,对某一张Cubemap的重建效果:
噢!我们再分析分析为什么3阶(9个函数)就足够模拟间接光漫反射了?——这与低频信息和高频信息有关。间接光漫反射属于低频环境光照,意味着少量的基函数就能高度拟合,意味着图不用非常清晰!模模糊糊就够用了!
有什么不足?
- LightProbe会使得动态物体本身不会有反射光:但通常用probe的都是较小的物体,反射的光很微弱,对周围环境的影响也很小
- LightProbe难以表达出复杂的照明效果:这一点你要么模拟精度高一点(取比3更高阶的基函数),但是这开销又大了,出于性能考虑本身引擎就会限制LightProbe的计算精度
总结总结,LightPrbe应用在小的、凸状物体效果会很好!
3.2 Unity的思路
球谐基函数
开始进入正题,Unity中间接光漫反射的实现本质上就是采样light probe的过程。Unity使用了3阶的伴随勒让德多项式作为基函数,也就是l=0,l=1,l=2:
Unity中定义的系数
9个函数的系数取值如下所示:
我们只需要知道,这9个系数实际上是代表着球的每个面的光照就行了,如下图,
因为光照颜色是RGB,也就是3通道,每个分量都需要跟这9个系数做运算,运算数字就有27个,Unity把这27个变量存入了7个float4变量中,存在了UnityShaderVariables.cginc文件中,系数定义源码如下:
// SH lighting environment
half4 unity_SHAr;
half4 unity_SHAg;
half4 unity_SHAb;
half4 unity_SHBr;
half4 unity_SHBg;
half4 unity_SHBb;
half4 unity_SHC;
从LightProbe重构光照
参考:unity中的球谐光照_unity 球谐光照
最后一步了!要用的时候需要把光照信息取出来,这就是光照信息的重构环节。Unity把这个步骤封装到了ShaderSH9()中,传入法线信息,返回的就是环境光照信息。它的源码:
half3 ShadeSH9 (half4 normal)
{
// Linear + constant polynomial terms
half3 res = SHEvalLinearL0L1 (normal);
// Quadratic polynomials
res += SHEvalLinearL2 (normal);
# ifdef UNITY_COLORSPACE_GAMMA
res = LinearToGammaSpace (res);
# endif
return res;
}
它将SHEvalLinearL0L1和SHEvalLinearL2两个函数结果累加,并根据gamma空间的宏决定是否转换到gamma空间。
我们用的话,就用ShadeSH9()就好,传入世界空间下归一化后的法线,就能取出储存的漫反射光照信息。
3.3 实现漫反射
其实就是ShadeSH9函数了,上面的vertexGIForward()函数的属于UNITY_SHOULD_SAMPLE_SH且光源是重要光源的分支就做了计算了。
当然,我希望像参考文章那样,把漫反射和镜面反射都封装出来,而不是制作精要的计算,这样的话代码适用性不强。这样的话,我也为间接光漫反射计算单独建一个函数:
//间接光漫反射
// 参考自https://zhuanlan.zhihu.com/p/60972473
inline half3 ComputeIndirectDiffuse(float4 ambientOrLightmapUV,float occlusion){
half3 indirectDiffuse = 0;
//动态物体
#if UNITY_SHOULD_SAMPLE_SH
indirectDiffuse = ambientOrLightmapUV.rgb; // 顶点or探针SH
#endif
//静态物体
#ifdef LIGHTMAP_ON
//对光照贴图进行采样和解码
//UNITY_SAMPLE_TEX2D定义在HLSLSupport.cginc
//DecodeLightmap定义在UnityCG.cginc
indirectDiffuse = DecodeLightmap(UNITY_SAMPLE_TEX2D(unity_Lightmap,ambientOrLightmapUV.xy));
#endif
#ifdef DYNAMICLIGHTMAP_ON
//对动态光照贴图进行采样和解码
//DecodeRealtimeLightmap定义在UnityCG.cginc
indirectDiffuse += DecodeRealtimeLightmap(UNITY_SAMPLE_TEX2D(unity_DynamicLightmap,ambientOrLightmapUV.zw));
#endif
//将间接光漫反射乘以环境光遮罩,返回
return indirectDiffuse * occlusion;
}
还需要在vertex shader就传入环境光的UV:
o.ambientOrLightmapUV = VertexGIForward(v.texcoord1, v.texcoord2, worldPos, worldNormal); // 环境光orlightmap的UV坐标
前两个是输入struct定义的,
struct appdata
{
float4 pos : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float2 texcoord1 : TEXCOORD1; // 储存动态环境光照的uv坐标
float2 texcoord2 : TEXCOORD2; // 储存静态光照贴图的uv坐标
但说实话,感觉这里面texcoord1和2没起到作用。
事实上,这部分做了这么多,如果不考虑这么复杂,就是一句代码的事:
float3 ambient_contrib = ShadeSH9(half4(worldNormal, 1));
这样的话就是完全只能采用采样lightprobe存入SH并重构出环境光颜色的方法去计算间接光漫反射了。
3.4 效果展示
这里单独输出
float3 result = indirectDiffuse * kd;
金属度=1的两个球完全是黑色,这是因为我们的PBR默认金属没有漫反射,kd=0.
4 镜面反射:采样Cubemap
【学习笔记】Unity PBR的实现在镜面反射部分涉及到了很多探针的内容,我其实没太看明白。这里我还是采用大部分实现PBR的文章的方法吧。
还记得之前的分析文章中说的吗,间接光镜面反射分为两个部分,正常的计算方案是,
- 前项:根据粗糙度采样Cubemap
- 后项:预计算LUT
而Unity在计算后项时没有采样LUT贴图,而是选择了曲线拟合的思路。
本小节先介绍如何根据粗糙度采样,后面第5小节介绍Unity的拟合计算方法。
4.1 基于粗糙度计算Mip层
由于Unity的粗糙度和Mip层不是线性关系,如下图:
所以需要拟合一下求出近似的层mip_roughness,至于为什么取值,在这篇文章中作者有所体现,就不赘述了,放代码:
// Mip层
float CubeMapMip(_Roughness){
//基于粗糙度计算CubeMap的mip层
float mip_roughness = _Roughness * (1.7 - 0.7 * _Roughness);
float mip = mip_roughness * UNITY_SPECCUBE_LOD_STEPS;
return mip;
}
UNITY_SPECCUBE_LOD_STEPS
是个常数值,默认为6,意思是整个粗糙度划分为0-6,7个阶层
4.2 采样贴图LOD
下一步需要对天空盒立方体贴图进行采样,再次封装一个方法:
// 反射探针获取颜色值
inline float3 IndirectSpecularCube(float _Roughness, float3 viewDir, float3 worldNormal, float occlusion){
float mip = CubeMapMip(_Roughness); // 按粗糙度取mip层
float3 reflectVec = normalize(reflect(-viewDir, worldNormal)); // 计算采样方向
float4 rgbm = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, reflectVec, mip); // 采样内部存的一个Cubemap的LOD
float3 iblSpecular = DecodeHDR(rgbm, unity_SpecCube0_HDR); // 把颜色从HDR编码下解码
return iblSpecular * occlusion;
}
先按粗糙度取mip层,然后计算了一个反射方向,最后采样,最后那个是解码HDR,因为HDR本身非常亮,要是不解码会让传出的结果不正确?这里我简单试了一下没发现有什么不同,可能是我尝试方式不对吧。。。先不管了,就写在这儿。
反射向量reflectVec
这里相当于基于世界法线对视线方向的负方向做了个镜面,用得到的这个反射向量作为采样Cubemap的方向向量。在Cubemap实现环境映射那篇文章里,已经提到过了:
unity_SpecCube0
UnityShaderVariables.cginc中定义的变量,它会把传入的Cubemap模糊模糊,储存成带LOD的图:
这个变量的类型取决于目标平台。
4.3 单独输出
我们可以单独输出这一项,看看是什么效果:
可以看出这是没有任何光照的反射效果,只是一个采样结果。粗糙度的不同,采样结果也不同。
5 镜面反射:曲线拟合LUT
没有选择传入LUT那个红红的图,Unity选择了用系数拟合。Unity计算这部分的源码:
surfaceReduction * gi.specular * FresnelLerp (specColor, grazingTerm, nv);
其中gi.specular这一项就是我们在第4节里计算的东西,于是!Unity曲线拟合具体应该就是体现在了surfaceReduction这个系数。
5.1 SurfaceReduction
Unity源码计算这一项:
ifdef UNITY_COLORSPACE_GAMMA
// 1-0.28*x^3 as approximation for (1/(x^4+1))^(1/2.2) on the domain [0;1]
surfaceReduction = 1.0-0.28*roughness*perceptualRoughness;
# else
// fade \in [0.5;1]
surfaceReduction = 1.0 / (roughness*roughness + 1.0);
# endif
照着写一个:
float surfaceReduction = 1.0 / (roughness * roughness + 1.0); // linear空间下
//float surfaceReduction = 1.0 - 0.28 * roughness * _Roughness; // Gamma
单独给他输出的话,
后面两个roughness=0,前面的roughness=1.
5.2 菲涅尔项的影响
我们可以拿直接光中计算菲涅尔项和间接光的做对比,下面是直接光考虑菲涅尔自定义的函数:
// Unity这里传入的是ldoth,而非vdoth
inline float3 Unity_Fresnel(float3 F0, float cosA){
float a = pow((1 - cosA), 5);
return (F0 + (1 - F0) * a);
}
这个是Unity源码中定义的FresnelLerp函数:
half3 FresnelLerp(half3 F0,half3 F90,half cosA){
half t=Pow5(1-cosA);
return lerp(F0,F90,t);
}
其中,
float3 F0 = lerp(unity_ColorSpaceDielectricSpec.rgb, albedo, metallic);
float grazingTerm = saturate(1 - _Roughness + (1 - kd));
直接光传入的是ldoth,间接光的是ndotv。后者的亮度变化效果完全符合菲涅尔效果的样子,
- 正对视角F0的地方为0
- 掠射角F90为1
这会带来什么最终效果?
我们先只把grazingTerm这一项输出:
噢!影响的是非金属,随着_Roughness的增大而变灰,这一项就是一个灰度值在调整非金属,其实是很符合菲涅尔效应的,越光滑,菲涅尔效应才越强。
再只把FresnelLerp这一项输出,看看效果:
总结一下,
- 金属:F0处是albedo值,随着视角向F90移动,边会变成亮度高的白色
- 非金属:F0处的影响是很大的,会衰减反射效果
5.3 结果
都算了一下,然后整个乘在一起:
float surfaceReduction = 1.0 / (roughness * roughness + 1.0); // linear空间下
//float surfaceReduction = 1.0 - 0.28 * roughness * _Roughness; // Gamma
float grazingTerm = saturate(1 - _Roughness + (1 - kd));
float3 indirectSpecular = surfaceReduction * indirectSpecularPro * FresnelLerp(F0, grazingTerm, ndotv) * occlusion;
场景中给了一个cubemap:
6 PBR最终效果
忘了标哪一排的,中间一排是我实现的,下面一排是Unity的Standard效果,勉强八九不离十!
这里其实没有把多光源考虑进去!后面会补充。
参考
Unity中的light map - 知乎 (zhihu.com)
URP管线的自学HLSL之路 第三十七篇 造一个PBR的轮子
如果恋爱和球谐函数一样简单就好了
Unity Standard Shader 技术分析 - 知乎 (zhihu.com)
Unity的PBR扩展(二)——PBS代码剖析 - 知乎 (zhihu.com)文章来源:https://www.toymoban.com/news/detail-702769.html
Unity PBR Standard Shader 实现详解 (四)BRDF函数计算 - 知乎 (zhihu.com)文章来源地址https://www.toymoban.com/news/detail-702769.html
到了这里,关于【Unity 手写PBR】Build-in管线:实现间接光部分的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!