渲染流水线:英文“Rendering Pipeline”,也称 渲染流水线 。是GPU中处理图形信号相对独立的并行处理单元。其工作任务为“通过给定的视点(viewport)、三维物体(three-dimensional geometry)、光源(light source)、照明模式(light mode),和纹理(texture)等元素,渲染生成一幅二维图像”。
此概念的定义来自图形学巨著《Real-Time Rendering, 3th》
使用高级编程语言(C/C++)进行开发,和CPU、内存进行交互。
(碰撞检测、场景图建立、空间八叉树更新、视锥裁剪等经典算法。)
阶段末期,让CPU,内存与GPU交互
(把所有渲染所需要的数据加载到显存中,然后设置渲染状态,最后调用用Draw Call)
所有渲染所需的数据都需要从硬盘(Hard Disk Drive,HDD)加载到系统内存(Random Access Memory,RAM)中。然后,几何体数据(顶点坐标、法向量、纹理坐标、纹理等)再通过数据总线(或称端口)加载到GPU的存储空间(显存)中(由于大多数GPU对RAM没有直接访问的权限,所以通过数据总线传输这些数据)。
当把所需的渲染数据加载到显存后,RAM中的数据就可以清除了。但对于一些CPU仍然需要访问的数据(比如当项目开发希望CPU可以访问网格数据来进行碰撞检测时)来说,由于从HDD加载到RAM所需时间过大(时间瓶颈),所以这些数据仍需保留。
渲染状态,指游戏世界中的三维模型是如何被渲染的。例如使用不同的着色器(顶点着色器Vertex Shader、片元着色器Fragment Shader等)、使用不同的光源或更改光源属性、更改材质的属性等都会影响到三维网格模型的渲染状态。如果不更改网格模型的渲染状态,那么场景中所有网格模型将使用同一种渲染状态。
联想:UE4引擎中可以让模型用自定义的渲染通道,从而达到不受关卡世界中后处理盒子的渲染影响
Draw Call就是指由CPU发起,GPU接收的命令,该命令给定一个需要被渲染的图元(primitives)列表。由于在设置渲染状态时已经完成,所以此时不用指定任何材质信息。
当给定一个Draw Call时,GPU就会根据渲染状态和所有输入的几何体数据来进行计算,最终绘制渲染成二维图像显示在屏幕上。
几何阶段处理所有和要绘制的几何元素相关的事情。例如,决定需要绘制的图元是什么,怎么绘制它们,这一阶段通常在CPU完成。
几何阶段负责和每个渲染图元打交道,进行逐顶点、逐多边形的操作。几何阶段的一个重要任务就是把顶点坐标变换到屏幕空间中,再交给光栅化处理器。通过对输入的渲染图元进行多步处理后,这一阶段将会输出屏幕空间的二维顶点坐标,每个顶点对应的深度值,着色等相关信息,并传递给下一阶段。
顶点着色器是流水线的第一个阶段,且是完全由开发者控制的一个阶段,它的输入来自CPU。顶点着色器的处理单位是顶点。即输入的每个顶点都会调用一次顶点着色器。顶点着色器本身并不能创建或销毁任何顶点,而且无法得到顶点之间的关系(例如,无法得知两个顶点是否属于同一个三角网格)。但正因为这样的相互独立性,GPU可以利用本身的特性并行化处理每一个顶点。这意味着这一阶段的处理速度会非常快。
顶点着色器需要完成的工作任务主要有:坐标变换和逐顶点光照与着色。除了这两项重要工作外,顶点着色器还负责输出后续阶段所需的数据。
齐次裁剪空间:也被称为观察空间道裁剪空间。裁剪空间的目标是能够方便地对渲染图源进行裁剪:完全位于这块空间内部的图元将会被保留,完全位于这块空间外部的图元将会被剔除,而与这块空间边界相交的图元就会被裁剪。齐次裁剪空间由 视锥体 决定
视锥体:空间中的一块区域,这块区域决定了摄像机可以看到的空间,视锥体由六个平面包围而成,这些平面被称为裁剪平面
一个图元(Primitive)和视口(Viewport)的关系有3种:完全在视口内,部分在视口内,完全在视口外。完全在视口内的图元就继续传递给下一个流水线阶段,完全在视口外的图元不会向下传递,因为它们不需要被渲染。而那些部分在视口内的图元,就需要用到 裁剪 这个操作了。
图元(Primitive):用来描述各种图形元素的函数,。描述对象几何要素的输出图元一般称为几何图元。点的定位和直线段是最简单的几何图元。而对于三维模型来说,就是组成三维模型基础的最小单位,3D模型的图元普遍是三角形(少数会使用四边形)
例如,一条线段的一个顶点在视口内,而另一个顶点不在视口内,那么在视口外的顶点就应该有一个新的定点代替,这个新的顶点通常位于这条线段和视口边界的焦点处。
由于已知在 NDC 下的顶点位置,即顶点在一个单位立方内,那么裁剪的过程大致就是,裁剪掉图元暴露在单位立方体外的部分,在图元与单位立方体边界的交点处各添加一个新的顶点。
和顶点着色器阶段不同,这一阶段允许开发者自定义一个裁剪操作,但无法通过编程控制裁剪的过程,即可配置但不可编程。例如可以使用自定义的裁剪平面来配置裁剪区域,或通过指令控制裁剪三角图元的正面或背面。
光栅化阶段将会使用上一个阶段传递的数据来产生屏幕上的像素,并渲染出最终屏幕视口上的二维图像。这一阶段也是在GPU上运行。光栅化的任务主要是决定每个渲染图元中哪些像素应该被绘制在屏幕上。它需要对上一个阶段得到的逐顶点数据(如纹理坐标、顶点颜色等)进行插值,然后再进行逐像素处理。
从上一个阶段输出的信息时屏幕坐标系下的顶点位置以及和它们相关的额外信息,如深度值(Z坐标)、法线方向、视角方向等。光栅化阶段就是通过这些数据来达到两个最重要的目标:计算每个图元覆盖了那些像素、为这些像素计算它们的颜色。
光栅化阶段主要分为 三角形设置(Triangle Setup)、三角形遍历(Triangle Traversal)、片元着色器(Fragment Shader)、逐片元操作(Per-Fragment Operations) 等主要四个阶段。前两个阶段都是固定函数(Fixed-Function)设置的阶段;第三个阶段时完全可编程的阶段,用于实现逐片元着色操作;第四个阶段负责执行注入修改颜色、深度缓冲、进行混合等重要操作,虽然不可编程,但具有高度的可配置性。
三角形设置这一阶段会计算 光栅化(Rasterization) 一个三角网格所需的信息。具体来说,上一个各阶段输出的都是三角网格的顶点,即我们得到的是三角网格每条边的两个端点。但如果要得到整个三角网格对像素的覆盖情况,就必须计算每条边上的像素坐标。为了能够计算边界像素的坐标信息,就需要得到三角形边界的表达方式。这样一个计算三角网格表示数据的过程就叫做三角形设置。他的输出是为了给下一阶段做准备。
光栅化(Rasterization):就是将图转化为一个个栅格组成的图像的过程,其流程简言之就是把顶点数据转换为片元,即通过扫描原图像,再通过扫描计算得到的像素点填充一个三角形
三角形遍历阶段将会检查每个像素是否都被某一个三角网格所覆盖到。如果被覆盖的话,就会生成一个 片元(Fragment) 。而这样一个找到哪些像素被三角网格覆盖的过程就是三角形遍历,这一阶段也被称为 扫描变换(Scan Conversion) 。
片元(Fragment):二维图象上每个点都包含了颜色、深度和纹理数据,将该点和相关信息叫做一个片元
三角形遍历阶段会根据上一个阶段的计算结果来判断一个三角形网格覆盖了哪些像素,并使用三角网格3个顶点的定点信息对整个覆盖区域的像素进行插值(用像素来尽可能还原原图像)。
这一步的输出就是得到一个片元序列。需要注意的是,一个片元并不是真正意义上的像素,而是包含了很多状态的几何,这些状态用于计算每个像素的最终颜色。这些状态包括(但不限于)它的屏幕坐标、深度信息,以及其他从几何阶段输出的顶点信息,例如法线、纹理等。
片元着色器是另一个非常重要的可编程着色器阶段(上一个是“顶点着色器)。在DirectX中,片元着色器被称为“像素着色器(Pixel Shader)”,但片元着色器是一个更合适的名字,因为此时的片元并不是一个真正意义上的像素(像素一般包含的是一个点的位置和颜色信息,而片元则远不止如此)。
前面的光栅化阶段实际上并不会影响屏幕上每个像素的颜色值,而是会产生一系列的数据信息,用来表述一个三角网格是怎样覆盖每个像素,而每个片元就负责存储这样一系列数据。真正会对像素产生影响的阶段是下一个流水线阶段。
片元着色器的输入是上一个阶段对顶点信息插值得到的结果,更具体来说,是根据那些从顶点着色器中输出的数据插值得到的。而它的输出是一个或多个颜色值。
这一阶段可以完成很多重要的渲染技术,其中最重要的技术之一就是纹理采样。为了在片元着色器中进行纹理采样,我们通常会在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角网格的3个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理坐标了。
虽然片元着色器可以完成很多重要效果,但它的局限在于,它仅可以影响单个片元。也就是说,当执行一个片元着色器时,它不可以将自己的任何结果直接发送给它的邻居们。有一个情况例外,就是片元着色器可以访问到 导数信息(gradient,或者说是derivative) 。
逐片元操作是OpenGL中的说法,在DirectX中,这一阶段被称为 输出合并阶段(Output-Merger) 。逐片元操作阶段是高度可配置的,即开发者可以设置每一步的操作细节。
这一阶段的主要任务如下:
这个阶段首先需要解决每个片元的可见性问题,这需要进行一系列测试,一个片元只有通过了所有的测试,才能在GPU中和颜色缓冲区中已经存在的像素颜色进行混合,最后再写入颜色缓冲区中。如果某个片元未能通过其中的某一个测试,那么之前为了产生这个片元所做的所有工作都是白费的。
测试的整体过程比较复杂,且在不同的图形接口(DirectX、OpenGL等)的实现细节也不尽相同。但是都会有两个最基本的测试过程:模板测试、深度测试。
与模板测试相关的是模板缓冲(Stencil Buffer)。实际上,模板缓冲和颜色缓冲、深度缓冲的区别不大。在模板测试中,开发者可以指定GPU对读取到的模板缓冲区中某片元位置的模板值和读取到的参考值的比较算法(例如小于时舍弃该片元,或大于时舍弃该片元)。
模板测试通常用于限制渲染的区域。模板测试还有一些更高级的用法,如渲染阴影,渲染轮廓等。
不管一个片元有没有通过模板测试,开发者都可以指定根据模板测试和深度测试的结果来对模板缓冲区的修改操作。比如,失败时模板缓冲区保持不变;通过时将模板缓冲区中对应位置的值 加1 等。
深度测试也属于高度可配置的测试过程。在深度测试中,开发者可以指定GPU对某片元的深度值和已经存在于深度缓冲区中的深度值进行比较的算法(例如小于时舍弃该片元,或大于时舍弃该片元)。但通常比较算法是小于等于的关机,即如果某片元的深度值大于等于当前深度缓冲区的值,那么就会舍弃它。因为大多时候只需要显示出李摄像机最近的物体,而那些被其它物体遮挡的部分就不需要出现在屏幕上。
和模板测试不同的是,如果一个片元没有通过深度测试,它就没有权利更改深度缓冲区中的值。而如果一个片元通过了深度测试,开发者还可以指定是否要用这个片元的深度值覆盖掉原有的深度值(通过开启/关闭深度写入来做到)。
渲染过程是一个物体接着一个物体在屏幕上渲染出图像,而每个像素的颜色信息被存储在一个名为颜色缓冲的区域。因此当执行渲染时,颜色缓冲往往已经有了上次渲染之后的颜色结果。“混合操作”解决的问题就是判断是否使用当前的颜色完全覆盖掉之前的结果,还是进行其他的处理。
对于不透明物体,开发者可以关闭混合操作。这样片元着色器计算得到的颜色值就会直接覆盖掉颜色缓冲区中的像素值。但对于半透明物体,就需要使用混合操作来让这个物体看起来是透明的。
将源颜色(片元着色器计算到的颜色值)和目标颜色(已经存在于颜色缓冲区中的颜色值)进行混合的算法通常和透明通道息息相关,例如根据透明通道的值进行相加、相减、相乘等。
虽然从逻辑上来说各种测试应该是在片元着色器之后进行,但对于大多数GPU来说,它们会尽可能在执行片元着色器之前就进行这些测试,从而尽早得到哪些片元是会被舍弃的,避免许多根本不会渲染出的片元仍然需要进行测试从而耗费了不必要的性能。
作为一个像充分提高性能的GPU,它会希望尽可能早的知道那些片元是被舍弃的,对于这些片元就不需要再使用片元着色器来计算它们的颜色。在Unity给出的渲染流水线中,深度测试也是在片元着色器之前(这种将深度测试提前执行的技术通常被称为“Early-Z技术”)。
但是如果将这些测试提前的话,其检验结果可能会与片元着色器中的一些操作冲突(例如,如果在片元着色器中进行了透明度测试,而某个偏远并没有通过透明度测试,就需要开发者在着色器中调用API来手动将其舍弃掉,这就导致GPU无法提前执行各种测试)。因此,现代的GPU会判断片元着色器中的操作是否和提前测试发生冲突,如果有冲突,就会禁用提前测试。但是,这样也会造成性能上的下降,因为有更多的偏远需要被处理了。这也是透明度测试会导致性能下降的原因。
当模型的图元经过了上面层层计算和测试后,就会显示到屏幕上。屏幕中显示的也就是颜色缓冲区中的颜色值。但是为了避免用户看到正在进行光栅化的图元,GPU会使用 双重缓冲(double buffering) 的策略。这意味着对场景的渲染是在幕后发生的,即在 后置缓冲(back buffer) 中。一旦场景已经被渲染到了后置缓冲中,GPU就会交换后置缓冲和 前置缓冲(front buffer) 中的内容,而前置缓冲是之前显示在屏幕上的图像。由此,保证了用户看到的图像总是连续的
Draw Call,也就是“CPU调用图像编程接口,以命令GPU进行渲染的操作”。
一个常见的误区是,“Draw Call”中造成性能问题的元凶是GPU,这种观点认为在GPU上的状态切换操作是耗时的。但其实调用“Draw Call”时真正耗时的是CPU。
如果没有流水线化,那么CPU需要等到GPU完成上一个渲染任务才能再次发送渲染命令,但这种方法效率很低下。而实现让CPU和GPU并行工作的解决方案,就是使用一个 命令缓冲区(Command Buffer) 。
命令缓冲区包含了一个命令队列,由CPU向其中添加命令,而由GPU从中读取命令,添加和读取的过程是相互独立的。命令缓冲区使得CPU和GPU可以相互独立工作。当CPU需要渲染一些对象时,它可以向命令缓冲区中添加命令,而当GPU完成了上一次的渲染任务后,它就可以从命令队列中再取出一个命令并执行它。
命令缓冲区中的命令由很多种类,而“Draw Call”是其中的一种,其他命令还有改变渲染状态等(如改变使用的着色器,使用不同的纹理等),而改变渲染状态一类的命令往往比“Draw Call”更加耗时。
(此举例来自与《Unity Shader 入门精要》一书) 创建10000个大小1KB的文件(总共约10MB),然后把它们从一个文件夹复制到另一个文件夹,其耗时要远远大于将一个大小约10MB的文件从一个文件夹复制到另一个文件夹。原因在于,每一个复制动作都需要很多额外操作,如分配内存、创建各种元数据等。这些操作将会造成很多额外的性能开销。
虽然渲染的过程和复制文件大不相同,但是从抽象角度看还是很类似的。每次调用“Draw Call”之前,CPU需要向GPU发送很多内容(RU数据、状态、命令等)。在这一阶段CPU同时也要完成很多工作(如检查渲染状态)。而一旦CPU完成了这些准备工作,GPU就可以开始本次的渲染,GPU的渲染能力是很强的,渲染200个还是2000个三角网格通常没有什么区别,因此渲染速度往往快于CPU提交命令的速度,如果“Draw Call”的数量太多,CPU就会把大量时间花费在提交“Draw Call”上,造成CPU过载。
(此处仅对《Unity Shader 入门精要》一书中提到的批处理方法进行记录与整理)
由于提交大量很小的“Draw Call”会造成CPU的性能瓶颈,即CPU把时间都花费在准备“Draw Call”的工作上了。所以,一个很显然的“优化想法”就是把很多小的“Draw Call”合并成一个大的“Draw Call”,这就是批处理的思想。
需要注意的是,由于需要在CPU的内存中合并网格,而合并的过程是需要消耗时间的。因此,批处理技术更加适合于那些静态的物体,例如不会移动的大地,石头等,对于这些静态物体只需要合并一次即可。当然,开发者也可以对动态物体进行批处理。但是,由于这些物体时不断运动的,因此每一帧需要重新进行合并然后再发送给GPU,这对空间和时间都会造成一定的影响。
联想:UE4引擎中网格模型的运行时可移动性(Mobility)分为“Static”、“Stationary”、“Movable”,三种属性对应三种不同的光照烘焙算法
利用批处理,CPU在RAM把多个网格合并成一个更大的网格,再发送给GPU,然后再一个“Draw Call"中渲染它们。但要注意的是,使用批处理合并的网格将会使用同一种渲染状态。也就是说,如果网格之间需要使用不同的渲染状态,那么就无法使用批处理技术。
在游戏开发过程中,为了减少“Draw Call”的开销,有两点需要注意:
利用批处理,CPU在RAM把多个网格合并成一个更大的网格,再发送给GPU,然后再一个“Draw Call"中渲染它们。但要注意的是,使用批处理合并的网格将会使用同一种渲染状态。也就是说,如果网格之间需要使用不同的渲染状态,那么就无法使用批处理技术。
在游戏开发过程中,为了减少“Draw Call”的开销,有两点需要注意: