Skip to content

Latest commit

 

History

History
1030 lines (667 loc) · 80.4 KB

第十章渲染原理与知识.md

File metadata and controls

1030 lines (667 loc) · 80.4 KB

第十章 渲染原理与知识

渲染原理与知识

为什么要有渲染顺序

前面章节中我们介绍了深度测试这个模块阶段,它用片元的深度值与深度缓存中的值对比,测试的结果决定是否要写入深度缓存中,如果判断不符合则抛弃片元不再继续下面的流程。这其中涉及到了 ZTest On/Off 状态开关,和,ZWrite On/Off 状态开关,其中ZTest 用于控制是否开启深度测试,ZWrite 用于控制是否写入深度缓存。

渲染管线中的深度测试最大的好处是帮助我们尽早的发现不需要渲染的片元,及时抛弃它们以节省GPU开销从而提高了效率。大部分情况下我们都使用 ZTest LEqual 来做深度测试的判断,也就是离摄像机越近的物体绘制的偏远越容易遮挡住离得远的物体。从这个角度看渲染机制,如果能先把离屏幕近的物体放前面渲染,那么离屏幕远的物体则能在深度测试的机制下早早的闭屏掉很多片元的渲染,提升不少的GPU效率。

我们发现从上述角度看,渲染顺序就成了提高GPU效率的关键,Unity3D引擎对所有不透明物体在渲染前都做了排序的工作,离摄像机近的排在前面渲染,离的远的排在后面渲染,这个渲染队列就有了排序规则。

那么半透明物体怎么办呢?因为半透明物体需要Blend混合,Blend混合就需要先将不透明物体先渲染完成再做Blend混合操作,因此它通常被引擎安排在所有不透明物体渲染后才渲染,只有这样才能发挥出它半透明的效果。在半透明物体中,ZWrtie通常都是关闭状态,如果将ZWrite开启后半透明部分在深度测试时就会抛弃比它深度高的像素,这导致多个半透明物体在叠加渲染时由于深度测试而被抛弃,丢失了Blend混合的效果使得画面有点错乱,这也是半透明物体通常不开启ZWrite的原因,它的主要方式还是混合而非测试。

Unity3D引擎在提交渲染时就有这么条规则,即对所有半透明物体的渲染都排在了不透明物体的后面,这样就确保了半透明物体能在不透明物体渲染完毕后才开始渲染,以保证半透明物体的Blend混合效果。其半透明物体的队列也同样使用排序算法在渲染前排序,只是排序负责与不透明物体相反,即离摄像机越远的物体越先渲染。

那么怎么判定物体是不透明还是半透明呢,虽然Blend是半透明的特色但也不是唯一标准,不透明物体同样可以使用Blend混合增强效果。Unity3D引擎为了解决这个问题在Shader中使用了标记功能,将渲染顺序放在Shader中去标记,即用Shader中的 Queue 标签来决定我们的模型归于哪个渲染队列。

Unity3D在内部使用了一系列整数索引来表示渲染的次序,索引越小表示排在前面被渲染。Queue 标签:

	Background,背景层,索引号1000

	Geometry,不透明物体层,索引号2000

	AlphaTest,AlphaTest物体层,索引号2450

	Transparent,半透明物体层,索引号3000

	Overlay,覆盖层,索引号4000

Shader中我们选择Queue标签就会指定索引类型。我们来看一个例子:

Shader "Transparent Queue Example"
{
     SubShader
     {
        Tags { "Queue" = "Transparent" }
        Pass
        {
            // rest of the shader body...
        }
    }
}

这里例子中将物体标记为半透明队列,当被标记为标记为半透明物体时,Unity3D引擎就会将这些物体放在不透明物体渲染之后做渲染。

我们前面了解到,不透明物的排序与半透明物的排序是相反的,因为半透明物需要Blend混合,必须先绘制远处的物体,这样Blend混合的效果才正确。Unity3D在渲染队列标签中,每个标签都有一个索引号,Unity3D规定2500以下的索引号,排序规则以根据摄像机的距离由近到远顺序渲染,2500索引号以上的渲染队列标号则相反,排序规根据摄像机的距离由远到近顺序渲染排列。

为什么要这么排序呢?因为2500以下物体都是不透明物体,渲染在深度测试阶段越早剔除掉越好,所以对摄像机由近及远的渲染方式对早早的剔除不需要渲染的片元有莫大的帮助,这种方式提高了GPU效率。而2500索引以上的物体,通常都是半透明物体或者置顶的物体(例如UI),如果依然保持由近到远的渲染规则,其中的半透明物体就无法混合到被它覆盖的物体。因此2500索引及以后的物体与2500索引以前的物体规则是相反的。

半透明的排序问题通常是头疼的,为什么呢?因为前面我们说的它是需要由blend混合完成半透明部分的操作,而Blend操作必须在前面物体已经绘制好的情况下才能有Blend混合后成为半透明或全透明效果。

Shader中的Queue标签在Transparent半透明索引号下,相同索引号是从远到近渲染的,在粗糙颗粒的排序上尚可以解决部分叠加问题,即两个物体模型没有相交部分,前后关系的blend混合是可以依靠模型中点离摄像机的远近做排序的,Unity3D引擎就是这么做的。但是如果两个物体网格面片相交,或者同一个物体中面片相互交错,则无法再区分片元的前后关系了。原因是他们没有写入片元的深度值,即ZWrite为关闭状态,不能用深度值去判定片元是否覆盖或被覆盖,倘若开起来则又会出现Blend混合失效,因为片元底下的覆盖的片元被彻底抛弃了,就无从Blend混合一说了。

因此使用Blend混合做半透明物体,在复杂的半透明交叉情况下通常很难做到前后关系有秩序,特别是当模型物体有交集的时候。此时我们通常都采用手动排序的方法来纠正排序问题,例如在Queue标签上用+1的方式表明层级被优先渲染,即Tag{ Queue = “Transparent+1” } 的形式。

我们说所有物体的渲染顺序都是引擎自主排列的,而不是由GPU排序的,GPU只知道渲染、测试、裁切,完全不会去管物体的前后次序,这也是为什么称GPU渲染为“渲染流水线”的原因,它就像工厂里的作业流水线一样,每个工人都只是一个节点的螺丝钉(代表渲染流水线中各个阶段的结点),它们大部分时候只要记住一个动作就可以“无脑”的重复劳动,GPU里就是这样的做法。

Alpha Test

上面几节讲了好多关于半透明物体的知识,而Alpha Test其实也是属于半透明物体的特征,不过它不是混合,而是裁切。

我们在制作模型过程中,很多模型的边角都需要极其细微的面片,比如树上的叶子,一堆乱糟糟的草,还有许许多多圆形的洞等,这些模型如果用网格来表达的话会多出很多很多面片,制作时间长,调整起来慢,同屏面数高,问题滚滚而来。

那么怎么办呢,Alpha Test能很好的解决这些问题,Alpha Test 用纹理图片中的 Alpha 来测试判定该片元是否需要绘制。当我们尝试展示一些很细节的模型时,如果使用Alpha Test,原本要制作很多细节网格,现在只要用一张图片和两三个面片就能代替巨量的面片制作效果,即使有时需要调整,也只需要调整纹理图片和少量顶点就可以完成工作。

