《Real-Time Rendering》第三章 图形处理单元
开篇
从历史上说,图形加速从在与三角形重叠的像素扫描线上插值颜色并显示这些值开始。后续增加了访问图形数据的能力来把纹理映射到表面上。此外,增加的用于插值并测试z深度的硬件提供了内建的可见度检查。正因为它们的频繁使用,这些过程被交给了专用硬件来提升性能。渲染管线的更多部分和这些部分的更多功能,则在连续几代中被添加。专用图形硬件在CPU之上的唯一计算优势就是速度,而速度是至关重要的。
在过去二十几年中,图形硬件经历了一个难以置信的转变。第一个包含硬件顶点处理的消费级图形芯片Geforce256在1999年上市。英伟达用图形处理单元(Graphics Processing Unit)把GeForce256从过去的只能进行光栅化的芯片区分开来,而且就这样持续了下去。在接下来的几年中,GPU从提供可配置实现的复杂固定功能管线进化到了高度可编程的白板,这使得开发者可以实现他们自己的算法。各种类型的可编程着色器(Shader)是GPU被控制的主要方式。为了效率,管线的有些部分虽然还是可配置的,但是一直朝着可编程度和灵活性而发展。
GPU通过专注于高度可并行的窄任务集来获得速度。它们有专用电路用来实现z缓冲、快速访问纹理图像以及其它缓冲、找到与三角形重叠的像素。这些专用电路是如何实现它们的功能在第23章被涵盖,我们应该更早地了解GPU是如何为它的可编程着色器实现并行化的。
后续的部分会解释着色器的功能,目前我们只需要知道着色器核心是一个小处理器,它会执行一些相对独立的任务,例如把顶点从它在世界空间中的位置变换到它在屏幕空间内的位置,或者计算被三角形覆盖的一个像素的颜色。如果每帧有成千上万的三角形被送到屏幕,那么每秒就有数十亿的着色器调用(Shader Invocation),也就是有很多单独的着色器程序实例在运行。
首先,延迟(Latency)是所有处理器都要面对的问题,而访问数据会花些时间。理解延迟的基础方式就是信息离处理器越远,等待时间就越长。在内存芯片上存储的信息相比于本地寄存器会花费更多的时间来访问。最关键的一点就是等待数据意味着处理器会停滞,而这会减少性能。
数据并行的架构(Data-Parallel Architectures)
不同的处理器架构都使用了各种各样的策略来避免停滞。CPU被优化来处理各种各样的数据结构和庞大的代码库。CPU可以拥有多个处理器,每个处理器以几乎以串行的方式执行代码,SIMD向量处理是个小例外。为了最小化延迟的影响,CPU芯片的很大一部分由快速的本地缓存构成,这些后续的操作可能需要的数据。CPU也通过一些聪明的技术例如分支预测、指令重排序、寄存器重命名和缓存预取来避免停滞。
GPU则采用了不同的方法。GPU芯片的很大一部分都是大量处理器的集合,这些处理器被称为着色器核心(Shader Core),通常有数千个。GPU是一个流处理器,有序的数据集会被依次处理。由于这些数据的相似性,例如顶点和像素,GPU可以以大规模并行的方式处理这些数据。另一个重要的地方是这些调用都是尽可能独立的,它们不需要周围调用的信息,而且也不会共享可写的内存位置。这个规则有时被打破来允许新的有用的功能,但是会以潜在的延迟为代价,因为一个处理器可能会等待另一个处理器完成工作。
GPU为了吞吐量(Throughput)而被优化,它是数据能被处理的最大速率。然而,快速处理是有代价的。会导致更少的芯片面积用来缓存内存和控制逻辑,每个着色器核心的延迟通常比CPU处理器的延迟高许多。
假设一个网格被光栅化,有两千个像素有片段要被处理,那么一个像素着色器程序会被调用两千次。想象一下我们只有一个着色处理器,世界上最弱的GPU。它会从第一个片段开始执行着色器程序。着色处理器会对寄存器存储的值执行一些算术操作。由于寄存器是本地的,可以被快速地访问,所以没有停滞发生。着色处理器接着遇到了纹理访问这种指令,对于给定的表面位置,程序需要知道纹理映射到网格上的像素颜色。一个纹理是完全独立的资源,不属于着色器程序本地内存的一部分,因此纹理访问会相对复杂。一次内存获取可以花费成百上千个时钟周期,在这段时间内GPU处理器什么也做不了,只能等待纹理颜色值的返回。
为了让这个糟糕的GPU做点更好的事,可以给予每个片段一定的存储空间用于它的本地寄存器。现在,着色处理器可以切换执行其它的片段,而不用等待一次纹理获取。这个切换会非常快,不过要注意第一个片段执行的是哪条指令。现在第二个片段被执行,和第一个片段一样,也是执行一些算术函数,接着又遇到了一次纹理获取。着色器核心又切换到了第三个片段。这两千个片段都是这样被处理的,着色处理器最终会回到第一个片段,在这个时候纹理颜色已经被取回了,因此着色器程序可以继续执行。处理器会以上述这种方式一直执行,直到遇到会导致停滞的指令,或者程序执行完毕。虽然单独一个片段会花费更多的时间来执行,但是所有片段的总执行时间会大幅降低。
在这种架构中,延迟通过让GPU切换片段执行来保持忙碌从而被隐藏。GPU采用了这种设计,而且进一步从数据分离了指令执行的逻辑。被称为单指令多数据(Single Instruction, Multiple Data,SIMD),这种安排让相同指令在固定数量的着色器程序上以锁步的方式执行。相比于使用单独的逻辑和调度单元来运行每个程序,SIMD的优势在于更少的硅需要被用来处理数据和进行切换。把我们的两千个片段的例子翻译到现代GPU的术语,每个片段对应的每个像素着色器调用被称为一个线程(Thread)。它不像CPU的线程那样,而是由输入着色器的值的一些内存和着色器执行需要的一些寄存器空间构成的。使用相同着色器程序的线程会被捆绑到组中,它被NVIDIA称为Warp,被AMD称为Wavefront。一个warp或wavefront会被一些GPU着色器核心调度执行,通常包含8到64个线程,以SIMD的方式进行处理。每个线程被映射到一个SIMD通道(SIMD Lane)。
假设我们有两千个线程要被执行。在英伟达GPU上的warp包含32个线程。那么有\(2000/32=62.5\)个warp,这意味着63个warp会被分配,有一个warp的一半是空的。一个warp的执行和我们的单个GPU处理器的例子很像。着色器程序会在所有32个处理器上以锁步的方式被执行。当遇到内存获取时,所有线程会在同一时间遇到,因为执行的指令是一样的。获取会让warp的线程等待,这个时候与其进行等待,不如让32个着色器核心切换执行另一个warp的32个线程。这个切换和我们的单处理器系统一样快,因为进行切换时没有触碰任何线程的数据。每个线程都有它自己的寄存器,而且每个warp都会追踪正在执行的指令。着色器核心会一直执行warp中的线程或进行切换,直到所有warp完成工作。详见下图

