《Real-Time Rendering》3. The Graphics Processing Unit

GPU 从一个可配置的复杂固定功能管线,发展为了一个高度可编程的“白板”

各种可编程的着色器(shader)是控制 GPU 的主要手段

为了获得更高的效率,渲染管线中的有些部分仍然只是可配置的,而并非是可编程的,但是 GPU 的整体发展趋势是可编程性和灵活性

GPU 专注于一组高度并行化的任务,从而获得了很高的处理速度,使用专门的硬件来执行某些特定的任务

数据并行结构

GPU 与 CPU 不同,GPU 芯片中的很大一片面积是处理器,也叫做着色器核心(shader core),GPU 中通常有数千个着色器核心

GPU 是一个流处理器,它会依次处理有序的相似数据。由于数据的相似性(如一组顶点或像素),它可以通过大规模并行的方式来处理这些数据

而且,这些着色器调用都是尽可能独立的,不需要其他调用的信息,也不需要共享内存。有时候为了使用一些新功能,这个规定会被打破,可能带来额外的延时(需要等待另一个处理器的结果)

GPU 专门对吞吐量(throughput,指数据能够被处理的最大速度)进行了优化,但是有代价,每个着色器核心的延迟通常比 CPU 更大

GPU 可以将指令执行的逻辑与数据分离开来,这种设计叫做单指令、多数据(single instruction,multiple data,SIMD),这种设计会在固定数量的着色器程序上,以一个固定的步长来执行完全相同的指令

以现代 GPU 的术语来说,每个片元的像素着色器调用都可以被称为一个线程(thread),但是与 CPU 上不同,它还包括用于存储着色器输入数据的存储空间,以及用于着色器执行的任何寄存器空间。这些使用相同着色器程序的线程会被打包成组,被称为一个 warp(NVIDIA)或者 wavefront(AMD),一个 warp/wavefronts 负责调度一定数量的 GPU 处理核心,可能是 8 到 64 个,并且都会使用 SIMD 处理。每个线程都会被映射到一个 SIMD 通道(SIMD lane)

理解:

  • 每个线程拥有自己的存储空间和寄存器空间,每个处理器可以运行任何的线程,处理器并没有自己的存储空间
  • 在 warp 之上还有 warp 调度器,用来调度 warp 的执行

下面是一个例子:

img

其中,有一个程序:madmultxrcmpemt,其中 txr 指令需要从内存中读取纹理,此时会进入停滞状态

每 4 个线程被分为一个 warp,当一个 warp 进入停滞状态时,会切换到另一个 warp 进行执行(切换的成本非常低)

着色器程序的结构会影响执行效率:每个线程使用的寄存器越多,能同时存在的 warp 就越少

if 语句和循环语句可以导致动态分支(dynamic branching),这也会影响执行效率:每个 warp 的不同线程可能进入不同的分支,由于要保证其中的线程同步,这个 warp 就必须把所有分支都执行一遍。这个问题叫做线程发散(thread divergence)

所有的 GPU 实现都应用了这些架构思想,虽然这样会导致系统具有严重的限制

GPU 管线概述

第二章描述了概念上的几何处理阶段、光栅化阶段和像素处理阶段

GPU 则在硬件上实现了这些阶段,它们被划分为了几个不同的硬件阶段,每个阶段都有着不同程度的可配置性和可编程性:

img

其中,绿色表示完全可编程,黄色表示可配置但不可编程,虚线表示可选(并不是所有 GPU 都支持曲面细分和几何着色器这两个可选阶段,尤其是移动设备上的 GPU)

这些物理阶段的划分与第二章中所示的功能阶段有所不同

这里所描述的是 GPU 的逻辑模型(logical model),它通过图形 API 的形式暴露给程序员,这个逻辑模型的具体实现,即物理模型(physical model),取决于硬件供应商

可编程着色器阶段

现代的着色器程序都使用了统一的着色器设计,这意味着顶点着色器、像素着色器、几何着色器以及与曲面细分相关的着色器,都共享一个通用的编程模型,它们内部的指令集架构(instruction set architecture,ISA)都是相同的

这种架构背后的思想是,这些着色处理器可以用于执行很多的任务,而 GPU 可以视情况来对这些处理器进行分配

一次 draw call 会调用图形 API 来绘制一组图元,渲染管线也会相应执行它所对应的着色器。每个可编程的着色器阶段都包含两种类型的输入:

  • 统一输入(uniform input):在一次 draw call 中不会发生改变的常量
  • 可变输入(varying input)

