《Unity3D高级编程之进阶主程》第七章,渲染管线与图形学(三) - 渲染原理与知识2

这节我们将继续补充前面渲染管线中没有讲到的渲染知识和原理。

纹理贴图的 Filter 滤波方式

纹理贴图的Filter滤波其实在图形引擎中被用到的地方有很多,我们在做项目时却很少察觉到,它的重要性不容忽视,我们来讲讲它的来龙去脉。

每张纹理贴图可能都是大小不一的贴图,渲染时它们被映射到网格三角形的表面上,转换到屏幕坐标系之后,纹理上的独立像素(纹素)几乎不可能直接和屏幕上的最终画面像素直接对应起来。这是因为物体在屏幕上的显示的大小会随着物体离摄像机远近而改变在屏幕上的占有率,当物体非常靠近摄像机时屏幕上的一个像素有可能对应纹理贴图上一个纹素中一小部分(因为物体覆盖了摄像机视口的大部分面积),而当物体离摄像机很远时,屏幕上一个像素包含了纹理贴图上很多个纹素(因为物体只覆盖了相机视口的很小一部分)。因此贴图中的一个纹素与屏幕上的一个像素通常都是无法有一比一的对应关系。

无论哪种情况我们都无法精确的知道应该对这些纹素做怎样的插值。OpenGL就为我们提供了多种Filter 滤波方式,不同的滤波方式在速度和画质上会有所不同,这也是我们需要做出了的权衡的事。

滤波方式分三种,一种是最近采样即Nearest,一种是线性采样即Linear,另一种各向异性采样。在Unity3D中Point类型的采样就是最近采样(Nearest Point Sampling),线性采样又分为双线性采样(Bilinear)和三线性采样(Trilinear)。

其中Nearest最近采样,当纹素与像素大小不一致时,它会采样取最接近的纹素。这种方法取的只是寻找了位置最接近的纹素所以并不能保证连续性,即使使用了Mipmap技术,像素点与纹素也仍然没有得到很好的匹配,因此这种方法使得纹理在屏幕上显得有些尖锐。

线性滤波技术的含义是:使用坐标值从一组离散的采样信号中选择相邻的采样点,然后将信号曲线拟合成线性近似的形式。在图像采样中,OpenGL会将用户传递的纹理坐标视为浮点数值,然后找到两个离它最近的采样点。坐标到这两个采样点的距离也就是两个采样点参与计算的权重,从而得到加权平均后的最终结果。双线性滤波采取的是离纹素最近的4个纹素,这4个文素在线性计算上的权重值为纹素与中心点的距离,把所有采样得到的纹素进行加权平均后得到最终的像素颜色。

我们来看看Nearest最近采样与双线性采样的不同之处,假设源图像长度m像素,宽度为n像素,即m x n像素大小,目标图像为a x b像素。那么两幅图像的边长比分别为:m/a和n/b。目标图像的第(i,j)个像素点(i行j列)可以通过边长比对应到源图像。其对应坐标关系为(im/a,jn/b)。显然这个对应坐标一般来说不是整数,非整数的坐标是无法在图像中无法取得正确的像素。Nearest最近采样直接取小数最接近的整数(小数部分四舍五入取整)作为纹理对应的坐标点,显然这样做有些突兀,双线性采样则通过寻找距离坐标附近的4个像素点,再通过这4个像素点做加权平均来计算该点的像素坐标。双线性滤波映射点计算方法:

	srcX=dstX* (srcWidth/dstWidth)+0.5*(srcWidth/dstWidth-1)

	srcY=dstY* (srcWidth/dstWidth)+0.5*(srcWidth/dstWidth-1)

双线性过滤

双线性滤波在像素之间的过渡显然比最近采样方式更加平滑。不过双线性采样只选取一个MipMap Level,它选取纹素和像素之间大小最接近的那一层MipMap进行采样,这导致当像素大小匹配的纹素大小在两层Mipmap Level之间时,双线性过滤在有些情况效果就不太好,这时三线性过滤则能更好的做到平滑的效果。