在我们这个简单的例子中,对于纹理的内存获取会导致warp切换。实际上warp切换会有更短的延迟,因为进行切换的开销很低。虽然有许多别的技术来进行优化,但是warp切换是所有GPU使用的主要的隐藏延迟的机制。有一些因素会影响处理的效率。例如,如果只有少量线程,那么只能创建少量的warp,这会导致延迟隐藏出现问题。
着色器程序的结构是影响效率重要因素。一个主要因素是每个线程所使用的寄存器的量。在我们的例子中,我们假设两千个线程可以同时存在于GPU中。与每个线程关联的着色器程序需要的寄存器越多,那么更少的线程更少的warp可以驻留于GPU中。warp的短缺意味着停滞不能通过切换来缓和。驻留的warp被称为处于活动状态,它们的数量叫占用率(Occupancy)。高占用率意味着有很多可用的warp,因此处理器空闲的可能将更低。低占用率通常会导致性能低下。内存获取的频率也会影响延迟隐藏需要的程度。
另一个影响总体效率的因素是动态分支,由if语句和循环导致。假如着色器程序遇到了if语句,如果所有线程在评估后选择了相同的分支,那么warp可以继续执行而无需关心其它分支。然而,如果有些线程或者甚至是一个线程选择了另一个路径,那么warp必须把两个分支都执行,并为每个线程丢掉不需要的结果。这个问题被称为线程分歧(Thread Divergence),当少量线程可能需要执行循环迭代或执行一个warp中别的线程不会执行的路径时,会导致别的线程空闲。
所有GPU都实现了以上这些架构上的想法,导致了一个有严格限制但是每瓦计算能力极强的系统。理解这些系统是如何操作的可以帮助作为编程者的你更好地利用GPU的能力。下面的部分将讨论GPU是如何实现渲染管线的,以及可编程着色器是如何操作的,还有GPU是如何演化的。
GPU管线概述(GPU Pipeline Overview)
GPU实现了概念上的几何处理、光栅化和第二章描述的像素处理管线阶段。这些被分成了一些有着不同可配置度和可编程度的硬件阶段。下图用不同颜色展示了这些硬件阶段的可编程度和可配置度。

绿色表示阶段是完全可编程的,黄色表示阶段是可配置的但不是可编程的,蓝色表示阶段是完全固定功能的,虚线表示阶段是可选的。要注意的是这些物理阶段的划分和第二章展示的功能阶段不一样。
我们在这里描述GPU的逻辑模型(Logical Model),也就是暴露给你的API。正如第十八章和第二十三章讨论的那样,逻辑管线的实现也就是物理模型(Physical Model)是取决于硬件供应商的。一个在逻辑模型中固定功能的阶段可能通过GPU在相邻的可编程阶段增添指令而被执行。一个在管线中单独的程序可能被分为一些被单独的子单元执行的部分,或者在一个完全单独的通道中被执行。逻辑模型可以帮助你思考什么会影响性能,但是不应该把它当作GPU实际实现管线的方式。
顶点着色器是一个完全可编程的阶段,它被用来实现几何处理阶段。几何着色器是一个完全可编程的阶段,它会操作图元的顶点。它能被用来执行逐图元着色操作、删除图元、生成新图元。镶嵌细分阶段和几何着色器都是可选的,而且不是所有GPU都支持,特别是在那些移动式设备上。
裁剪、三角形设定、三角形遍历阶段都由固定功能的硬件实现。屏幕映射被窗口和视口影响,在内部就是简单的缩放和再定位。像素着色器阶段是完全可编程的。尽管输出合并阶段是不可编程的,但是高度可配置的,可以被设置来实现各类操作。它实现了“合并”这一功能阶段,负责修改颜色、z缓冲、混合、模板以及其它和输出有关的缓冲。像素着色器执行和输出合并阶段构成了第二章所展示的像素处理阶段。
随着时间推移,GPU管线从硬编码操作演化而来,一直在朝着增加灵活性和控制而发展。可编程着色器阶段的引入是演化中最重要的一步,接下来的部分将讨论不同可编程阶段的共同特性。
可编程着色器阶段(The Programmable Shader Stage)
现代着色器程序使用了统一着色器设计。这意味着顶点、像素、几何以及和镶嵌细分相关的着色器共享一个编程模型。在内部,它们有着相同的指令集架构(Instruction Set Architecture,ISA)。一个实现这种模型的处理器在DirectX中被称为一个通用着色器核心(Common-Shader Core),一个有着这种核心的GPU则被认为有一个统一着色器架构。在这种架构背后的理念是着色处理器可以被用于多种类型的工作,而且GPU可以进行合理分配。例如,相比于组成大正方形的两个三角形,有着一系列小三角形的网格将会需要更多的顶点着色器进行处理。一个有着分开的顶点着色器核心和像素着色器核心的池子意味着要进行理想的工作分发,来确保所有的核心忙碌。使用统一着色器核心,GPU可以决定如何均衡负载。
着色器都是使用类C着色语言(Shading Language)进行编程的,例如DirectX的High-Level Shading Language(HLSL)和OpenGL Shading Language(GLSL)。DirectX的HLSL可以被编译成虚拟机器字节码用来提供硬件独立,它也被称为中间语言(Intermediate Language,IL,DXIL)。一个中间表示可以让着色器被离线编译和存储。中间语言会被驱动转化为GPU特定的ISA。游戏主机编程通常会避免中间语言这一步,因为系统只有一个ISA。
基础的数据类型是32位单精度浮点标量和向量,要注意的是向量只是着色器代码的一部分,在硬件中是不被支持的。在现代GPU上,32位整数和64位浮点数都是原生支持的。浮点向量通常包含例如位置(\(xyzw\))、法线、矩阵行、颜色(\(rgba\))或纹理坐标(\(uvwq\))。整数几乎通常被用来表示计数器、索引、位掩码。聚合数据类型例如结构、数组和矩阵也都是被支持的。
一个绘制调用(Draw Call)会调用图形API来绘制一组图元,因此导致图形管线执行并运行它的着色器。每个可编程着色器阶段有两种输入类型。第一种为uniform输入,这种输入类型的数据会在一次绘制调用中保持不变。第二种为varying输入,这种输入类型的数据来自三角形的顶点或是光栅化。例如,一个像素着色器可能提供光源的颜色作为uniform值,并提供逐像素改变的三角形表面的位置作为varying值。纹理是特殊类型的uniform输入,在曾经常作为彩色图像应用于表面,在当下可以被认为是数据的二维数组。
底层的虚拟机为不同类型的输入和输出提供了特殊的寄存器。对于uniform类型的输入来说,可用的常量寄存器(Constant Register)比varying类型的输入或输出可用的要多。这是因为varying类型的输入或输出需要为每个顶点或像素分开进行存储,因此数量会有限制。uniform类型的输入则会被存储一次,接着为一次绘制调用涉及到的所有顶点和像素重复使用。虚拟机也有通用的临时寄存器(Temporary Register),这些寄存器被用于临时存储空间(Scratch Space)。所有类型的寄存器都能使用存储于临时寄存器的整数值进行数组索引。着色器虚拟机的输入和输出如下图所示。

