《Unity3D高级编程之进阶主程》第七章,渲染管线与图形学(二) - 渲染管线1

编程的世界里其实是很纯粹的,说起来似乎没有什么高深的技术,无非就是进程、线程、内存、硬盘、CPU。随着现代编程技术的发展,很多时候引擎或者框架帮我们屏蔽了很多底层上的接口调用和引擎的业务逻辑使得我们程序员可以将所有精力放在应用层的业务架构和逻辑,这是符合社会进步规律的,我们需要站在巨人的肩膀上才能走得比巨人更快更远。Unity3D就是帮助我们快速建立业务架构的好引擎,有了Unity3D我们才能在较低门槛的情况下快速制作出自己的想法和创意。

===

除了进程、线程、内存、硬盘、CPU这些通常我们程序员在编程时要考虑的因素外,对于我们前端程序员来说还多了一个GPU的技术范畴,因此我们需要学习和考虑的问题比其他程序员会更加多一些也会更加高阶一些,Unity3D虽然封装了所有引擎需要的GPU接口但我们还是必须要了解渲染管线是如何处理的,它是如何将渲染数据显示在屏幕上的,底层的知识和原理是打通任督二脉比较关键的一步。

OpenGL、DirectX究竟是什么

OpenGL 和 DirectX 其实是一回事,它们都是图形渲染的应用程序编程接口,它们都是一种可以对图形硬件设备特性进行调用的软件库。它们的区别只是服务的系统可能有所不同,接口的命名方式也有所不同,DirectX专门服务于微软开发出来的系统,比如Windows和Xbox,他们本身分别由不同的两个组织开发出来。

为什么会是两个不同群体开发出来的两套差不多功能的软件,并且还同时运行在现有的世界中呢?OpenGL是由SGI(Silicon Graphics 美国硅图公司)开发的,而DirectX是由微软开发的,由于市场竞争的关系两家公司做了同样的事,最后导致现在的局面。这种局面也很正常,打个比方,你会用微信去聊天也有时会用旺旺去聊天,都是聊天只是聊天的场景不同心情和目标有点不同而已,图形接口也是一样,不只是OpenGL和DirectX还有专门为苹果系统服务的Metal。从现在的局面来看,我们可以想象的到,在当初还没有形成统一的硬件渲染接口时,各家公司的图形编程接口有多混乱情况有多复杂,对标准统一接口的标准竞争有多激烈。在这种严峻的环境下才使得两家公司为了各自的利益,一直在不断维护和升级着各自的驱动接口直到今天。

幸运的是 Unity3D 已经帮我们封装好了 OpenGL 和 DirectX 的接口,我们无需关心到底是调用 OpenGL 还是 DirectX。我们这里会以OpenGL为例来讲解渲染过程,DirectX也是相似的原理与过程。

OpenGL究竟处在哪个位置

Unity3D通过调用 OpenGL 的图形接口来渲染图像,OpenGL 定义了各种标准接口就是为了让像 Unity3D 这样的应用程序在面对不同类型的显卡硬件时可以不必慌张,也由于 OpenGL 的存在Unity3D完全不需要去关心硬件到底是哪个厂家生产的,以及它们的驱动是什么。与其说OpenGL在标准接口中适配了硬件厂商的驱动程序,不如说硬件厂商的驱动程序适配了OpenGL,事实上确实是这样。

当Unity3D渲染调用时会去设置OpenGL的渲染状态,OpenGL就会去检查显卡驱动程序里是否有该功能,有就会调用没有就不调用,那些比较特殊的渲染功能接口有些底端的硬件上并没有该功能。显卡驱动与OpenGL不同的是,显卡驱动是在硬件之上的专门为硬件服务的程序,它是用来将指令翻译成机器语言并调用硬件的那个程序,而OpenGL则调用的只是显卡驱动。

显然OpenGL是在驱动程序之上的应用程序接口,我们可以把它看做是适配了很多不同驱动程序的中间件。如下图:

opengl

