实时渲染 - GPU和图形管线(转):

本文章是Real-Time Rendering一书的读书笔记。

在正式开始介绍实时渲染之前,让我们首先定义我们如何衡量渲染的速度,我们都非常熟悉的一个指标是帧率(frame per senconds, FPS),正如其名所示,帧率衡量的是一秒内程序渲染的图片数量。帧率更多地是程序方面的渲染速度指标,而硬件上的渲染速度则以刷新率衡量,单位为赫兹Hz,表示显示设备一秒内更新画面的次数。刷新率是固定不变的,当程序的渲染速度跟不上刷新率时,显示设备会多次更新同样的画面,而当帧的生成速度超过刷新率时,显示设备展现的帧数和刷新率相同。

图形管线

接下来我们从硬件的角度来粗略地介绍一下实时渲染的图形管线,总的来说我们可以将其分为四个大的部分,分别是应用(application)、几何处理(geometry processing)、光栅化(rasterization)和像素处理(pixel processing),这四大部分各自又有自己的管线结构:

应用阶段(Application Stage)

在四个阶段中,程序员只对应用阶段有完全的控制权,这是因为只有应用阶段是运行在CPU上的,而其他三个阶段都是运行在GPU上的,由于GPU是为图形处理而生的高度特化的硬件,我们对其运行没有完全的控制权。应用阶段的功能繁多,可以说所有不在GPU上完成的功能都在应用阶段解决了,例如在游戏中十分常见的碰撞检测,或者检测来自键盘、鼠标以及其他设备的输入。总的来说应用阶段是为后面的几何处理阶段准备输入的,也就是一系列将要被画在屏幕上的几何体。

我们可以从第一幅图看到这一阶段是没有子结构的,这是因为这一阶段发生在CPU中,大多数程序都是串行运行而非并行运行。应用阶段的一些功能也可以在GPU上运行,我们通过使用大多数图形学API都提供的compute shader功能来在GPU上运行串行程序。

几何处理(Geometry Processing)

几何处理是第一个在GPU上运行的阶段,这一阶段的处理对象主要是三角形顶点,几何处理又可以分为四个子阶段,分别为顶点着色(vertex shading)、投影(projection)、裁剪(clipping)和屏幕映射(screen mapping)

顶点着色阶段主要用于处理顶点属性,如顶点位置、颜色、向量、纹理坐标等等,而这个阶段被称为顶点着色是因为曾经着色是首先在顶点完成,然后再插值至顶点之间的片段的。接下来的投影阶段将顶点位置转换到标准视口,在进入裁剪阶段和最终的屏幕映射阶段之前,存在三个可选的针对顶点的处理阶段(按处理顺序列出):

  • 镶嵌(tessellation):在这个阶段,程序会根据需要以及输入的曲面生成顶点。镶嵌阶段的一个重要的应用就是简化模型,例如当物体距离摄像机较远时,大部分三角形的面积都相对较小,不值得我们耗费性能去渲染,因此这一阶段会相对应地减少生成的顶点数。
  • 几何渲染(geometry shading):几何渲染会根据输入的图元(顶点)信息生成新的图元,这一阶段的功能相对上一阶段来说更简单,其处理的区域和生成的图元类型都是十分有限的。几何渲染的经典应用之一就是生成粒子效果,这一阶段接收一个个单独的顶点然后在这个位置生成面向摄像机的正方形,将没有面积的点转化成二维的面。
  • 流输出(stream output):这一阶段我们可以选择性地将前面阶段输出的信息存储到一个数组当中,用于之后的处理,这一数组可以被CPU和GPU访问。

光栅化(Rasterization)

光栅化阶段只做了一件事——找到需要渲染的像素,然后将渲染的工作交给接下来的阶段:

光栅化分为两个子阶段:

  • 准备三角形(triangle setup):顾名思义,这一阶段负责将屏幕映射阶段输出的处于屏幕空间的零散的顶点集整理成一个个三角形,同时计算如边的直线方程等三角形相关的数据,然后交给下一阶段。
  • 遍历三角形(triangle traversal):这一阶段会找到所有认为被三角形覆盖的像素,至于如何认定“覆盖”则取决于编写渲染程序的程序员,然后为被覆盖的部分生成片段(fragment),交给像素处理阶段。