那些在图形学计算中常见的操作都在现代GPU上被高效地执行。着色语言通过\(*\)和\(+\)等运算符暴露了最常见的一些操作。剩余的则通过内建函数(Intrinsic Function)被暴露,例如atan()、sqrt()、log()等等,这些内建函数都为GPU而被优化。此外,还有那些为了实现更加复杂的操作而存在的函数,例如向量的归一化、反射、叉乘,矩阵的转置和行列式计算。
流控制(Flow Control)这一术语指代使用分支指令来改变代码的执行流。和流控制有关的指令被用来实现了高级语言的构造,例如if和case,还有各种类型的循环。着色器支持两种类型的流控制。静态流(Static Flow)控制分支都基于uniform类型的输入值,这意味着代码流在绘制调用中不变。静态流控制的主要好处是允许相同的着色器被用于不同的情况下,而且在这种情况下是没有线程分歧的,因为所有的调用都采取了相同的代码路径。动态流(Dynamic Flow)控制基于varying类型的输入值,意味着每个片段能执行不同的代码。虽然比静态流控制要强大得多,但是更加地耗费性能,特别是当代码流在着色器调用之间改变很大的时候。
可编程着色和API的演化(The Evolution of Programmable Shading and APIs)
可编程着色框架的理念可以追溯到1984年的Cook的着色树(Shade Tree)。一个简单的着色器和它对应的着色树如下图所示

RenderMan着色语言在80年代末由这个理念开发而来。在当下,它任然被用于影视制作渲染以及其它正在演进的语言规范,例如开放式着色语言(Open Shading Language,OSL)项目。
消费级图形硬件首先由3dfx Interactive在1996年10月1日引入。下方为相关的时间线。

他们的Voodoo图形卡可以高质量且高性能地渲染游戏Quake的画面,这让它被市场快速地接受。这个硬件实现了一整个固定功能的管线。在GPU原生支持可编程着色器前,也有一些通过多个渲染通道实现实时的可编程着色操作的尝试。在1999年,Quake III: Arena的脚本语言是这个领域内的第一个被广泛传播的商业成功。和这章开头提到的那样,英伟达的GeForce256是第一个被称为GPU的硬件,但是它不是可编程的。然而,它是可配置的。
在2001年初,英伟达的GeForce 3是第一个支持可编程顶点着色器的GPU,通过DirectX 8.0和OpenGL的扩展暴露。这些着色器是使用类汇编的语言进行编程的,在运行的时候会被驱动转换成微码。DirectX 8.0也包含了像素着色器,但是缺少编程度。它所支持的受限“程序”会被驱动转化为纹理混合状态,在这之中硬件“寄存器组合器”被连接起来。这些“程序”不只受限于指令长度,同时也缺少重要的功能。支持依赖型纹理读取以及浮点数据被Peercy等人认为是有真正编程度的标准。
着色器在这个时期不允许流控制,因此条件语句必须通过运行所有的路径,然后选择结果或插值结果来做到。DirectX定义了着色器模型(Shader Model,SM)这一概念来区分支持不同着色器能力的硬件。2002年DirectX 9.0问世,随之一起的还有Shader Model 2.0,它标志着正真可编程的顶点和像素着色器。相似的功能也在OpenGL中,通过不同的扩展被暴露。随机的依赖型纹理读取以及16比特浮点值的存储的支持也在这个时候被添加,终于符合了Peercy等人定义的标准。着色器资源的限制,例如指令、纹理和寄存器被增加了,因此着色器变得有能力来实现更加复杂的效果。流控制的支持也被添加。但是增长的指令长度以及着色器的复杂度使得汇编语言编程模型变得越来越繁琐。幸运的是,DirectX 9.0也包含了HLSL。这个着色语言由Microsoft和NVIDIA协同开发。在差不多相同的时间,OpenGL ARB发布了GLSL,是个非常相似的用于OpenGL的语言。这些语言被C编程语言的语法和设计哲学深深地影响,而且也包含了RenderMan着色语言的一些元素。
Shader Model 3.0在2004年问世,增加了动态流控制,这让着色器变得更加强大。它也让一些可选的特性变成了硬性要求,此外还进一步降低了资源限制,而且也为顶点着色器提供了有限的纹理读取的支持。当新一代游戏主机在2005年末和2006年末发布时,它们都装备上了支持Shader Model 3.0级别的GPU。任天堂的Wii主机是最后一批之一搭载了固定功能GPU的主机,它在2006年末上市。在这之后,纯固定功能的管线就去而不复返了。着色语言已经演化到了有各种工具被用来创建和管理它们的一个时间点,一个使用了Cook的着色树概念的这种工具的截图,如下图所示