这种方式被大量用在节省面片数量的渲染上,因为它的制作简单,调整容易,被众多模型设计师和开发人员所喜爱。其渲染的过程也相对比较简单,在片元着色器中判断该片元 Alpha 值是否小于了某个阈值,一旦判定小于某个阈值就调用clip或者discard丢弃该片元,该片元的不再进行后面的流水线。

Shader "Example Alpha Test"
{
	Properties
	{
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Cutoff("Cut off",range(0,1))=0.5
    }

    ...

    SubShader
    {
     	...

        //Alpha Test示例
		Pass
        {
            struct v2f {
                float4 pos : SV_POSITION;
                float4 uv:TEXCOORD0;
            };

        	v2f vert(appdata_base v)
        	{
        		v2f o;
        		o.pos = UnityObjectToClipPos(v.vertex); //转换顶点空间
        		o.uv = v.texcoord; // 传递uv值
        		return o;
        	}

        	fixed4 frag(v2f i) : SV_Target
        	{
        		fixed4 _color = tex2D(_MainTex,i.uv.xy); // 根据uv获取纹理上的纹素

        		//clip函数非常简单,就是检查它的参数是否小于0。如果是,就调用discard舍弃该fragment;否则就放过它。
        		clip(_color.a - _Cutoff);

        		return _color;
        	}
        }
    }

    ...
}

上述Shader剥离了干扰因素,代码极简的表现了Alpha Test。先将顶点uv从顶点着色器上传递到片元着色器上,再用uv坐标数据取出纹理中的颜色,使用clip函数判定片元是否通过测试。clip函数非常简单,就是检查它的参数是否小于0。如果是,就调用discard舍弃该fragment,否则就放过它。

Early-Z GPU硬件优化技术

前面我们介绍过些深度测试的知识,即深度测试在片元着色器之后对片元之间的前后做了遮挡测试,这使得GPU对哪些片元需要绘制又有哪些片元因被遮挡而不需要绘制有了数据依据。不过深度测试是在片元计算完毕后才做的测试,使得大部分被遮挡的片元在被剔除前就已经经历过了着色器的计算,这使得当片元重叠遮挡比较多时许多片元的前期计算浪费的较为严重,被遮挡部分的片元计算完就被抛弃,浪费了算力。

这种情况频繁发生,特别是在摄像机需要渲染很多物体的时候,相互叠加遮挡的情况会越来越严重,每个物体生成的片元无论是否被遮挡都经过了差不多是一整个的渲染流程,深度测试前的渲染计算几乎全部浪费掉。

Early-Z 技术就专门为这种情况做了优化,我们可以称它为前置深度测试。由于渲染管线中,深度测试作用在片元着色器之后,这时候再进行深度测试时,所有渲染对象的像素都已经经历了计算的整个过程没有半点优化,因此深度测试几乎没有性能提升,仅仅是为了得出正确的遮挡结果,造成大量的无用计算算力浪费,每个像素点上重叠了许多次计算。

在现代GPU中更多的运用了Early-Z的技术,它在几何阶段与片元着色器之间(光栅化之后,片元着色器之前)先进行一次深度测试,如果深度测试失败,就认为是被遮挡的像素,直接跳过片元阶段的计算过程,节省了大量的GPU算力。

我们来看下它的具体流程,我们来看看如下图:

	Early-Z--|
	|        |no
	|yes     |
	|    片元着色计算
	|        |
	|        |
	ZTest 深度测试 -- 抛弃
	|
	|
	屏幕像素缓冲

上图中展示了Early-Z 前置深度测试的流程,光栅化后的片元先进入Early-Z 前置深度测试阶段,如果片元测试被遮挡,则直接跳过片元着色计算,如果没有被遮挡则继续片元着色的计算,无论是否通过Early-Z 前置深度测试,最终都会汇集到ZTest 深度测试再测试一次,由后置的深度测试来最终决定是否抛弃该片云,由于前置深度测试已经测试完毕了片元的前后关系,因此所有跳过片元着色计算的片元都会在后置深度测试的节点上被抛弃,反之则会继续渲染流程像素最终进入屏幕像素缓冲区。

Early-Z的实现是GPU硬件自动调用的,它主要是通过两个pass来实现,即第一个是Z-pre-pass,对于所有写入深度数据的物体,先用一个超级简单的pass不写入像素缓存,只写深度缓存,第二个pass关闭深度写入,开启深度测试,用正常渲染流程进行渲染。

由于Alpha Test的做法让我们在片元着色器中可以自主的抛弃片元,因此问题又出现了。

片元在着色器中被主动抛弃后,Early-Z 前置深度测试的结果就会出现问题,因为测试通过的可见片元被抛弃后,被它遮挡的片元就成为了可见片元,导致前置的深度测试结果出现问题。因此GPU在优化算法中,对片元着色器抛弃片元和修改深度值的操作做了检测,如果检查到片元着色器中存在抛弃片元和改写片元深度的操作时,Early-Z 将被放弃使用。

简单来说,Early-Z 对遮挡处理做了很大的优化,但是如果我们使用了Alpha Test 来渲染物体时,Early-Z 的优化功能将被弃用。

Mipmap的原理

Mipmap是目前应用最为广泛的纹理映射技术之一,Mip来源于拉丁文中的multum in parvo,意思是“在一个小区域里的很多东西”。引擎将Mipmap技术与材质贴图技术结合,根据物体距摄像机远近距离的不同,分别使用不同分辨率的纹理贴图,不仅提高了画面效果还提高了GPU渲染效率。Mipmap功能在3D游戏中非常常见,但很多人还是不太了解Mipmap的来龙去脉,我们在这里详细的讲一讲。

在我们为3D物体渲染纹理贴图时,经常出现物体离摄像机很远的情况,屏幕像素与纹理大小的比率变得非常低,此时纹理采样点的变化会非常大,这样会导致渲染图像上的瑕疵。

我们举例来说,假设我们要渲染一面墙,这面墙纹理有 1024 x 1024像素的大小,当摄像机距离墙适当时渲染的图像是没有问题的,因为每个像素都有各自对应的纹理贴图上合理的像素。但是当摄像机向这面墙渐渐远离,慢慢它在屏幕上的像素范围越来越小时就出现问题了,原因是物体所呈现的像素点越来越少,这使得纹理采样的坐标变化比较大,可能会在某个过度点上发生突然的变化导致图像产生瑕疵。特别是在屏幕上不断前后运动的物体可能会使得屏幕上渲染产生类似闪烁的劣质效果。

Mipmap为了修正这种劣质效果,将纹理贴图提前存储成不同级别大小的纹理贴图,并在渲染时将它们传入OpenGL,OpenGL会判断当前应当使用纹理贴图的哪个层级大小的贴图,判断的依据是基于物体在屏幕上所渲染的像素大小决定的。

除了能更好的平滑渲染远近物体像素上的瑕疵和闪烁问题外,Mipmap还能很好的提高采样的效率,由于采用从已经缓存的不同分辨率纹理的采样对象,那些远离摄像机的物体采用了更小分辨率的纹理贴图,这使得采样时内存与GPU缓存之间传输的带宽减轻了不少压力从而获得更高的效率。实际项目中大部分物体都离摄像机较远,这使得Mipmap的采样效率提升在渲染中发挥了重要的作用。

在使用Mipmap时,通常OpenGL负责计算细节层次并得到所应该选择的Mipmap层级,再将采样结果返回给着色器。不过我们也可以自己取代这个计算过程再通过OpenGL纹理获取函数(textureLod)来选取指定的纹理层次。

那么在OpenGL中到底 Mipmap 是怎么决定采用哪层分辨率的贴图的呢?我们来详细的讲解一下。首先我们有2个概念要复习一下:

	1.屏幕上的颜色点叫像素,纹理贴图上的颜色点叫纹素。

	2.屏幕坐标系我们用的是XY坐标系,纹理贴图坐标系用的是UV坐标系。

在片元着色器中,每个片元即屏幕空间XY上的像素都会找到对应的纹理贴图中的纹素来确定像素的颜色。这个查找纹素的过程就是一个从XY空间到UV空间的一个映射过程。我们可以通过分别求x和y偏导数来求屏幕单个像素宽度纹理坐标的变化率。

由于物体离的远,像素覆盖屏幕的范围比较小,这使得屏幕上的像素区块,对应到实际的纹理贴图中可能是一个矩形的区域。那么x轴方向上的纹理贴图大小和屏幕上的像素区域大小有一个比例,y轴方向上的也同样有一个比例。

例如,获取到的纹理贴图上的纹素大小为 64x64,屏幕上的像素区域大小为32x32,那么它们在x轴上的纹素和像素大小比例为 2.0 (即64/32),y轴上的也同样是 2.0 (即64/32)。如果纹理贴图上的纹素大小为 64x32,屏幕上的像素区域大小为 8x16,那么它们在x轴上的纹素和像素大小比例为 8.0(即64/8),在y轴上的纹素和像素大小比例为2.0(即32/16)。

这个比例就是纹素的覆盖率,当物体离摄像机很远时,纹素的覆盖率就很大,当物体离摄像机很近时则很小,甚至小于1(当纹素覆盖率小于1时则会调用纹理放大滤波器,反之则用到了Mipmap,如果刚好等于1则使用原纹理)。

在着色器中为了求得覆盖率,我们可以用ddx和ddy求偏导的方式分别求这个两个方向上的覆盖率,然后取较大的覆盖率。为什么ddx和ddy偏导函数就能计算覆盖率呢。这里稍微复习一下,我们知道在光栅化的时刻,GPU会在同一时刻并行运行很多片元着色器,但是并不是一个像素一个像素的放入到片元着色器去执行的,而是将其组织成 2x2 为一组的像素块再去并行执行。而偏导数就正好能计算这一块像素中的变化率。

我们来看下偏导的真相:

	ddx(p(x,y)) = p(x+1,y) - p(x,y)

	ddy(p(x,y)) = p(x,y+1) - p(x,y)

x轴上的偏导就是 2x2 像素块中 x轴方向上附近的数值之差。同理,y轴上的偏导就是 2x2 像素块中 y轴方向上附近的数值之差。因此MipMap层级的计算可以描述为:

float MipmapLevel(float2 uv, float2 textureSize)
{
    float dx = ddx(uv * textureSize.x);
    float dy = ddy(uv * textureSize.y);
    float d = max(dot(dx, dx), dot(dy, dy));  
    return 0.5 * log2(d);
} 

上述函数中,先求出x轴和y轴方向上的覆盖率后,再取得dx和dy的最大值(dot(dx,dx)其实就是dx的平方,同理dy),再log2后获得Mipmap层级,这里0.5是技巧,本来应该是d的平方。大部分时候OpenGL已经帮我们做了Mipmap层级的计算,也就是说我们在Shader中使用tex2D(tex, uv)获取颜色的时候就相当于在GPU内部展开成了如下面所示:

tex2D(sampler2D tex, float4 uv)
{
    float lod = CalcLod(ddx(uv), ddy(uv));
    uv.w= lod;
    return tex2Dlod(tex, uv);
}

我们可以从这段代码中得知uv所求的导数越大,在屏幕中占用的纹理范围就越大。当我们在片元计算中发现uv导数很大时,就说明这个片元离摄像机很远,从这个角度来理解uv在片元着色器中的求偏导会稍微容易些,这样我们就只需要通过uv的求偏导就能间接计算出x轴和y轴方向的覆盖率。在OpenGL中Mipmap的计算就依赖于片元中的uv求偏导值,片元所映射的uv范围越大,计算出来的Mipmap层级越高,纹理贴图选取的分辨率就越小。

从显存里看问题

显存经常被我们忽视,因为近几年流行的手机端的游戏项目比较多,手机设备上的没有显存的概念,它让GPU与系统共用一块内存,所以通常显存被理解为只在PC端存在。显卡除了有图像传给处理单元GPU外,还拥有自己的内存,即显存VRAM(Video Random Access Memory),像安卓和IOS这样的架构的设备中,虽然没有大块独立的显存但GPU仍然有自己的缓存。

GPU可以在显存中存储很多数据,包括贴图、网格、着色器实例等,除了这些渲染所必须的资源,缓存自然是更接近GPU内核的地方,顶点缓存、深度缓存、模版缓存、帧缓存大都存放在那里。GPU自己的缓存就相当于GPU内部的共享缓存部分,GPU中有很多个独立的处理单元,每个处理单元都有自己的缓存以存储一部分自己需要处理的数据。

除了这几个必要的缓存外,显卡中存放着渲染时需要用到的贴图纹理、网格数据等,这些内容都需要从系统内存中拷贝过来的。在调用渲染前,应用程序可以调用图形应用接口OpenGL将数据从系统内存中拷贝到显卡内存中,当然这个过程只存在于PC端和主机端,因为只有它们拥有显存。显存更接近GPU处理器,这直接导致存取数据会更快,因此从系统内存中拷贝过来是值得的。

手机端就没有这样的拷贝过程,手机端大都是ARM架构,芯片中嵌入了各种各样的硬件系统,包括SoC(即芯片级系统,包含了完整系统并有嵌入软件的全部内容)、图像处理GPU、音频处理器等。而显存由于种种限制没有被设计加入到ARM中去,因此在手机端中CPU和GPU共用同一个内存控制器,也就是说没有独立的显存只有系统内存。不过GPU仍然需要将数据拷贝到自己的缓存当中,只是这一步原本是从显存拷贝的,现在从系统内存中拷贝而已,GPU中每个处理单元也仍然要从共享缓存中拷贝自己需要处理的数据。

由此可见,GPU处理数据前拷贝的过程仍然存在,只是原来从显存拷贝到缓存的过程变成了系统内存直接拷贝到缓存,速度自然没有原来的快,这种拷贝的过程每帧都在进行,当然也有缓存命中的情况,但仍避免不了重复拷贝,图片大小、网格大小也会成为拷贝的瓶颈点,我们通常称它们为带宽压力,由此看来压缩纹理贴图、使用大小适中的纹理贴图、减少网格数据也是优化性能的一个重要部分。

