【Unity 手写PBR】Build-in管线:实现间接光部分

写在前面

直接光昨天已经实现了:【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部分就完成了!给个法线贴图试试:

【Unity 手写PBR】Build-in管线:实现间接光部分_第1张图片

芜湖,一切正常!那我们继续!

2 捋一捋Unity中间接光来源

 回顾一下:【技术美术图形部分】PBR全局光照:理论知识补充

Unity中间接光照有3个来源:Light Probe、LightMap和实时GI,划分的话,这里放上一张简洁明了的图:

【Unity 手写PBR】Build-in管线:实现间接光部分_第2张图片 图源水印

所以说我们写的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

但是场景中可能会有很多小球欸!就像下图:

【Unity 手写PBR】Build-in管线:实现间接光部分_第3张图片 图源 如果恋爱和球谐函数一样简单就好了——序章 (qq.com)

每个小球来一张Cubemap?

这有点类似于给场景中的静物每个都给个Cubemap的操作,反正都是一个缺点——开销太大。

或许可以降低Cubemap精度?

emmm,我们先一步一步来想吧,要么就尽可能把Cubemap缩小,例如256x256缩放成4x4(仅假设),原本需要给100个小球每个分配一张256x256,现在变成了100张4x4,确实节省了很多,但采样效果一定大打折扣!

球谐函数

不行了,继续在Cubemap上绕来绕去似乎必定行不通,需要请出我们的球谐函数——球谐光照。

还记得我们讨论过Cubemap和球谐函数的关联吗?球谐函数是搬出来的救兵,给Cubemap的缺点打补丁的,这里也差不多,过程大概就是:

先给光照信息编码 -> 编码后的信息存入一个大小为27的float数组中 -> 游戏运行起来时,重构光照信息(这个具体过程可以看看后面会体现的Unity中实现过程)

求解间接光漫反射只需要3阶球谐基函数就能模拟个大概,例如下图就是仅仅通过前几项基函数,对某一张Cubemap的重建效果:

【Unity 手写PBR】Build-in管线:实现间接光部分_第4张图片 图源 如果恋爱和球谐函数一样简单就好了——序章 (qq.com)

噢!我们再分析分析为什么3阶(9个函数)就足够模拟间接光漫反射了?——这与低频信息和高频信息有关。间接光漫反射属于低频环境光照,意味着少量的基函数就能高度拟合,意味着图不用非常清晰!模模糊糊就够用了!

有什么不足?

  • LightProbe会使得动态物体本身不会有反射光:但通常用probe的都是较小的物体,反射的光很微弱,对周围环境的影响也很小
  • LightProbe难以表达出复杂的照明效果:这一点你要么模拟精度高一点(取比3更高阶的基函数),但是这开销又大了,出于性能考虑本身引擎就会限制LightProbe的计算精度

总结总结,LightPrbe应用在小的、凸状物体效果会很好! 

3.2 Unity的思路

球谐基函数

开始进入正题,Unity中间接光漫反射的实现本质上就是采样light probe的过程。Unity使用了3阶的伴随勒让德多项式作为基函数,也就是l=0,l=1,l=2:

【Unity 手写PBR】Build-in管线:实现间接光部分_第5张图片 截图自百度百科

Unity中定义的系数

9个函数的系数取值如下所示:

【Unity 手写PBR】Build-in管线:实现间接光部分_第6张图片 图源 如何在Unity中造一个PBR Shader轮子 - 知乎 (zhihu.com)

我们只需要知道,这9个系数实际上是代表着球的每个面的光照就行了,如下图,

【Unity 手写PBR】Build-in管线:实现间接光部分_第7张图片 图源 如何在Unity中造一个PBR Shader轮子 - 知乎 (zhihu.com)

因为光照颜色是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;

【Unity 手写PBR】Build-in管线:实现间接光部分_第8张图片

金属度=1的两个球完全是黑色,这是因为我们的PBR默认金属没有漫反射,kd=0.

4 镜面反射:采样Cubemap

【学习笔记】Unity PBR的实现在镜面反射部分涉及到了很多探针的内容,我其实没太看明白。这里我还是采用大部分实现PBR的文章的方法吧。

还记得之前的分析文章中说的吗,间接光镜面反射分为两个部分,正常的计算方案是,

  • 前项:根据粗糙度采样Cubemap
  • 后项:预计算LUT

而Unity在计算后项时没有采样LUT贴图,而是选择了曲线拟合的思路。

本小节先介绍如何根据粗糙度采样,后面第5小节介绍Unity的拟合计算方法。

4.1 基于粗糙度计算Mip层

由于Unity的粗糙度和Mip层不是线性关系,如下图:

【Unity 手写PBR】Build-in管线:实现间接光部分_第9张图片 截图自 unity build-in管线中的PBR材质Shader分析研究_pbr shader_郭大钦的博客-CSDN博客

所以需要拟合一下求出近似的层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 手写PBR】Build-in管线:实现间接光部分_第10张图片

unity_SpecCube0

UnityShaderVariables.cginc中定义的变量,它会把传入的Cubemap模糊模糊,储存成带LOD的图:

【Unity 手写PBR】Build-in管线:实现间接光部分_第11张图片 图源水印

 这个变量的类型取决于目标平台。

4.3 单独输出

我们可以单独输出这一项,看看是什么效果:

【Unity 手写PBR】Build-in管线:实现间接光部分_第12张图片

可以看出这是没有任何光照的反射效果,只是一个采样结果。粗糙度的不同,采样结果也不同。

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

单独给他输出的话,

【Unity 手写PBR】Build-in管线:实现间接光部分_第13张图片

后面两个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这一项输出:

【Unity 手写PBR】Build-in管线:实现间接光部分_第14张图片

噢!影响的是非金属,随着_Roughness的增大而变灰,这一项就是一个灰度值在调整非金属,其实是很符合菲涅尔效应的,越光滑,菲涅尔效应才越强。

再只把FresnelLerp这一项输出,看看效果:

【Unity 手写PBR】Build-in管线:实现间接光部分_第15张图片

总结一下, 

  • 金属: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: 

【Unity 手写PBR】Build-in管线:实现间接光部分_第16张图片

6 PBR最终效果

忘了标哪一排的,中间一排是我实现的,下面一排是Unity的Standard效果,勉强八九不离十!

【Unity 手写PBR】Build-in管线:实现间接光部分_第17张图片

这里其实没有把多光源考虑进去!后面会补充。

参考

Unity中的light map - 知乎 (zhihu.com)

URP管线的自学HLSL之路 第三十七篇 造一个PBR的轮子

如果恋爱和球谐函数一样简单就好了

Unity Standard Shader 技术分析 - 知乎 (zhihu.com)

Unity的PBR扩展(二)——PBS代码剖析 - 知乎 (zhihu.com)

Unity PBR Standard Shader 实现详解 (四)BRDF函数计算 - 知乎 (zhihu.com)

你可能感兴趣的:(Unity,Shader学习,unity,游戏引擎)