上图指出了OpenGL所处的位置,图形引擎Unity3D调用OpenGL图形接口,告诉OpenGL某个模型数据需要渲染或者说某个渲染状态需要要设置,再由OpenGL发送指令给显卡驱动程序,显卡驱动程序将指令翻译为机器码后,将指令机器码发送到GPU,最后GPU根据指令做相应的处理。我们看到这个过程中显卡驱动程序只是做了传递指令消息的工作,指令从OpenGL那里发起到GPU接收到指令,显卡驱动起到了翻译的工作。

当然这里GPU不只会处理一次,OpenGL会通过显卡驱动发送很多次指令给GPU,让它处理一连串的操作,每次指令都有可能不一样,经过一系列的处理过程后,最终形成了一张屏幕大小的图像存放在缓存中,这时GPU才向屏幕输出最终画面。

下面我们就来详细介绍一下这一整条渲染管线的处理过程。

究竟渲染管线是什么?

上面所说的 OpenGL 通过驱动程序向 GPU 发送很多个指令,这一系列指令加起来才形成一整个渲染画面。

渲染管线就是指令中完成一个绘制命令(drawcall)的流水线。这条流水线中有很多个环节,每个环节都自己干自己的事,就像工厂里的流水作业一样,每个节点的工人都会拧属于自己的螺丝,完全不会去管前面节点发生了什么事情。在现代GPU中也会做些流程上的优化,比如某种情况下跳过某些节点的以节省开销,但节点还是自顾自的工作,这部分会在后面的文章中提到。

我们可以描述说渲染管线是一系列数据处理的过程,这个过程最终的目的是将应用程序的数据经过计算最终输出到帧缓存上最后输出到屏幕。渲染管线从接受到渲染命令后开始,分几个阶段处理了这些数据,这几个阶段分别是应用阶段,几何阶段,光栅化阶段,经过这几个阶段处理最终输出到屏幕上。

	缺图片

图中每个阶段都有各自细分的流程,我们来一一讲述下。

应用阶段

应用阶段就是我们执行Unity3D引擎和业务逻辑的过程,在逻辑代码执行过程中,我们实例化了很多个模型或者UI(UGUI的UI也是网格,跟渲染场景中的3d模型从根本上是没有区别的),这些模型有贴图,有材质球,有网格,对于引擎来说,在这个阶段代码执行完毕后它知道了哪些模型需要被渲染,有哪些光源存在,摄像头的位置大小,总的来说这个阶段是准备渲染数据的阶段,为调用渲染准备。

引擎除了知道有哪些东西(数据)需要被提交到GPU渲染外,在提交前还会对这些数据做很多优化工作,从而省去很多不必要的渲染工作提高性能。优化工作有很多,前面的章节中我们也讲到了很多很多关于在逻辑端上优化的工作,这里我们来重点说一下引擎的‘剔除’优化部分。

Unity3D引擎会对不需要渲染的物体进行剔除,原本这是在GPU中做的事搬到了CPU上做。为什么要搬到CPU上做呢?因为在引擎端掌握的是第一手数据信息,引擎大可以从粗颗粒上下手做剔除工作,这样GPU的负担就会减轻很多,倘若所有的网格都放到GPU去剔除,会浪费很多算力同时降低了渲染的功效。

引擎在粗颗粒上是怎么剔除的呢,引擎当然不会像GPU那样去计算每个三角面的有效性,而是去计算比较粗的模型颗粒,即以包围盒形式判断模型是否需要被剔除。

引擎会计算一个模型的包围盒,包围盒信息存放在Unity3D的Mesh.bounds变量中,这个包围盒是一个长方体我们常称它为AABB包围盒,即一个顶点与长宽高四个变量组成。我们可以理解为包围盒是个立方体有8个顶点,这8个顶点决定了这个模型是否会被剔除,在判断模型是否有被需要渲染的可能时只要有一个顶点在摄像机可视范围内(锥视体内或正交范围),就不会被剔除,否则将被无情的抛弃。

引擎通过这种快速的判断包围盒与锥视体的关系来剔除不需要渲染的物体,以达到对模型粗颗粒的渲染优化。除此之外,对粗颗粒的剔除判断还有 occlusion culling 即遮挡剔除,也是属于应用阶段的优化剔除,它其实也是属于业务逻辑层的优化方案并不是所有项目都会使用,而通常只在第一人称视角的游戏上使用这种剔除方式,通过对场景的离线计算来确定每个点所看能看到的物体的列表数据,将这份数据保存下来,当角色在此场景中时根据当前点的位置去寻找已经计算好的可见物体列表既然展示物体,这样就不需要实时去判定物体是否可见,也能轻松得到可见物体的列表,即在离线时在该位置被计算为被遮挡的物体不会进入渲染队列。