编程度演化的另一大步在近2006年末出现。Shader Mode 4.0被DirectX 10.0包含,它引入了一些主要的特性,例如几何着色器和流输出。Shader Model 4.0包含了一个对于所有着色器来说统一的编程模型,就是先前描述的统一着色器设计。资源限制被进一步降低,而且也增加了对整数数据类型的支持(包含位操作)。OpenGL 3.3中引入的GLSL提供了一个相似的着色器模型。
在2009年,DirectX 11和Shader Model 5.0被发布,增加了镶嵌细分(Tessellation)阶段的着色器,还有计算着色器(也被称为Direct Compute)。它们的发布也专注于提供更加高效的CPU并行处理能力。OpenGL在4.0版本增加了镶嵌细分,在4.3版本增加了计算着色器。DirectX和OpenGL演化得不一样。它们两个都对特定的发布版本划定了需要的硬件支持的等级。微软控制着DirectX API,并且直接与独立的硬件供应商(Independent Hardware Vendors,HIVs),例如AMD、NVIDIA、Intel,还有那些游戏开发者和计算机辅助设计的软件公司合作,来决定要暴露什么特性。OpenGL则由硬件和软件的供应商联盟开发而来,由非盈利的Khronos Group管理。因为涉及到了许多的公司,OpenGL的API通常都晚于DirectX发布。然而,OpenGL允许扩展(Extension),它是供应商特定的或者是更加通用的,允许最新的GPU功能在正式支持的发布前被使用。
API后续的显著改变由AMD在2013年引入的Mantle所主导,它由AMD和视频游戏开发者DICE共同开发而来。Mantle背后的理念是移除图形驱动的大部分开销,并交给开发者来直接控制。随着这一次重构的还有进一步支持高效的CPU并行。这种新一类的API专注于极大地减少CPU花在驱动上的时间,还有更加高效的CPU并行处理的支持。Mantle中先进的理念随后被Microsoft采纳并在2015年发布了DirectX 12。要注意的是DirectX 12没有专注于暴露新的GPU功能,它和DirectX 11.3暴露了相同的硬件特性。这两个API都能被用来把图形发送到虚拟现实系统,例如Oculus Rift和HTC Vive。然而,DirectX 12是一次激进的API再设计,它能更好地映射到现代GPU架构。低开销驱动很适合CPU驱动的开销导致瓶颈,和使用更多的CPU处理器用于图形来提升性能,这两类场景。从更早的API移植过来会显得困难,而且幼稚的实现会导致低性能。
在2014年,Apple发布了一个叫Metal的低开销API。它最初在iPhone 5S和iPad Air这些移动式设备上可用,一年后的新Macintosh电脑可以通过OS X El Capitan访问。除了效率,它还减少了CPU的占用节省了功率,这是它能在移动式设备上被使用的一个重要因素。这个API有它自己的着色语言,同时用于图形和GPU计算程序。
AMD把它的Mantle捐献给了Khronos Group,而Khronos Group后续在2016年初发布了叫Vulkan的新API。和OpenGL一样,Vulkan可以在多个操作系统上运行。Vulkan使用了一个新的叫SPIR-V的高级中间语言,它被同时用于着色器表示和通用GPU计算。预编译的着色器是可移植的,可以在任何支持其所需能力的GPU上被使用。Vulkan也能被用于非图形的GPU计算,因为它不需要一个显示窗口。Vulkan与其它的低开销驱动的一个显著区别就是可以工作在非常多的系统上,从工作站到移动式设备。
在移动式设备上的标准是使用OpenGL ES。这里的“ES”指代嵌入式系统,因为它是考虑到移动式设备而被开发的。在某些调用结构中,标准的OpenGL会看起来很臃肿而且缓慢,而且也要求支持不怎么被使用的功能。在2003年,OpenGL ES 1.0被发布,它是OpenGL 1.3的精简版本,描述了一个固定功能的管线。DirectX是随着支持它的图形硬件同步发布的,而为移动式设备的图形支持的开发却没有以同样的方式进行。
OpenGL ES的一个分支是一个基于浏览器的叫WebGL的API,它是通过JavaScript调用的。在2011年,这个API的第一个版本在大多数移动式设备上就可用了,它在功能上和OpenGL ES 2.0等价。和OpenGL一样,它的扩展给予了访问更加先进的GPU特性的能力。WebGL 2假定支持OpenGL ES 3.0。
WebGL非常适用于进行特性的实验,或是在教室中使用,主要有如下原因:
-
它是跨平台的,在所有的个人电脑上和几乎所有的移动式设备上都能运行。
-
显卡驱动的兼容性与安全性审核是由浏览器处理的。甚至是如果有一个浏览器不支持一个特定的GPU或扩展时,通常有另一个浏览器可以。
-
代码是被解释的,而不是被编译的,只需要一个文本编辑器就能进行开发。
-
大多数浏览器都有内置的调试器,在任何网站上运行的代码都能被检查。
-
程序可以通过上传到网站或Github上被部署。
比如高级的场景图和效果库例如three.js给予了代码,提供了轻松访问一些不同的较复杂的效果的能力,就比如阴影算法、后处理效果、基于物理的渲染和延迟渲染。
顶点着色器(The Vertex Shader)
顶点着色器是功能管线的第一个阶段。而这个阶段是由编程者直接控制的,值得注意的是,有些数据操作会发生在这个阶段前。DirectX称这一阶段为输入装配阶段(Input Assembler),不同的数据流会被编织到一起,最终变成顶点和图元的集合被送入管线。
例如,一个物体可以用一个位置数组和另一个颜色数组来表示。输入装配阶段会通过创建有着位置和颜色的顶点来创建物体的三角形(或者点或线)。第二个物体可以使用相同的位置数组,和一个不同的模型变换矩阵,以及另一个不同的颜色数组,用于它的表示。输入装配阶段也支持实例(Instancing),使用一次绘制调用,就能让一个物体能使用一些随实例变化的数据被绘制数次。
三角网格是使用一系列顶点来表示的,每个顶点与模型表面上的一个特定位置关联。除了位置外,还有其它的与每个顶点关联的可选属性,例如一个颜色或纹理坐标。表面法线也可以在顶点被定义,这可能看起来有点怪。因为从数学上说,每个三角形都有一个明确定义的表面法线,直接使用三角形的法线进行着色似乎更加合理。然而,在渲染的时候,三角形网格通常被用来表示一个原始曲面。在这种情况下,顶点的法线是被用来表示表面在这个位置的朝向的,而不是表示三角网格它自己。
顶点着色器是处理三角形网格的第一个阶段。描述三角形组成了什么的数据对于顶点着色器来说是未知的。正如它的名字暗示的那样,它只单独地处理每个到达这个阶段的顶点。顶点着色器提供了一个途径来修改、创造或是忽略与每个三角形顶点关联的值,例如颜色、法线、纹理坐标、位置。顶点着色器程序一般都会把顶点从模型空间变换到齐次裁剪空间,顶点着色器的最低要求就是输出这个空间内的坐标。
一个顶点着色器和之前所描述的统一的着色器差不多。每个传递进来的顶点都会被顶点着色器程序处理,接着输出一些在三角形或线段上插值后的值。顶点着色器既不能生成也不能销毁顶点,而且为一个顶点生成的结果不能被传递到其它的顶点。由于每个顶点都是被单独地处理的,因此在GPU上的任意数量的着色处理器可以以并行的方式处理到来的顶点流。
输入装配阶段通常以在顶点着色器被执行前的一个过程的形式存在。这就是个物理模型通常区别于逻辑模型的一个例子。事实上,读取数据来创建顶点可能就发生在顶点着色器中,驱动也许会为顶点着色器悄悄地预加恰当的指令来完成这件事,而这些指令对于编程者来说是不可见的。
下面的章节会介绍一些顶点着色器效果,例如用于关节动画的顶点混合,以及轮廓渲染。其它的顶点着色器的用途则包括以下这些:
- 物体生成,通过只创建一次网格接着让它被顶点着色器改变形状来做到
- 角色身体和脸部的动画,通过使用顶点蒙皮(skinning)和形态变形(morphing)技术实现
- 过程式变形,例如旗帜、布料或水的动作
- 粒子生成,通过发送无面积的网格到管线中,接着给予这些粒子一定的面积做到
- 镜面扭曲、热浪、水纹、书页卷曲以及其它效果,通过把整个帧缓冲的内容当作一个纹理,把它映射到一个与屏幕对齐的网格,然后经历过程式变形做到
- 应用地形的高度场,通过使用顶点纹理读取做到
有些使用顶点着色器能做到的变形如下图所示