像素处理(Pixel Processing)

如上图所示,像素处理阶段分为像素着色(pixel shading)和合并(merging)阶段:

  • 像素着色(pixel shading):这一阶段是可编程的(后面将会介绍这一概念),和光栅化阶段运行在特化的数字电路上不同,程序员需要为这一阶段提供一个像素着色器(pixel shader),实现纹理映射、光照等需要在每个片段上分别计算的效果
  • 合并(merging):合并阶段会处理大量缓冲区,这些缓冲区存储了前面各个阶段的计算结果,合并阶段就负责将这些计算结果合并起来最终变成屏幕上的像素。上一个阶段计算出来的颜色值存储在颜色缓冲区(color buffer)中,物体的遮挡关系存储在深度缓存区(z-buffer)中。这些是我们已经熟悉的缓冲区,除此之外还有模板缓冲区(stencil buffer),这一缓冲区的内容是提前写好的,与渲染的各个阶段独立,模板缓冲区就像遮光板那样,会遮挡特定部分的像素,效果见下图。最后,在将合并后的值写入屏幕的输出缓存区时,如果我们使用单一缓存区,那么渲染的过程就会体现在屏幕上,造成画面撕裂,为了避免这种情况,一般我们会使用一个以上的输出缓存区,一个用于输出,剩下的在后台等待写入,写入完成后与前台正在显示的输出缓存区交换

The Graphics Processing Unit

最开始的图形程序都是运行在CPU上的,后来才逐渐出现了图形加速(graphics acceleration)硬件,最初的图形加速硬件只是帮助计算渲染中的插值以及将计算出来的像素值显示在显示设备上,其功能十分受限,且只能调整参数,而不能修改其行为,随后图形加速硬件的发展,才逐渐出现了GPU以及各种可编程硬件,图形学硬件的整体发展方向就是从可调参到可编程,如今我们甚至可以在GPU上做和图形学完全无关的事,如深度学习等。

应当记住,和CPU想要做许多事不同,如今的GPU虽然已经有了很高的可编程性,其仍然是一个专注于渲染的硬件,因此其组件自然也不会像CPU中的组件那样“通用”,我们可以在其中找到专门实现深度缓存的电路、专门访问纹理的电路、专门光栅化三角形的电路等等。

Data-Parallel Architecture

现代GPU通过将大量重复的渲染工作放在大量计算能力相对弱的计算单元中并行计算来提高渲染速度,上面通常有上千个被称为shading core的小型处理器。基于这些数量庞大的处理器群,GPU的工作是高度流水化的,可以说GPU就是上一节讨论的图形管线的具象体现,每一个被输入图形管线的几何体的处理流程都是相似的,因此我们可以用几乎相同的图形管线并行处理这些几何体。例如在光栅化阶段GPU可能同时处理上万个三角形,每一个三角形都需要光栅化,并且它们的光栅化都是彼此独立的,所以我们可以用大量功能相同的计算单元来并行处理这些工作。

当然,事情并不总是一帆风顺,当渲染器想要读取纹理等存储在内存里的数据时,由于这个时代的处理器速度普遍快于内存速度,GPU通常要花费相对较长的时间来访问这些资源,在算术计算只需要花费几个时钟周期的时候,内存读取却可能花费几千个时钟周期,如果此时发出内存读取请求的处理器只是在原地等待数据到来会造成极大的性能损失。

针对这一问题,我们讨论一种相对简单的策略:每当遇到内存读取时,就让处理器交换正在执行的内容,先去执行其他待执行的程序,这样在内存读取的这段较长的时间里GPU也没有闲着,我们通过保持GPU的运作来尽可能地提高性能。我们还可以更进一步,前面已经说过,GPU上通常有上千个小型处理器,而让它们各自为营,维护上千个程序流是不方便的,现代GPU会将执行相同程序的线程(thread)组织起来,采用单指令流多数据流(single instruction multiple data, SIMD)的策略达到一呼百应的效果。如此组织起来的线程就可以同步执行,当它们遇到内存读取时,就换上另一批线程继续运行。假设一组(warp,Warp 本质上是一组线程,这些线程在 GPU 上同时并行执行。在 NVIDIA 的 CUDA 架构中,一个 warp 包含 32 个线程)里有四个线程,同时处理四个fragment,渲染程序一共有五个部分,下面展示了这个简单模型渲染一个三角形的过程:

