“The display is the computer”——Jen-Hsun Huang
历史上来说,图形加速开始于对扫描线和三角形重叠的每个像素计算颜色插值然后显示在这些值。包括访问图像数据的能力使得贴图能被应用于物体表面。增加用于插值和z深度测试的硬件提供了内置的可见性检测。因为这些都是高频率使用的,这些处理对于专用硬件提升性能做出了贡献。渲染管线里更多的部分,和每个部分更多的功能,在后续的改进中被添加进来。专用的图形硬件相比于CPU唯一的优势就是速度,但是速度是至关重要的。
过去的二十年里,图形硬件经历了难以置信的改变。第一个包含了顶点处理的消费级图形芯片(NVIDA的GeForce256)在1999年发货。NVIDIA创造了这个词图形处理单元graphics processing unit(GPU)来把GeForce256和之前的只能做光栅化的芯片区分开,并且这个名词流传了下来。在接下来的几年里,GPU从一个可以配置的固定功能管线进化为高度可编程的白板,程序员们可以应用自己的算法。各种可编程的着色器成为首选的控制GPU的方式。为了效率,管线的一些部分仍然只是可配置的,并非可编程的,但是趋势是朝着可编程和灵活性发展的。
GPU通过把功能聚焦于一些狭小的高并行化任务来获取高速运行。举例来说,他们自定义芯片来实现z缓冲,高速访问贴图和其他的缓冲,以及寻找哪个像素是被三角形覆盖的。这些元素如何执行各自的工作会在23章介绍。更重要的是需要提前知道GPU如何并行化处理他的着色器程序。
3.3节解释了着色器如何工作。现在,你需要知道的是一个着色器核心是一个小的处理器可以做一些相对独立的任务,比如把顶点坐标从从世界空间转到屏幕坐标,或者计算三角形覆盖的像素的颜色。随着每帧上百万三角形发送到屏幕上,每一秒都可能有十亿调着色器调用shader invocations,即是,独立的运行着色器程序的实例。
首先,延迟latency是所有处理器要面对的一个关注点。访问数据需要消耗一些时间。一个简单的理解延迟就是信息离处理器越远,需要等待的时间就越长。23.3节谈到了延迟的细节。存在内存芯片上的信息会比存在本地寄存器的需要花费更多时间访问。18.4.1节深入讨论了内存访问。关键点在于等待数据检索意味着处理器被阻塞,这会降低性能。
3.1 Data-Parallel Architectures 数据并行的架构
不同的处理器架构使用了很多策略也避免阻塞。一个CPU是对于处理各种不同数据结构和大规模代码来优化的。CPU可以有多个处理器,但是每个核多数情况下都是执行串行,受限制的单指令多数据SIMD向量处理是例外。为了最小化延迟的影响,多数CPU芯片都包含高速的本地缓存,是一块存满最可能被用到的数据。CPU也会用一些聪明的策略来处理阻塞,比如分支预测,指令排序,寄存器重命名,和缓存预载入。
GPU使用了不同的方法。多数GPU的芯片范围是一大组处理器,叫做着色器核shader cores,一般都是上千的数量。GPU是流处理器,这种类型就是排好序的相似的数据是轮流处理的。因为这种相似性——例如一组顶点或者像素——GPU可以用大量并行处理的方式。另一个重要的的点是这些调用是尽可能做到相互独立的,比如他们不需要获取邻近调用的数据信息,也不需要共享可写的内存地址。这个规则有时候也会被打破,为了一些新的有用的功能,但是这种例外会带来潜在的延迟代价,因为一个处理核心可能会等待另一个处理核心完成工作。
GPU是针对吞吐量来优化的,定义为最大的处理数据比率。但是,这个快速处理也有代价。缓冲和控制逻辑上更少的芯片区域,就会导致每个着色器核心的延迟显著高于CPU里处理器。
假设一个网格被光栅化并且有2000个像素有片元要被处理;一个像素着色器程序会被调用2000遍。想象只有一个单一的着色器处理器,世界上最弱的GPU。它会执行着色程序从第一个片元到第两千个。这个着色处理器使用寄存器里的值进行了一些算数运算操作。寄存器是在本地的,访问很快,所以没有阻塞。这个着色处理器随后执行到了一个指令,像是访问贴图;例如,对一个给定的表面位置,程序需要知道应用于这个网格的像素颜色。一个贴图是一个完全独立的资源,并不在像素程序的本地内存里,访问贴图就会被涉及。一次内存访问可以耗费成百上千个时钟周期,这段时间里GPU处理器是没有执行操作的。在这里,着色处理器会被阻塞,等到返回贴图的颜色值。
为了让这个糟糕的GPU变好一点,在寄存器里给每个片元一点存储空间。现在,不会再因为访问贴图被阻塞了,着色器处理器可以切换执行另一个片元,2000个里面的第二个。这个切换是非常快的,第一个和第二个片元之间没有什么东西是相互影响的,除了要注意第一条执行的是什么指令。现在第二个片元被执行了。和第一个的情况一样,一些算数操作执行后,然后再次要读取一张贴图的数据。着色器核心现在就转向执行第三个片元。最终所有的2000个片元都按照这个方式执行。这时处理器回到第一个片元。这个时候贴图颜色已经拿到了,所以着色程序可以继续执行了。处理器会继续用这种方式执行直到遇到了另一个会阻塞的指令,或者整个程序执行完毕。一个片元会消耗更多的时间比起处理器一直等待执行完这个片元,但是总体上的执行时间会显著的下降。
在这种架构下,延迟会在GPU频繁的切换片元中隐藏不见。GPU沿着这个设计更进一步,把指令逻辑和数据分离开。叫做单一指令多数据single instruction, multiple data(SIMD),这种安排是在锁定步进lock-step的方式在固定数量的程序上执行同一条指令。SIMD的优势是显著的减少处理数据和切换成时需求的硅(和电量消耗),相比于使用独立的逻辑和发送来处理每个程序。把我们两千个像素的例子放到现代GPU上,每个对一个片元的像素着色器调用叫做线程thread。这种线程和CPU的线程不一样。它是由一些用于存储来自于着色器的数据的内存和用于执行着色器运算的寄存器空间组成。使用相同着色器程序的线程会放在同一组group里,NVIDIA叫做打包warps,AMD称之为波前wavefronts。一个warp/wavefront一堆GPU着色处理器的调度,从8到64,使用SIMD的处理过程。每个线程都会被映射到衣蛾SIMD线lane。
假设我们有两千个线程需要执行。NVIDIA的GPU上的wrap包含32个线程。这产生了2000/32=65.2个wraps,意味着会分配63个warp,有一个warp是空了一半的。一个warp的执行和我们的单核GPU相似。着色器程序在32个处理器上都以lock-step的方式执行。当遇到了一个内存读取时,所有的线程都在同一时间遇到,因为所有的核心都在执行相同的指令。读取指令会让这个warp阻塞住,全都在等各自的结果。为了不被阻塞,这个wrap就被替换了另外32个线程继续执行。这种替换和我们之前的单处理器系统一样快,因为没有一个线程内部有正在使用的数据。每个线程有自己的寄存器,每个warp保持跟踪自己在执行的命令。和一个新的warp交换只是改变了核心指向新的一组线程执行;没有别的话费。warps执行或者替换知道所有的内容执行完。见图3.1。
在我们的简单例子里,一次贴图内存读取的延迟会引起一次warp的替换。实际上更短的延迟也会引起warp的替换,因为替换的代价太低了。还有几个别的技术可以用来优化执行,但是warp替换是主要的GPU使用的降低延迟的机制。处理过程的效率包含多个因素。距离来说,如果线程更少,创建的warp也会更少,会使得延迟隐藏出问题。
着色器程序的结构是一个影响性能的重要原因。一个主要的因素是每个线程使用的寄存器数量。在我们的例子里我们假设2000个线程可以同时常驻于GPU。着色器程序在每个线程上需要的寄存器越多,就会使得GPU上常驻的线程数量越少,因此warps也越少。warp的不足意味着阻塞不会被替换操作缓和。常驻的warp叫做在执行的in flight,这个数量叫做占有率occupancy。高占有率意味着有更多的warp可用,所以处理器更小的可能会被闲置。低占有率经常带来差的性能。内存读取的频率也影响延迟隐藏的需求程度。Lauritzen描述了占有率如何被寄存器数量和着色器使用的共享内存影响。Wronsk讨论了理想状态下占有率如何随着着色器程序的不同操作来变化。
另一个影响整体销量的就是动态分支,由if分支语句和循环引起的。说到一个if语句出现在着色器程序里。如果所有的线程进行求值并使用同样的分支,warp可以在执行过程中不需要考虑其他分支。但是,如果一部分线程,甚至只有一个线程,走了另一条分支warp就必须执行两个分支,扔掉被对应分支不需要的结果。这个问题叫做线程分异thread divergence,一部分线程可能需要执行一个loop迭代或者执行一个if分支语句,warp里其他的线程则不需要,使得他们在这段时间里处于空闲的状态。
所有的GPU实现这种体系思想,结果是系统有严格的限制但是每瓦塔的电能消耗可以执行海量的运算。理解这个系统如何运转会帮助你成为一个更高效利用开销的程序员。在这节里跟随我们讨论GPU如何实现光纤,可编程着色器如何执行操作,以及每个GPU的阶段的演变和功能。
3.2 GPU Pipeline Overview GPU管线综述
第二章介绍了GPU实现了概念上的几何处理、光栅化和像素处理管线阶段。这些步骤被细分为多个硬件阶段分别拥有不同程度的可配置性和可编程性。图3.2展示了依据可编程或可配置的程度来标注不同颜色的阶段。要注意第二章里实际上的阶段划分和概念上的有所不同。
我们这里谈论的是GPU的逻辑模型logical model,是以API形式暴露给程序员的东西。如同18和23章讨论的,这个逻辑管线的实现,逻辑模型,取决于硬件厂商。逻辑模型里一个固定功能的阶段可能被GPU以给邻近的一个可编程阶段添加几条指令的方式执行。单一一个程序可能被分割为多个部分由独立的子单元执行,或者完全由一个独立的过程执行。这个逻辑模型可以帮你理解什么对性能有影响,但是不能被误认为是GPU实际上的管线实现。
顶点着色器是一个完全可编程的阶段被用于实现几何处理阶段。几何着色器是一个完全可编程的阶段用于操作图元(点,线或三角形)的顶点。它可以用来执行逐图元着色操作,销毁图元或者创建新图元。曲面细分阶段和几何着色都是可选的,并非所有GPU都支持,特别是移动设备。
裁剪,三角形设置,好三角形遍历阶段是由功能固定的硬件实现的。屏幕映射受到窗口和视角的设置影响,内在的执行一个简单的缩放和重定位操作。像素着色器阶段是完全可编程的。虽然合并阶段不是可编程的,但它是高度可配置的并且可以被设定执行非常多的操作。它实现了合并功能阶段,负责修改颜色,z缓冲,颜色混合,模板和其他任意输出相关的缓冲。在第二章中讲到像素着色器核合并阶段组成了概念上的像素处理阶段。
随着时间的推移,GPU管线从硬编程的操作往愈发灵活可控的方向进化。可编程着色器阶段的引入是进化中最关键的一步。下一节描述了对于不同可编程阶段常见的一些特性。
3.3 The Programmable Shader Stage
现代着色器程序使用同一的着色器设计。这意味着顶点,像素,几何,和曲面细分相关的着色器共享一个通用的编程模型。内在的他们拥有同一个指令集架构instruction set architecture(ISA)。DirectX里面把一个实现了这个模型的处理器叫做一个通用着色器核心common-shader core,并且一个拥有这种核心的GPU叫做标准着色器架构。这种架构类型背后的想法是着色器处理器被用于多种角色,并且GPU可以按照合适的方式分配。举例来说,一组包含细小的三角形的网格会需要更多的顶点处理,相比于由2个三角形构成的大四边形。一个拥有独立的顶点着色器核心池和像素着色器核心池的GPU意味着预先就确定了一种理想的任务分配使得每个核心都处于工作状态。拥有这些标准的着色器核心,GPU可以决定如何平衡负载。
介绍完整的着色器编程模式不属于本书的范围,市面上有太多的资料,书籍和网站已经做了这件事。着色器是用类似C的语言编程,像是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,在一个draw call中保持值不会变化的,和变量输入varying,来自于三角形和顶点或者光栅化的数据。举例来说,一个像素着色器可能提供光的颜色作为一个不变值,和三角形的表面坐标随着每个像素变化,所以是变化的。贴图是一种特殊的不变输入,曾经是一个应用于表面的颜色贴图,现在可以被认为是一个大数组。
基础的虚拟机提供特别的寄存器给不同的输入输出。给不变值的可用的常量寄存器constant register数量远大于给输入输出的变量的寄存器。这是因为输入输出的变量需要对每个顶点和像素都单独存放,所以就根据需求会一个限制。不变的输入一次存储可用在整个draw call内被所有的顶点和像素使用。虚拟机也有通用的临时寄存器temporary register,被用于暂存空间。所有类型的寄存器都可以使用临时寄存器中的整数按数组下标检索。着色器虚拟机的输入输出可以在图3.3看到。
现代GPU上图形计算中的常见操作执行起来非常快。着色器编程语言通过操作符*和+提供这些操作(像是加法和乘法)。其他的用过内置函数提供,像是atan() sqrt() log(),和很多其他的操作,都针对GPU优化。也有一些复杂操作的函数,像是向量归一化,和反射,叉乘,矩阵装置和行列式计算。
这个词流控制flow control意思是使用分支指令来改变代码的执行流。和流控制相关的指令被用于实现高级语言结构像是“if”和“case”语句,还有各种各样的循环语句。着色器支持两种类型的流控制。静态流Static flow control分支基于不变的输入值。这意思是在一个draw call里的分支走向是固定的。静态流主要的好处是允许同一个着色器被不同场景应用(例如一个变化的灯光数量)。没有分支差异,因为所有的调用都走同一条路。动态流控制dynamic flow control是基于变量输入,意思是每个片元的执行都可能不同。这个比静态的功能更强但是开销也更高,特别是在着色器调用中代码流不规律的改变。
3.4 The Evolution of Programmable Shading and APIs 可编程着色技术和APIs的演进
可编程着色技术框架的想法可以追溯到1984年Cook的shade trees。一个简单的shader和对应的着色树在图3.4中显示。RenderMan着色编程语言基于这种想法在1980年代晚期开发。今天仍然在电影渲染中使用,以及一些其他革命性的规格,像是Open Shading Language(OSL)项目。
消费级图形硬件第一次取得成功是1996年十月一号发布的3dfx。图3.5是这一年的时间线。他们的Voodoo显卡能够高质量高性能渲染游戏Quake使得其广受欢迎。这个硬件实现了一个完全固定功能的管线。在GPU原始的支持shader编程之前,也有几个尝试实现实时的可编程着色操作通过多重渲染通道。游戏Quake III: Arena脚本语言在1999年取得了第一个传播很广的商业成功。如同在本章一开始提到的,NVIDIA的GeForce256是第一个被称作GPU的硬件,但是这不是可编程的。不过,它是可配置的。
在2001年初,NVIDIA的GeForce3是第一个支持可编程顶点着色器的GPU,通过DirectX8调用,并且扩展到了OpenGL。这些着色器是用类似于汇编的语言开发并由驱动程序转化为微码microcode。像素着色器也包含在DirectX 8.0里,但是像素着色器准确说不是可编程的——有限的程序支持,由驱动转化到贴图混合状态,轮流连接硬件的寄存器混合器。这些程序不止是长度受限(12条指令或者更少),而且缺少重要的功能。Peercy等人在关于RenderMan的研究中,认为相关的贴图读取和浮动的点数据对于真正的可编程性是至关重要的。
那个时候的着色器还不支持流控制(分支),所以条件判断需要被计算两种情况并且选择或者插值得到的结果。DirectX定义的着色器模型shader model(SM)的概念用来区分对于着色器有不同程度支持能力的硬件。2002年发布了DirectX9.0包含了Shader Model2.0,这版真正支持了可编程的顶点和像素着色器。类似的功能在OpenGL里也通过一些扩展来提供。最终加入了对于任意相关的贴图读取和16位的浮点数值的支持,完成了Peercy等人设定的一系列要求。着色器资源(指令,贴图,寄存器)的限制增加,所以着色器变得有能力实现更复杂的效果。流控制的支持也加入了。着色器的长度和负责度增加使得汇编程序模型变得越来越笨重。幸运的是,DirectX9.0也包含了HLSL语言。这个着色编程语言是微软和英伟达合作开发的。与此同时,OpenGL的ABB(体系结构评审委员会)发布了GLSL语言,一个用于OpenGL的很相似的语言。这些语言的语法和设计哲学都收到了C语言极大的影响,也有一些来自RenderMan 着色语言的影响。
着色器模型3.0在2004年提出,并且增加了动态的流控制。使得着色器变得极为强大。它也把一些额外可选的功能转化为了要求,进一步增加了资源限制并且增加了在顶点着色器里对贴图读取的有限制的支持。当新世代的游戏主机在2005年(微软的Xbox360)和2006年(索尼的PS3)发布,他们都装备了支持ShaderModel3.0的GPU。任天堂的Wii主机是最后一个使用固定管线的GPU的主流设备,首发于2006年底。纯粹的固定功能管线已经过时很久了。着色器语言进化到了一个点,大量的工具用于创建和管理他们。图3.6是一个叫做Cook的工具截图,展示了着色树的概念。
可编程的下一个大进步发生在2006年底。Shader Model4.0,包含在DirectX10里,引入了几个主要的功能,比如几何着色器和流输出。Shader Model 4.0包含了一个对所有着色器(顶点,像素和几何)统一的编程模型,这个统一着色器设计前面提过。资源的限制进一步增加,增加了对于整数的支持(包括位操作)。OpenGL3.3的GLSL3.3也引入了类似的着色器模型。
在2009年DirectX11和Shader Model5.0发布,增加了曲面细分阶段着色器和计算着色器,也叫作DirectCompute。这次发布也关注与支持GPU的多线程处理更高效,18.5节里讨论。OpenGL在4.0版本增加了曲面细分和4.3引入了计算着色器。DirectX和OpenGL发展的有所不同。都对于每个版本设定了硬件支持需求。微软控制DirectX API所以可以直接与独立的硬件厂商合作(IHV),比如AMD,NVIDIA,和Intel,还有游戏公司和计算机辅助设计软件公司,来决定提供哪些功能。OpenGL是一个硬件和软件厂商的联合团体开发的,由非盈利组织Khronos Group管理。因为大量公司参与,API功能经常在DirectX发布之后才在OpenGL里出现。但是,OpenGL允许一个拓展机制extensions,对于特定厂商支持或者是通用的,这个可以允许在官方发布之前就使用最新的GPU功能。
下一个重大的API变革是由AMD在2013发布Mantle API带来的。与游戏开发商DICE合伙开发,Mantle的理念就是压缩图形驱动的开销,把功能交给开发者直接控制。同时这个重构进一步支持高效的CPU并行处理。这种新的API关注于显著的降低CPU在驱动部分花的时间,以及更有效的CPU多核处理支持(18章)。Mantle开拓的这个理念被微软所采纳并发布在2015年的DirectX 12中。注意DirectX 12并不是关注与曝光新的GPU功能——DirectX 11.3就曝光了相同的硬件功能。两种API都可以被用于发送图形给虚拟现实系统如Oculus Rift和HTC Vive。但是,DirectX 12是彻底的API重新设计,更好的映射于现代GPU架构。低开销的驱动对于应用非常有用当CPU驱动的消耗是一个性能瓶颈时,或者使用更多的CPU核心用于图形可以有利于性能。移植早期的API会很困难,并且原生的实现会造成低性能表现。
Apple在2014年发布了自己的低开销API叫做Metal。Metal最开始用于移动设备如iPhone 5S和iPad Air,一年后新的Macintoshes系统OS X EI Capitan也可以使用。除了性能,降低CPU使用率也可以省电,是移动设备的一个重要因素。这个API有自己的着色语言,用于图形和GPU compute程序。
AMD把它的Mantle工作捐给了Khronos Group,并在2016年发布了新的API叫做Vulkan。正如OpenGL一样,Vulkan也是跨平台的。Vulkan使用了一种新的高级中间语言叫做SPIRV,用于编写着色器和GPU计算程序。预编译的着色器是便携的可以用于任何支持相关性能的GPU上。Vulkan也可以用于非图形的GPU计算,因为它不需要显示一个窗口。Vulkan对比其他低开销驱动的一个显著的区别是它意味着可以在更大范围的设备上运行,从工作站到移动设备。
在移动设备中使用的规范是OpenGL ES。“ES”的意思是嵌入式系统Embedded Systems,正如这套API就是面向移动设备开发的。标准的OpenGL此时显得太庞大了,并且有时它自身的调用系统会很慢,也需求对于一些很少用到的功能支持。发布于2003年,OpenGL ES 1.0是OpenGL 1.3的一个缩水版本,描述了一个固定功能的管线。虽然DirectX的发布是时间上和图形硬件一起并作为支持,开发移动设备的图形支持并不是用同样的处理方式。举例来说,第一个ipad,2010年发布,实现了OpenGL ES 1.1。在2007年OpenGL ES 2.0版本就发布了,提供了可编程着色器。它基于OpenGL 2.0但是没有固定功能的组件,所以并不对OpenGL ES1.1逆向兼容。OpenGL ES3.0在2012年发布,提供了许多功能像是多目标渲染multiple render targets,纹理压缩texture compression,变换反馈transform feedback,实例化instancing,和一个更大的贴图格式和模式支持范围,以及着色器语言改进。OpenGL ES3.1增加了计算着色器,3.2版本增加了几何和曲面细分着色器,和一些其他的功能。23章讨论了移动设备架构的细节。
一个OpenGL ES是分流是基于浏览器的WebGL API,通过JavaScript调用。2011年发布,这个API的第一个版本在大多数移动设备上可用,它在功能上等同于OpenGL ES2.0。像OpenGL一样,扩展支持使用一些先进的GPU功能。WebGL2假设OpenGL ES3.0是支持的。
WebGL是特别适合用来实验新功能或者在教学中使用:
- 它是跨平台的,在所有的电脑上都能用,几乎所有的移动设备也都支持。
- 驱动许可是由浏览器处理的。即使一个浏览器对于某款GPU或者扩展不支持,也能找到另一个支持的。
- 代码是解释执行的,不需要编译,开发只需要一个文本编辑器。
- 大部分浏览器都有内置调试器,任何页面上的运行代码都可以被检测。
- 程序员可以通过上传到网页来部署,比如GitHub。
高级的场景图scene-graph和效果库像是three.js可以轻易的访问使用各种效果的代码像是阴影算法,后处理效果,基于物理的着色和延迟渲染。
3.5 The Vertex Shader 顶点着色器
图3.2中顶点着色器是渲染管线的第一个阶段。尽管这是程序员可以直接控制的第一个阶段,这一步之前的数据操作也是值得关注的。在DirectX称为输入集input assembler中,几个不同的数据流可以被交织在一起形成顶点和图元的集合发给管线的下一个阶段。举例来说,一个物体用一个坐标数组和一个颜色数组来代表。这个输入集可以创建这个物体的三角形(或者线段和点)通过基于顶点和颜色创建的顶点。第二个物体可以使用同一个坐标数组(同时需要一个不同的模型变换矩阵)和一个不同的颜色数组用于表示。数据的表示在16.4.5节中细致讨论。也有对于输入集执行实例化的支持。这允许一个物体画多次,每次个实例改变一些数据,在同一个draw call里执行。实例化的使用在18.4.2节里介绍。
一个三角形的网格是通过一组顶点来表示,每个顶点都关联一个特定的模型表面的坐标。除了坐标,还有其他可选的顶点属性,像是颜色和纹理坐标。表面法线也在网格顶点里定义,可能看上去像一个奇怪的选择。数学上,每个三角形都有一个定义好的表面法线,可能更好理解直接使用三角形的法线去着色。但是,当渲染时,三角形网格经常被用于表示一个曲面,顶点的法线用于表示曲面的朝向,并不是三角形的法线。16.3.4节会讨论计算法线的方法。图3.7展示了两个三角形网格来表示曲面的侧视图,一个是平滑的,另一个是有锋利的褶皱。
顶点着色器是处理三角形网格的第一个阶段。描述哪些三角形形成的数据对于顶点着色器是不可获取的。如同名字所暗示,它专门处理输入的顶点。顶点着色器提供了一个方法来改变,创建,或者忽略一些放在三角形顶点里的值,像是它的颜色,法线,纹理坐标,和位置。一般来说顶点着色器程序把顶点从模型空间转换到裁剪空间(4.7节)。至少,顶点着色器必须输出这个位置。
一个顶点着色器是和前面讲的标准着色器非常一样的。每个顶点传入然后被处理,然后输出一系列依据三角形或者线段插值的结果。顶点着色器既不能创建也不能销毁顶点,一个顶点着色器生成的结果也无法传递给另一个顶点着色器。因为每个顶点着色器都是单独对待的,拥有任何数量处理核心的GPU都可以采用并行的处理输入的顶点数据流。
输入几何经常表示为一个顶点着色器执行之前的处理流程。这是一个例子,用来说明物理实现经常和逻辑上不完全一样。实际上,获取数据创建顶点可能发生在顶点着色器并且驱动可能默默的预先考虑了每个着色器通过核实的指令,这对于程序员不可见。
接下来的章节解释了集中顶点着色器效果,显示动画对接的顶点混合,轮廓渲染。其他对于顶点着色器的使用包括:
- 物体的生成,通过创建一次网格,然后通过顶点着色器来变形。
- 使角色的身体和脸动起来通过skinning和morphing技术。
- 程序化的变形操作,显示旗帜,衣服和水的动态变化。
- 粒子创建,通过发送退化(没有范围)的网格给管线,然后使其被给定一个需求的区域。
- 镜头扰动,热气朦胧,水波纹,页面弯曲,和其他的效果,通过使用整个帧缓冲的内容作为一个贴图,在一个屏幕匹配的网格上执行程序化的形变。
-
应用地形高度场通过获取顶点贴图。
一些使用顶点着色器的形变在图3.8中显示。
3.6 The Tessellation Stage 曲面细分阶段
曲面细分阶段允许我们渲染曲面。GPU的任务是把输入的表面表述转化为一组三角形。这个阶段是一个可选的GPU功能,第一次出现在DirectX 11上。这个功能也在OpenGL4.0和OpenGL ES3.2中支持。
有几个使用曲面细分阶段的好处。对于对应的三角形本身来说,曲面的描述往往太紧凑了。除了节省内容,这个功能可以保持CPU和GPU中的数据总线不要成为一个动画角色或者一个每帧都改变形状的物体的瓶颈。有了对于给定视角合适数量的生成的三角形,表面可以被高效的渲染出来。举个例子,如果一个球离相机很远,只需要一点点三角形。这个控制细节等级的能力也可以使得一个应用能控制性能,比如使用一个低模在一个较弱的GPU上来保持帧率不掉。模型一般是用平面来表示,可以被转换为一个三角形网格然后可以按希望来弯曲,或者可以被细分来减少执行高开销的着色计算。
曲面细分通常由三个元素组成。使用DirectX的术语,就是外壳着色器hull shader,曲面细分器tessellator,和域着色器domain。在OpenGL里外壳着色器叫做曲面细分控制着色器tessellation control shader,域着色器叫做曲面求值着色器tessellation evaluation shader,这种说明更清楚一点,尽管有点啰嗦。固定功能的曲面细分器叫做曲面生成器primitive generator在OpenGL里,我们会看到这确实就它具体做的事情。
如何指定和细分曲面和曲线会在17章里说。这里我们总结一下曲面细分阶段的目的。开始,传入hull shader的是一个指定的批patch的图元。这个由几个控制点组成,顶一个这个面的细分,贝瑟尔批Bezier patch,或者其他类型的弯曲的元素。hull shader有两个功能。第一,它告诉曲面细分器有多少有多少三角形需要生成,并且以何种配置来操作。第二,它处理每个控制点。同时,可选的是,hull shader可以改变输入的批的描述,根据意愿增加或者减少控制点。hullshader输出控制点集合,以及一些曲面细分控制数据,给域着色器。见图3.9。
这个曲面细分器是管线里一个固定功能的阶段,只能通过曲面细分着色器使用。它负责添加一些顶点给domain shader处理。hull shader发送给曲面细分器的信息是想要的曲面类型:三角形,四边形,等值线。等值线是一组线段集合,有时会用于头发渲染。另一个由hull shader发送的重要的值是曲面细分因子(OpenGL里面叫做曲面细分级别tessellation levels)。这些有两种类型:内边和外边。两个内因子决定了三角形或者四边形内部曲面细分的程度。外因子决定了多少外部的边被分割(17.6节)。图3.10里展示了一个增加曲面细分因子的例子。通过执行分别的控制,我们可以使相邻的曲面的边在曲面细分里匹配,不用考虑内部如何细分。边匹配就可以避免裂痕或者其他的一些着色引起的人工痕迹。这些顶点被指定了一些有重心的坐标(22.8节),给想要的曲面上每个点指定了一个相对的坐标。
Hull shader一般是输出一个批,是一组控制点坐标。但是,它也可以通过发送给曲面细分器一个外部曲面细分等级为0或者更低(非数值,NaN)来提示这个批需要舍弃。否则,这个曲面细分器还是会生成一个mesh并且发送给domain shader。来自hull shader得出的曲面的控制点在domain shader中每次计算顶点输出值的调用中被用到。Domain shader有着一个和顶点着色器类似的数据流模式,每个来自曲面细分器的输入顶点都会被处理并生成一个对应的输出顶点。形成的三角形会继续传递给管线的下一步操作。
虽然这个系统听起来很复杂,他构建成这个样子只是为了更高效,每个shader其实都很简单。传进hull shader里的patch经常只有一点点修改或者完全没有。这个shader也能使用patch预估的距离或者屏幕尺寸来计算曲面细分因子,就像是在terrain渲染里做的。另外,hull shader可能简单的传递一组固定的值对于程序计算出来提供的所有的patch。曲面细分器扮演一个参与但是功能固定的生成顶点处理过程,给他们一些坐标,指定需要生成什么三角形或者线段。这个数据放大的阶段是在shader之外的,可以提高计算效率。Domain shader输入每个点生成的重心坐标并且用在patch的求值方程中来计算坐标,法线,纹理坐标和其他的希望得到的顶点信息。看图3.11中的例子。
3.7 The Geometry Shader 几何着色器
几何着色器可以改变图元,在曲面细分阶段无法做的。举例来说,一个三角形网格可以转化为一个线框通过每个三角形创建变。另外,线段可以用面向观察者的四边形取代,如此通过更厚的边来实现线框渲染。2006年发布的DirectX 10把几何着色器加入了硬件加速的图形管线里。这一步在管线里位于曲面细分之后,也是非必选的。由于需求Shader Model 4.0的配置,所以在早期的shader model里没有应用。OpenGL3.2和OpenGL ES3.2支持这个类型的shader。
几何着色器的输入是一个物体和其相关的顶点。这个物体一般组成是三角形,线段或者一个简单的点。扩展的图元可以通过几何着色器来定义处理。特别是,三角形外的三个额外点可以传入,多线段的两个邻近顶点也可以使用。见图3.12。有了DirectX 11和Shader Model 5.0,你可以传入更复杂的patch,拥有32个控制点。就是说,曲面细分阶段执行patch生成会更高效。
几何着色器处理一个图元然后输出0个或更多的顶点,这些被当做点,多线段,或者一条三角形处理。注意几何着色器可以产生没有输出的情况。这种方式,一个网格可以有选择的修改顶点,添加图元和删除一些内容。
几何着色器是设计用来修改输入数据或者作出有限份的复制。例如说,一个用法是对一份数据生成六份转换后的拷贝来同时渲染一个cube map的六个面;见10.4.3.节。它可以被用来生成高质量的层级阴影。其他利用几何着色器优势的算法包括从点的数据创建大小可变的粒子,给轮廓线挤压片用于进一步渲染,以及为阴影算法寻找物体边界。见图3.13里的更多例子。各种各样的用法讨论会贯穿余下的整本书。
DirextX 11为几何着色器增加了使用实例化的能力,这样几何着色器可以在给定图元上运行多次。在OpenGL4.0里这个叫做调用计数。几何着色器也可以最多输出4个数据流streams。一个数据流可以被送往后续的管线进一步处理。这些数据流都可以被传出到一些渲染目标中。
几何处理器保证能按照输入的顺序输出结果。这一点影响性能,因为如果几个着色器核并行处理,得到的结果必须要保存并排序。这一点和其他的一些因素会使得几何着色器难以在一次调用中用于复制或者创建大量的几何图片。
在一次draw call请求被发出,渲染管线里只有三个地方的工作可以被GPU创建:光栅化,曲面细分阶段,和几何着色器。这些,在考虑到资源和内存需求的时候,几何着色器的行为是最难以预测的,因为这是完全可编程的部分。实际中几何着色器一般很少使用,因为这个和GPU的能力不是很匹配。在一些移动设备上,这部分工作在软件阶段实现,所以对几何着色器的使用也是不鼓励的。
3.7.1 Stream Output 流输出
GPU的渲染管线标准用法是发送数据经历顶点着色器,然后是光栅化得到的三角形,并且在ps里处理。曾经是数据在管线里穿行,但是中间结果是不可访问的。Shader Model4.0引入了流输出stream output的概念。在顶点被vs处理之后(并且,可选的步骤还有曲面细分和几何着色器),这些可以在一个数据流里输出了,例如,一个有序的数组,而不是发送给光栅化阶段。光栅化可以,事实上,被完全关闭,并且渲染管线随后可以被纯粹的当做一个非图形的流处理器使用。这种方式处理的数据可以被送回管线,因此可以执行迭代的处理。这种类型的操作可以有效的用于仿真水流或者其他的粒子特效,这部分在13.8节有讨论。它也可以用于给一个模型蒙皮,并且虽然使得这些顶点数据能够重用(4.4节)。
流输出只会以浮点数的形势返回数据,所以它会造成一个显著的内存消耗。流输出在处理图元上生效,不是直接作用于顶点。如果管线发送一个网格,每个三角形都会生成自己的三个输出顶点的几何。任何原始网格中的共享顶点都会丢失。因此一个更典型的用法是只发送顶点作为点集图元。在OpenGL里流输出阶段杰作变换反馈transform feedback,因为他的使用重点在于变换顶点然后返回用作进一步处理。图元会被保证以输出的顺序输出到目标,意味着顶点的顺序会被保持下来。
3.8 The Pixel Shader 像素着色器
在顶点,曲面细分,和几何着色器执行了操作之后之后,图元被裁剪好并设置用于光栅化,如之前篇章所描述的。这部分管线的处理步骤是相对来说固定的,例如不可编程但是可配置。每个三角形被逐个遍历来决定它会覆盖哪个像素。光栅化处理器可能会粗略的计算在每个像素范围内有多少个三角形覆盖(5.4.2节)。三角形的这块部分或者全部和像素重叠的部分叫做片元fragment。
三角形的顶点数据,包括z-buffer里的z值,对于每个像素来说是通过三角形表面插值得到的。这些值传递给像素着色器pixel shader,在这里会处理片元。在OpenGL里像素着色器叫做片元着色器fragment shader,大概是一个更好的名字。我们对于“像素着色器”的名字使用会贯穿整本书以保持连贯性。管线里的线段和点的图元一样会创造出来覆盖像素的片元。
这种三角形插值操作是由像素着色器程序来指定的。一般来说我们使用透视矫正的插值,所以当物体后退的时候,两个像素之间的世界空间下的距离会变大。一个例子就是渲染铁轨延伸到地平线。越远处铁轨越接近,因为越接近地平线的地方两个像素之间的实际距离就越大。其他的一些插值选项也有,比如屏幕空间的插值,这里就不会考虑透视投影的事。DirectX 11还可以控制插值的实际和如何执行。
用编程术语说,顶点着色器的输出,在三角形(或者线)的插值,有效的转化为像素着色器程序的输入。由于GPU参与,其他的输入也已经暴露出来。例如,在Shader Model3.0和之后的版本,片元的屏幕坐标可以在像素着色器里获取到。同样的,三角形的哪个面是可见的也作为一个输入信号。这个点对于在一个pass里给三角形的正反两面渲染上不同材质很重要。
拿到了输入数据后,一般来说像素着色器就会计算并输出一个颜色。它也可能产生一个透明度值并修改z-depth。在合并阶段,这些值用于修改像素里的值。光栅化阶段生成的深度值也可以被像素着色器更改。模板缓冲值一般来说是不可更改的,只是被传递给合并阶段。DirectX 11.3允许着色器来更改这个值。一些操作像是雾计算和alpha测试被从合并阶段移动到像素着色器里,在SM4.0版本。
一个像素着色器也有一个独一无二的能力,可以丢弃一个输入的片元,例如完全不产生输出。一个关于如何舍弃一个片元的例子在图3.14里展示。裁剪平面功能曾经是固定管线里一个可配置的元素,后来定义在了顶点着色器里面。有了舍弃片元的功能,这个功能可以在像素着色器里按照自己的想法自由实现,像是决定裁剪体应该是AND还是OR或者都有。
最初像素着色器只能输出到合并阶段,用于最终显示。随着时间发展,像素着色器能够执行的指令数目显著的增长了。这个进步启发了多目标渲染multiple render targets的想法。不同于只是把像素着色器的结果发送给颜色和深度缓冲,每个片元可以生成多组值并且存在不同的缓冲里,每个都被称为一个渲染目标render target。渲染目标一般都有相同的x和y维度;一些API允许不同的尺寸,但是渲染区域会取最小的那个尺寸。一些结构会要求渲染目标每个都有同样的位深度,以及可能要求同样的数据格式。取决于GPU,渲染目标的数量可以是4或者8个。
即使有这些限制,MRT功能也是非常强大的,能够更高效的执行渲染算法。一个单一的渲染pass可以在一个渲染目标里生成一个色彩图片,物体标识在另一个目标,以及一个世界空间下的距离在第三个目标里。这个能力可以促生一个不同的管线,叫做延迟着色deferred shading,可见性和着色在不同的pass里完成。第一个pass存储关于物体位置信息和每个像素的材质数据。后续的pass可以高效的实现光照和其他的效果。这种渲染模式在20.1节里介绍。
像素着色器的限制在于他一般只能在片元的位置所在的写入渲染目标,不能读取当前的邻居像素的值。就是说,当一个像素着色器执行时,它不能直接把结果输出到邻居像素里,也不能访问其他像素近期的修改。进一步,他计算的结果只影响自己的像素。但是,这个限制并没有听起来严重。一个pass输出的图片可以在后续pass里在像素着色器里访问。邻近的像素值可以通过图形处理的技术处理,在12.1节里描述。
对于一个像素着色器不能得到或者影响邻居像素结果的规则,也有一些例外。一个是像素着色器可以立刻访问邻近片元的信息(尽管是间接的方式)在计算梯度或者导数信息时。这一步像素着色器会获取沿着屏幕x和y轴插值的逐像素的变化量。这些值在一些计算和纹理定位中非常有效。这些梯度对于一些操作特别重要,像是纹理过滤(6.2.2节),在我们想知道一个图片如何覆盖一个像素的地方。所有的现代GPU实现这个功能通过用2x2的组来处理片元,叫做一个quad。当像素着色器要一个梯度值时,就会返回一个邻近两个片元的插值。见图3.15.一个标准的核心拥有访问邻接数据的能力——同一个warp里不同的thread——如此来计算像素着色器里使用的梯度值。这种实现的一个结果是梯度信息无法在受到动态流控制的一部分shader里访问,显示“if”语句或者使用变量次数的循环。一个组里所有片元必须使用相同的指令处理所以全部的四个像素结果对于计算梯度都有意义。这个基础的限制在离线渲染的系统里也存在。
DirectX 11引入了一个缓冲类型允许对于任意位置进行写访问,无序访问视角unordered access view(UAV)。最初只用于像素着色器和compute shader,DirectX 11.1里所有shader都可以使用了。OpenGL 4.3称这个叫做着色器储存缓冲物体shader storage buffer object(SSBO)。两个名字都用自己的方式来诠释含义。像素着色器是并行执行的,以任意的顺序,这个存储缓冲是他们共享的。
经常有一些机制需要避免数据竞争data race condition(也叫作数据风险data hazard),就是说两个着色器程序可能同时影响同一个值,这会导致不确定的任意结果。举个例子,两个着色器程序的调用试图同时给检索值执行增加操作会引发一个错误。两个调用都会拿到原始值,并且在本地执行修改操作,但是最后一个执行写操作的调用会抹除掉其他所有调用的结果——只发生一次加法。GPU通过让shader访问专用的原子单元来避免这个问题。但是,原子意味着一些shader会阻塞住,因为他们需要等待正在被其他shader访问的内存。
尽管原子操作可以避免数据风险,需要算法需要按照指定的顺序执行。例如,你可能想先在远处画一个蓝色的半透明三角形然后再用一个红色的半透明三角形混合,红色在蓝色上面混合。有可能一个像素执行两次着色器调用,每个三角形一次,先画了红的再画了蓝的。在标准的管线里,混合阶段先对片元结果排序然后再处理。光栅化顺序视角Rasterizer order views(ROVs)在DirectX 11.3里引入来加强执行顺序。这有点像UAVs;他们可以用同样的方式让shader读写。区别在于ROVs保证数据会按照正确的顺序访问。这极大的增强了可以被shader访问的缓冲的作用。例如,ROVs可以让像素着色器实现自定义的缓冲模式,因为它可以在ROV里访问任意位置,因此不再需要合并阶段了。代价是,如果检测到了一个无需访问,一个像素着色器调用可能会阻塞住,直到早先的三角形画完。
3.9 The Merging Stage 合并阶段
如同2.5.2节里讨论的,合并阶段就是每个独立片元(像素着色器生成的)的颜色和深度与帧缓冲合并的地方。DirectX称这个阶段为输出合并器output merger,OpenGL叫做逐个采样操作per-sample operations。在最传统的管线流程图里(包括我们的自己的),这个阶段是执行模板缓冲和z缓冲操作的地方。如果这个片元是可见的,另一个操作叫做颜色混合也会执行。对于不透明表面不会执行真正的混合,因为片元的颜色会简单的取代之前存储的值。实际上片元和存储值的混合一般是用于透明和合成操作(5.5节)。
想象光栅化生成的片元在像素着色器里执行然后在z缓冲这一步发现被之前计算的值挡住了。所有在像素着色器里的操作都变成了多余的。为了避免这种浪费,许多GPU会在像素着色器之前执行合并测试。片元的z深度(或者其他任意使用的东西,像是目标缓冲或者剪切)被用于可见性测试。如果被挡住,片元就会被裁剪掉。这个功能叫做early-z。像素着色器也有改变z深度的能力或者完全舍弃一个片元。如果任意一种操作在像素着色器里存在,early-z就会被关闭,这会使得管线性能下降。DirectX 11和OpenGL4.2允许像素着色器强制开启early-z,虽然也有一些相应的限制。见23.7节里关于early-z和z缓冲优化的更多内容。有效的使用early-z可以大幅提升性能,这部分在18.4.5里探讨。
合并阶段处于固定功能阶段(像是三角形设置)和完全可编程阶段的中间位置。尽管这一步不可编程,但是高度可配置。特别是颜色混合可以设置用于执行各种不同的操作。最普通的混合是通过乘法,加法和减法对颜色和透明度操作,但是其他的操作也有,像是去最大值和最小值,或者位操作。DirectX 10增加了支持可以把来自像素着色器的两个颜色和帧缓冲混合。这个能力叫做双重来源颜色混合dual source-color blending,并且不能和多目标渲染一起使用。MRT也支持混合,DirectX 10.1引入了对于每个buffer分别执行不同混合操作的能力。
如同上一节末尾提到的,DirectX 11.3提供了一种方式通过ROVs来执行混合操作的编程,尽管会有性能代价。ROVs和合并阶段都会保证绘制顺序,也叫作输出不变性。不考虑像素着色器结果生成的顺序,这是API的要求关于结果需要排序,并且有序的发送给合并阶段,顺序保持和输入一直,逐物体,逐三角形。
3.10 The Compute Shader 计算着色器
GPU可以用于实现传统渲染管线意外的操作。有许多非图形的使用,显示估算股票和训练深度学习的神经网络。这种方式对于硬件的使用叫做GPU计算GPU computing。像是CUDA和OpenCL这样的平台用于控制GPU作为一个大型并行处理集群,完全不需要使用图形特定的功能。这些框架使用c或者c++编程,还有一些GPU的库。
在DirectX11中引入,computer shader是GPU计算的一种形式,是一个不会固定于图形管线某个位置的shader。它和渲染处理紧密相关所以通过图形API调用。它在顶点,像素和其他着色器里都有使用。它和渲染管线一样使用核心池。和其他的shader一样,它可以输入数据集,访问缓冲(像是纹理贴图)用于输入和输出。Warps和threads在compute shader里更加可见。例如,每次调用都会获得一个线程下标用于访问。这也是一个线程组thread group的概念,由1到1024个线程组成(DirectX 11版本)。这些线程组通过xyz坐标定位,主要为了shader代码里使用方便。每个线程组都有一块用于内部线程共享的小内存。在DirectX 11里,这个大小是32KB。Compute Shaders 通过线程组执行,所以一个组里所有的线程都是会同步执行的。
Compute shader一个重要的优势就是可以访问GPU生成的数据。从GPU向GPU发送数据会产生一个延迟,所以如果结果可以常驻GPU,那么性能也会有所提升。后处理,修改一个渲染好的图片,是compute shader常用的地方。共享的内存意味着从图片采样到的像素值可以在相邻线程之间共享。使用compute shader来决定图片的亮度分布或者平均值,例如,已经发现可以比像素着色器快两倍。
Compute shader对于例子系统也很有用,处理网格像是关键的动画,裁剪,图片过滤,提升深度值精度,阴影,景深,和其他的一组处理器核心可以发挥的任务。Wihlidal讨论了compute shader如何可以比曲面细分hull shader更高效。见图3.16里的其他用途。
这里我们对于GPU如何现实渲染管线的浏览就结束了。还有很多GPU功能用于和结合不同的渲染相关操作的方法。优化这些功能的相关理论和算法是这本书的核心。我们关注点现在转向变换和着色。
Further Reading and Resources
略