镶嵌细分阶段(The Tessellation Shader)
镶嵌细分阶段允许我们渲染曲面。GPU的任务是取每个表面的描述,并把它转变为表示曲面的顶点集。这个阶段是一个可选的GPU特性,在DirectX 11中首次可用。它也被OpenGL 4.0和OpenGL ES 3.2所支持。
使用镶嵌细分阶段可以带来一些优势。曲面描述通常比提供对应的三角形来表示曲面更紧凑。不只是能节省内存,对于角色的动画或是每帧都要改变形状的物体来说,这个特性还能让CPU和GPU之间的总线远离瓶颈。对于给定的视点来说,可以生成恰当数量的三角形来表示表面。例如,如果一个球远离相机,那么只需要少量三角形来表示球。相反,如果球距离相机很近,那么需要几千个三角形来表示球。这个能控制细节等级(Level Of Detail,LOD)能力也能让应用程序来控制它的性能,比如在较弱的GPU上使用更低质量的网格来维持帧率。那些使用平整表面来表示的模型可以被转化为三角形的精细网格,接着按预期来弯曲,或是被细分来让开销昂贵的着色计算变得更加不那么频繁地发生。
镶嵌细分阶段总是包含三个要素,使用DirectX的术语就是外壳着色器(Hull Shader)、细分器(Tessellator)、域着色器(Domain Shader)。在OpenGL中外壳着色器是镶嵌细分控制着色器(Tessellation Control Shader),而域着色器是镶嵌细分评估着色器(Tessellation Evaluation Shader),更加偏向描述性。固定功能的细分器则被称为图元生成器(Primitive Generator),它描述了细分器会做的事情。
如何声明待镶嵌细分的曲面和表面会在第十七章中被讨论。在这里我们给每个镶嵌细分的阶段的目的给予一个简要概述。首先,输入到外壳着色器的是特殊的曲面片(Patch)图元。它由定义一个待细分的表面的一些控制点构成,比如贝塞尔曲面片或者是其它类型的弯曲元素。外壳着色器有两个功能。首先,它告诉细分器有多少三角形要被生成,以及以哪种配置进行生成。第二,它会处理每个控制点。此外,外壳着色器还可以选择性地修改每个到来的曲面片描述,比如增加或移除控制点。外壳着色器会输出控制点的集合以及镶嵌细分控制数据到域着色器。
细分器是个在管线中固定功能的阶段,只被镶嵌细分着色器使用。它有着为域着色器增加一些新顶点的任务。外壳着色器会向细分器发送信息,来告知需要哪种类型的镶嵌细分表面,比如三角形、四边形、isoline。其中的isoline是线条带的集合,有时被用于头发的渲染。另一些被外壳着色器发送的值是镶嵌细分因子(在OpenGL中是镶嵌细分等级(Tessellation Level))。这些值有两种类型,一种为内部的镶嵌细分因子,另一种为外部边的镶嵌细分因子。内部的镶嵌细分因子决定三角形或四边形内部的细分程度,而外部边的镶嵌细分因子决定了边的细分程度。一个逐渐增加镶嵌细分因子的例子如下图所示。