纹理贴图的 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。

	光照颜色 = 间接光照颜色 + 直接光照颜色 * 阴影系数(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

UV Chart1

UV Chart2

UV Chart3

图中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两种算法的解决方案。

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

$$ L_{\mathrm{o}}(\mathbf{x},:\omega_{\mathrm{o}},:\lambda,:t):=:L_{e}(\mathbf{x},:\omega_{\mathrm{o}},:\lambda,:t):+:\int_{\Omega}f_{r}(\mathbf{x},:\omega_{\mathrm{o}},:\lambda,:t):L_{\mathrm{i}}(\mathbf{x},:\omega_{\mathrm{i}},:\lambda,:t):(\omega_{\mathrm{i}}\cdot\mathbf{n}):\mathrm{d}\omega_{\mathrm{i}} $$ 上述的渲染方程定义了光线是如何离开表面上某个点的,可是这个积分方程太复杂以至于计算机无法在较短时间内计算得出结果,Unity3D中Enlighten采用的近似方法即辐射算法,这可以大大提高计算渲染方程式的速度。

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

渲染方程

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

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

$$ B_i=L_e+\rho_i\sum_{j=1}^nF_{ij}L_j $$ 其中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还提供了一个预估的时间,所以烘焙时间更加可预测。

GPU Instancing 的来龙去脉

GPU Instancing 初次听到这个名词时还有点疑惑,其实翻译过来应该是GPU多实例化渲染,它本身是GPU的一个功能接口,Unity3D将它变得更简单实用。

前面讲过一些关于Unity3D的动态合批(Dynamic batching)与静态合批(Static batching)的功能,GPU Instancing 实际上与他们一样都是为了减少Drawcall而存在。那么有了动态合批和静态合批为什么还需要 GPU Instancing 呢,究竟他们之间有什么区别呢,我们不妨来简单回顾一下Unity3D动态合批(Dynamic batching)与静态合批(Static batching)。

开启动态合批(Dynamic batching)时,Unity3D引擎会检测视野范围内的非动画模型(通过遍历所有渲染模型,计算包围盒在视锥体中的位置,如果完全不在视锥体中则抛弃),筛选符合条件的模型进行合批操作,将他们的网格合并后与材质球一并传给GPU去绘制。

动态合批需要符合什么条件呢:

	1,900个顶点以下的模型。

	2,如果我们使用了顶点坐标,法线,UV,那么就只能最多300个顶点。

	3,如果我们使用了UV0,UV1,和切线,又更少了,只能最多150个顶点。

	4,如果两个模型缩放大小不同,不能被合批的,即模型之间的缩放必须一致。

	5,合并网格的材质球的实例必须相同。即材质球属性不能被区分对待,材质球对象实例必须是同一个。

	6,如果他们有lightmap的数据,必须是相同的才有机会合批。

	7,多个pass的Shader是绝对不会被合批。

	8,延迟渲染是无法被合批。

动态合批的条件比较苛刻,很多模型都无法达到合并的条件。为什么它要使用这么苛刻的条件呢,我们来了解下设计动态合批这个功能的意图。

动态合批(Dynamic batching)这个功能的目标是以最小的代价合并小型网格模型,减少Drawcall。

很多人会想既然合并了为什么不把所有的模型都合并呢,这样不是更减少Drawcall的开销。其实如果把各种情况的大小的网格都合并进来,就会消耗巨大的CPU算力,而且它不只一帧中的计算量,而是在摄像机移动过程中每帧都会进行合并网格的消耗算力,这使得CPU算力消耗太大,相比减少的Drawcall数量,得不偿失。因此Unity3D才对这种极其消耗CPU算力的功能做了如此多的的限制,就是为了让它在运作时性价比更高。

与动态合批不同,静态合批(Static batching)并不实时合并网格,而是在离线状态下生成合并的网格,并将它以文件形式存储合并后的数据,这样在当场景被加载时,这些合并的网格数据也一同被加载进内存中,当渲染时提交给GPU。因此场景中所有被标记为静态物体的模型,只要拥有相同实例的材质球都会被一并合并成网格。

静态合批能降低不少Drawcall,但也存在不少弊端。被合批的模型必须是静态的物体,它们是不能被移动旋转和缩放的,也只有这样我们在离线状态下生成的网格才是有效的(离线的网格数据不需要重新计算),因为合并后的网格,内部是不能动的,它也必须与原模型吻合,因此静态是必须的条件。其生成的离线数据被放在Vertex buffer和Index buffer中。

静态合批生成的离线网格将导致存放在内存的网格数据量剧增,因为在静态合批中每个模型都会独立生成一份网格数据,无论他们所使用的网格是否相同,也就是说场景中有多少个静态模型就有多少个网格,与原本只需要一个网格就能渲染所有相同模型的情况不一样了。

其好处是静态合批后同一材质球实例(材质球实例必须相同,因为材质球的参数要一致)调用Drawcall的数量合并了,合批也不会额外消耗实时运行中的CPU算力,因为它们在离线时就生成的合批数据(也就是网格数据),在实时渲染时如果该模型在视锥体范围内,三角形索引将被部分提取出来简单的合并后提交,而那些早就被生成的网格将被整体提交,当整体网格过大时则会导致CPU和GPU的带宽消耗过大,整个数据必须从系统内存拷贝到GPU显存或缓存,最后由GPU处理渲染。

简而言之,动态合批为了平衡CPU消耗和GPU性能优化,将实时合批条件限制在比较狭窄的范围内。静态合批则牺牲了大量的内存和带宽,以使得合批工作能够快速有效的进行。

GPU Instancing 没有动态合批那样对网格数量的限制,也没有静态网格那样需要这么大的内存,它很好的弥补了这两者的缺陷,但也有存在着一些限制,我们下面来逐一阐述。

与动态和静态合批不同的是,GPU Instancing 并不通过对网格的合并操作来减少Drawcall,GPU Instancing 的处理过程是只提交一个模型网格让GPU绘制很多个地方,这些不同地方绘制的网格可以对缩放大小,旋转角度和坐标有不一样的操作,材质球虽然相同但材质球属性可以各自有各自的区别。

从图形调用接口上来说 GPU Instancing 调用的是 OpenGL 和 DirectX 里的多实例渲染接口。我们拿 OpenGL 来说:

void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count, Glsizei primCount);

void glDrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei primCount);

void glDrawElementsInstancedBaseVertex(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instanceCount, GLuint baseVertex);

这三个接口正是 GPU Instancing 调用OpenGL多实例渲染的接口**,第一个是无索引的顶点网格集多实例渲染,第二个是索引网格的多实例渲染,第三个是索引基于偏移的网格多实例渲染。**这三个接口都会向GPU传入渲染数据并开启渲染,与平时渲染多次要多次执行整个渲染管线不同的是,这三个接口会分别将模型渲染多次,并且是在同一个渲染管线中。

如果只是一个坐标上渲染多次模型是没有意义的,我们需要将一个模型渲染到不同的多个地方,并且需要有不同的缩放大小和旋转角度,以及不同的材质球参数,这才是我们真正需要的。GPU Instancing 正为我们提供这个功能,上面三个渲染接口告知Shader着色器开启一个叫 InstancingID 的变量,这个变量可以确定当前着色计算的是第几个实例。

