0%

Real-Time Rendering 4th Edition学习笔记(二)

Chapter 3: 图形处理单元(Graphics Processing Unit, GPU)

用来操控GPU的主要手段是着色器(Shader)。GPU专注于并行执行一些特定任务,如设置各种buffer数据、读取贴图、光栅化等。

数据并行架构(Data-Parallel Architectures)

为了解决延迟和阻塞问题,GPU采用了和CPU不同的方案。GPU的大部分芯片面积上通常排布着上千个叫做着色器核心(shader cores)的处理器。它可以大规模地并行处理相似数据(比如一组顶点或像素信息)。这些任务尽可能地相对独立,不依赖于其他任务的结果,也不和其他任务共享可写内存地址。有时候为了实现特殊功能它们也可能互相依赖,但这么做可能导致延迟。

GPU为吞吐量(throughput) - 也就是处理数据的最大速率 - 进行了优化。这种优化的代价是减小了专门缓存内存和处理逻辑的芯片面积,单个着色器核心的延迟会比CPU高很多。

假设现在有一个物体完成了光栅化步骤,有2000个fragment需要进行处理,这意味着fragment着色器程序需要被调用2000次。假如现在有一个仅含1个着色器处理器的GPU,它开始处理2000个fragment中的第一个fragment。它首先对寄存器(registers)内的数据做了一些算术运算,由于寄存器内的数据读取很快,这些运算并没有产生阻塞。接下来,它接收到了一个读取贴图的指令。由于贴图不在寄存器中而在内存中,处理器需要花成百上千的时钟周期(clock cycles, CPU工作的最小时间单位)来读取内存中的贴图数据。在获取到贴图颜色之前,处理器什么都没做,处于阻塞状态。

为了优化上述流程,我们给每个fragment的本地寄存器分配一点存储空间。当处理器阻塞在读取第一个fragment的贴图颜色时,它可以切换到处理第二个fragment的任务上来。这个过程很快,只需要记录第一个fragment正在执行什么指令即可。处理器继续做一些算术运算,读取第二个fragment的贴图颜色,在阻塞时切换到第三个fragment的任务……如此循环,直到读取第2000个fragment的贴图颜色。这时处理器回到第一个fragment的任务,此时它的贴图颜色已经读取完成,处理器可以进行后续的任务了。

在上述流程中,我们通过切换到其他fragment来降低了延迟。GPU在这种设计上更近一步,将执行指令逻辑和数据分离,在多个处理器上同时处理一个指令,这种技术被称为单指令流多数据流(single instruction multiple data, SIMD)。

回到上面2000个fragment的例子,为每个fragment调用的shader程序被称为一个线程(thread),它包含了shader输入数据占用的内存和执行命令需要用到的本地寄存器。使用相同shader的线程会被打包成warps(NVIDIA)或wavefronts(AMD),一个warp/wavefront会被分配一定数量(8-64个)的着色器核心用SIMD方式来执行,每个线程对应一个SIMD通道(SIMD lane)。我们现在有2000个线程,在NVIDIA的GPU上,一个warp包含32个线程,因此将产生2000/32=62.5个warp,也就是分配63个warp,其中一个一半是空的。一个warp的执行方式和上面的例子类似,它在32个处理器上同时运行同样的shader程序,这就意味着当一个线程需要读取内存时,其他线程也同样需要读取内存,这时就会切换到另一个32线程的warp。每个warp有自己的寄存器来记录正在执行什么指令,在warp间切换没有其他开销。

由于切换warp的开销实在太小了,它成为了GPU降低延迟最主要的手段。上述例子中我们在读取内存时才切换warp,而在实际使用中,warp的切换会在更短的延迟时就被触发。

有几个因素会影响整个流程的效率,常见的有:

  • 如果线程数很少,那warp也会比较少,切换warp就不会很频繁
  • shader怎么写也会极大影响效率,其中一个重要因素是单个线程使用的寄存器大小。单个线程使用的寄存器越大,GPU中能驻留的线程数就越少,意味着warp就越少,越不能通过切换warp降低延迟。GPU中正在驻留的warp被称作"in flight",它们的数量叫做占有率(occupancy)。高占有率意味着更多warp正在被执行,闲置的处理器较少;低占有率往往意味着运行效率低下。
  • 读取内存的频率会影响需要多少warp切换。
  • 由if或循环语句带来的动态分支。如果所有线程都只使用了同一个分支,没有任何问题;但是如果少数几个甚至只有一个线程用到了其他分支,那整个warp都必须把用到的分支都执行一遍,再抛弃单个线程不需要的结果,导致线程闲置。这被称为线程分散(thread divergence)问题。