通过这种分开的控制,我们可以让相邻的表面被细分后进行无缝拼接,而又不用在乎内部是如何被细分的。顶点会被分配重心坐标,这些值被用来声明每个点在曲面上的相对位置。
外壳着色器总是输出曲面片,即控制点位置的集合。然而,它可以通过向细分器发送值为\(0\)或更小的外部边的镶嵌细分等级来抛弃掉曲面片。否则,细分器会生成一个网格然后发送给域着色器。来自外壳着色器的控制点会被域着色器的每个调用所使用,来为每个顶点计算输出值。域着色器有和顶点着色器相像的数据流形式,由细分器生成的每输入个顶点会被处理,然后生成一个与之对应的输出顶点。输出顶点形成的三角形接着被送入管线的后续阶段。
这个系统可能看起来很复杂,但是它的结构实际上有利于效率,此外每个着色器可以相当简单。被送入外壳着色器的曲面片通常会有小修改或是不被修改。这个着色器可能也会使用曲面片的估计距离或屏幕大小,在运行时计算镶嵌细分因子,就比如地形的渲染。相对地来说,外壳着色器可能仅仅传递由应用程序计算出的为所有的曲面片提供的固定的镶嵌细分因子。细分器接着执行一次复杂但是固定功能的生成顶点的操作,给予这些顶点位置,声明它们组成了什么样的三角形或线段。这个数据放大的步骤会在着色器之外为了计算效率进行。域着色器会取为每个点生成重心坐标,并在曲面片的评估式中使用,来生成位置、法线、纹理坐标以及其它的期望的顶点信息。
几何着色器(The Geometry Shader)
几何着色器可以把图元变成另外一些图元,做到镶嵌细分阶段做不到的一些事。例如,把实体三角形变成一个由三条线段连接成的线框三角形。相对地来说,也可以把线框三角形变成实体三角形。顶点着色器随着2006末DirectX10的发布被添加到了硬件加速的图形管线中。它在管线中位于镶嵌细分着色器之后,它的使用也是可选的。Shader Model 4.0要求了对几何着色器的支持,更早的着色器模型则没有。OpenGL 3.2和OpenGL ES 3.2也支持几何着色器。
几何着色器的输入是一个单独的物体,以及与之关联的顶点。这个物体通常由三角形条带、线段或者仅仅点组成。扩展类型的图元可以被定义并被几何着色器处理。特别的来说,三角形之外的额外三个顶点可以被传入,此外与多段线中的某条线段相邻的两个顶点可以被传入。详见下图

使用DirectX 11和Shader Model 5.0,你可以传递曲面片,至多有32个控制点。虽然可以这么做,但是镶嵌细分阶段还是更擅长曲面片的生成一些。
几何着色器处理这些类型的图元并输出\(0\)个或更多的顶点,这些顶点会被当作是构成了点、多段线、三角形条带。值得注意的是,几何着色器可以什么也不生成。通过这种方式,一个网格可以被选择性地编辑顶点、添加新图元、删除别的图元。
几何着色器被设计用来修改到来的数据或只做有限数量的拷贝。比如,有个用途是生成六个变换后的数据拷贝,来同时渲染到立方体贴图的六个面上。它也可以被用来高效地创建级联阴影贴图,用于高质量的阴影生成。其它的算法则利用几何着色器从点数据生成可变大小的粒子、从物体的轮廓线沿着某个方向生成薄片几何用于毛发渲染、找到物体的边用于阴影算法。下图是一些例子

这些以及剩下的一些用法会在余下的书中进行讨论。
DirectX 11为几何着色器增加了使用实例的能力,几何着色器可以为任意给定的图元运行数次。在OpenGL 4.0中,这是通过声明调用次数做到的。几何着色器也可以输出到至多四个流。其中一个流可以被送入管线的后续阶段用于处理。这些流都可以被选择性地送到流输出渲染目标上。
几何着色器保证来自图元的输出结果会和图元输入时一致(假设输入的图元为A、B、C,与生成的新图元的对应关系为A->D、B->E、C->F,那么输出顺序则为D、E、F)。这会影响性能,因为如果数个着色器核心以并行的方式运行,那么结果必须被保存下来并保持顺序。这个以及别的因素导致几何着色器不能在一次调用中生成数量庞大的几何体。
在一个绘制调用被发起后,管线中只有三个地方能有工作在GPU上被创建,分别为:光栅化、镶嵌细分阶段、几何着色器。当然,几何着色器的行为是最不可预测的,因为考虑到它需要的资源和内存,以及它是完全可编程的这几个情况。在实践中,几何着色器通常不会被频繁使用,因为它不能很好地利用GPU的能力。在一些移动式设备上,它是以软件的方式被实现的,因此几何着色器总是不被鼓励在这些移动式设备上使用。
流输出(Stream Output)
GPU管线的标准用法就是输入数据到顶点着色器,接着光栅化三角形并在像素着色器中处理生成的片段。在过去,数据总是在管线中流动,中间结果是不可被读取的。流输出从Shader Model 4.0开始被引入。在顶点被顶点着色器(也可以有镶嵌细分和几何着色器)处理后,处理后的顶点除了被送入光栅化阶段,也可以被输出到一个流中。在这种情况下,光栅化可以被完全关闭。这样,整个管线就被当作了一个非图形用途的流处理器。数据被管线处理后可以被送回管线,因此可以实现迭代处理。这种类型的操作对模拟水流或者其它的粒子效果很有用。它也可以被用于对模型进行顶点蒙皮,接着让这些顶点被重复利用。
流输出只能返回浮点类型的数字,因此它有可观的内存消耗。流输出是作用于图元的,并不直接作用于顶点。如果网格被送入管线,每个三角形则会生成它自己的三个输出顶点。任何在原始网格中被共享的顶点会丢失。正因为这样,一个更加典型的用法是让顶点作为点集图元被送入管线。在OpenGL中,流输出阶段被称为变换反馈(Transform Feedback),因为它的许多用途专注于变换顶点并返回它们,用于后续的处理。图元是如何被送到管线的决定了它们被送入流输出目标的顺序。
像素着色器(The Pixel Shader)
在顶点、镶嵌细分、几何着色器执行了它们的操作后,图元会被裁剪并为光栅化做准备,正如之前的章节解释的那样,在管线中的这个部分的处理步骤会相对的固定,是不可编程的但是可配置。每个三角形会被遍历,来找到它们覆盖的像素。光栅器也可能粗略计算三角形覆盖每个像素格子的面积。三角形与每个像素部分或完全重叠的每一小片被称为片段(Fragment)。
在三角形顶点上的值包括z值会在三角形表面上为每个像素插值。这些值会被传递给像素着色器,像素着色器接着处理片段。在OpenGL中,像素着色器被称为片段着色器,这也许是个更好的名字。在这本书中我们为了一致性使用“像素着色器”。被送入管线的点图元和线图元也会为覆盖的像素生成片段。
在三角形上的插值类型是由像素着色器程序声明的。一般来说,我们都使用透视修正的插值,为了让像素表面之间的距离随着物体远离而增加。一个例子是渲染向远方伸去的平行轨道,当距离越来越远时轨道离得越来越近,像素表面之间的距离就越来越大。也有另外一些插值选项,比如屏幕空间的插值,它不考虑透视投影这一情形。DirectX 11更进一步给予了插值在何时以及如何进行的控制。
用编程的术语来说,在三角形(或线)上被插值的顶点着色器程序的输出,高效地变成了像素着色器程序的输入。随着GPU不断演化,有其它的输入被暴露。例如片段的屏幕位置,它自Shader Model 3.0起可以被像素着色器使用。此外,三角形的哪一面是可见的是一个输入标志。这对在一次通道中渲染有着不同材质的三角形的正反两面很重要。
有了输入的数据,像素着色器一般都会计算并输出一个片段的颜色。它也可能生成一个不透明度值并选择性地修改片段的深度。在合并阶段,这些值会被用于修改存储于像素上的值。光栅化阶段生成的深度也可以被像素着色器修改。模板缓冲的值通常是不可被修改的,而是被传递到合并阶段的。自DirectX 11.3起模板缓冲的值可以被着色器修改。自Shader Model 4.0起,雾计算和阿尔法测试从合并操作被移动到了像素着色器中。
像素着色器也有独特的能力来抛弃到来的片段,也就是无输出。下方为一张相关的示例图