底层虚拟机为不同类型的输入提供了不同的寄存器:

  • 常量寄存器(constant register)
  • 可变寄存器
  • 通用临时寄存器(temporary register):用于临时存储

其中,常量寄存器数量远大于可变寄存器

所有类型的寄存器,都可以使用存储在临时寄存器上的整数数值,来进行数组索引

着色器虚拟机的输入输出如下所示:

img

这是 Shader Model 4.0 的标准。其中,每个部分旁边都显示了最大的可用数量,从左到右的数字分别代表顶点着色器、几何着色器和像素着色器的限制

图形计算中的常见操作和运算都可以在现代 GPU 上高效执行:操作符(+* 等)、内置函数(intrinsic function,如 atan、向量标准化等)

着色器支持两种类型的流程控制(flow control):

  • 静态流程控制(static flow control):分支情况基于统一输入的值,在一次 draw call 中代码的流程恒定不变
  • 动态流程控制(dynamic flow control):基于可变输入的值,功能更强大,但是也更消耗性能

顶点着色器

顶点着色器是之前描述的功能流水线的第一个阶段,在进入这个阶段之前,就已经存在一些数据计算了。这在 DirectX 中叫做输入汇编器(input assembler),几个数据流被编织在一起,形成了顶点集合和图元集合

在输入汇编器中还支持实例化(instancing),允许一个物体可以在每个实例中,使用不同的数据的来进行绘制

一个三角形网格由一组顶点构成,除了位置之外,每个顶点上还具有一些其他可选的属性,例如表面法线。

在渲染过程中,三角形网格通常会被用来表示一个潜在的曲面(例如曲面细分),而顶点法线则被用来表示这个曲面的朝向,而不是这个三角形网格本身的朝向

img

顶点着色器只会对顶点进行处理!顶点着色器无法创建或者销毁顶点,也不知道点和点之间的关系,并且每个节点的计算结果不能互相传递

顶点着色器的用途:

  • 动画关节的顶点混合
  • 轮廓渲染(描边)
  • 物体生成:仅创建一次模型,并通过顶点着色器对其进行变形
  • 使用蒙皮技术和变形技术来设置角色的身体动画和面部动画
  • 程序化变形:例如旗帜、布料和水面的运动
  • 粒子创建:通过向流水线发送简并(无面积)网格,并根据需要来设定它们的位置,从而来模拟粒子效果
  • 透镜畸变、热雾、水波纹、书页卷曲以及其他特效,可以通过将整个帧缓冲的内容作为一个纹理,然后将其应用在一个正在经历变形,并且屏幕对齐的网格上进行实现
  • 通过使用顶点纹理来获取并应用地形的高度场

曲面细分阶段

曲面细分阶段允许我们绘制曲面,GPU 的任务就是将每个曲面描述都转换成一组三角形

好处:

  • 节省内存
  • 防止 CPU 与 GPU 之间的总线带宽成为程序的性能瓶颈
  • 根据与相机的距离生成不同数量的三角形,从而提高性能,简单来说就是可以控制层次细节(level of detail)

曲面细分阶段同样包含三个子阶段:

  • 壳着色器(hull shader,DirectX)或细分控制着色器(tessellation control shader,OpenGL)
  • 曲面细分器(tessellator,DirectX)或图元生成器(primitive generator,OpenGL)
  • 域着色器(domain shader,DirectX)或细分评估着色器(tessellation evaluation shader,OpenGL)

壳着色器

壳着色器的输入是一个特殊的面片(patch)图元,它包含了若干个定义细分表面、Bezier 面片、以及其他类型曲线元素的控制点

壳着色器包含两个功能:

  • 告诉细分器需要生成多少个三角形,以及如何对它们进行配置
  • 对每个控制点进行处理,可以选择对输入的面片进行修改,根据要求添加或者移除一些控制点

壳着色器会将处理好的控制点和曲面细分相关的控制数据一起发送给域着色器,如图所示:

img

曲面细分器

曲面细分器是一个固定功能的阶段,并且只用于曲面细分着色器

它的任务是添加新的顶点,并发送给域着色器进行处理