三线性过滤在双线性过滤基础上对像素大小与纹素大小最接近的上下两层Mipmap Level分别再进行一次双线性过滤,然后再对两层Mipmap纹理上得到的像素结果再进行插值计算最终得到合理的纹素。

除了上面几种过滤外还有各向异性过滤(Anisotropic Filtering)。什么是各向异性和同性呢:

	各向同性,当需要贴图的三维表面平行于屏幕就是各向同性。

	各向异性,当要贴图的三维表面与屏幕有一定角度的倾斜时。

各向异性过滤,除了会把Mipmap因素考虑进去外,还会把纹理与屏幕空间的角度这个因素考虑进去。它会考滤一个像素对应到纹理空间中在u和v方向上与u和v的比例关系,如果u:v不是1:1时,将会按比例在各方向上采样不同数量的点来计算最终的结果。各向异性采样的多少取决于Anisotropic Filtering的X值,所以在Unity3D的纹理图片设置上有一个Aniso Level的设置选项,用来设置Anisotropic Filtering的级别。

这里我们介绍了纹理的滤波方式,它们主要是采样和计算方式不同,在计算中融入了更多的因素。采样方式从最近采样、双线性滤波、三线性滤波,再到各向异性滤波,采样次数也在逐级提高。最近采样的采样次数为1次,双线性滤波采样4次,三线性滤波采样8次,各向异性采样随着等级不同各有不同,效果也是逐级提高,随着采样次数的提高需要消耗的GPU也会逐级提高(这些采样与计算都是在GPU中完成的),因此我们在设置图片滤波时需要考虑这里画质与性能开销。

光照阴影是如何生成的

前面讲了很多关于Mipmap和纹理采样的知识,对Mipmap和纹理采样的理解对底层画面渲染的理解有很大的帮助。这节我们来讲讲实时光照阴影的生成,它也同样具有重要意义,3D渲染阴影模拟了实际生活中的光照知识,让原本虚拟的画面更加拟真现实生活。

为了能让画面中场景和人物看起来更加贴近真实被人意识所接受,光影效果是不可或缺的。我们经常能在画面中看到阴影跟随着物体摆动而变动,并且物体被光照遮挡的阴影投射在其他物体上,这样的效果十分动人,那么阴影是如何产生的呢?我们来细致的解析一下,通过解析我们能够更加深刻的理解阴影的生成原理,还可以通过对阴影原理的理解来有针对性的优化阴影对性能的消耗。

我们可以先想想真实生活中阴影的产生的过程,当一个光源发射一条光线遇到一个不透明物体时,这条光想不能再继续照亮它背后的物体,它周围的地面和物体都被照亮了只有这个背后的物体没有被光照到使得这块区域的变成了阴影,因为光线无法到达这块区域。

在计算机的实时渲染中我们无法用表达出每条光照的射线,这样的算力计算机承受不了,那么我们是如何表达阴影的投射的呢?

其实可以很简单,假设我们将摄像机放在光源的位置上,摄像机的方向与光源照射的方向一致,相机中那些看不到的区域就是阴影产生的地方。只是我们不可能真的将摄像机放在那里,但可以用这种方式单独渲染一次摄像机在该位置的图像。只是我们需要的不是图像,我们需要的是阴影,刚好物体从该位置渲染出来的片元的深度值提供了我们需要的参照数据,在光源位置上摄像机渲染的所有片元的深度值都被写入深度缓存中,我们可以用这个深度缓存做阴影计算,深度值越大的片元被遮挡的可能性越大,深度值最小的片元不会被遮挡。

这就是阴影映射纹理(Shadow Map)技术,在渲染中第一个渲染管线(pass)负责在光源点位置计算得到深度值,输出像素到阴影映射纹理(Shadow Map)。我们实质上得到是一张深度图,它记录了从该光源的位置出发,能看到的场景中距离它最近的表面位置的深度信息。

那么阴影图有了,应该怎么投射呢?

主动计算投射到其他物体产生阴影是比较难的,但反过来,根据阴影图主动计算当前渲染物体上的片元是否被阴影是相对比较容易。我们会看到Unity3D在渲染物体上看到有生成阴影和接受阴影两个选项,即Cast Shadows 和 Receive Shadows。