GPU管线概述

第二章中,我们介绍了概念上的渲染管线。但我们的目的是提高性能,因此在具体实现它的管线中,我们使用的逻辑模型以GPU暴露的API接口为基础,不同阶段有不同的可控程度。

Vertex Shader阶段用来实现概念上的几何处理阶段,它完全可控。Tessellation和Geometry Shader是完全可控但是非必须的,在很多移动设备的GPU上都不可用。

Clipping和Triangle Setup、Traversal阶段不可控制,由硬件的固定方法实现。

Screen Mapping取决于窗口设置。

Pixel Shader阶段是完全可控的。

Merger阶段可以配置很多操作,它负责实现第二章中的像素合并阶段,包括修改颜色、z-buffer、blend、stencil和其他buffer。它和Pixel Shader一起实现了第二章中的像素处理阶段。

可编程着色器(The Programmable Shader)

现代着色器程序拥有一样的指令集结构(instruction set architecture, ISA),与之对应的处理器在DirectX中被称为通用着色器核心(common-shader core)。

着色器的编程语言与C语言类似,如DirectX使用的HLSL(High-Level Shading Language)和OpenGL使用的GLSL(OpenGL Shading Language)。HLSL可以被编译为虚拟机字节码,又称中间语言(intermediate language, IL或DXIL),再被GPU的驱动转换为对应的ISA。主机项目通常会避免使用中间语言,因为整个系统只有一种ISA。

Shader中最基本的数据类型是32位单精度浮点矢量和向量。现代GPU原生支持32位整数和64位浮点数。浮点向量通常包含如坐标(xyzw)、法线、矩阵、颜色(rgba)、贴图坐标(uvwq)等数据。

一个draw call会调用图形API来绘制一组图元,并运行着色器程序。任何可编程着色器都包含两种输入内容:

  • 统一输入(uniform input):在单次draw call中保持不变,如光源信息
  • 可变输入(varying input):来源于三角形顶点或光栅化的数据,如三角形表面顶点的位置信息

底层虚拟机为不同类型的输入和输出数据提供了特殊的寄存器。由于可变输入需要为每个顶点和像素单独存储数据,它可用的寄存器比统一输入少很多。此外,还有用于暂存空间的通用临时寄存器。所有类型的寄存器都可以使用临时寄存器中的整数值进行数组索引。

流控制(flow control)是指实现高级语言中如if else和循环等语法的指令,分为:

  • 静态流控制(static flow control):根据统一输入进行分支处理,在同一次draw call中分支固定,没有线程分散问题
  • 动态流控制(dynamic flow control):根据可变输入进行分支处理,功能更强大,但消耗性能。

可编程着色器和API的发展(略)

顶点着色器(Vertex Shader)

尽管vertex shader是本章介绍的管道中的第一个阶段,但在它之前已经发生了一些数据操作,这个过程在DirectX中被称为输入汇编(Input Assembler)。例如,一个物体可以用一个位置的数组和一个颜色的数组来表示,那么Input Assembler会创建包含顶点坐标和颜色的数据来传入到Vertex Shader。Input Assembler还支持实例化(Instancing)操作,即在一次draw call中为包含不同数据的同一个物体绘制多次

一个三角形网格由一组顶点数据来表示,这些数据包括每个顶点的坐标、颜色、UV坐标、法线向量等。从数学角度来看,一个三角形的法线向量由这个三角形本身的朝向决定,和顶点没什么关系。但在实际渲染中,三角形网格通常用来表示一个曲面,法线向量是用来描述这个曲面的朝向,而非三角形本身的朝向。

Vertex Shader并不知道输入的顶点组成的是什么样的三角形,它的作用只是对输入的顶点进行逐一操作 - 通常会将顶点坐标从模型坐标转换成裁剪坐标,这也是它最起码要输出的结果。它无法新建或删除顶点,一个顶点的输出结果也无法作为其他顶点的输入数据。