应用阶段的最后时刻就是向GPU提交需要渲染的数据,通常数据会被拷贝到显存中,接着设置渲染参数,最后调用渲染接口。在PC端中显存的位置是最接近GPU的内存设备,将数据拷贝到显存中会加速GPU的工作效率,但在移动端里并没有显存,安卓和IOS的架构决定了它们只能用内存来为GPU提供服务,因此在手机端中没有拷贝数据到显存的这个过程,它们使用的都是同一个物理内存地址,除非我们需要读写这块内存的内容才会将它们另外复制一份以条件CPU与GPU之间的协作(三重缓存机制)。

什么是渲染状态

很多人都很困惑,其实就是一连串的开关或方法以及方法的地址指向。比如要不要开启混合,使用哪张纹理,使用哪个顶点着色器,使用哪个片元着色器,剔除背面还是剔除前面亦或都不剔除,使用哪些光源等等。通俗的来说,设置渲染状态,就是设置并决定接下来的网格如何渲染,有了渲染的具体方法,至于具体的渲染工作则是由GPU来执行。

有了渲染的具体方法,就要调用渲染的具体对象,这就是渲染调用做的工作。实际上 Draw call 就是一个渲染指令,发起方是CPU接收方是GPU,这个指令仅仅指向了一连串的图元(即点,线,面,我们可以理解为网格被拆分后的状态),并不会包含其他任何材质信息。每个 Draw call 前面都伴随着一连串渲染状态的设置,因此整个渲染命令队列中都是渲染指令与渲染状态交替出现。

为什么要有渲染命令队列呢?因为CPU和GPU相当于是两个大脑,它们是分离的就像两个线程那样如果没有很好的协调机制,它们无法正常梳理自己的工作。命令缓冲队列就是用来协调CPU与GPU的,CPU向命令缓冲队列中推入指令,GPU则从中取得指令并处理。这个命令缓冲队列成了CPU与GPU的关系纽带,这条关系纽带(命令缓冲队列)很好的连接了CPU与GPU,但也成了它们之间交互的瓶颈,即我们通常所说的 Draw call 太多时GPU的工作效率比较差。其根本原因就是 CPU 发送了渲染指令后,只是空转的等待GPU完成渲染操作。

几何阶段

在几何阶段前引擎已经准备好了要渲染的数据,同时向GPU发送了渲染状态的设置命令和渲染调用命令,接下来的工作就完全属于GPU了。首先进入的是几何阶段的工作,几何阶段的工作目标是将需要绘制的图元(三角形、点、线、面)转化到屏幕空间中,因此它也决定了哪些图元可以绘制以及怎么绘制。图元即点、线、面,我们可以理解为网格的拆分状态,是着色器中的基础数据,在几何阶段作用最大。

几何阶段会经过几个处理过程,按顺序排列为,顶点着色器、曲面细分着色器、细分计算着色器、几何着色器、图元装配、裁减。如下图所示:
	缺图

图中几何阶段分拆成了,顶点着色器、曲面细分着色器、细分计算着色器、几何着色器、图元装配、裁剪6个节点。其中顶点着色器会对每个顶点进行逐一的计算,OpenGL会调用我们在渲染状态设置时的顶点处理函数来处理顶点数据。

顶点处理函数就是我们可编程的部分,它可以很简单只将数据传递到下一个节点,也可以用变换矩阵的方式来计算顶点在投影空间的位置,或者通过光照公式计算来得到顶点的颜色,或者计算并准备其他下一个阶段需要的信息。

曲面细分着色器、细分计算着色器、几何着色器,这三个着色器是非必须着色器,很多手机设备上的GPU并没有这几个功能。细分着色器包括曲面细分着色器和细分计算着色器会使用面片来描述一个物体的形状,并且增加顶点和面片数量使得模型外观更加平顺。几何着色器则允许自定义增加和创建新的图元,这是唯一一个能自定义增加新图元的着色器。