裁剪平面功能在过去的固定功能的管线中是作为可配置的元素存在的,而随后可以在顶点着色器中被声明。有了可抛弃的片段,这个功能可以在像素着色器中以任意方式被实现,比如决定裁剪体应该是被“与”还是被“或”运算到一起。
最初,像素着色器只能输出结果到合并阶段,用于最终的显示。像素着色器的指令数量随时间已经有了可观的增长,这给予了多渲染目标(Multiple Render Targets,MRT)的想法。多组数据可以为每个片段生成并被保存到不同的缓冲中,而不是仅仅发送像素着色器程序的结果到颜色缓冲和z缓冲。在这里,每个不同的缓冲被称为一个渲染目标(Render Target),它们通常都有相同的水平维度和竖直维度。有些API允许不同的大小,但是被渲染的区域会以这些渲染目标中最小的尺寸为准。有些架构要求渲染目标有相同的位深度,甚至是相同的数据格式。取决于GPU,可用的渲染目标的数量通常是\(4\)或\(8\)个。
尽管有着这些限制,MRT功能在帮助实现更高效的渲染算法上很有用。单独一个渲染通道可以在一个渲染目标上生成颜色图像,在另一个生成物体标识符,在第三个渲染目标上生成世界空间的距离。这个能力也让另一种类型的渲染管线得以实现,它被称为延迟着色(Deferred Shading)。在这之中,可见度确定和着色是在分开的通道进行的。第一次通道存储关于一个物体位置和材质在每个像素上的数据。后续的通道则可以高效地应用光照和其它效果。这种类型的渲染方法会在后续被描述。
像素着色器的限制是它通常只能写入到它负责的片段在渲染目标中对应的位置,而且不能读取相邻像素的值。也就是说,当一个像素着色器程序执行,它既不能把它的结果直接输出到相邻像素上,也不能读取相邻像素最近的改变。相反,它只能计算结果并把它输出到片段对应的像素上。像素着色器可以读取在一次通道后被创建的输出图像的任意位置。相邻像素可以使用一些图像处理技术被处理,也会在后续讨论。
上述所说的像素着色器不能知道或不能影响相邻像素的结果这一规则有时候有例外。其中一个是像素着色器可以在计算梯度或导数信息时立刻读取邻接片段的信息。像素着色器会被提供被插值后的任意值是如何逐像素随着屏幕的\(x\)轴和\(y\)轴改变的。这些值对于很多用途的计算和纹理寻址来说很有用。梯度值对于纹理滤波这种操作非常重要,比如当我们想知道一张图像覆盖了像素多少区域的时候。所有的现代GPU通过处理\(2 \times 2\)的被称为四元组(Quad)的片段组实现了这一特性。当像素着色器需要一个梯度值时,邻接片段之间的差值会被返回。详见下图

