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

计算机的世界里其实是很纯粹的,没有什么高深的技术,无非就是进程、线程、内存、硬盘、CPU。

在现代社会的实际编程中,很多时候引擎或者框架帮我们屏蔽了底层上的操作使得程序员只需要将所有精力放在上层应用就可以了,因此数学和算法很多时候成了重点,这是符合社会进步规律的,我们需要站在巨人的肩膀上才能走得比巨人更快更远。

===

Unity3D就是帮助我们快速建立程序结构的好引擎,有了Unity3D我们才能在较低门槛的情况下,创造出自己的想法和创意,让’我‘里面有的内在才能快速和有效的得到放大。

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

OpenGL、DirectX究竟是什么

OpenGL 和 DirectX 其实是一回事,它们都是图形渲染的应用程序编程接口,它们都是一种可以对图形硬件设备特性进行访问的软件库。它们的区别只是接口名字不一样,并且,分别由不同的两个开发群体开发出来的两套程序。

为什么会是两个不同群体开发出来的两套差不多功能的软件,并且还同时运行在现有的世界中呢?

OpenGL是由SGI(Silicon Graphics 美国硅图公司)开发的,而DirectX是由微软开发的,由于市场竞争的关系两家公司做了同样的事,最后导致现在的局面。从现在的局面看,我们可以想象的到,在当初还没有形成统一的硬件渲染接口时,各家公司的编程方式有多混乱,情况有多复杂,对标准统一接口的标准竞争有多激烈。在这种严峻的情况下才使得两家公司为了各自的利益,一直在不断维护和升级着各自的驱动接口直到今天。

幸运的是 Unity3D 已经帮我们封装好了 OpenGL 和 DirectX 的接口,我们无需关心到底是调用 OpenGL 还是 DirectX。我们这里会以OpenGL为例来讲解Unity3D与OpenGL的关系,暂时免去DirectX的麻烦。

OpenGL究竟处在哪个位置

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

当Unity3D在渲染调用时去设置OpenGL的渲染状态,OpenGL就会去检查显卡驱动程序里是否有该功能,如果有就会调用,如果是那些比较特殊的渲染接口,有些硬件驱动是没有的则不调用。

如果把GPU硬件看做是最底层的那个模块的话,显卡驱动就是在硬件之上的模块,它是用来将指令翻译成机器语言并调用硬件的那个程序。

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

opengl

上图中很清晰的表达OpenGL所处的位置,应用程序(如Unity3D)向OpenGL发送渲染指令,告诉OpenGL我的某个模型数据需要渲染或者说某个状态要设置,OpenGL发送指令给显卡驱动程序,显卡驱动程序将指令翻译为机器码后,将指令机器码发送到GPU,GPU先从显存中获得需要的数据再根据指令做处理。

我们看到这个过程中显卡驱动程序只是做了传递指令消息的工作,指令从OpenGL那里发起到GPU接受到指令,显卡驱动只是起到了翻译的作用。因此我们在平常的讨论中,常常将显卡驱动程序的工作忽略掉,而用 OpenGL 与 GPU 的互动代替之。甚至有时会将OpenGL也忽略掉用GPU代替,或把GPU忽略掉用OpenGL代替,因为GPU的工作是机械式的它俩的工作完全可以看做一个整体。

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

下面我们就来详细介绍一下渲染管线。

究竟渲染管线是什么?

上面所说的 OpenGL 通过驱动程序向 GPU 发送很多个指令,其实为了渲染很多个模型每一帧都会发送很多个指令,这一系列指令加起来,才形成一整个渲染过程,拥有完整的一个画面。

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

从严肃的理论描述来说渲染管线:是一系列数据处理的过程,这个过程最终的目的是将应用程序的数据转换到最终输出的缓存上最后输出到屏幕。

我们说的再白话一点。渲染管线从接受到渲染命令后开始,分几个阶段处理了这些数据,这几个阶段分别是应用阶段,几何阶段,光栅化阶段,经过这几个阶段处理最终输出到屏幕上。

    缺图片

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

应用阶段

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

引擎除了知道有哪些东西(数据)需要被提交到GPU渲染外,在提交前还会对这些数据做很多优化工作,从而提高渲染性能。

优化工作有很多种,从具体业务逻辑上做优化的方案,前面的章节中我们也讲到了很多很多关于在逻辑端上优化的工作,现在我们来重点说一下引擎的‘剔除’优化部分。