传统的接受阴影的方式,是将当前顶点的位置变换到光源点的空间下得到它在光源空间中的位置,再根据xy轴分量对阴影映射纹理(Shadow Map)进行采样,从而得到阴影映射纹理中该位置的深度值,如果这个深度值小于该顶点的深度值即z轴分量,那么说明该点位于阴影中,于是在片元颜色输出上加深阴影颜色,反之则没有被阴影遮盖。

另一种方式为屏幕空间的阴影投影技术(Screenspace Shadow Map),它需要显卡支持MRT(Multiple Render Targets),有些移动平台并不支持这种特性。屏幕空间阴影投射技术(Screenspace Shadow Map)对从光源出发的深度图与摄像机产生的深度图做比较,如果摄像机的深度图中记录的点的表面深度大于转化到光源出发生成的深度图的点的深度,那么就说明表面虽然是可见的但却处于该光源的阴影中。

通过这样的方式,屏幕空间阴影投射技术(Screenspace Shadow Map)得到了当前摄像机屏幕空间中的阴影区域,即得到了当前摄像机屏幕的阴影图。因为已经得到了当前摄像机整个屏幕的阴影图,在当前像素位置对阴影图进行采样便能知道该像素是否在阴影下,即只要根据像素坐标采样阴影图中的像素即可得到阴影系数,不需要再将坐标转换到光源空间。相对于传统的阴影渲染来说,屏幕空间阴影映射技术提高了更多的GPU性能效率。

Shader "Example ShadowCaster"
{
     SubShader
     {
        Tags { "Queue" = "Geometry" }
        //渲染阴影图
		Pass 
		{
			Name "ShadowCaster"
			Tags { "LightMode" = "ShadowCaster" }
			
			ZWrite On ZTest LEqual Cull Off

			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			#pragma target 2.0
			#pragma multi_compile_shadowcaster
			#include "UnityCG.cginc"

			struct v2f { 
				V2F_SHADOW_CASTER;
				UNITY_VERTEX_OUTPUT_STEREO
			};

			v2f vert( appdata_base v )
			{
				v2f o;
				UNITY_SETUP_INSTANCE_ID(v);
				UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
				TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
				return o;
			}

			float4 frag( v2f i ) : SV_Target
			{
				SHADOW_CASTER_FRAGMENT(i)
			}
			ENDCG
		}
    }

    // Fallback "Legacy Shaders/VertexLit"
}

在Unity3D中使用 LightMode 为 ShadowCaster 的Pass标记为阴影生成管线,它为渲染产生阴影图。当Unity3D在渲染时会首先在当前Shader中找到LightMode为ShadowCaster的Pass,如果没有则会在Fallback指定的Shader中继续寻找,如果没有则无法产生阴影,因为无论传统的阴影投射还是屏幕空间阴影投射都需要第一步先产生阴影纹理图(Shadow Map)。当找到LightMode为ShadowCaster的Pass后,Unity3D会使用该Pass来绘制该物体的阴影映射纹理(Shadow Map)。

有了阴影图就可以将物体的阴影部分绘制出来了:

Shader "Example ShadowReceive"
{
     SubShader
     {
        //从阴影图中绘制阴影
		Pass
        {
        	struct a2v {
                float4 vertex : POSITION;
            };

            struct v2f {
                float4 pos : SV_POSITION;
                SHADOW_COORDS(1) //阴影uv变量
            };
        	v2f vert(a2v v)
        	{
        		v2f o;
        		o.pos = UnityObjectToClipPos(v.vertex); //转换顶点空间

        		TRANSFER_SHADOW(o); //计算顶点坐标再阴影纹理中的位置
        		return o;
        	}

        	fixed4 frag(v2f i) : SV_Target
        	{
        		fixed4 _color = fixed4(1,1,1,1);

        		fixed shadow = SHADOW_ATTENUATION(i); //从阴影纹理图中计算阴影系数

        		_color.rgb *= shadow; //颜色与阴影系数相乘,系数从0-1,完全在阴影中为0,完全不在阴影中为1

        		return _color;
        	}
        }
    }

    Fallback "Example ShadowCaster"
}