我们首先运行第一个warp中的线程,直到遇到txr指令中的内存读取:

交换到第二个warp,然后继续运行:

在第三个warp也遇到内存读取之后,如果第一个warp的数据仍未到达,那么我们只能在原地干等了,因为此时已经没有更多的warps等待我们执行了。最后在第一个warp的数据到达之后第一个warp运行结束,自然切换到第二个warp,然后再交换到第三个warp,运行结束。

从warp的组织方式可以看出,一个warp中包含有多少个线程是对性能有关键影响的,在寄存器数量给定的情况下,如果渲染器使用的寄存器越多,那么GPU中能同时存在的线程就越少,warp的数量也就越少,我们自然也就更容易陷入无warp可换的境地,像上图的例子一样,只能在原地干等数据到达。

另外一个对性能有显著影响的因素是分支(branching),渲染器中类似ifwhile这样的语句会造成分支,而对于一个warp中的不同线程,可能有的线程进入了某个分支,而其他的并没有,这时为了保证整体的同步,其他线程将不得不等待进入了分支的线程运行完成。

GPU管线

我们在前面已经介绍过抽象的图形管线,GPU厂商的任务就是制造出能够实现这一抽象管线的GPU硬件,而上图所示的GPU管线则是作为API提供给程序员的。可以看到图中的各个阶段被涂上了不同的颜色,分别代表可编程(绿色)、可调参(黄色)以及完全不可调整(蓝色)的阶段,作为程序员,我们会更多地将目光放在可编程的阶段上。

当然,GPU管线作为API自然不可以和实际的硬件实现混淆,GPU管线中的一个阶段可能是通过两个甚至更多个不同的部分完成的,之后的章节会介绍更多GPU硬件方面的知识。

Programmable Shader Stage可编程shader阶段

现代GPU中,各种着色器shader都使用同一个指令集,也就是说我们使用相同的语言编写vertex shader顶点着色, tessellaton shader, geometry shader几何着色以及pixel shader像素着色,同时这也意味着GPU可以使用相同的处理器运行所有类型的shader,这也使得上一节我们讨论的data-parallel architecture成为可能。我们可以使用HLSL(High-Level Shading Language)以及GLSL(OpenGL Shading Language)编写shader,它们的语法都与C语言类似,这也是为了方便厂商推广这些语言。

除去C中的基本数据类型、数组和结构体之外,这两个语言还有内置的浮点数向量类型和矩阵类型,用于表示图形学中常见的位置、法向量、颜色等数据。那些在图形学中常见的操作也在现代GPU中高效地实现了,比如常见的数学函数atan()sqrt()等,以及更加复杂的向量标准化,光照计算中常用的反射函数等函数均是内置在语言当中的。

shader在编译完成之后,就可以被运行在CPU中的主程序调用了,主程序会在每一次shader调用中指定这次调用将要绘制的一组图元。值得一提的是,为了提升性能,shader将输入分为了两种,一种是统一(uniform)的输入,其在这一次shader调用当中是不变的,例如光照计算中一个光源的位置就应该作为uniform的输入提供给shader;另外一种是变化的(varying),例如每个fragment的位置是随着fragment变化的。根据两种输入的性质差异,GPU的架构也因此做出了调整:uniform输入是可以用更少的寄存器以调用为单位存储的,而varying输入则需要更多的寄存器并以fragment或顶点为单位存储。除此之外,shader还需要临时寄存器来存储中间的运算结果、纹理寄存器来存储数量级相对大的纹理数据以及输出寄存器:

顶点着色器(Vertex Shader)