Unity3D引擎会对不需要渲染的物体进行剔除,原本这是在GPU中做的事搬到了CPU上做。为什么要搬到CPU上做呢?因为在引擎端掌握的是第一手数据信息,如果从粗颗粒上下手做剔除工作,CPU会更快更方便,如果放到GPU去剔除,则更可能是三角形级别的裁剪,会浪费很多计算同时降低了功效。

引擎在粗颗粒上是怎么剔除的呢,引擎当然不会像GPU那样去计算每个面是否可展示,因为这个操作对GPU来说更快,而是去计算更粗的颗粒,即整个模型的包围盒是否需要被剔除。

引擎会计算一个模型的包围盒,这个包围盒就是Unity3D的Mesh.bounds变量,这个包围盒为AABB包围盒,即一个顶点与最大最小长宽高,我们可以理解为包围盒是个立方体有8个顶点,这8个顶点决定了这个模型是否会被剔除,即只要有一个顶点在摄像机可视范围内(锥视体内或正交范围),就不会被剔除,否则将被剔除。

引擎通过这种快速的判断包围盒与锥视体的关系来剔除不需要渲染的物体,以达到对粗颗粒的渲染优化。

除此之外,对粗颗粒的剔除判断还有 occlusion culling 即遮挡剔除,也是属于应用阶段的优化剔除,它其实也是属于业务逻辑层的优化方案并不是所有项目都会使用,而通常只在第一人称视角的游戏上使用这种剔除方式,即被遮挡的物体不进入渲染队列。

除了得到和优化需要渲染的数据外,应用阶段的最后时刻就是向GPU提交需要渲染的数据,即拷贝数据到显存中、设置渲染状态、渲染调用(Draw call)。

在PC端中显存是最接近GPU的内存设备,将数据拷贝到显存中会加速GPU的工作效率,但在移动端里并没有显存,安卓和IOS的架构决定了它们只能用内存来为GPU提供服务,因此在手机端中没有拷贝数据到显存的这个说法,使用的都是同一个内存地址,除非我们需要读写这块内存内容才将它们另外复制一份。

那么什么是渲染状态?很多人都很困惑,其实就是一连串的开关或方法以及方法的地址指向。

比如:要不要开启混合,使用哪张纹理,使用哪个顶点着色器,使用哪个片元着色器,剔除背面还是剔除前面亦或都不剔除,使用哪些光源等等。

通俗的来说,设置渲染状态,就是设置并决定接下来的网格如何渲染,有了渲染的具体方法,至于具体的渲染工作则是由GPU来执行。

有了渲染的具体方法,就要调用渲染的具体对象,这就是渲染调用即 Draw call 做的工作。实际上 Draw call 就是一个命令,它的发起方是CPU,接收方是GPU,这个命令仅仅指向了一连串的图元(即点,线,面,我们可以理解为网格被拆分后的状态),并不会包含其他任何材质信息。

每个 Draw call 前面都伴随着一个渲染状态的设置,因此整个渲染命令队列中都是一对对出现的,并且都是由CPU向GPU提交。

那么为什么要有这个渲染命令队列呢?

因为CPU和GPU相当于是两个大脑,它们是分离的,如果没有很好的协调机制,它们无法正常梳理自己的工作。一个命令缓冲队列就是用来协调CPU与GPU的,CPU只管向命令缓冲队列中推数据,GPU只管取数据并且处理数据,取一个处理一个,其他时间的都自顾自的忙自己的事。这个命令缓冲队列成了CPU与GPU的关系纽带。

这条关系纽带(命令缓冲队列)很好的连接了CPU与GPU,但也成了它们之间交互的瓶颈,即我们通常所说的 Draw call 太多时GPU的工作效率比较差。其根本原因就是 CPU 发送了很多渲染命令后,只是空转的等待GPU完成这些渲染操作。

几何阶段

CPU准备好数据后,向GPU发送了渲染状态的设置命令和渲染调用命令后,接下来的工作就完全属于GPU了。

接下来进入的是几何阶段的工作。几何阶段最终的工作目标是将需要绘制的图元转化到屏幕空间中,所以它决定了哪些图元要绘制,怎么绘制。

图元即点、线、面。我们可以理解为网格的拆分状态,是着色器中的基础数据,在几何阶段作用最大。

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