上述Shader代码我们将其他干扰因素去除,只剩下阴影绘制,至于阴影图的绘制我们使用了Fallback策略,引用了Fallback中的ShadowCaster管线。这个简易的Shader中 SHADOW_COORDS、TRANSFER_SHADOW、SHADOW_ATTENUATION这三个宏,Unity3D已经为我们准备好了,在AutoLight.cginc中。这三个宏都有针对不同情况不同设备的变种,大致分为,实时阴影,离线烘培阴影,传统阴影,屏幕空间阴影这几个变种。我们可以理解为,SHADOW_COORDS是定义阴影图uv的变量,TRANSFER_SHADOW则计算顶点坐标再阴影纹理中的位置,SHADOW_ATTENUATION是从阴影纹理图中获得深度值从而计算阴影的明暗系数,完全被遮挡的情况下系数为0,此时颜色应该为黑色,相反完全没有被遮挡的情况下为1,此时不影响像素颜色显示,当然还有中间状态,阴影的绘制也很有讲究,有软阴影和硬阴影的计算方式区分,这里不再深入。

Lightmap烘培原理

随着硬件技术的发展,人们对场景的画质效果越来越高,实时光照早已经满足不了人们对画质的需求,想要更加细腻真实光照效果,只能通过离线的烘培技术才能达到理想画质的效果。

全局光照,简称GI(Global Illumination),是在真实的大自然中,光从太阳照射到物体和地面再经过无数次的反射和折射,使得地面的任何物体和地面反应出来的光亮都叠加着直接照射的光和许许多多物体反射过来的间接光(反射光),使得我们眼睛里看到画面是光亮又丰富的。这种无数次反射和折射形成的高质量画面,才符合人们意识当中的真正世界的模样。但是即使今天硬件技术发展的如此迅速,也无法做到实时的进行全局光照(Realtime Global Illumination),实时的计算量太大CPU和GPU无法承受。

离线全局光照就担负起了这个丰富画面光照效果的重任,它不需要实时的CPU和GPU算力,只要一张或几张光照图(Lightmap)就能将全局光照的效果复原到物体上,不过也仅限于场景静态物体的光照烘培。

其实烘培这趟水很深,如果要具体深入到工程上的实现,涉及到的算法和图形学知识比较多,这里并不打算深究,而是讲讲我们能相对容易能获得的关于Lightmap的原理和知识。根据这个原理知识,我们在项目的制作和优化中能起到很好的作用。

什么是烘焙?个人认为从英文‘Bake’翻译过来有点偏差,导致很多工具按钮用‘Bake’表示时,很多人都同样把它理解成了烘培,其实‘Bake’更应该理解为‘制作’或‘制造’。场景烘培简单来说 就是把物体光照的明暗信息保存到纹理上, 实时绘制时不再需要进行光照计算,因为结果就在光照纹理(Lightmap)中,只需从光照纹理(Lightmap)中采样便能得到光照计算的结果。

我们在渲染3D模型时用到的基本元素有顶点、UV、纹理贴图等(这里不多展开),顶点上的UV数据在形成片元后就成了顶点间的插值后的UV数据,我们通常使用这个UV坐标去纹理贴图上采样取得文素作为像素,再将像素填充到帧缓存中最后显示到画面上。光照纹理(Lightmap)的显示也是同样道理,用UV坐标来取得光照纹理(Lightmap)上的文素作为像素,将这个像素叠加到片元颜色上输出给缓存。

这其中的UV有一点讲究,我们在制作模型时顶点数据中的UV数据可以不只一个,其中UV0通常情况下是为了映射贴图纹理而存在的,它在模型的蒙皮制作过程中就在模型数据中记录下来了,而UV1也就是我们程序中的uv2或俗称的2u,通常都是为Lightmap所准备的。除了UV0,UV1还有UV2,它是为实时全局光照准备的。只有UV3开始才是我们程序可以自定义使用的UV数据,其实UV可以有很多个UV4,UV5,不过Unity3D的网格类(Mesh)暂时只提供到UV3的获取接口即程序中的mesh.uv4。