前面几个着色器节点处理的都是顶点数据,到了图元装配节点,它将这些顶点与相关的几何图元之间组织起来为下一步的裁剪工作做准备。

经历过前面几个阶段的变换,特别是在顶点着色器中将顶点从模型空间转换到投影空间,这个转换过程为从模型空间到世界空间再到视口空间再到投影空间,Unity3D的Shader中常见的UNITY_MVP宏定义变量就是坐标空间转换的变化矩阵,它在渲染前就已经计算好并存储起来不需要再次计算。在转换了坐标空间之后会经硬件上的透视除法得到归一化的设备坐标,归一化的设备坐标会使裁剪更加容易,不仅如此还对后面的深度缓冲和测试有很大的帮助。

其中归一化后的设备坐标(Normalized Device Coordinates, NDC)可以看做是一个矩形内的坐标体系,这个经转化后的坐标体系是个限制在立方体内的坐标体系,所有在这个坐标体系内的顶点的坐标都不会超过1到-1之间,无论x、y、z轴。

为了能更好的理解经过空间转换后的顶点在后面几个阶段上应用的数据,有必要在这里来理解一下空间坐标系转换前后的样子,如下图:

	缺图1

	缺图2

上述图1中我们看到原本在视锥体上的物体,在经过空间矩阵转换后,视口从锥体变为了立方体,而原本在视锥体中的物体则从长方体变成了锥体,这是空间坐标系转化后的结果。其中需要特别注意的是原本在视锥体坐标系上向前方向(forward)的X坐标轴变为了Z轴方向,平面由x、y组成二维坐标。

相当于,x、y坐标成为了可以映射到屏幕上的相对坐标,z坐标则被用来作为离视口有多远的数值参考,这是因为归一化后的NDC坐标系与原本视锥体坐标系相比其Z轴方向发生了翻转。

归一化坐标让坐标范围固定在1到-1之间,使得后续对图元数据的处理变得更为简单。不过归一化坐标范围在OpenGL和DirectX上标记也有所不同,在OpenGL上x、y、z坐标范围在[-1,1]之间,而在DirectX上则是[0,1]之间,但这并不影响最终在屏幕上的表达,只是规则不同而已。最终他们都会进行简单的线性变换映射到屏幕的平面矩形范围内。在屏幕映射时,OpenGL和DirectX两者的也有所差异,OpenGL以左下角为(0,0)点,而DirectX则以左上角为(0,0)点,显然这不统一的做法是由于两个商家的竞争而故意造成的,不管怎样差异已经存在了我们只能小心留意。

说了这么多就是为了更好的理解几何阶段最后一步裁剪。我们将顶点转化到了归一化的坐标空间后,裁剪就容易多了。通过图元装配,有了线段和三角形数据,裁剪就可以开始了。

一个三角是否需要被裁剪,由归一化后的坐标判断,它或许完全在范围内,或许完全在范围外,或许部分在里面部分在外面。倘若三角形完全在可视的长方体范围内的则数据会被继续传递下去,完全在范围外的则被剔除掉不再进入到后面的阶段,若是部分在视野内则需要进一步做切割处理,把在范围外的部分剔除掉并在边界处生成新的顶点来连接没有被剔除的顶点。如下图:

	缺图

图中一个完整的三角形两个顶点在范围内,一个顶点在范围外,剪切后在边界上增加了两个顶点,再由这两个顶点与原来的三角形两个顶点生成2个不同的三角形。另一种情况相对比较简单,即一个顶点在范围里,两个顶点在范围外,剪切后形成新的两个顶点与范围内的顶点结合后替换了原来的三角形。

我们来分析下那种复杂的情况,因为经过裁剪后原来的三角形由3个顶点变成了4个顶点,成为了4边形,所以需要对这个四边形进行切割。切割的方法其实很简单,选一个新增的顶点与原本的两个顶点替换原来的三角形,另一个新增的顶点与前一个新增的顶点再加上一个旧顶点(这个旧顶点一定是剪切后新增的这个顶点的线段里的)形成新的三角形。如下图所示:

缺图

不仅如此,裁剪不仅仅是视口的裁剪,还有会有背面裁剪(Back-Face Culling),即剔除面朝视口反方向的面片,将在后面的章节中详细讲述。