图中几何阶段分拆成了,顶点着色器、曲面细分着色器、细分计算着色器、几何着色器、图元装配、裁剪和剪切,6个节点。

顶点着色器会对每个顶点进行逐一的计算,OpenGL会调用一个顶点处理函数来处理顶点数据。

这个顶点处理函数就是我们可编程的部分,它可以很简单,只是数据复制并传递到下一个节点,也可以很复杂,例如变换矩阵的方式来得到顶点在屏幕上的位置,或者通过光照计算来判断顶点的颜色,或者记录和计算其他下一个阶段需要的信息。具体我们将在后面的章节中详细讲解。

曲面细分着色器,细分计算着色器,几何着色器,都是可选的着色器。细分着色器包括曲面细分着色器和细分计算着色器会使用面片来描述一个物体的形状,并且增加面片数量使得模型外观更加平顺。几何着色器则允许增加和创建新的图元,这是唯一一个能自定义增加新图元的着色器。

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

经历过前面几个阶段的变换,特别是在顶点着色器中的顶点空间转换,从模型空间到世界空间再到视口空间再到投影空间,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)点,显然是两个商家因为竞争而故意造成的,还好主流的图形编程接口并不多,不管怎样差异已经存在了我们只能小心留意。

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

一个图元和可视范围关系,要么完全在范围内,要么完全在范围外,要么就是部分在里面部分在外面。完全在范围内的图元继续向下传递,不做任何操作,完全在范围外的图元则被剔除掉不再进入到后面的阶段,部分在视野内的图元则需要进一步剪切处理,把在范围外的部分剔除掉并在边界处生成新的顶点来连接没有被剔除的顶点。如下图:

    缺图

图中一个完整的三角形两个顶点在范围内,一个顶点在范围外,剪切后在边界上增加了两个顶点,这是比较复杂的情况,另一个中比较简单的情况时一个顶点在范围里,两个顶点在范围外,剪切后形成新的两个顶点与范围内的顶点结合后替换了原来的三角形。

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

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

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

光栅化阶段

光栅化阶段分为三个节点,光栅化、片元着色器、逐片元操作。

其中光栅化可以分成,三角形设置、三角形遍历两个节点。

三角形设置即Triangle Setup,由于前面阶段都是空间意义上的顶点和三角形,到了光栅化阶段我们更加需要的屏幕上的像素,于是三角形设置可以认为是将所有三角形都铺在屏幕坐标平面上,这样就知道了每个三角形片面在屏幕上的范围,它用三角形边界的形式表达了这个覆盖面。

知道覆盖面还不够,因为屏幕中展示的画面都是以像素为单位计算的,所以一个三角形覆盖哪些像素需要依靠扫描变换(Scan Conversion)得到,这个像素扫描阶段就是三角形遍历(Triangle Traversal)。如下图:

    缺图

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

经过光栅化的节点,我们得到了三角形内每个像素上的信息,我们称它们为片元,每个片元包含了诸多信息。这个片元将被传递到下一个阶段即片元着色器。

片元着色器(Fragment Shader)就如字面意思那样,是处理片元的地方,它是可编程的阶段,我们在这里可以编写很多我们喜欢操作来改变片元的颜色,或者也可以丢弃该片元(discard 或者 clip)。

每个片元就相当于一个像素,只是比起像素,片元装载了很多的信息,这些信息都是通过前面三角形遍历时对三个顶点中的信息插值得到的。

经过片元着色器的处理,也就是我们编写的片元着色程序的处理后,最终输出的也是片元,我们通常都在片元着色器中计算改变片元的颜色,最终得到一个我们想要的输出到屏幕的片元。

这里有一个重点,每次片元着色器处理片元的只是单个片元,也就是单个一个像素,对于片元着色器来说它并不知道相邻的片元是什么样,因此每个片元在处理时无法得到邻近的片元的信息。

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

    缺图

上图中,描绘了4个片元组成的片元组,以及偏导数函数对它们的计算过程。我们可以通过ddx和ddy这两个偏导数函数来求得邻近片元的差值。偏导数函数可以用于片元着色器中的任何变量。对于向量和矩阵类型的变量,该函数会计算变量的每一个元素的偏导数。

偏导数函数是纹理Mipmaps实现的基础,我们将在后面的章节中详细讲解。

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

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

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

下一节继续讲解剩余的逐片元操作阶段。

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

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

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

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号