既然光照纹理(Lightmap)存储的是光照信息,那么它到底存了哪些信息呢?我们先来看下这幅图:

	缺图

这幅图解释了烘培的简单模型,它分为三个部分,第一部分为光线射到墙壁后反射过来照到模型上,第二部分为光线照射过来时被其他模型挡住,导致当前的模型没有被光线照射到并且有阴影产生,第三部分为光线直接照射到模型上产生的颜色信息。

这三者之和最终形成了完全的光照颜色。可以用一个简单的公式来说明这三者的结合方式:

	光照颜色 = 间接光照颜色 + 直接光照颜色 * 阴影系数(0到1)

其中直接光的计算代价不高,在一些光照并不复杂的场景中并不记录直接光信息,而是由Shader自己计算直接光照。因此我们能看到,很多项目中并没有记录直接光,而只是记录间接光,即光照纹理中只记录了从其他物体反射过来的光产生颜色的总和。除了直接光照和间接光照,场景烘培还会产生一张阴影纹理来记录阴影信息。如果你希望记录主要光的方向,也可以开启Directional Model的Directional来获得另一张存有光照方向的图,这个贴图上存储的光方向信息可以被用在Shader中作为计算的变量。

现在我们知道了烘培(Bake)会最多产生3种贴图,一种是光照纹理图(可能是间接光照纹理图,也可能是间接光照+直接光照+阴影合并的纹理图,取决于你在Unity3D中Lighting Mode的设置),一种是阴影纹理图,一种是主要光方向纹理图,以及模型的UV1数据。

其中UV1会被存储到模型网格信息中去,也就是烘培后模型prefab的mesh.uv2的数据会被改写。我们在制作和导出模型时要稍加注意,烘培需要用到模型的UV1数据,在导出模型时如果没有加入UV1数据则可能无法得到正确的烘培结果。

那么烘培器是如何生成uv和贴图的呢?我们需要理解下UV Chart

在烘培时,烘培器会对所有场景中的静态物体上的Mesh网格进行扫描,按块大小和折线角度大小来制作和拆分Mesh上的对应的UV块,这个UV块就是UV Chart。

UV Chart是静态物件在光照纹理(Lightmap)上某块Mesh对应的UV区块,一个物体在烘培器预计算后会有很多个UV Chart。因此每个物件的UV Charts是由很多个UV Chart组成,每个UV Chart为一段连续的UV片段。默认情况下,每个Chart都至少是4x4的纹素,无论模型的大小一个Chart都需要16个纹素。UV Chart之间预留了0.5个像素的边缘来防止纹理的溢出。如下图:

UV Chart0

	图0

UV Chart1

	图1

UV Chart2

	图2

UV Chart3

	图3

图中1描述了,当一个场景只有1个立方体物体时,这个立方体网格物体被烘培后,6个面上的UV Chart是如何映射到烘培纹理上的。图2描述了场景中当有多个简单的立方体时,每个物体被扫描后制成UV Chart的情况。图3描述了当烘培场景更加复杂时,扫描后UV Chart被制作的情况,不同规格的模型UV被映射到Lightmap纹理贴图上。

我们能从图中直接清晰的了解到,在烘培时每个场景中的静态物体都会被扫描Mesh网格,并且将其计算出来的UV Chart合并起来制作成一张或几张(可能场景太大一张不够用)光照纹理贴图lightmap。

那么什么决定了烘培中扫描网格时形成的UV Chart大小和数量呢?相邻顶点间的最大简化距离和最大夹角值。

烘培器为了能更加快速的计算制作出UV Chart,烘培器需要对模型网格顶点扫描进行简化。简化方式为,将相邻顶点间距离小于某个数值的顶点归入一个UV Chart,这个合并间距的数值越大UV Chart生成的速度就会越快。如果只是顶点距离上的简化往往会出现很多问题,我们需要从相邻面的角度上对合并进行约束,当相邻面间的角度大于某个值时,即使顶点距离符合合并间距也不能简化成同一个UV Chart。这两个参数在Unity3D中都有设置,点击静态物体在右边的版面上就能看到。如图位置:

UV Chart1