壳着色器会发送给曲面细分器一些信息和参数:

  • 细分曲面类型,如三角形、四边形(quadrilateral)还是等值线(isoline,一组线条,有时候也会用于头发渲染)
  • 曲面细分因子(tessellation factor)或曲面细分等级(tessellation level,OpenGL),它有两种类型:
    • 内因子:有两个,它决定了在三角形或者四边形内部进行细分的次数
    • 外因子:决定了三角形或四边形的每条边应该被细分成多少段,如果该参数是 0 或者 NaN,那么表示丢弃这个面片,不发送给域着色器

域着色器

它会使用来自壳着色器的曲面控制点,来计算每个顶点的输出值

域着色器具有一个和顶点着色器类似的数据流模式,来自曲面细分器的每个顶点都会被处理:它会获取为每个顶点生成的重心坐标,并在面片的计算方程中使用这些重心坐标来生成顶点的位置、法线、纹理坐标以及其他需要的顶点信息

随后,它生成一个相应的输出顶点。然后生成的三角形会被输入到管线中的下一个阶段

几何着色器

几何着色器可以将一种图元转换为另一种图元,这是曲面细分着色器所无法实现的

几何着色器的输入是一个独立物体(条状的三角形、折线或者仅仅是一个点)和与其相关联的顶点

几何着色器会对这些输入的图元进行处理,会输出 0 个或者更多数量的顶点

在几何着色器中生成的图元不可以直接输出,但是可以通过编辑顶点、添加新图元和移除其他图元的方式,来对网格进行选择性的修改

它的设计目的是对输入的顶点数据进行修改,或者是创建有限数量的副本。例如:生成六个变换后的数据副本,从而同时渲染一个立方体的六个面

它也可以:

  • 高效地创建级联阴影贴图(cascaded shadow map,CSM),从而生成高质量的阴影
  • 从点数据中创建大小可变的粒子
  • 沿着模型轮廓挤压出鳍片从而模拟毛发渲染
  • 找到物体的边缘从而用于阴影算法等

几何着色器最多可以输出四个数据流(stream),其中一个数据流可以被发送到渲染管线的下一阶段进行处理,所有这些数据流都可以选择性地发送到流式输出的渲染目标中

几何着色器会保证按照图元的输入顺序来输出图元,这会影响性能,所以不要使用几何着色器在一次调用中大量复制或者创建图形

几何着色器的行为是最不可预测的,因为它是完全可编程的。在实践中,几何着色器很少会被使用,因为它和 GPU 并行计算的优势并不相符

流式输出

流式输出(stream output)的想法是在 Shader Model 4.0 中引入的

我们还可以完全关闭光栅化阶段,然后将管线作为一个纯粹的、非图形的流处理器。这些处理过的数据可以通过流式输出从管线中返回,从而允许对其进行迭代处理。

这类操作在模拟流动的水体,或者其他粒子特效的时候十分有用。它还可以用于对模型进行蒙皮操作,然后让这些顶点数据可以重复使用

流式输出只能以浮点数的形式返回数据,因此它可能会占用很多存储空间

流式输出是作用于图元的,而不是直接作用于顶点的,这就意味着如果我们将一个网格输入到管线中,这个网格中的每个三角形都会生成自己的集合,每个集合都会包含三个输出的顶点,而原始网格中任何共享的顶点都会丢失。因此,在实际使用中,通常会直接将顶点作为一个点集图元直接输入到管线中

在 OpenGL 中流式输出被叫做变换反馈(transform feedback),因为流式输出的大部分用途都是对顶点进行变换,然后再将它们返回进行其他处理

在流式输出中,图元保证会按照它们的输入顺序进行输出

像素着色器

在 OpenGL 中像素着色器被称为片元着色器(fragment shader)

光栅化是管线中相对固定的处理步骤,它不具备任何可编程性

光栅化阶段的插值操作是由像素着色器程序所指定的(比如 linearnointerpolationcentroid 等插值修饰符)。通常使用透视矫正插值(perspective-correct interpolation),也可以使用屏幕空间插值

顶点着色器程序的输出,在经过三角形(或者线段)插值之后,会成为像素着色器程序的输入。随着 GPU 的不断发展,其他的一些输入数据也逐渐暴露给了像素着色器,例如:

  • 从 Shader Model 3.0 开始,我们可以在像素着色器中使用片元的屏幕空间位置
  • 有一个 flag 来标识三角形的哪一侧是可见的

有了这些输入数据,通常像素着色器就可以计算并输出一个片元的颜色值,也可以选择性修改片元的深度值

模板缓冲区(stencil buffer)通常是不可修改的,而是会直接将其发送给合并阶段。但是在 DirectX 11.3 中,也允许着色器对模板缓冲区进行修改