后续章节会讲到一些Vertex Shader的效果,如关节动画的顶点混合、渲染轮廓等。Vertex Shader还有其他一些作用,如:

  • 生成一个基础网格,将其变形以生成不同的物体
  • 使用蒙皮和变形技术实现角色的身体和脸部动画
  • 程序化变形,如旗子、衣服、水的动画
  • 生成粒子,方法是先生成一个没有面积的退化网格(Degenerate Mesh)传入到后续管线,在需要的时候给予它面积
  • 镜头扭曲、热浪、水波纹、页面卷曲等效果,方法是将整个帧缓存作为贴图用在和屏幕一样大小的网格上并进行变形
  • 通过地形高度图数据对网格进行变形

曲面细分(Tessellation)

Tessellation允许我们渲染曲线表面。GPU会将一个表面信息转化为可以代表这个表面的一组三角形。这个功能需要至少DirectX 11、OpenGL 4.0和OpenGL ES 3.2。

使用Tessellation有几个好处。对曲线表面的描述通常比对应的三角形更严谨。除了节省内存,这个功能可以确保当渲染每一帧都在改变形状的物体时,CPU和GPU之间的数据传输不会成为瓶颈。比如,当一个球离相机很远时,我们只需要几个三角形;当它靠近时,可能需要几千个三角形效果才会好。这种控制细节层次(Level of Detail, LOD)的方法也可以用在应用阶段来控制性能,比如在较弱的GPU上使用面数较少的模型来保持帧率。

Tessellation总是包含三部分。在DirectX中,它们是外壳着色器(Hull Shader)、曲面细分器(Tessellator)和域着色器(Domain Shader)。在OpenGL中它们分别对应细分控制着色器(Tessellation Control Shader)、片元生成器(Primitive Generator)和细分评估着色器(Tessellation Evaluation Shader)。

Hull Shader

输入Hull Shader的数据叫做面片(Patch Primitive),它包含多个控制点定义的细分表面、Bézier patch、或其他格式的曲线元素。Hull Shader有如下作用:

  • 告诉Tessellator需要生成多少个三角形,配置是什么
  • 对每个控制点执行操作
  • (可选)修改输入的patch描述,增减控制点

输出

  • patch:一组控制点的坐标。如果外部细分级别(Outer Tessellation Level)设置为≤0或n/a,则舍弃此patch
  • 细分表面类型:三角形、四边形还是等值线(isoline)。等值线有时用来渲染头发。
  • 细分系数(Tessellation Factors),在OpenGL中称为Tessellation Levels
    • 内边缘系数:两个,决定了在三角形或四边形内部产生多少细分
    • 外边缘系数:决定了外边缘被拆分成多少段,确保相邻曲面的边缘可以匹配

Tessellator

固定方法,无法编程,作用是根据hull shader的输出生成新的顶点。

输出:新生成顶点的质心坐标(Barycentric Coordinates)

质心坐标:三角形内一点P可以把三角形分割为三个子三角形,这三个子三角形与大三角形的面积比,即三个顶点对P点的值贡献的权值。通过这个坐标可以获取新生成的顶点相对于原本三角形三个顶点的位置。

Domain Shader

通过质心坐标和patch的计算方程生成每个顶点的相关信息,如坐标、法线向量、UV坐标等。

几何着色器(Geometry Shader, GS)

GS可以将图元转换成其他图元,比如通过给三角形的每个边生成新线条,可以将其变成线框;如果将这些新线条替换成针对观察者的四边形,那这个线框就会有描边。这个功能需要至少DirectX 10、OpenGL 3.2和OpenGL ES 3.2。

GS仅仅对输入的图元信息进行修改,而不会生成新的输出格式。在DirectX 11中,GS可以使用Instancing。GS也可以输出4个stream,它们都可以被输出到Stream Output阶段,其中一个还可以被传到后续的渲染管线做进一步处理。

GS要确保输出时的图元顺序和输入时一样,当多个着色器核心并行工作时,必须将结果保存,在所有结果出来后再进行排序。这个特性注定了GS无法胜任在单次draw call中创建大量顶点的工作。在一次draw call中,只有三个渲染阶段能让GPU创建新任务:光栅化、曲面细分、GS。其中,GS对于资源的消耗是最无法预测的,因为开发者对它完全可控。因此在实际运用中很少会用到GS。

流输出(Stream Output)