至此所有几何阶段的操作都结束了,总体来说几何阶段处理的是顶点,以及计算和准备下一个阶段需要用到的数据。

光栅化阶段

光栅化阶段分为三个节点,光栅化、片元着色器、逐片元操作。其中光栅化又可以分成,三角形设置、三角形遍历两个节点。

三角形设置即Triangle Setup。前面阶段都是空间意义上的顶点和三角形,到了光栅化阶段我们更加需要的是屏幕上的像素,于是三角形设置可以认为是将所有三角形都铺在屏幕坐标平面上,这样就知道了三角形面片在屏幕上的情况,三角形面片会以三条线的边界形式来表达面片的覆盖面积。其实覆盖面积面积并不是关键,因为屏幕中展示的画面都是以像素为单位计算的,因此一个三角形覆盖哪些像素需要依靠扫描变换(Scan Conversion)得到,这个像素扫描阶段就是三角形遍历(Triangle Traversal)的环节。如下图:

	缺图

图中,在三角形遍历节点中像素依据三角形三条边计算得到像素覆盖范围,再通过三个顶点中信息的插值获得每个像素中需要具备的信息,这些信息包括像素坐标,苏像素深度,像素颜色,像素法线,像素纹理坐标等都是从三个顶点上的信息经过插值得到。

经过光栅化过程,我们得到了三角形覆盖的像素上的信息,我们称这些像素为片元,每个片元包含了经过三个顶点中的信息插值后的信息。接着这个片元被传递到下一个阶段即片元着色器。

片元着色器(Fragment Shader)就如字面意思那样,是处理片元的地方,就是我们上面介绍的光栅化后三角覆盖的像素。在现代的渲染管线上它已经被设计成可编程的阶段,我们在这里可以编写很多技巧来改变片元的颜色,或者也可以直接丢弃该片元(discard 或者 clip)。

每个片元就相当于一个像素,只是比起像素,片元装载了很多的信息,这些信息都是通过前面三角形遍历时对三个顶点中的信息插值得到的。经过片元着色器的处理,也就是我们编写的片元着色程序的处理后,最终输出的也是片元,我们通常都在片元着色器中计算改变片元的颜色,最终得到一个我们想要的输出到屏幕的片元。

这里有一个重点,每次片元着色器处理片元时都只是单个片元(只是处理片元的单元有很多很多个,他们相互独立),对于片元着色器来说它并不知道相邻的片元是什么样,因此每个片元在处理时无法得到邻近的片元的信息。

得不到邻近的片元信息并不代表我们就没有方法让片元受到邻近的片元影响,虽然每次片元着色器传入的和处理的都是单个片元,但GPU在跑片元着色器时并不是只跑一个片元着色器,而是将其组织成2x2的一组片元块同时跑4个片元着色器。如下图:

	缺图

上图中,描绘了4个片元组成的片元组,以及偏导数函数对它们的计算过程。我们可以通过ddx和ddy这两个偏导数函数来求得邻近片元的差值。偏导数函数可以用于片元着色器中的任何变量。对于向量和矩阵类型的变量,该函数会计算变量的每一个元素的偏导数。偏导数函数是纹理Mipmaps实现的基础,我们将在后面的章节中详细讲解。

除了计算片元的颜色,我们还可以在片元着色器中丢弃某些片元(discard 或者 clip),我们常说的Alpha Test就是一个用丢弃片元函数来实现的效果,我们将在后面的章节中详细讲解 Alpha Test的原理与利弊。

片元着色器和顶点着色器是我们在着色器编程时最重要的两个节点,如果我们想要更通俗简单的理解顶点着色器和片元着色器的区别的话,可以认为:顶点着色器(包括细分着色器和几何着色器)决定了一个三角形应该放在屏幕的什么位置,而片元着色器则用这个三角形面片计算三角形范围内的像素拥有什么样的颜色。

片元着色器输出片元后,进入了逐片元操作阶段,也是渲染管线的最后一步。

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

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

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

    《Unity3D高级编程之进阶主程》第七章,渲染管线与图形学(二) - 渲染管线1

    Copyright attention

    Please don't reprint without authorize.

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

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