顶点渲染是API提供给我们的第一个可编程阶段,不过在此之前我们通常还需要为顶点着色器“组装”其输入,例如在DirectX中,input assembler会根据程序员提供的顶点的数据格式(如包含位置和颜色),将原始数据拼装成顶点着色器需要的顶点输入。顶点中可以包含各种各样不同的数据,我们可以统一称其为顶点属性。其中值得注意的是很多时候顶点中都会包含法向量数据,放着三角形所在平面提供的法向量不用而去定义新的法向量看起来是一件很奇怪的事,但人们这么做另有考量:渲染中我们为了尽可能高效地渲染更加精致的画面,通常会使用三角形网格来粗略表示真正想要渲染的曲面,我们通过修改各个fragment的法向量来达到这种近似的效果,如下图所示:

顶点着色器的主要功能就是将顶点从model space转换到homogeneous clip space,这一过程是独立的,每个顶点的坐标转换和其他顶点都没有关系,因此GPU可以并行执行大量顶点渲染。除此之外,顶点着色器还可以用于动画中的关节点计算、物理模拟以及实现其他各种神奇的效果。

镶嵌阶段(The Tessellation Stage)

镶嵌阶段的输入是以控制点(control point)描述的曲面,也即经过顶点着色器处理的各个控制点,输出则是基于输入曲面形状生成的三角形网格。使用镶嵌阶段生成三角形网格有几个相当明显的好处,首先使用关键点描述的曲面的数据量远少于三角形网格,这不仅节省了内存,还省出了CPU和GPU之间宝贵的带宽;同时,我们在生成三角形网格的时候还可以控制渲染的密度,为距离摄像机更近的曲面生成更多数量的三角形,而远离摄像机的曲面则生成较少三角形,进一步提升了之后的渲染阶段的效率。

镶嵌阶段分为三个子阶段,分别是hull shader, tessellator和domain shader(DirectX),或者说tessellation control shader, primitive generator和tessellation evaluation shader(OpenGL).

第一个子阶段首先确定曲面的形状,处理过程中可能添加或者删除控制点,并将处理后的信息发给后面的两个子阶段;第二个子阶段则像在平板上刻出凹槽那样,为第三个子阶段的“弯折”做准备;第三个子阶段将第二个子阶段处理后的平板“掰”成类似输入曲面的形状。

上图中hull shader输入给tessellator的所谓type其实是告诉第二个子阶段要生成的图元的类型,有三角形、四边形以及线段(用于毛发的渲染),而tessellation factors(在OpenGL中称为tessellation level)则指导tessellator如何生成图元,其包含inner和outer两种类型的数据,分别指导如何在平板的内部和边缘进行“刻蚀”,这一数据是hull shader根据距离、屏幕大小等信息计算出来的,其和图元的数量直接相关。以一块方形的版为例,其在OpenGL中的tessellation level如下:

gl_TessLevelInner = { 4, 5 };
gl_TessLevelOuter = { 2, 3, 2, 4 };

那么tessellator处理这块方形的版的过程可以分为以下几步:

  1. tessellator得到这个四边形的四个边作为输入
  2. 四边形的内部首先根据gl_TessLevelInner切割为 4×5=20 块
  3. 不在边界上的所有块先被切割成三角形
  4. 四边形的四条边再根据gl_TessLevelOuter分别切割为2, 3, 2以及4个子线段
  5. 将步骤2和步骤4产生的点彼此相连,将外围三角形化

hull shader通过给tessellator发送非正的outer tessellation level告诉后者放弃这块“版”。最后来自hull shader和tessellator的输入进入domain shader,后者根据这些输入生成需要的顶点,domain shader的输出格式与vector shader基本如出一辙。

几何着色器(The Geometry Shader)

几何着色器可以将一个图元转化为一个或多个其他图元,其输入为一个物体以及其相关的顶点,输出为零个或多个顶点。几何着色器主要是用于修改物体的顶点信息以及复制顶点信息的,例如几何着色器的一个应用是根据一张纹理复制出五个面并进行变换得到cubemap,另外一个应用则是生成粒子效果,输入顶点生成有面积的图元。