在传统的渲染管线中,中间数据是无法被读取的。Shader Model 4.0引入了Stream Output的概念,在Vertex Shader(或可选的Tessellation、GS阶段)后,顶点数据除了被输出到后续光栅化阶段外,还可以被输出到一个流(stream)中,也就是一个有序数组。事实上,光栅化阶段可以完全被关闭,整个管线就变成了一个非图形相关的流处理器。相关的数据可以被传回到管线前段进行交互,这在模拟水流或其他粒子效果、复用蒙皮数据时都很有用。

Stream Output输出的数据格式只有浮点数,因此对内存消耗比较大。

像素着色器(Pixel Shader)

每个像素的z-value、颜色等值,都由它所在三角形的三个顶点的值插值而来。使用哪种插值方法由Pixel Shader决定,通常使用透视矫正插值(Perspective-Correct Interpolation)。也有使用屏幕空间插值(Screen Space Interpolation)的,不考虑透视的影响。

除了Vertex Shader的输出会变成Pixel Shader的输入外,现代GPU也使得Pixel Shader能获得更多的输入信息,如Shader Model 3.0之后,Pixel Shader能获得fragment的屏幕坐标。三角形哪个方向可见也可作为输入信息。

Pixel Shader还可以舍弃(不渲染)输入的fragment。

随着Pixel Shader能执行的指令数量变多,多重渲染目标(Multiple Rendering Target, MRT)的概念出现了。除了将fragment的信息输出到color buffer和z-buffer外,还可以将其他信息输出到其他buffer,每个buffer叫做一个渲染目标(render target)。render target的x、y尺寸通常相同,有些API也允许不同尺寸,但是渲染结果的尺寸取决于最小的一个render target的尺寸。有些架构还要求render target的位深(bit depth)相同,甚至数据格式也要相同。不同的GPU允许不同的render target数量,要么是4个要么是8个。

MRT限制很多,但功能依然很强大。在一个渲染通道中,我们可以在一个render target中生成颜色,一个render target中生成物体标识符,另一个中生成世界空间内的距离。它也衍生出了另一种渲染管线,即延迟着色(Deferred Shading)。在这个管线中,第一个通道会先存储每个像素对应物体的坐标和材质信息,后续通道可以更高效地处理照明和其他效果。

Pixel Shader的局限性在于它只能计算一个像素自己的信息,而无法获取其他像素的信息。但是这个问题并不致命,因为一个通道输出的图像数据可以被后续的通道读取,相邻像素的数据也可以下面的方法来获得:在计算梯度或导数时,Pixel Shader可以间接地读取相邻的fragment信息。在进行纹理过滤(Texture Filtering)等操作时,Pixel Shader会用到某个插值在相邻两个像素沿x、y轴方向的差值,它在现代GPU中的实现方式是将相邻2x2的4个fragment打包成一个quad。当Pixel Shader请求一个梯度值时,就能获得相邻像素和当前像素的差值。为了使一个quad中4个像素的梯度值都有意义,必须保证它们的运算使用相同的指令集,即运行在同一个warp中。这也就导致了在dynamic flow control(带有分支循环且顶点数据变化的语句)中无法获取梯度值。

DirectX 11中引进了允许多个GPU线程读取和写入的buffer类型,叫做无序访问视图(Unordered Access View, UAV),最初只能用于Pixel Shader和Compute Shader,在DirectX 11.1中被扩展到所有Shader类型。同样的概念在OpenGL 4.3中被称作Shader Storage Buffer Object(SSBO)。由于Pixel Shader是并行无序运行的,需要有一种机制来避免数据竞争(Data Race Condition / Data Hazard)。例如,同一个像素有两次Pixel Shader在几乎同一时间被调用,它们读取了同样的数据,但经过计算返回不同的结果,那必然是后写入的数据覆盖掉先写入的数据。GPU采用原子操作(Atomic Operation)来避免这个问题,但这也意味着某个Pixel Shader会在等待其他线程读写数据时进入闲置状态。

Atomic Operation:引申自原子(Atomic)的概念,表示不可被拆分的操作,也即某个线程一旦开始,就不会被线程调度机制切换到其他线程,直到运行完成。其实现机制简单概括就是当一个线程在使用某个内存地址时,将该内存地址的拥有者标记为该线程的ID,此时其他线程就无法对该内存地址进行操作。