有了这个 InstancingID 就能使得我们在多实例渲染中,辨识当前渲染的模型到底使用哪个属性参数。Shader的顶点着色器和片元着色器可以通过这个变量来获取模型矩阵、颜色等不同变化的参数。我们来看看在Unity3D的Shader中我们应该做些什么:

Shader "SimplestInstancedShader"
{
    Properties
    {
        _Color ("Color", Color) = (1, 1, 1, 1)
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_instancing // 开启多实例的变量编译
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                UNITY_VERTEX_INPUT_INSTANCE_ID //顶点着色器的 InstancingID定义
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                UNITY_VERTEX_INPUT_INSTANCE_ID //片元着色器的 InstancingID定义
            };

            UNITY_INSTANCING_BUFFER_START(Props) // 定义多实例变量数组
                UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
            UNITY_INSTANCING_BUFFER_END(Props)
           
            v2f vert(appdata v)
            {
                v2f o;

                UNITY_SETUP_INSTANCE_ID(v); //装配 InstancingID
                UNITY_TRANSFER_INSTANCE_ID(v, o); //输入到结构中传给片元着色器

                o.vertex = UnityObjectToClipPos(v.vertex);
                return o;
            }
           
            fixed4 frag(v2f i) : SV_Target
            {
                UNITY_SETUP_INSTANCE_ID(i); //装配 InstancingID
                return UNITY_ACCESS_INSTANCED_PROP(Props, _Color); //提取多实例中的当前实例的Color属性变量值
            }
            ENDCG
        }
    }
}

上述的Shader是一个很常见的GPU Instancing 写法,使用 Instancing 在Shader作为选取参数的依据。Shader中_Color 和 unity_ObjectToWorld (模型矩阵)是多实例化的,它们通过 InstancingID 作为索引来确定取数组中的变量。

InstancingID被包含在了宏定义中我们无法看到,我们来看看上述Shader中包含有 INSTANCE 字样的宏定义是怎样的,以此来剖析GPU Instancing是怎样用InstancingID来区分不同实例的变量的。

首先编译命令 multi_compile_instancing 会告知着色器我们将会使用多实例变量。

其次在顶点着色器和片元着色的输入输出结构中,加入 UNITY_VERTEX_INPUT_INSTANCE_ID 告知结构中多一个变量即:

	uint instanceID : SV_InstanceID;

这么看来我们就知道了每个顶点和片元数据结构中都定义了 instanceID 这个变量,这个变量将被用于确定使用多实例数据数组中的索引,它很关键。

接着Shader中把需要用到的多实例变量参数定义起来:

		UNITY_INSTANCING_BUFFER_START(Props)
            UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
        UNITY_INSTANCING_BUFFER_END(Props)

上述中的宏很容易从字面看出它们为”开始多实例宏定义”,”对多实例宏属性定义参数”,以及”结束多实例宏定义”。这三个宏定义我们可以在 UnityInstancing.cginc 中看到,即如下:

		#define UNITY_INSTANCING_BUFFER_START(buf)      UNITY_INSTANCING_CBUFFER_SCOPE_BEGIN(UnityInstancing_##buf)  struct {
        #define UNITY_INSTANCING_BUFFER_END(arr)        } arr##Array[UNITY_INSTANCED_ARRAY_SIZE]; UNITY_INSTANCING_CBUFFER_SCOPE_END
        #define UNITY_DEFINE_INSTANCED_PROP(type, var)  type var;

我们可以从上述的宏定义代码中理解到,这三个宏组合起来可以对GPU Instancing的属性数组进行定义。

        UNITY_INSTANCING_BUFFER_START(Props)
            UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
        UNITY_INSTANCING_BUFFER_END(Props)

就等于

        UNITY_INSTANCING_CBUFFER_SCOPE_BEGIN(UnityInstancing_Props) struct {
            float4 _Color;
        } arrPropsArray[UNITY_INSTANCED_ARRAY_SIZE]; UNITY_INSTANCING_CBUFFER_SCOPE_END

我们看到三个宏加起来组成了一个颜色结构的数组,我们从Unity3D接口传进去的参数就会装入这样的结构中被顶点和片元着色器使用。

在顶点着色器与片元着色中,我们对 InstancingID 进行装配,它们分别为:

	UNITY_SETUP_INSTANCE_ID(v) 和 UNITY_SETUP_INSTANCE_ID(i);

装配过程其实就是从基数偏移的过程 unity_InstanceID = inputInstanceID + unity_BaseInstanceID。最终我们通过 UNITY_SETUP_INSTANCE_ID 装配得到了 unity_InstanceID 即当前渲染的多实例索引ID。

有了多实例的索引ID,我们就可以通过这个变量获取对应的当前实例的属性值,于是就有了以下的宏定义 UNITY_ACCESS_INSTANCED_PROP,通过这个宏定义我们能提取多实例中的我们需要的变量。

#define UNITY_ACCESS_INSTANCED_PROP(arr, var)   arr##Array[unity_InstanceID].var

UNITY_ACCESS_INSTANCED_PROP(Props, _Color); //提取多实例中的当前实例的Color属性变量值

等于

arrPropsArray[unity_InstanceID]._Color

有了类似_Color的多实例属性操作,在模型矩阵变化中也需要具备同样的操作,我们没看到模型矩阵多实例是因为Unity在Shader编写时用宏定义把它们隐藏起来了,它就是 UnityObjectToClipPos。

UnityObjectToClipPos 其实是一个宏定义,当多实例渲染开启时,它被定义成了如下:

#define unity_ObjectToWorld     UNITY_ACCESS_INSTANCED_PROP(unity_Builtins0, unity_ObjectToWorldArray)

inline float4 UnityObjectToClipPosInstanced(in float3 pos)
{
    return mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(pos, 1.0)));
}
inline float4 UnityObjectToClipPosInstanced(float4 pos)
{
    return UnityObjectToClipPosInstanced(pos.xyz);
}
#define UnityObjectToClipPos UnityObjectToClipPosInstanced

这个定义也同样可以在 UnityInstancing.cginc 中找到,其中unity_ObjectToWorld是关键,它从多实例数组中取出了当前实例的模型矩阵,再与坐标相乘后计算投影空间的坐标。也就是说当开启 Instancing 多实例渲染时,UnityObjectToClipPos 会从多实例数据数组中取模型矩阵来做模型到投影空间的转换。而当不开启 Instancing 时,UnityObjectToClipPos 则只是用当前独有的模型矩阵来计算顶点坐标投影空间的位置。

到此我们就从着色器中获取了多实例的属性变量,根据不同实例的不同索引获取不同属性变量包括模型矩阵,从而渲染到不同的位置,以及不同的旋转角度和不同的缩放大小,更多的比如颜色、反射系数等属性都可以通过传值的方式传入Shader中,用GPU Instancing的方式渲染。

知道了 GPU Instancing 是如何渲染任然还不够,最好我们还知道数据是怎么传进去的。我们拿 OpenGL 接口代码来分析下:

//获取各属性的索引
int position_loc = glGetAttribLocation(prog, "position");
int normal_loc = glGetAttribLocation(prog, "normal");
int color_loc = glGetAttribLocation(prog, "color");
int matrix_loc = glGetAttribLocation(prog, "model_matrix");