在 Shader Model 4.0 中,诸如雾效计算和透明度测试(alpha test)等操作,都从合并阶段转移到了像素着色器中进行执行

像素着色器还有一个独有能力,那就是将一个输入片元丢弃,也就是说不生成任何输出

多重渲染目标(multiple render target,MRT)

延迟着色(deferred shading):可见性计算和着色计算在两个单独的 pass 中完成。第一个 pass 计算并存储了每个像素上的物体位置和材质信息,并在之后的 pass 中来高效计算光照以及其他效果

像素着色器的限制:无法读取相邻像素上的计算结果。但是可以通过多 pass 的方式或者使用一些图像处理技术来间接实现

这个限制有个例外:像素着色器可以在计算梯度或者导数的时候,获取相邻片元的信息。这对于纹理过滤(texture filtering)来说尤其重要

现代 GPU 通过处理 $2 \times 2$ 的片元组,这被称作一个 quad,来实现这个特性。每个 quad 所有片元线程被存在一个 warp 中,其中的处理器核心可以访问不同线程的数据,所以可以支持这种操作。但是有一个限制:动态流程控制影响的着色器部分无法访问梯度信息

DirectX 11 引入了一种缓冲区类型,它叫做无序访问视图(unordered access view,UAV),这个缓冲区允许在其任何位置上进行写入。在 OpenGL 4.3 中这个缓冲叫做着色器存储缓冲区对象(shader storage buffer object,SSBO)

两个着色器程序在“相互竞争”地影响同一个值时,可能会导致数据竞争问题(data race condition,简称数据冲突,data hazard),GPU 通过着色器专用的原子操作来避免这个问题

虽然原子操作避免了数据冲突问题,但是很多算法实际上都需要一个特定的执行顺序(着色器的执行顺序)。在 DirectX 11.3 中引入了光栅器有序视图(rasterizer order view,ROV),它强制保证了执行的顺序。ROV 可以使得像素着色器来编写自己的混合方法,而不需要通过合并阶段来完成,但是这种功能可能会引入额外的停滞

合并阶段

在合并阶段中,我们会将每个独立片元的颜色和深度进行混合,并最终形成帧缓冲

DirectX 将这个阶段叫做输出合并(output merger),OpenGL 将其称为逐样本操作(per-sample operation)

在大多数传统渲染管线的示意图中(包括本书中的),模板缓冲和深度缓冲的相关操作都将在这个阶段执行

颜色混合(color blending)

为了避免性能浪费,许多 GPU 都会在执行像素着色器之前,进行一些合并测试。片元的深度值(以及其他任何可以使用的内容,例如模板缓冲或者裁剪测试,即 scissor)可以用于对可见性进行测试,不可见的片元将会被直接剔除,这个功能被称作为 early-z

如果在像素着色器中存在“修改片元的深度值”这种类型操作的话,通常将无法使用 early-z,这会降低整个管线的效率

DirectX 11 和 OpenGL 4.2 可以允许像素着色器强制开启 early-z,尽管会存在一些限制

合并阶段并不是完全可编程的,但是它是高度可配置的。特别是颜色混合,可以为其设置大量不同的操作,最常见的颜色混合方式涉及颜色和透明度的乘法、以及加减操作等

双色源混合(dual source-color blending):允许将像素着色器中的两种颜色与帧缓冲颜色相混合,但是它无法和多重渲染目标一起使用

ROV 与合并阶段都可以保证绘制的顺序,即输出不变性(output invariance)

计算着色器

GPU 不仅可以用来实现传统的图形渲染管线,还可以用于很多非图形的领域,这种使用硬件的方式叫做 GPU 计算(GPU computing)

DirectX 11 引入了计算着色器(compute shader),它是利用 GPU 进行计算的一种方式,它是一种特殊的着色器,但是它并没有锁定在图形管线中的固定位置,与顶点着色器、像素着色器以及其他着色器可以一起进行使用

线程组(thread group),在每个线程组中,线程之间会共享一小部分内存

算着色器的一个普遍用途就是后处理计算,即以某种方式来对图像进行修改。线程之间共享内存,这意味着某些图像采样的中间结果可以与相邻线程进行共享

计算着色器对于实现许多功能都非常有用,例如:

  • 粒子系统
  • 网格处理(例如面部动画)
  • 剔除
  • 图像过滤
  • 改进深度精度
  • 阴影
  • 景深效果