除了原子操作外,很多算法都要求有一个特定的执行顺序。假设我们需要在远处一个半透明的蓝色三角形前面再叠加一个红色半透明三角形,可能会出现这样的错误:同一个像素为两个三角形分别同时调用了一次Pixel Shader,而红色三角形的计算先完成了(导致蓝色三角形在前,覆盖红色三角形)。在传统的渲染管线中,fragment的结果会在后面的像素融合阶段先进行排序再进行处理。DirectX 11.3引入的Rasterizer order views(ROVs)就是用来强制排序执行顺序的。它和UAV类似,不同的地方在于它可以保证数据被有序访问。因此,Pixel Shader可以包含自带的叠加方法,不再需要像素合并阶段,代价就是当检测到无序访问时,Pixel Shader只能等到前面绘制的三角形完成之后才能继续运行。

像素合并阶段(Merging Stage)

这一阶段就是每个像素的深度值和颜色值与framebuffer合并的阶段。这一阶段在DirectX中被称为输出合并(Output Merger),在OpenGL中被称为逐样本操作(Per-sample Operations)。在大多数传统渲染管线中,这一阶段会进行stencil-buffer和z-buffer的操作。如果fragment可见,则还会进行颜色混合。事实上对于不透明表面,颜色并不会进行混合,而是被新颜色替换,只有半透明的物体才会涉及颜色混合。

如果有一个fragment已经运行了Pixel Shader,但在应用z-buffer时发现它被其他物体挡住了,那它之前跑的进程就都浪费了。为了避免这种情况,很多GPU会在Pixel Shader之前就做一些合并测试。一个fragment的z-depth会被用来测试其可见性,如果它不可见,那它就会被剔除(culled)。这种测试方法被称为early-z。Pixel Shader可以修改一个fragment的z-depth值或完全丢弃一个fragment,但Pixel Shader中一旦出现这种操作,就无法使用early-z,导致渲染管线效率降低。DirectX 11和OpenGL 4.2都允许强制打开early-z,虽然会有一些额外限制。合理使用early-z可以大幅提高性能。

这一阶段尽管无法编程,但是可配置度很高,尤其是颜色的混合模式,可以通过对颜色和透明度的加、减、乘操作排列组合出很多效果。还有其他诸如最大最小值、位运算等操作。DirectX 10加入了将Pixel Shader中的颜色和framebuffer中的颜色混合的功能,被称为dual source-color blending,但它不适用于MRT。但MRT确实支持颜色混合,并且在DirectX 10.1中加入了针对不同buffer使用不同混合操作的功能。

根据API的要求,输入到像素合并阶段的数据顺序必须和输入Pixel Shader时一致。

3.10 Compute Shader

除了传统的渲染管线,GPU还可以用在其他非图形学的地方,如估算股票期权价值、训练神经网络等。这种用法被称为GPU计算(GPU Computing)。CUDA或OpenCL等平台可以将GPU作为大型的并行处理器使用,无需用到GPU图像相关的功能。

DirectX 11中引入了Compute Shader作为一种GPU Computing的形式。它并不被限死在渲染管线的某个特定阶段。它由图形API调用,可以和Vertex Shader、Pixel Shader等一起使用,使用的着色器处理器也和它们一样,同样拥有输入数据集,能访问输入和输出的缓冲区(如贴图)。Warp和Thread的概念在Compute Shader中更明显,比如每次调用它都会返回一个可读取的线程索引。此外,还有一个线程组(Thread Group)的概念,它在DirectX 11中包含1-1024个Thread。这些Thread Group由x、y、z三个坐标表示,以方便在Shader代码中使用。每一个Thread Group都有一块可供其中Thread共享的内存,在DirectX 11中为32kB。Compute Shader以Thread Group为单位运行,一个Thread Group中的所有线程都同时运行。

Compute Shader的一个优势是可以直接访问GPU生成的数据。由于GPU向CPU传递数据会有延迟,直接在GPU上处理和保存数据能显著提高性能。Compute Shader的一个常见用法是后处理(Post-processing),即用某种方式修改渲染得到的图像。由于存在共享内存,临时的图片采样数据就可以和其他线程共享。经测试,使用Compute Shader计算图片的分布/平均照明信息比用Pixel Shader快一倍。

Compute Shader在粒子系统、网格处理(如脸部动画)、剔除、图像过滤、提高深度精度、阴影、景深等方面都很有用。