//按正常流程配置顶点和法线
glBindBuffer(GL_ARRAY_BUFFER, position_buffer); //绑定顶点数组
glVertexAttribPointer(position_loc, 4, GL_FLOAT, GL_FALSE, 0, NULL); //定义顶点数据规范
glEnableVertexAttribArray(position_loc); //按上述规范,将坐标数组应用到顶点属性中去
glBindBuffer(GL_ARRAY_BUFFER, normal_buffer); //绑定发现数组
glBertexAttribPointer(normal_loc, 3, GL_FLOAT, GL_FALSE, 0, NULL); //定义发现数据规范
glEnableVertexAttribArray(normal_loc); //按上述规范,将法线数组应用到顶点属性中去

//开始多实例化配置
//设置颜色的数组。我们希望几何体的每个实例都有一个不同的颜色,
//将颜色值置入缓存对象中,然后设置一个实例化的顶点属性
glBindBuffer(GL_ARRAY_BUFFER, color_buffer); //绑定颜色数组
glVertexAttribPointer(color_loc, 4, GL_FLOAT, GL_FALSE, 0, NULL); //定义颜色数据在color_loc索引位置的数据规范
glEnableVertexAttribArray(color_loc); //按照上述的规范,将color_loc数据应用到顶点属性上去

glVertexattribDivisor(color_loc, 1); //开启颜色属性的多实例化,1表示每隔1个实例时共用一个数据

glBindBuffer(GL_ARRAY_BUFFER, model_matrix_buffer); //绑定矩阵数组
for(int i = 0 ; i<4 ; i++)
{
	//设置矩阵第一行的数据规范
	glVertexAttribPointer(matrix_loc + i, 4, GL_FLOAT, GL_FALSE, sizeof(mat4), (void *)(sizeof(vec4)*i));

	//将第一行的矩阵数据应用到顶点属性上去
	glEnableVertexAttribArray(matrix_loc + i);

	//开启第一行矩阵数据的多实例化,1表示每隔1个实例时共用一个数据
	glVertexattribDivisor(matrix_loc + i, 1);
}

这个示例很精准的表达了数据是如何从CPU应用层传输到GPU上再进行实例化的过程。我在代码上做了比较详尽的注释,首先获取需要推入顶点属性的数据的索引,再将数组数据与OpenGL 缓存进行绑定,这样才能注入到OpenGL里去,接着告诉 OpenGL 每个数据对应的格式,然后再根据前一步描述的格式应用到顶点属性中去,最后开启多实例化属性接口,让 InstancingID 起效。

总结,我们解析了 GPU Instancing 在 Unity3D 中的工作方式,得知了它能用同一个模型同一个材质球渲染不同的位置、角度、缩放大小、以及不同颜色等属性。GPU Instancing 并没有对模型网格做任何限制,也没有占用大量内存的来换取性能,很好的弥补了动态合批(Dynamic batching)与静态合批(Static batching)的不足。

只是它毕竟是只能围绕一个模型来操作,只有相同网格(Mesh)和相同的材质球实例(参数可以不同,但必须使用API来设置不同参数)的情况下才能启动多个实例在同一个渲染管线中渲染的优化操作,而动态合批和静态合批却只需要材质球实例一致,网格是可以有差别的。

GPU Instancing、动态合批、静态合批三者所擅长的各不相同,有互相弥补的地方,各自本身也存在着不同程度的限制和优缺点。从整体上来看,GPU Instancing 更适合同一个模型渲染多次的情况,而动态合批(Dynamic batching)更适合同一个材质球并且模型面数较少的情况,静态合批(Static batching)更适合当我们能容忍内存扩大的情况。

多重采样以及着色器编译原理

GPU上的多重采样(Multisampling)与反走样(Antialiasing)

多重采样(Multisampling)是一种对几何图元的边缘进行平滑处理的技术,也称为反走样技术之一。

OpenGL支持几种不同的反走样技术,比如多重采样、线段反走样、多边形反走样、纹理图像压缩的质量以及导数精度设置等。事实上这几种反走样技术都是以开关的形式在OpenGL中存在,我们无法修改它们只能开启关闭或设置几个简单的参数。其中图形上走样算法大致是将原本单一的线条或像素块周围填充更多的像素块,具体的填充算法细节比我们想象的要复杂的多,而且不同OpenGL的版本算法中也有细微的差异,我们在这里不深入。

多重采样也是反走样技术中的一种,它的工作方式是对每个像素的几何图元进行多次采样。在多次采样后,每个像素点不仅仅只是单个颜色(以及除了颜色外的深度值、模板值等信息),还记录了许多样本值。

这些样本值类似于将一个像素分拆成了更小型的像素,每个像素都存储着颜色、深度值、模板值等信息,当我们需要呈现最终图像的内容时,这个像素的所有样本值会被综合起来成为最终像素的颜色。也就是说采样的数量越多,线条与周围的像素点的融合越平滑,简单说就是颜色与颜色之间会有平滑过渡的颜色例如红色的线条周围是白色的背景于是红色线条上的像素与白色背景的交接处会有更多的粉色、浅粉色来过渡。

在Unity3D中对这方面的反走样功能也提供了支持,我们可以通过Quality Settings中的AntiAliasing来设置,它将开启图形接口(OpenGL或DirectX)中的多边形的反走样算法,并且开启多重采样,根据多重采样信息对多边形边缘进行像素填充。

	AntiAliasing 可以设置3档采样质量分别是 2倍, 4倍 and 8倍的多重采样。

GPU上的反走样代价是消耗更多的GPU算力和显存,它并不消耗任何CPU算力。

着色器编译过程与变体

Unity2022已经对着色器变体进行优化,不会产生大量变体

我们在知道GPU渲染管线如何运作后,对着色器编译过程仍然需要深入了解一下,我们还是以使用OpenGL为例来学习着色器在Unity3D中从编译到执行的全过程。

着色程序的编译过程与C语言等编译语言的编译过程非常类似,只是C语言在编译时是以离线的方式进行,而着色器程序的编译则是当引擎需要时,通过引擎调用图形接口(OpenGL或DirectX)的方式将代码读进来再编译,着色器程序只需要编译一次后面可以重复利用,这和我们通常所说的JIT(Just in time 即时编译)有点相似。

着色器编译前的准备工作都是Unity3D控制和执行的,当需要某个着色器程序时Unity3D引擎通过判断是否存在已经编译好的着色器程序,来决定是否编译着色器代码或是重用已经编译好的着色器程序。

那么着色程序从编译到执行过程到底是怎样的呢?

首先当Unity3D引擎得知渲染需要用到的Shader不曾被编译过时,就会调用图形接口OpenGL的 glCreateShader 为着色器创建一个新的着色器对象。

然后通过文件程序从Shader文件中获取Shader内容(字符串)并调用OpenGL的 glShaderSource 将源代码(字符串)交给刚刚创建的着色器对象上。

这时一个空的着色器对象已经关联了着色器源代码,我们可以通过调用编译接口OpenGL的 glCompileShader 对这个着色器对象进行编译。

编译完成后,我们可以通过OpenGL的 glGetShaderInfoLog 来获得编译信息以及是否成功的结果。