图中展示了静态物体Mesh Renderer中设置Lightmap UV生成参数,参数包括最大简化顶点距离,最大邻接面角度。

当设置的最大简化距离和邻接面最大角度数值比较大时,计算生成UV Chart的数量就会比较少,反之设置的最大简化距离和最大邻接面角度比较小时则需要计算和生成的UV Chart会比较多,此时烘培的速度也会比较慢,因为在预计算实时全局光照(GI)时,每个UV Chart上的像素都会计算灯光,预计算的时间跟Chart的数量直接的关系。

上述描述了烘培的前置制作中Lightmap纹理分布和场景中物体的UV映射的原理,那么绘制Lightmap纹理贴图时纹理上颜色是怎么生成的呢?

我们知道如果不用烘培技术,在实时渲染中,因为算力的原因我们只能计算直接光对物体的明暗影响。如果想要在实时渲染中计算间接光的影响是非常消耗GPU的算力的,即使有足够强大的显卡支撑使用光线跟踪计算,也只能在带有RTX的显卡计算机上使用。暂时还没有做到普及的程度,因此离线烘培成了我们解决间接光的主要手段。

在一个场景中如果这些物体只考虑直接光的影响,则会缺乏很多光影细节,导致视觉效果很“平”。而间接光则描述了光线在物体表面之间的折射,增加了场景中明暗变化以及光线折射的细节,提高了真实感。

光照纹理贴图的像素主要根据光的折射与反射现象来计算得到,那么它具体是用怎样的算法来计算得到光照纹理图中的像素颜色的呢,这里我们来了简单解一下Unity3D中采用的Enlighten和Progressive Lightmapper两种算法的解决方案。

全局照明可以用一个称为渲染方程的复杂方程来描述:

渲染方程

上述的渲染方程定义了光线是如何离开表面上某个点的,可是这个积分方程太复杂以至于计算机无法在较短时间内计算得出结果,Unity3D中Enlighten采用的近似方法即辐射算法,这可以大大提高计算渲染方程式的速度。

辐射算法假设了场景中存在一组有限的静态元素,以及仅有漫射光传输来简化计算。在计算过程中它把场景拆分成很细很细的面片,分别计算它们接收和发出的光能,逐次迭代直到每个面片的光能数据不再变化(或者到一定的阀值)为止,得到最终的光照图。场景拆分后的以及每个面片之间的作用,如下图所示:

渲染方程

Enlighten将场景切割成很多个面片我们称它们为Cluster(Cluster大小可以通过Unity3D的烘培参数设置数值大小),这些Cluster会对其映射的静态物体的纹理中的反射系数进行采样,然后计算Cluster之间的关系使得光在Cluster之间传递。

Enlighten将渲染方程简化成了迭代公式即:

渲染方程

其中Bi指的是在i点最终的光,Le是i点本身的光,而两个Cluster之间光的反弹系数由Fij来决定,Lj则是J点的光。这也是为什么Enlighten能够支持场景物体不变的情况下允许光源发生变化的原因:因为几何体素化和辐射系数计算代价比较大,需要离线计算,而迭代每个Cluster形成最终结果则计算量相对比较小可以实时进行。

Progressive Lightmapper即渐进式光照贴图,是Unity3D 2018版本后才能使用的烘培算法。

Progressive Lightmapper是一种基于路径追踪(fast path-tracing-based)的光照贴图系统,它能在编辑器中逐步刷新的烘焙光照贴图(baked lightmaps)和光照探针(Light Probes)。

Progressive Lightmapper主要的优势是能随着时间的推移逐步细化输出画面,及时逐步的看到画面效果,这样能够实现更完善的交互式照明工作流。另外Progressive Lightmapper还提供了一个预估的时间,所以烘焙时间更加可预测。

参考文献:

《OpenGL编程指南》

《OpenGL ES 3.0编程指南》

《Unity移动平台下的烘焙使用及优化》

《浅析Unity中的Enlighten与混合光照》

《Progressive CPU Lightmapper》

《光照贴图Lightmap初探》

《辐射度算法(radiosity)原理》

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第七章,渲染管线与图形学(三) - 渲染原理与知识2

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)