几何着色器保证了其输出的顺序和输入的顺序相同,这是其性能的一大负担,并行运行的多个几何着色器的结果必须被存储起来然后按顺序输出。这使得使用几何着色器进行大量的几何体复制变得不现实。实践当中几何着色器也因为其相对孱弱的性能很少被使用。

流输出(Stream Output)

经过几何着色器以及后续两个可选的阶段之后,处理完毕的顶点信息可以被选择性地输出到其他地方。实际上我们可以完全不理会随后的管线阶段,将顶点信息的结果输出而不传递给裁剪阶段。这一阶段在流体以及布料的模拟中发挥了重要的作用。

流输出的数据只会以浮点数的格式返回,因此其有不小的内存开销。

像素着色器(The Pixel Shader)

在经过裁剪等三个阶段之后,顶点被转换到了屏幕空间,并且其依附的图元覆盖到的像素(可能还有覆盖的面积)被找出并输入给像素着色器。此时输入给像素着色器的像素(或者说fragment)数据中包括原顶点的所有属性,这些属性默认是使用透视矫正插值(perspective-correct interpolation)得到的,也即在世界空间插值,大部分图形学API同样给了使用其他种类的插值的选项。

像素着色器可以弃置输入的像素,也即不进行处理。我们可以通过这一特性来实现一些特殊的裁剪效果,裁剪通常来说是在裁剪阶段完成的,这是个不可编程的阶段,因此其效果较为受限,而使用像素着色器,我们可以实现任何我们想要的裁剪效果。

在输出端,像素着色器可以输出给多个目标,而不止是合并阶段,这被称为多渲染目标(multiple render targets, MRT):对每个输入的像素,像素着色器都会输出多组值并保存在不同的缓存区中,此时的一个缓存区就被称为一个渲染目标。有了这一机制,我们就可以同时输出许多有用的信息而不必重复调用像素着色器。

多渲染目标使得另一种渲染管线的实现成为可能:deferring shading,目前我们在讨论的渲染管线是forward shading。deferring shading和forward shading最大的区别在于,在forward shading中,对每一个像素我们是同时处理其遮挡关系和着色的(在像素着色器中);而在deferring shading中我们进行两次遍历,第一次遍历将物体的位置以及其他计算光照所需的信息按照遮挡关系写入缓存区中,第二次遍历使用上一次遍历存储的信息计算光照以及实现其他效果。后面将会详细地介绍这一渲染管线。

像素着色器的一大问题是其没有一个合法的方法访问邻近像素的信息,因为每个像素都是单独处理的,其输入只在这一次调用中可用,其输出也只会影响这一个像素。为了访问邻近的像素数据,一个解决办法是进行多次遍历。另外,所有现代GPU在纹理过滤时都会一次访问 2×2 个像素,这是为了计算纹理坐标的梯度,这也是访问邻近像素的一个解决方案,一次访问多个像素:

为了一劳永逸解决这一问题,DirectX 11引入了一种被称为unordered access view(UAV)的缓存区,在OpenGL4.3中有着类似功能的缓存区被称为shader storage buffer object(SSBO),它们是一类被所有运行的像素着色器共享的可以随机访问的缓存区。它们作为并行程序的共享内存区域自然存在数据冲突的问题,针对这一问题,GPU内置了一类只能执行原子操作的存储设备。

渲染中的许多算法是对运行顺序有严格要求的,例如对于两个半透明的物体,我们必须先渲染距离更远的物体,然后再渲染更近的物体。Rasterizer order views(ROV)作为UAV的加强版解决了这一问题,其可以像UAV一样读写,同时保证数据的访问是按照一定顺序的。

合并阶段(The Merging Stage)

这一阶段用于处理物体的遮挡关系以及合并同一个位置上的多个像素值,但我们不能完全仰仗这一阶段。任何在这个阶段因为深度测试或其它类似的遮挡测试失败而被抛弃的像素值都意味着在此之前的所有阶段的计算是被浪费掉的,我们可不能这么奢侈,为了减少可能的无用功,许多GPU会在像素着色器运行之前先进行一次深度测试。

图形学API一般会要求合并阶段按照输入的顺序进行输出。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值