一个统一的着色器核心有能力来访问在相同的warp中,不同的线程所存储的信息。因此可以计算梯度值用于像素着色器的使用。这样实现的后果是梯度信息不能在动态流控制中被获取,比如if或可变次数的循环迭代。在组中的四个像素必须使用相同的指令被处理,来让四个像素的结果对于计算梯度值来说有意义。这是甚至存在于离线渲染系统中的一个底层限制。
DirectX 11引入了一种缓冲类型来允许任意位置写入,也就是无序访问视图(Unordered Access View,UAV)。一开始只能被像素和计算着色器使用,自DirectX 11.1后所有着色器都可以访问无序访问视图。OpenGL 4.3称之为着色器存储缓冲对象(Shader Storage Buffer Object,SSBO)。像素着色器都是以并行的方式运行的,没有特定的顺序,这个存储缓冲会在像素着色器之间被共享。
通常,需要某些机制来避免数据竞争状态(Data Race Condition)。当着色器程序之间在竞争来影响相同的值时,可能会导致随机的结果。比如考虑两个像素着色器的调用想累加相同位置的值时,当两个调用在同一时间读取了相同的值,并在本地进行修改,随后进行写入,这个时候只会有一次累加发生。GPU通过拥有着色器可访问的专用原子单元避免了这一问题。然而,原子操作意味着有些着色其可能会停滞,因为它们需要等待其它着色器完成读取、修改、写入。
虽然数据冒险可以用原子操作来避免,但是有许多算法会要求特定的执行顺序。例如,在绘制更近的红色透明三角形时,你可能想先绘制一个更远的蓝色透明三角形,以确保正确的颜色混合。对于一个像素来说可能有两个像素着色器的调用,一个调用用于红色透明三角形,另一个用于蓝色透明三角形,负责红色透明三角形的着色器要先于负责蓝色透明三角形的着色器完成。在标准的管线中,片段结果会在被处理前在合并阶段被排序。光栅顺序视图(Rasterizer Order View,ROV)在DirectX 11.3被引入来强制执行的顺序。这些视图和UAV相像,它们可以以相同的方式被着色器读取和写入。关键区别是ROV保证了数据会以恰当的顺序被访问。这让这些着色器可访问的缓冲又更加有用了。例如,ROV让像素着色器编写它自己的混合算法变得可能,因为像素着色器可以访问和写入ROV中的任意位置,因此不需要合并阶段来实现。代价是,当无序的访问被侦测到时,像素着色器的调用可能要停滞到先前被绘制的三角形被处理完为止。
合并阶段(The Merging Stage)
正如之前的部分讨论的那样,合并阶段是每个片段的深度和颜色与帧缓冲相结合的阶段。DirectX称这个阶段为输出合并阶段(Output Merger),而OpenGL称之为逐样本操作(Per-Sample Operation)。在绝大多数的传统管线的范例中,这个阶段会有模板缓冲和z缓冲的操作。如果片段是可见的,另一个会发生的操作是颜色混合。对于那些不透明的表面,不会涉及到混合,片段的颜色会直接替代当前存储的颜色。片段和存储的颜色的混合通常被用于透明度和组合操作。
想象一下,由光栅化生成的片段通过了像素着色器,接着在合并阶段由z缓冲上对应位置存储的值发现它应该被隐藏,那么之前在像素着色器上的计算都成了无用功。为了避免这种浪费,许多GPU会在像素着色器被执行前执行一些混合测试。片段的z深度会被用来测试可见度。如果片段被发现是隐藏的,那么会被剔除。这个功能被称为早期深度测试(early-z)。像素着色器有能力改变片段的深度或者抛弃掉片段,如果这两个操作不在像素着色器程序中,那么早期深度测试通常不能被使用而被关闭,这会让管线更低效一些。DirectX 11和OpenGL 4.2允许像素着色器强制早期深度测试开启,虽然有着一些限制。
合并阶段介于固定功能的阶段和完全可编程的着色器阶段之间。虽然它是不可编程的,但它的操作是高度可配置的。特别是颜色混合,它可以被设置来执行大量不同类型的操作。最常见的是涉及到颜色和阿尔法值的乘法、加法、减法的组合。其他操作也是可能的,比如取最大和取最小。此外,还有位逻辑操作。DirectX 10增加了能力来混合来自像素着色器的两个颜色和帧缓冲上的颜色。这个能力被称为双源色混合(Dual Source-Color Blending),它不能被用于多个渲染目标。DirectX 10.1引入了为每个渲染目标使用分开的混合操作的能力。
正如前一个部分提到的那样,DirectX 11.3提供了方法以牺牲性能为代价让混合通过ROV可编程。ROV和合并阶段都保证了绘制顺序,也被称为输出不变性(Output Invariance),这是一个API要求,结果应该被排序以输入的顺序被送入合并阶段,一个物体接一个物体,一个三角形接一个三角形。
计算着色器(The Compute Shader)
GPU不止能被用于实现传统的图形管线。它还有很多的非图形用途,比如用于深度学习的神经网络训练。这样使用硬件的方式被称为GPU计算(GPU Computing)。CUDA和OpenCL这种平台都被用来控制GPU,让它作为一个大规模并行的处理器,而不需要或访问图形特定的功能。这些框架通常使用C语言或有着扩展的C++,以及一些用于GPU的库。
计算着色器(Compute Shader)在DirectX 11中被引入,它是GPU计算的一种形式,不被锁定于图形管线中的某个位置,和渲染过程密切相关由图形API调用。它与顶点、像素还有其它的着色器被一起使用,和别的着色器一样有着相同的统一着色处理器的池子。此外,它也有着一些输入数据并且可以访问缓冲,用于输入和输出。warps和线程在计算着色器中变得更可见。比如,每个调用都会被分配它能读取的线程索引。此外,也有线程组(Thread Group)这一概念,在DirectX 11中由\(1\)至\(1024\)个线程组成。这些线程组都通过x、y、z坐标来声明,主要方便在着色代码中使用。每个线程组都有小的共享内存可以被线程组中的线程使用。在DirectX 11中有\(32\)kB。计算着色器是被线程组执行的,组内的所有线程都保证以并行的方式运行。
计算着色器的一个显著优势是可以访问GPU生成的数据。数据从GPU向CPU传输会有延迟,如果处理还有处理的结果都能驻留在GPU上,那么就能提升性能。后处理,被渲染的一张图像以某种方式被修改,是计算着色器常见的用途。共享内存意味着来自图像采样的中间结果可以被相邻的线程共享。例如,使用一个计算着色器计算分布或平均化一张图像的亮度,已经被证实两倍快于使用像素着色器的实现。
计算着色器对粒子系统、网格处理、剔除、图像滤波、改善深度精度、阴影、景深等可以使用一些GPU处理器实现的任务来说也很有用。下图展示了计算着色器的另外一些用途

小结
到这里,我们对GPU实现的渲染管线的述评就结束了。有非常多的方法使用并结合GPU的功能来实现各种和渲染相关的处理。相关的理论以及算法被调整来利用这些能力的优势是这本书的中心主题。我们学习的重心现在将移动到变换和着色。
本文来自博客园,作者:TiredInkRaven,转载请注明原文链接:https://www.cnblogs.com/TiredInkRaven/p/19156391

浙公网安备 33010602011771号