到此仅仅是1个着色器对象编译完成,这个着色器对象可能是顶点着色器,也可能是片元着色器,或也许是细分着色器或几何着色器。通常情况下,有好几个着色器需要编译,顶点着色器和片元着色器通常都成对出现,则会创建相应的着色器对象来分别编译它们的源代码。

有了着色器对象还不够,我们需要把这些着色器关联起来。首先Unity3D引擎会使用OpenGL的 glCreateProgram 接口需要创建一个空的着色器程序。然后多次调用 glAttachShader 来一个个地绑定着色器对象。

当所有必要的着色器对象关联到着色器程序之后,就可以链接对象来生成可执行程序了,引擎将调用OpenGL的 glLinkProgram 接口将所有关联的着色器对象生成一个完整的着色器程序。

当然,着色器对象也可能存在某些问题,因此在链接过程中依然可能失败,通常引擎会通过 glGetProgramiv 来查询链接操作的结果,也会通过 glGetProgramInfoLog 接口来获取程序链接的日志信息,由此我们就可以判断错误原因。

成功完成了着色器程序的链接后,Unity3D引擎就可以通过调用 glUseProgram 来运行着色器程序。

我们平常在Unity3D中用到的Shader中的Pass,每个Pass中都有着色器需要编译,因此每次在绘制不同的Pass时都会对Pass中的顶点着色器和片元着色器进行编译。也就是说,Unity引擎会为每个Pass标签生成一个着色器程序,生成这些着色器程序后,执行顺序仍然按照Pass的先后次序来。

着色器编译流程

现在我们了解了着色器的编译过程,它通过编译和连接的方式将一个Shader中的多个着色器制作成一个着色程序,当渲染需要时交给GPU去执行渲染。只是这样的编译过程都是在以阻塞的方式进行的,因此我们在引擎运行的过程中常常会在某针消耗些许CPU来编译Shader,如果Shader的量非常大的话可能就会有卡顿的现象发生。

Shader变体常常就会发生这种我们不希望的编译卡顿,因为它的量比较大,我们在Unity3D中使用Shader Varant(变体)时常常有比较严重的编译和内存困扰。

那么什么是“变体”呢。其实它是由Unity3D自身的宏编译指令引发的多种变化实体生成的Shader文件,它为不同情况而编译生成不同的着色器程序。从引擎端的做法来看,Unity3D把不同的编译版本拆分成了不同的着色器源代码文件,在运行时选择对应适合的着色器源文件,再通过图形接口将这些着色器源代码编译成着色器程序关联到渲染中。

为什么要使用宏编译指令导致生成这么多的着色器程序呢?因为要简化Shader,Unity3D要让一个Shader在不同材质球上的应用不同的效果时更加便捷,有了引擎识别变体的功能,我们修改和完善Shader起来会更加方便和高效。

假如没有变体,我们在编写很多同一个风格但不同效果的Shader时,在使用和维护过程中会有诸多的麻烦和不便。为了统一风格,为了提高工作效率,也为了能更好的打通各部门之间的沟通渠道,以及能让美术同学能更好的发挥对画面效果的调整,将同一个风格不同效果的Shader写在同一个Shader文件里是必不可少的,这样能更加容易的统一美术风格和制作流程,目的就是为了让风格更加统一,沟通更加便捷,效率更加高。

我们来看看Unity3D是怎么通过编译指令来编写变体的,编写变体后它是怎么生成着色器源代码的。

在Unity3D的Shader中我们使用

#pragma multi_compile
#pragma shader_feature

两个指令来实现着色器程序的自定义宏,它既适用于顶点片元着色器也适用于表面着色器。我们通过 multi_compile 指令编写例如:

#pragma multi_compile A_ON B_ON

这样会生成并编译两个Shader(变体),一个是A_ON的版本,一个是B_ON的版本。

在运行时,Unity3D会根据材质(Material)的关键字(Material的对象方法EnableKeyword和DisableKeyword)或者全局着色器关键字(Shader的类方法EnableKeyword和DisableKeyword)来选择使用对应的着色器。运行的时候Unity3D会根据材质(Material)的关键字或者Shader全局关键字判断应该使用哪个Shader,如果两个关键字都为false,那么会使用第一个(A_ON)Shader变体。

我们也可以创建多个组合关键字例如:

#pragma multi_compile A B C
#pragma multi_compile D E

这种多组合关键字会使得Shader的变体成倍的增加,例如上述的预编译方式,会生成 3x2 = 6 个变体,分别是 A+D、 B+D、 C+D、 A+E、 B+E、 C+E 六种。

假如multi_compile组合多到10行,每行2个,就是2的10次方个Shader(变体)就是1024个,这样生成这1024个Shader(变体),把这1024个变体Shader全部加载到内存的话恐怕互占用非常多的内存,1024个Shader每个50K的话就会占用50MB内存。不仅如此,实时编译Shader是非常耗时的操作,如果没有提前编译Shader而在场景中使用Shader,就会不断有不同的Shader被实时的编译,这常常是导致游戏卡顿的重要原因之一。

除了 multi_compile 之外,另外一个指令 shader_feature 也可以设置预编译宏,与 multi_compile 的区别是 shader_feature 不会将没有被使用到的Shader(变体)打包进包内,因此 shader_feature 更适合材质球的关键字指定预编译内容,因为Unity3D只生成和编译被使用的预编译情况,而 multi_compile 更适合全局Shader指定关键字,因为它会把所有组合都编译一遍,无论有没有用到。

除了这两个自定义预编译指令,Unity3D 本身自带的一些内建的 multi_compile 的快捷写法也会导致Shader变体的产生:

	multi_compile_fwdbase 为前向渲染编译多个变体,不同的变体处理不同的光照贴图的计算,并且控制了主平行光的阴影的开关。
	
	multi_compile_fwdadd 为前向渲染额外的光照部分编译多个变体,不同的变体处理不同灯光类型,平行光,聚光灯,点光,以及他们附带的cookie纹理版本。
	
	multi_compile_fwdadd_fullshadows 和 multi_compile_fwdadd 一样,并且包含了灯光的实时阴影功能。
	
	multi_compile_fog 为处理不同的雾效类型(off/linear/exp/exp2)扩展了多个变体。

总结,无论是 multi_compile 还是 shader_feature 亦或内建预编译指令,都会造成 Shader(变体)数量的增多,使得内存增加,运行时编译次数增多,每次编译Shader都会消耗CPU。当Unity3D在运行时检测到需要渲染的材质球里是不曾被编译的Shader时,则会将与自己匹配的Shader变体拎出来编译一下生成一个着色器程序,因此为了应对变体在运行时的编译消耗,通常会在运行时提前将所有Shader变体编译一下,使得运行中不再有Shader编译的CPU消耗。

Projector投影原理

Projector 投影的原理与应用

Unity3D中的 Projector 组件投影像是一个很神秘的组件,但其实它依然运用的是以着色器为基准的渲染流程,和普通的3D模型渲染从本质上来看并没有实质上的区别,唯一的区别是它从它自己的视体(视锥体或平视体)中检测到的模型,并根据默认或者自定义的材质球与Shader着色器,将这些物体又重新绘制了一遍。

从Unity3D引擎上来讲,可以这样解释 Projector组件:

根据 Projector组件自身的视体的范围,平视体或视锥体,遍历并计算出视体范围内与视体占边的所有物体。接着 Projector组件取得这些物体模型数据,并计算投影矩阵。这个投影矩阵是什么呢,其实就是前面说的 Projector 组件视体空间的投影矩阵。最后将投影矩阵传入Shader中,根据这个投影矩阵,对这些物体再渲染一次。

投影中的Shader着色器是投影绘制的主要手段,Projector组件的工作只是检测了所有范围内的模型,并传递了投影空间的矩阵而已。投影Shader中,通常会结合传入的投影矩阵,将顶点转为 Projector 组件投影空间中,并以此为投影贴图的UV来渲染模型。

例如这个简单的投影着色器:

sampler2D _MainTex;
float4x4 unity_Projector;
struct v2f{
	float4 pos:SV_POSITION;
	float4 texc:TEXCOORD0;
}

v2f vert(appdata_base v)
{
	v2f o;
	o.pos = mul(UNITY_MAXTRIX_MVP, v.vertex);
	o.texc = mul(unity_Projector, v.vertex);
	return o;
}

float4 frag(v2f i) : COLOR
{
	float4 c = tex2Dproj(_MainTex, i.texc);
	return c;
}

投影着色器中 unity_Projector 变量就是由 Projector 组件传入到材质球的投影矩阵。在顶点着色器中 unity_Projector 矩阵被用来构造投影坐标,和普通的空间投影转换矩阵MVP(Model View Project)不同的是,其中的V是 Projector 空间的相关矩阵,即 Projector 组件所属的 Transform 的 worldToLocalMatrix变量,而P则是和Projector远近裁切相关的矩阵。用 unity_Projector 矩阵计算出vertex(顶点)在投影空间中的坐标后,我们就可以以此坐标为uv坐标绘制物体了。

投影着色器中为什么可以将顶点坐标视为uv坐标呢?当坐标变换到投影空间后,其坐标空间就变成了投影平面视角,在这个视角中如果我们将平面视为纹理大小,就相当于一一匹配上顶点的uv坐标。

这个转化后的投影空间中的坐标,也就是我们常说的”投影纹理坐标”。”投影纹理坐标”不能直接当作uv来作为纹理坐标来使用,需要调用 tex2Dproj 方法来获取纹理坐标:

	tex2Dproj(texture,uvproj);

	这个纹理投影函数,其实就是在使用之前会将该投影纹理坐标除以透视值

	可以等价于按如下方法使用普通二维纹理查询函数

	float4 uvproj = uvproj/uvproj.w;

	tex2D(texture,uvproj);

投影的技巧还有很多,我们下面介绍几种投影技巧在游戏项目中的运用。

平面阴影

平面阴影也是投影技巧的一种,它稍微需要运用些图形计算,主要的原理是在着色渲染模型时,另外做一个Pass将模型上顶点转换到平面上再渲染一次,以此作为平面的阴影,无论地上有没地形,都以平面呈现。

要计算这个平面阴影需要我们从光源点,顶点,法线,平面这些已知数据出发计算顶点转换到平面上的点。

我们首先已知的是地面法线向量 TerrainNormal,和平面上随便一个初始点点的坐标 TerrainPos,我们假设我们需要计算的点为p点

由平面表达公式得知,平面上的任意向量与该平面的点乘所得值为0,因此地面上的方向向量 TerrainNormal 与 要投影的坐标与初始点所形成的方向向量点乘为零,即:

	(p - TerrainPos) 点乘 TerrainNormal = 0

又由于平面映射的P点是由光射到顶点延伸到平面而得到的,所以

	p = d*L + L0 其中L0为顶点,的l为光到顶点的射线方向,d为L0射到p点的距离。

因此根据这两个公式,代入得到:

	(dL + L0 - TerrainPos) 点乘 TerrainNormal = 0

解析后为

	dL 点乘 TerrainNormal + (L0 - TerrainPos) 点乘 TerrainNormal = 0

于是再得到d为:

	d = ((TerrainPos - L0) 点乘 TerrainNormal) / (l 点乘 TerrainNormal)

再代入进 p = d * l + L0 这个公式得到p,即如下Shader中的顶点函数所写

Pass
{
	CGPROGRAM

	#pragma vertex vert
	#pragma fragment frag
	#include "UnityCG.cginc"

	struct appdata
	{
		float4 vertex : POSITION;
	};

	struct v2f
	{
		float4 vertex : SV_POSITION;
	};            

    float4 TerrainPos, TerrainNormal;
	v2f vert (appdata v)
	{
		v2f o;
        float4 wPos = mul(unity_ObjectToWorld, v.vertex);
        // 光的方向
        float3 direction = normalize(_WorldSpaceLightPos0.xyz);
        // d 值的计算
        float dist = dot(TerrainPos.xyz - wPos.xyz, TerrainNormal.xyz) / dot(direction, TerrainNormal.xyz);
        // 代入 p = d * l + L0 公式
        wPos.xyz = wPos.xyz + dist * direction;
        // 空间顶点转换
		o.vertex = mul(unity_MatrixVP, wPos);
		return o;
	}
	
	fixed4 frag (v2f i) : SV_Target
	{
		return fixed4(0,0,0,1);
	}
	ENDCG
}

上述Shader中,利用了_WorldSpaceLightPos0来确定光的反向,也可以用一个光的坐标与顶点的差值来得到一个光的方向。计算过程的公式转换可以以这张手绘的图作为参考。

平面阴影

图中清晰的标明了所有已知向量,和可计算向量,以及最终需要结算的点,上述所说的这些公式的转换都是基于这个图来做的。

利用深度信息计算图片投影

图片投影其实就是贴花的动态版,其做法也有很多种,我们在这里简单讲讲其中一种方法为:

	绘制一个box,其纹理的展示方式则以深度信息为依据,纹理贴近其绘制的模型上。

这种方式可以随着这个box的移动贴到不同物体上。

大致的方向是,以一个BOX为渲染对象,渲染时在片元着色器中重新判定渲染纹理与渲染坐标。怎么判定呢?

先从顶点着色器上获得顶点坐标的屏幕坐标,在片元着色器中传入的屏幕坐标时成了片元在屏幕上的坐标,用屏幕坐标获取对应的深度值,再让深度值作为z坐标来形成一个三维坐标,这时三维坐标只是屏幕空间上的坐标,我们让其屏幕空间转换到相机空间,再从相机空间转换到世界空间,再转换到投影空间,这样坐标就到了投影空间,再除以透视值,就得到坐标在0-1范围的坐标值,用这个值去提取纹理上的颜色,最后用这个颜色绘制片元。

这个方法比较费的点为片元着色器中重新计算渲染坐标,这个坐标会以深度信息为z轴信息需要经过几个空间的转换,导致计算量比较大,不能放在顶点着色器中的原因是因为只有在片元着色器中才能得到片元深度信息。

贴花(喷图)

贴花的制作,可以以摄像机为节点向外计算一个长方体,所有与长方体有交集的模型上的面上的顶点都被存储起来,另外如果有顶点里外都有的面,则计算与长方体的面相交的新节点并加入进来,最后把所有得到的顶点和三角面制作成一个新的模型,这个模型的顶点转换到立方体空间再除以透视值,则成为了uv点,以此来呈现一个贴花(喷图)的效果。