找回密码
 立即注册
查看: 312|回复: 0

Chapter-3 图形处理单元

[复制链接]
发表于 2022-7-7 08:53 | 显示全部楼层 |阅读模式
历史上,图形加速开始于在重叠三角形的每个像素扫描线上插入颜色,然后显示这些值。包括访问图像数据的能力,允许纹理应用到表面。添加硬件来插值和测试z深度提供了内置的可见性检查。由于它们的频繁使用,这些进程被用于专用的硬件来提高性能。渲染管线的更多部分,以及每个部分的更多功能,在后续的迭代中被添加。专用图形硬件相对CPU的唯一计算优势是速度,但速度是关键。
在过去的二十年里,图形硬件经历了令人难以置信的转变。第一个包含硬件顶点处理的消费图形芯片(NVIDIA的GeForce256)于1999年发布。NVIDIA创造了图形处理单元(GPU)这个术语来区分GeForce 256和之前的光栅化芯片,并且一直沿用至今。在接下来的几年里,GPU从一个复杂的固定功能管道的可配置实现发展到高度可编程的空白板,开发人员可以实现他们自己的算法。各种可编程着色器是控制GPU的主要手段。为了提高效率,管道的某些部分仍然是可配置的,而不是可编程的,但趋势是可编程和灵活性。
GPU之所以能获得如此高的速度,是因为它专注于一组小范围的、高度并行化的任务。例如,他们定制了专用于实现z缓冲区、快速访问纹理图像和其他缓冲区、以及查找三角形覆盖的像素的硅。这些元素如何执行其功能的在23章有介绍。更重要的是要尽早了解GPU如何实现其可编程着色器的并行性。
第3.3节解释了着色器的功能。现在,你需要知道的是着色器核心是一个小型处理器,它可以执行一些相对独立的任务,比如将一个顶点从它在世界中的位置转换为屏幕坐标,或者计算一个三角形覆盖的像素的颜色。随着每一帧成千上万的三角形被发送到屏幕上,每一秒钟可能有数十亿次着色器调用,也就是说,在着色器程序运行的单独实例。
首先,延迟是所有处理器都面临的问题。访问数据需要一些时间。考虑延迟的一种基本方法是,信息离处理器越远,等待的时间就越长。第23.3节更详细地介绍了延迟。存储在存储器芯片中的信息要比本地寄存器中的信息访问时间长。第18.4.1节更深入地讨论了内存访问。关键是等待数据被检索意味着处理器被暂停,这会降低性能。
3.1 并行数据架构
不同的处理器架构使用不同的策略来避免挂起。CPU经过优化,可以处理各种各样的数据结构和大型代码库。CPU可以有多个处理器,但每个处理器都以串行方式运行代码,这种方式是有限的SIMD向量处理是次要的异常。为了最小化延迟的影响,CPU芯片的大部分都由快速本地缓存组成,内存中充满了接下来可能需要的数据。CPU还通过使用诸如转移预测、指令重新排序、寄存器重命名和缓存预取等聪明的技术来避免暂停。
GPU采用了不同的方法。GPU芯片的大部分区域都专门用于一个大型的处理器集,称为着色器核心,通常有数千个。GPU是一个流处理器,在这个处理器中,类似的有序数据集依次被处理。由于这种相似性——例如一组顶点或像素——GPU可以以大规模并行的方式处理这些数据。另一个重要的元素是,这些调用尽可能独立,因此它们不需要来自邻近调用的信息,也不共享可写内存位置。这个规则有时会被打破,以允许新的和有用的功能,但这种例外的代价是潜在的延迟,因为一个处理器可能等待另一个处理器完成它的工作。
GPU对吞吐量进行了优化,定义为数据处理的最大速率。然而,这种快速处理是有代价的。由于较少的芯片区域用于缓存内存和控制逻辑,每个着色器核心的延迟通常比CPU处理器遇到的要高得多。
假设一个网格是光栅化的,有2000个像素有片元需要处理:一个像素着色程序将被调用2000次。想象一下,这里只有一个着色器处理器,这是世界上最弱的GPU。它开始为2000的第一个片段执行着色程序。着色处理器对寄存器中的值执行一些算术操作。寄存器是本地的,可以快速访问,所以不会出现停顿。然后,着色器处理器进入一个指令,比如纹理访问;例如,对于给定的表面位置,程序需要知道应用到网格的图像的像素颜色。纹理是一个完全独立的资源,而不是像素程序本地内存的一部分,因此可能涉及到纹理访问。一次内存获取可能需要数百到数千个时钟周期,在此期间GPU处理器什么都不做。此时,着色器处理器会暂停,等待纹理的颜色值被返回。
为了让这个糟糕的GPU变得更好,给每个片段一个小小的本地寄存器存储空间。现在,与其在纹理获取上停滞不前,shader处理器被允许切换和执行另一个片段,2000中的第二个片段。这个切换非常快,除了注意在第一个或第二个片段上执行了什么指令外,没有任何东西在第一个或第二个片段中受到影响。现在执行第二个片段。与第一个相同,执行一些算术函数,然后再次遇到纹理获取。着色器核心现在切换到另一个片段,三号。最终,所有的2000个片段都以这种方式处理。此时,着色器处理器返回片段1。到这个时候,纹理颜色已经被提取出来,可以使用了,所以着色程序可以继续执行。处理器以同样的方式继续执行,直到遇到另一条已知会停止执行的指令,或者程序完成。单个片段的执行时间会比shader处理器专注于它的时间更长,但作为一个整体的片段的整体执行时间会大大减少。
在这个架构中,延迟是通过切换到另一个片段来隐藏的。GPU通过将指令执行逻辑与数据分离,使这个设计更进一步。叫做单指令,多数据(SIMD),这种安排在固定数量的着色程序上执行相同的命令锁步。SIMD的优点是,与使用单独的逻辑和调度单元来运行每个程序相比,用于处理数据和交换的硅(和功率)要少得多。把我们的2000个片段的例子转换成现代的GPU术语,每个像素着色器调用一个片段被称为一个线程。这种类型的线程不同于CPU线程。它包含一些用于着色器输入值的内存,以及着色器执行所需的任何寄存器空间。使用相同着色程序的线程被捆绑成组,NVIDIA称之为经线,AMD称之为波阵面。一个翘曲/波前计划由一些GPU着色器核心执行,从8到64,使用simd处理。每个线程都映射到一个SIMD通道(这块纯机翻)。
假设我们有两千个线程要执行。NVIDIA GPU上的经线包含32个线程。这产生2000/32 = 62.5经线,这意味着分配了63条经线,其中一条经线有一半是空的。经线的执行类似于我们的单一GPU处理器的例子。着色程序在所有32个处理器上执行锁步。当遇到一个内存读取时,所有线程都会同时遇到它,因为所有线程都执行了相同的指令。获取信号表明这个线程的经线会暂停,所有的都在等待他们(不同的)的结果。该经线被替换为32个线程的不同经线,然后由32个内核执行,而不是停滞。这种交换与我们的单处理器系统一样快,因为当经线被交换时,每个线程中的数据都不会被触及。每个线程都有自己的寄存器,每个偏差都跟踪它正在执行的指令。在一个新的经线中,交换只是将一组核心指向一个不同的线程集来执行;没有其他开销。经线执行或换出,直到全部完成。参见图3.1。


在我们的简单示例中,获取纹理的内存延迟可能会导致经线交换。在现实中,由于交换的成本非常低,可以将经线交换为了更短的延迟。还有其他几种技术用于优化执行,但经线交换是所有GPU使用的主要延迟隐藏机制。有几个因素影响了这个过程的效率。例如,如果有很少的线程,那么可以创建很少的经线,从而造成延迟隐藏问题。
着色程序的结构是影响效率的一个重要特征。一个主要因素是每个线程的寄存器使用量。在我们的例子中,我们假设2,000个线程可以同时驻留在GPU上。着色程序需要的寄存器越多,与每个线程相关的线程就越少,因此在GPU中的经线就越少。缺乏经线可能意味着暂停不能通过交换来缓解。驻留的经线被称为“在飞行中”,这个数字被称为“占用率”。高占用率意味着有许多可用于处理的经线,因此空闲处理器的可能性较小。占用率低往往会导致性能不佳。内存提取的频率也会影响需要隐藏多少延迟时间。Lauritzen概述了着色器使用的寄存器数量和共享内存是如何影响占用的。Wronski讨论了理想的占用率如何根据着色器执行的操作类型而变化。
另一个影响整体效率的因素是动态分支,由“if”语句和循环引起。假设在着色程序中遇到一个“if”语句。如果所有线程都计算并采用相同的分支,则经线可以继续,而不需要关心其他分支。但是,如果某些线程(甚至是一个线程)采用替代路径,则经线必须执行两个分支,丢弃每个特定线程不需要的结果。此问题称为线程发散,其中可能需要几个线程执行循环迭代或执行“如果”的路径,其他线程在经纱不,让他们在这段时间空闲。(纯机翻,看懵逼了)
所有的GPU都实现了这些架构思想,导致系统受到严格的限制,但每瓦特的计算功率非常大。作为一名程序员,了解这个系统是如何运行的将有助于您更有效地使用它所提供的功能。在接下来的章节中,我们将讨论GPU如何实现渲染管道,可编程着色器如何操作,以及GPU每个阶段的演化和功能。
3.2 GPU管线概述
GPU实现了概念几何处理、光栅化和像素处理的流水线阶段,如第二章所述。这些阶段分为几个硬件阶段,具有不同程度的可配置性或可编程性。图3.2显示了根据可编程或可配置的不同阶段的颜色编码。请注意,这些物理阶段的划分与第二章中介绍的功能阶段有所不同。


我们在这里描述了GPU的逻辑模型,它是通过API向程序员公开的。正如第18章和第23章所讨论的,这个逻辑管道(即物理模型)的实现取决于硬件供应商。在逻辑模型中具有固定功能的阶段可以在GPU上通过添加命令到相邻的可编程阶段来执行。流水线中的单个程序可以被分割成由单独的子单元执行的元素,或者被完全单独的传递执行。逻辑模型可以帮助你推断是什么影响了性能,但它不应该被误解为GPU实际实现管道的方式。
顶点着色器是一个完全可编程的阶段,用于实现几何处理阶段。几何着色器是一个完全可编程的阶段,它在一个图元(点,线,或三角形)的顶点上操作。它可以用于执行每图元的着色操作、销毁图元或创建新图元。细分阶段和几何着色器都是可选的,并不是所有的GPU都支持它们,特别是在移动设备上。
裁剪、三角形设置和三角形遍历阶段由固定功能硬件实现。屏幕映射受窗口和视口设置的影响,内部形成一个简单的比例和重新定位。像素着色阶段是完全可编程的。虽然合并阶段不是可编程的,但它是高度可配置的,可以设置为执行各种各样的操作。它实现了“合并”功能阶段,负责修改颜色、z缓冲区、混合、模板等任何与输出相关的缓冲区。像素着色器的执行和合并阶段构成了第二章中介绍的概念像素处理阶段。
随着时间的推移,GPU流水线已经从硬编码操作进化为增加灵活性和控制。可编程着色器阶段的引入是这一发展过程中最重要的一步。下一节描述不同可编程阶段的共同特征。
3.3 可编程着色阶段
现代着色器程序使用统一的着色器设计。这意味着顶点、像素、几何和细分相关的着色器共享一个共同的编程模型。在内部,它们具有相同的指令集架构(ISA)。实现这个模型的处理器在DirectX中被称为公共着色器核心,具有这种核心的GPU被称为具有统一的着色器架构。这种架构背后的想法是,着色处理器可以用于各种角色,GPU可以根据自己的需要分配这些角色。例如,一组带有小三角形的网格将需要比由两个三角形组成的大方块更多的顶点着色器处理。一个GPU有独立的顶点和像素着色器核心池意味着理想的工作保持所有核心忙碌的分配是严格预定的。使用统一的着色器核心,GPU可以决定如何平衡这个负载。
描述整个着色器编程模型远远超出了本书的范围,已经有很多文档、书籍和网站这样做了。着色器是使用类C语言编程的,比如DirectX的高级着色语言(HLSL)和OpenGL着色语言(GLSL)。DirectX的HLSL可以编译成虚拟机字节码,也称为中间语言(IL或DXIL),提供硬件独立性。一个中间表示也可以允许着色程序被编译和离线存储。这种中间语言被驱动程序转换为特定GPU的ISA。控制台编程通常避免中间语言步骤,因为此时系统只有一个ISA。
基本的数据类型是32位的单精度浮点标量和向量,尽管向量只是着色器代码的一部分,并且如上所述在硬件中不受支持。在现代GPU上,也支持32位整数和64位浮点数。浮点向量通常包含位置(xyzw)、法线、矩阵行、颜色(rgba)或纹理坐标(uvwq)等数据。整数最常用来表示计数器、索引或位掩码。还支持聚合数据类型,比如结构、数组和矩阵。
一个绘制调用调用图形API来绘制一组图元,因此导致图形管线执行和运行它的着色器。每个可编程着色器阶段都有两种类型的输入:uniform输入,在整个绘制调用中保持不变的值(但可以在绘制调用之间更改),以及varying输入,来自三角形顶点或光栅化的数据。例如,一个像素着色器可以提供一个光源的颜色作为一个uniform值,三角形表面的位置逐像素变化,所以是varying值。纹理是一种特殊的统一输入,以前总是应用于表面的彩色图像,但现在可以认为是任何大的数据数组。
底层的虚拟机为不同类型的输入和输出提供了特殊的寄存器。可用的uniform寄存器的数目比可用的varying寄存器的数目要大得多。这是因为varying的输入和输出需要逐顶点或者像素单独存储,所以他们的数量是有天然限制的(uniform不变,所以用一个寄存器就可以在整个过程中使用,但是varying值是需要每像素做插值的量,需要的寄存器数量大大多于前者,就导致它的变量个数少于前者)。uniform输入只存储一次,并在一次draw call中的所有顶点或像素之间重用。虚拟机也有通用的临时寄存器,用于临时空间。所有类型的寄存器都可以在临时寄存器中使用整数值进行数组索引。shader虚拟机的输入和输出如图3.3所示。


图形计算中常见的操作可以在现代GPU上高效地执行。着色语言通过操作符(如*和+)暴露了最常见的这些操作(如加法和乘法)。其余的则通过内置函数(如atan、sqrt、log等)暴露出来,这些函数都是GPU优化过的。还存在用于更复杂操作的函数,如单位化向量和反射、叉乘、矩阵转置和行列式计算。
术语流控制指的是使用分支指令来改变代码执行流。与流控制相关的指令用于实现高级语言构造,如“if”和“case”语句,以及各种类型的循环。着色器支持两种类型的流控制。静态流控制分支是基于输入的uniform值。这意味着在draw call中,代码流是恒定的。静态流控制的主要好处是允许相同的着色器在各种不同的情况下使用(例如,不同数量的灯光)。不存在线程分叉,因为所有调用都采用相同的代码路径。动态流控制是基于varying输入值,意思是每个片元都不同地执行相同的代码。这比静态流控制更强大,但会降低性能,特别是当代码流在着色器调用之间不规律地变化时。
3.4 可编程着色和API的演变
可编程着色框架的想法可以追溯到1984年库克的着色树。图3.4中显示了一个简单的着色器和它相应的着色树。RenderMan阴影语言就是从这个想法发展而来的。时至今日,它仍被用于电影制作渲染,以及其他不断发展的规范,如Open Shading Language(OSL)项目。


消费者级图形硬件于1996年10月1日由3dfx Interactive首次成功推出。这年的时间线见图3.5。Voodoo显卡高品质和高性能地渲染游戏雷神之锤的能力导致其迅速被采用。该硬件实现了贯穿整个系统的固定功能管道。在GPU原生支持可编程着色器之前,有好几次尝试通过多个渲染通道实时实现可编程着色操作。1999年,《雷神之锤3:竞技场》脚本语言在这一领域获得了首次广泛的商业成功。正如本章开头所提到的,NVIDIA的GeForce256是第一个被称为GPU的硬件,但它是不可编程的。然而,它是可配置的。


早在2001年,NVIDIA的GeForce 3是第一个支持可编程顶点着色器的GPU,通过DirectX 8.0和OpenGL扩展公开。这些着色器是用汇编语言编程的,被驱动程序转换成动态的微码。像素着色器也包含在DirectX 8.0中,但是像素着色器缺乏实际的可编程性——支持的有限的“程序”被驱动程序转换成纹理混合状态,然后连接到硬件“寄存器组合器”上。这些“程序”不仅限于长度(12条指令或更少),也缺少重要的功能。Peercy等人从他们对RenderMan的研究中发现,依赖纹理读取和浮点数据对真正的可编程性至关重要。
这个时候的着色器不允许流控制(分支),所以条件必须通过计算两项和选择或在结果之间插值模拟。DirectX定义了Shader Model (SM)的概念,以区分具有不同Shader能力的硬件。2002年,DirectX发布了9.0版本,包括Shader Model 2.0,它的特点是真正可编程的顶点和像素着色器。类似的功能也在OpenGL中通过各种扩展公开。增加了对任意依赖纹理读取和16位浮点值存储的支持,最终被Peercy等人完成了需求集。对着色器资源如指令、纹理和寄存器的限制增加了,所以着色器变得能够产生更复杂的效果。还增加了对流控制的支持。随着着色器越来越长和复杂,使得汇编编程模型越来越麻烦。幸运的是, DirectX 9.0引进了HLSL。这种着色语言是由微软与NVIDIA合作开发的。大约在同一时间,OpenGL ARB(架构评论董事会)发布了GLSL,这是一种用于OpenGL的与HLSL相似的语言。这些语言深受C编程语言的语法和设计理念的影响,并包含了RenderMan着色语言的元素。
Shader模型3.0在2004年被引入,并增加了动态流控制,使得Shader更加强大。它还将可选的特性转化为需求,进一步增加了资源限制,并在顶点着色器中增加了对纹理读取的有限支持。当新一代游戏机在2005年末(微软的Xbox 360)和2006年末(索尼电脑娱乐的PLAYSTATION 3系统)推出时,它们都配备了Shader Model 3.0级别的GPU。任天堂(Nintendo)的Wii游戏机是最后一批著名的固定功能GPU之一,它的最初发售的时间在2006末。在这个时间点上,纯固定功能的管线早就消失了。着色器语言已经发展到可以使用各种工具来创建和管理它们的程度。图3.6显示了其中一个工具的截图,使用了Cook的着色树概念。


在可编程性方面的下一大的进步也是在接近2006年底的时候。包含在DirectX 10.0中的着色器模型4.0,引入了几个主要的特性,如几何着色器和流输出。Shader Model 4.0引入了前面描述过的统一的着色编程模型为所有类型的着色器(顶点,像素和几何着色器)。资源限制进一步增加,并增加了对整数数据类型(包括按位操作)的支持。OpenGL3.3中引入的GLSL3.30提供了一个类似的着色器模型。
2009年,DirectX 11和Shader Model 5.0发布,增加了细分阶段着色器和计算着色器,也称为DirectCompute。该版本还着重于更有效地支持CPU多处理,这一主题将在第18.5节中讨论。OpenGL在4.0版本中增加了细分,在4.3版本中增加了计算着色器。DirectX和OpenGL的发展方式不同。两者都设置了特定版本发布所需的特定级别的硬件支持。微软控制着DirectX API,因此直接与独立硬件供应商(IHVs)合作,如AMD、NVIDIA和Intel,以及游戏开发商和计算机辅助设计软件公司,以决定哪些功能应该公开。OpenGL是由一个硬件和软件供应商联盟开发的,由非营利组织Khronos Group管理。由于涉及的公司数量众多,API特性通常会在引入DirectX后的一段时间才出现在OpenGL的一个版本中。然而,OpenGL允许扩展,供应商特定的或更通用的,允许最新的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移植可能很困难,而且简单的实现可能导致较低的性能。
苹果在2014年发布了自己的低开销API Metal。Metal最早可在iPhone 5S和iPad Air等移动设备上使用,一年后,新推出的麦金塔电脑可通过OS X El Capitan操作系统使用。除了效率,减少CPU使用节省电力,这是移动设备的一个重要因素。这个API有自己的着色语言,这意味着图形和GPU计算程序。
AMD将其Mantle项目捐赠给了Khronos集团,后者于2016年初发布了自己的新API,名为Vulkan。与OpenGL一样,Vulkan可以在多个操作系统上运行。Vulkan使用了一种叫做SPIRV的新的高级中间语言,它用于着色器表示和通用GPU计算。预编译的着色器是可移植的,所以可以在任何GPU上支持所需的能力。Vulkan也可以用于非图形化的GPU计算,因为它不需要显示窗口。Vulkan与其他低开销驱动程序的一个显著区别是,它适用于从工作站到移动设备的各种系统。
在移动设备上,标准是使用OpenGL ES。“ES”代表嵌入式系统,因为这个API是针对移动设备开发的。标准OpenGL在当时的一些调用结构中相当笨重和缓慢,并且需要支持很少使用的功能。OpenGL ES 1.0发布于2003年,是OpenGL 1.3的精简版,描述了一个固定功能管线。虽然DirectX的发布与支持它们的图形硬件同步,但为移动设备开发图形支持却没有以同样的方式进行。例如,2010年发布的第一代iPad就实现了OpenGL ES 1.1。2007年,OpenGL ES 2.0规范发布,提供可编程的着色。它基于OpenGL2.0,但没有固定的功能组件,因此不能向后兼容OpenGL ES 1.1。OpenGL ES 3.0于2012年发布,提供了多个渲染目标、纹理压缩、转换反馈、实例化、更广泛的纹理格式和模式,以及着色器语言的改进等功能。OpenGL ES 3.1增加了计算着色器,3.2增加了几何和细分着色器,以及其他功能。第23章更详细地讨论了移动设备架构。
OpenGL ES的一个分支是基于浏览器的API WebGL,通过调用JavaScript。该API发布于2011年,第一个版本可以在大多数移动设备上使用,因为它在功能上相当于OpenGL ES 2.0。与OpenGL一样,扩展可以访问更高级的GPU特性。WebGL 2采用OpenGLES 3.0支持。
WebGL特别适合在课堂上进行特性实验或使用:
它是跨平台的,可以在所有个人电脑和几乎所有移动设备上运行;
驱动程序审批由浏览器处理。即使一个浏览器不支持特定的GPU或扩展,通常另一个浏览器会支持;
代码是解释的,而不是编译的,开发只需要一个文本编辑器;
大多数浏览器都内置了调试器,可以检查运行在任何网站上的代码;
可以将程序上传到网站或Github上进行部署;

更高级的场景图和效果库,如three.js,可以方便地访问各种更复杂的效果的代码,如阴影算法、后处理效果、基于物理的着色和延迟渲染。
3.5 顶点着色器
顶点着色器是图3.2所示的功能管线中的第一个阶段。虽然这是程序员直接控制的第一个阶段,但值得注意的是,在这个阶段之前会发生一些数据操作。在DirectX所称的输入汇编器中,可以将几个数据流编织在一起,形成顶点集和图元集,并通过流水线发送。例如,一个对象可以由一个位置数组和一个颜色数组表示。输入汇编程序将通过创建带有位置和颜色的顶点来创建这个对象的三角形(或线或点)。第二个对象可以使用相同的位置数组(以及不同的模型变换矩阵)和不同的颜色数组来表示它。数据表示将在第16.4.5节中详细讨论。输入汇编程序也支持执行实例化。这允许一个对象被多次绘制,每个实例都有一些不同的数据,所有这些都需要一个绘制调用。实例化的使用将在第18.4.2节中介绍。
一个三角形网格由一组顶点表示,每个顶点都与模型表面上的特定位置相关联。除了位置之外,还有与每个顶点相关联的其他可选属性,比如颜色或纹理坐标。曲面法线也定义在网格顶点上,这似乎是一个奇怪的选择。从数学上讲,每个三角形都有一个定义良好的表面法线,直接使用三角形的法线着色似乎更有意义。然而,在渲染时,三角形网格经常被用来表示一个潜在的曲面,顶点法线被用来表示这个曲面的方向,而不是三角形网格本身的方向(可以这么理解,通过三角形顶点法线之间的插值就获取到三角形内不同点的法线方向,这样就模拟出了一个曲面,而不是用很多三角形模拟曲面,可以在不影响性能的基础上提升品质)。第16.3.4节将讨论计算顶点法线的方法。 图3.7显示了两个代表曲面的三角形网格的侧视图,一个是光滑的,另一个是尖锐的折痕。


顶点着色器是处理三角形网格的第一步。描述三角形构成的数据(拓扑数据)对于顶点着色器来说是不可用的(或者说用不到)。顾名思义,它只处理传入的顶点。顶点着色器提供了一种方法来修改、创建或者忽略与每个三角形顶点相关的值,比如它的颜色、法线、纹理坐标和位置。通常,顶点着色程序将顶点从模型空间转换到均匀裁剪空间(章节4.7)。至少,一个顶点着色器必须总是输出这个位置。
顶点着色器与之前描述的统一着色器非常相似。每个传入的顶点都由顶点着色程序处理,然后输出一些值,这些值在三角形或直线上插值。顶点着色器既不能创建也不能销毁顶点,由一个顶点生成的结果不能传递给另一个顶点。因为每个顶点都是独立处理的,所以GPU上任意数量的着色处理器都可以并行处理进入的顶点流。
输入组装通常是一个发生在顶点着色器执行之前的过程。在这个例子中,物理模型通常与逻辑模型不同。从物理上讲,获取数据来创建一个顶点可能发生在顶点着色器中,驱动程序会悄悄地在每个着色器前添加适当的指令来创建好顶点,这个步骤对程序员是不可见的。
后面的章节解释了几个顶点着色器的效果,例如顶点混合的动画关节,和剪影渲染。顶点着色器的其他用途包括:
·对象生成,通过创建一个网格只一次,并让它被顶点着色器变形。
·动画人物的身体和脸使用皮肤和变形技术。
·程序性变形,如旗帜、布料或水的移动
·粒子的创建,通过发送退化的(无面积)网格的管道,并给这些网格一个需要的面积。
·镜头变形,热雾霾,水波纹,页面卷曲和其他效果,使用整个framebuffer的内容作为屏幕对齐网格上的纹理进行过程变形。
·使用顶点纹理获取应用地形高度字段
使用顶点着色器进行的一些变形如图3.8所示。


顶点着色器的输出可以以几种不同的方式使用。通常的路径是对于每个图元实例,例如三角形,然后被生成和光栅化,而产生的单个像素片元被发送到像素着色程序继续处理。在一些GPU上,数据也可以发送到细分阶段或几何着色器或存储在内存中。下面几节将讨论这些可选阶段。
3.6 细分阶段
细分阶段允许我们渲染曲面。GPU的任务是将每个表面的描述转换成一组代表三角形。这个阶段是一个可选的GPU特性,第一次在DirectX11中可用(并且是必需的)。 OpenGL4.0和OpenGL ES 3.2也支持它。
使用细分阶段有几个优点。曲面描述通常比提供相应的三角形本身更紧凑。除了节省内存,该功能还可以避免CPU和GPU之间的总线成为动画角色或对象的瓶颈,因为动画角色或对象的形状会在每帧中发生变化。通过为给定视图生成适当数量的三角形,可以有效地渲染表面。例如,如果一个球远离相机,只需要几个三角形。近看,它可能看起来最好用数千个三角形来表示。这种控制层次细节的能力也允许应用程序控制其性能,例如,为了保持帧率,在较弱的GPU上使用较低质量的网格。通常由平面表示的模型可以转换为精细的三角形网格,然后根据需要进行扭曲,或者为了减少昂贵的着色计算,可以对模型进行细分。
细分阶段通常由三个要素组成。使用DirectX的术语,这些是壳着色器,细分器和域着色器。在OpenGL中,壳着色器是细分控制着色器和域着色器是细分评估着色器,它们更有描述性,虽然冗长。固定功能细分器在OpenGL中被称为图元生成器,正如我们所看到的,这确实是它所做的。
如何指定和细分曲线和曲面将在第17章详细讨论。在这里,我们简要总结了每个细分阶段的目的。首先,壳着色器的输入是一个特殊的面片。它由几个控制点组成,这些控制点定义了细分曲面、贝塞尔面片或其他类型的曲面元素。壳着色器有两个功能。首先,它告诉细分器应该生成多少个三角形,以及以何种配置。其次,它对每个控制点执行处理。同样,可选地,壳着色器也能根据需要修改输入的面片描述,增加或者移除控制点。壳着色器输出它的控制点集,连同细分控制数据,到域着色器。入图3.9所示。


细分器是流水线中的一个固定功能阶段,只在细分着色器中使用。它的任务是为域着色器添加几个新的顶点来处理。壳着色器发送关于需要什么样的细分面类型的信息:三角形,四边形,或等值线。等值线是一系列的线条,有时用于头发渲染。另外一个重要的值是由壳着色器发送过来的细分因子(在OpenGL中的叫细分等级)。它们有两种类型:内边和外边。两个内部因素决定了在三角形或四边形中有多少细分发生。外部因素决定了每个外部边缘被分割的程度。图3.10显示了一个增加细分因子的例子。通过允许独立的控制,我们可以使相邻曲面的边缘在细分中匹配,而不管内部是如何细分的。匹配边缘可以避免裂纹或其他渲染瑕疵。这些顶点被赋以重心坐标(第22.8节),这是指定所需曲面上每个点的相对位置的值。


壳着色器总是输出一个面片,一组控制点的位置。然而,它可以通过向细分器发送0或更少(或非值, NaN)的外部细分级别来表示将这个面片丢弃。否则,细分器生成一个网格并将其发送到域着色器。来自壳着色器的曲面的控制点被用于域着色器的每次调用来计算每个顶点的输出值。域着色器有一个类似于顶点着色器的数据流模式,每一个都被细分器处理的顶点输入并生成一个相应的输出顶点。然后形成的三角形沿着管线继续传递。
虽然这个系统听起来很复杂,但它这样的结构是为了效率,并且每个着色器可以相当简单。通过壳着色器的面片通常很少或没有修改。这个着色器也可以使用面片的估计距离或屏幕大小来动态计算细分因子,就像地形渲染一样。另外,壳着色器可以简单地传递应用程序计算和提供的所有面片的固定值集。细分器执行一个复杂但固定的功能过程,生成顶点,给出它们的位置,并指定它们形成的三角形或线。为了计算效率这个数据放大步骤是在shader之外执行的。域着色器取每个点的重心坐标,并在面片的评估方程中使用这些来生成位置、法线、纹理坐标和其他所需的顶点信息。示例见图3.11。


3.7 几何着色器
几何着色器可以将图元转换为其他图元,这是细分阶段做不到的。例如,一个三角形网格可以通过让每个三角形创建线边来转换为线框视图。另一种方法是,线可以被面向观察者的四边形所代替,这样就可以使线框渲染具有更厚的边缘。在2006年末,随着DirectX 10的发布,几何着色器被添加到硬件加速图形管线中。它位于管线中的细分着色器之后,它的使用是可选的。而作为Shader Model 4.0必需的一部分,它没有在早期的Shader模型中使用。OpenGL3.2和OpenGLES 3.2也支持这种类型的着色器。
几何着色器的输入是一个单一的对象及其相关的顶点。该对象通常由带状三角形、线段或点组成。扩展的图元可以由几何着色器定义和处理。特别是,可以传入三角形外的三个额外顶点,并且可以使用线段上的两个相邻顶点。参见图3.12。使用Directx11和着色器模型5.0,您可以传递更复杂的面片,最多有32个控制点。也就是说,细分阶段对于面片生成更有效。


几何着色器处理这个图元并输出零个或多个顶点,这些顶点被视为点、线段或三角形带。注意,任何输出都不能由几何着色器生成。通过这种方式,一个网格可以通过编辑顶点、添加新的图元和删除其他图元来有选择地修改。
几何着色器是为修改传入数据或制作有限数量的副本而设计的。例如,一个用途是生成六个转换后的数据副本,以同时呈现立方体映射的六个面;看10.4.3节。它还可以用于高效地创建级联阴影地图,以生成高质量的阴影。其他利用几何着色器的算法包括从点数据创建可变的粒子,沿着毛皮渲染的轮廓挤出鳍,以及为阴影算法寻找物体边缘。图3.13给出了更多的例子。这些用法和其他用法将在本书的其余部分进行讨论。


DirectX 11增加了几何着色器使用实例化的能力,在这里,几何着色器可以在任何给定的图元上运行多次。在OpenGL4.0中这是通过调用计数指定的。几何着色器也可以输出多达四个流。一个流可以被发送到渲染管线进行进一步的处理。所有这些流都可以被发送到流输出渲染目标。
几何着色器保证以相同的图元输入顺序输出结果。这会影响性能,因为如果几个着色器核心并行运行,结果必须保存和排序。这个因素和其他因素都不利于几何着色器在一次调用中复制或创建大量的几何图形。
在一个绘制调用被发出之后,在管线中只有三个地方可以在GPU上创建工作:栅格化,细分阶段和几何着色器。其中,考虑到所需的资源和内存,几何着色器的行为是最不可预测的,因为它是完全可编程的。在实践中,几何着色器通常很少使用,因为它不能很好地映射到GPU的优势。在一些移动设备上,它是在软件中实现的,所以它的使用在那里是非常不受欢迎的。
3.7.1 流输出
GPU管线的标准使用是通过顶点着色器发送数据,然后光栅化产生的三角形,并在像素着色器中处理这些数据。过去,数据总是通过管线传递,中间结果是无法访问的。在Shader模型4.0中引入了流输出的思想。顶点被顶点着色器(以及细分和几何着色器)处理后,这些可以在一个流中输出,例如,一个有序的数组,除了被发送到光栅化阶段。实际上,光栅化可以完全关闭,然后将管线纯粹用作非图形流处理器。以这种方式处理的数据可以通过管线发回,从而允许迭代处理。这种类型的操作可以用于模拟流动的水或其他粒子效应,如第13.8节所讨论。它还可以用于给模型蒙皮,然后让这些顶点可以重用(4.4节)。
流输出只以浮点数的形式返回数据,因此它可能有显著的内存开销。流输出工作在图元上,而不是直接在顶点上。如果网格被传送到流水线上,每个三角形会生成自己的三个输出顶点集合。原始网格中的任何共享顶点都会丢失。出于这个原因,更典型的用法是通过管线将顶点作为点集图元发送。在OpenGL中,流输出阶段被称为转换反馈,因为它的主要用途是转换顶点并返回它们进行进一步处理。图元按照输入的顺序发送到流输出目标,这意味着顶点顺序将得到维护。
3.8 像素着色器
在顶点,细分和几何着色器执行它们的操作后,图元被裁剪和为光栅化设置,正如在前一章中解释的那样。管线的这一部分在其处理步骤中是相对固定的,即,不能编程,但多少可以配置。遍历每个三角形以确定它覆盖的像素。光栅化器还可以粗略计算三角形覆盖每个像素单元格区域的面积(5.4.2节)。三角形中部分或完全与像素重叠的部分称为片元。
三角形顶点处的值,包括z缓冲区中使用的z值,是用于在三角形的表面上为每个像素进行插值。这些值被传递给像素着色器,然后片元处理。在OpenGL中,像素着色器被称为片段着色器,可能是一个更好的名称。为了一致性,我们在本书中使用“像素着色器”。通过流水线发送的点和线图元也为覆盖的像素创建片段。
在三角形上执行的插值类型是由像素着色程序指定的。通常我们使用透视校正插值,这样像素表面位置之间的世界空间距离就会随着物体距离的减少而增加。一个例子是渲染铁路轨道延伸到地平线。铁轨越远的地方,铁轨枕木的间距就越近,因为每一个连续的像素接近地平线的距离就越大(每两个相邻像素覆盖的场景的距离离摄像机越远,值越大)。其他插值选项是可用的,比如屏幕空间插值,其中不考虑透视投影。DirectX 11对何时以及如何执行插值提供了进一步的控制。
在编程术语中,顶点着色程序的输出,跨三角形(或直线)插值,有效地成为像素着色程序的输入。随着GPU已经进化,其他输入已经暴露。例如,片段的屏幕位置在shader Model 3.0和更高版本的像素着色器中是可用的。同样,三角形的哪边是可见的是一个输入标志。这些知识对于在单个通道中渲染每个三角形的前后不同材质非常重要。
有了输入,通常像素着色器会计算并输出一个片段的颜色。它还可能生成一个不透明度值,并可选地修改其z深度。在合并期间,这些值用于修改存储在像素上的内容。光栅化阶段生成的深度值也可以通过像素着色器修改。模板缓冲区的值通常是不可修改的,但是它会被传递到合并阶段。DirectX 11.3允许着色器改变这个值。在SM 4.0中,像雾计算和alpha测试这样的操作已经从合并操作变成了像素着色计算。
像素着色器还具有丢弃传入片段的独特能力,即不产生输出。如何使用片段丢弃的一个示例如图3.14所示。裁剪平面功能曾经是固定功能管线中的一个可配置元素,后来在顶点着色器中被指定。在片段丢弃可用的情况下,这个功能可以在像素着色器中以任何想要的方式实现,比如决定裁剪体是否应该与操作或或操作在一起(搞不懂这句话啥意思,懵逼)。
最初,像素着色器只能输出到合并阶段,以便最终显示。随着时间的推移,一个像素着色器可以执行的指令数量已经大大增加。这种增加产生了多渲染目标(MRT)的想法。不同于将像素着色器程序的结果只发送到颜色和z缓冲区,可以为每个片段生成多个值集,并保存到不同的缓冲区,每个缓冲区称为渲染目标。渲染目标通常具有相同的x和y维;一些API允许不同的大小,但呈现的区域将是其中最小的。一些架构要求渲染目标具有相同的位深度,甚至可能具有相同的数据格式。根据GPU的不同,可用的渲染目标数量是4个或8个。
即使有这些限制,MRT功能仍然是更有效地执行渲染算法的强大辅助。一个单独的渲染通道可以在一个目标中生成彩色图像,在另一个目标中生成对象标识符,在第三个目标中生成世界空间距离。这种能力也产生了一种不同类型的渲染管线,称为延迟着色,其中可视性和着色是在不同的通道中完成的。第一个通道传递存储的关于对象位置和每个像素上的材质的数据。后续通道可以在上面叠加地、有效地应用照明和其他效果。这类渲染方法在第20.1节中描述。
像素着色器的限制是,它通常只能在交付给它的片段位置写入渲染目标,而不能从邻近像素读取当前的结果。也就是说,当一个像素着色程序执行时,它不能将其输出直接发送到邻近的像素,也不能访问其他像素最近的更改。相反,它计算的结果只影响它自己的像素。然而,这种限制并不像听起来那么严重。在一次传递中创建的输出图像可以在以后的传递中被像素着色器访问其任何数据。相邻像素可以使用图像处理技术进行处理,如12.1节所述。
一个像素着色器不能知道或影响邻近像素的结果也有例外的情况。其一是像素着色器可以在计算梯度或导数信息时立即(尽管是间接地)访问相邻片段的信息。像素着色器提供了任意插值值沿着x和y屏幕轴改变每个像素的数量(搞不懂这句话啥意思)。这些值对于各种计算和纹理寻址很有用。这些梯度对于纹理过滤(第6.2.2节)等操作特别重要,在这些操作中,我们想知道有多少图像覆盖了一个像素。所有的现代GPU都是通过以2×2为一组(称为四元组)处理片段来实现这一特性的。当像素着色器请求一个梯度值时,相邻片段之间的差值就会被返回。,如图3.15,一个统一的内核具有访问相邻数据的能力,这些数据保存在同一条曲线上的不同线程中,因此可以计算用于像素着色器的梯度。这个实现的一个结果是,梯度信息不能在受动态流控制影响的部分着色器中访问,也就是说,一个“if”语句或一个具有可变迭代次数的循环。一组中的所有片段必须使用相同的指令集处理,以便所有四个像素的结果对于计算梯度是有意义的。这是即使在离线呈现系统中也存在的一个基本限制。


DirectX 11引入了一种缓冲区类型,允许对任何位置进行写访问,即无序访问视图(UAV)。最初仅用于像素和计算着色器,在DirectX 11.1中,对UAV的访问扩展到所有着色器。OpenGL4.3称此为着色器存储缓冲区对象(SSBO)。两个名字都有各自的描述性。像素着色器以任意顺序并行运行,这个存储缓冲区在它们之间共享。
通常需要一些机制来避免数据竞争条件(即数据危险),在哪里两个着色程序都是“竞赛”影响相同的值,可能会导致不可预知的结果。例如,如果一个像素着色器的两个调用试图在大约同一时间添加相同的检索值,可能会发生错误。两者都将检索原始值,都将在本地修改它,但随后无论哪个调用最后写入其结果,都将消除另一个调用的贡献——只会发生一次添加。GPU通过拥有着色器可以访问的专用原子单元来避免这个问题。然而,原子意味着一些着色器可能会在等待访问被另一个着色器读/修改/写的内存位置时停止。
虽然原子可以避免数据风险,但许多算法需要特定的执行顺序。例如,您可能想要绘制一个更远处的透明蓝色三角形,然后用一个红色透明三角形叠加它,将红色混合在蓝色之上。对于一个像素,可以有两个像素着色器调用,每个三角形调用一个,执行的方式是红色三角形的着色器在蓝色三角形之前完成。在标准管线中,片段结果在处理之前在合并阶段进行排序。在DirectX11.3中引入了光栅顺序视图(ROVs)加强执行命令。这些就像UAVs;它们可以被着色器以同样的方式读取和写入。关键的区别是,ROVs保证数据以适当的顺序被访问。这大大增加了这些可访问shader缓冲区的有用性。例如,ROV可以让像素着色器编写自己的混合方法,因为它可以直接访问并写入ROV中的任何位置,因此不需要合并阶段。代价是,如果检测到无序访问,像素着色器调用可能会暂停,直到之前绘制的三角形被处理。
3.9 混合阶段
正如2.5.2节所讨论的,合并阶段将单个片段的深度和颜色(在像素着色器中生成的)与帧缓存结合起来。DirectX称这个阶段的为output merger;OpenGL将其称为per-sample operations。在大多数传统的管线示意图(包括我们自己的)中,这个阶段是模板缓冲区和z缓冲区操作发生的地方。如果片断是可见的,在这个阶段发生的另一个操作是颜色混合。对于不透明的表面,没有真正的混合涉及,因为碎片的颜色只是替换之前存储的颜色。片段和已存储颜色的实际混合通常用于透明和合成操作。想象一下,光栅化生成的片段通过像素着色器运行,然后发现在zbuffer应用时被一些先前渲染的片段隐藏。所有在像素着色器中完成的处理都不是必要的。为了避免这种浪费,许多GPU在执行像素着色器之前执行一些合并测试。片段的z深度(以及其他任何正在使用的内容,如模板缓冲区或裁剪)用于测试可视性。如果隐藏,碎片将被剔除。这个功能叫做early-z。像素着色器可以改变片段的z深度,或者完全丢弃片段。如果在一个像素着色程序中发现有任何一种类型的操作,那么early-z通常不能使用并被关闭,这通常会降低管道的效率。DirectX 11和OpenGL4.2允许像素着色器强制启动early-z测试,尽管有一些限制。请参阅23.7节了解更多关于early-z和其他z缓冲区优化的信息。有效地使用early-z可以对性能产生很大的影响,这将在第18.4.5节中详细讨论。
合并阶段位于固定功能阶段(如三角形设置)和完全可编程着色阶段之间的中间地带。虽然它不能编程,但其操作是高度可配置的。颜色混合,特别是可以设置执行大量的不同操作。最常见的是涉及颜色和alpha值的乘法、加法和减法的组合,但也可以进行其他操作,如最小值和最大值,以及按位逻辑操作。DirectX 10增加了从像素着色器和帧缓冲的颜色混合两种颜色的能力。这种能力被称为双源颜色混合,不能与多个渲染目标一起使用。MRT也支持混合,并且DirectX 10.1引入了在每个单独的缓冲区上执行不同的混合操作的能力。
正如上一节末尾所提到的,DirectX 11.3提供了一种通过ROVs实现混合可编程的方法,但在性能上要付出一定的代价。ROVs和合并阶段都保证绘制顺序,也就是输出不变性。不管像素着色器的结果是按什么顺序生成的,API要求结果按照输入的顺序被排序并发送到合并阶段。
3.10 计算着色器
GPU可用于实现传统图形管线的更多用途。在计算股票期权的估值和为深度学习训练神经网络等领域,有许多非图形化的应用。以这种方式使用硬件被称为GPU计算。像CUDA和OpenCL这样的平台被用来将GPU控制为一个巨大的并行处理器,而不需要或访问特定于图形的功能。这些框架经常使用C或C++语言来扩展,以及为GPU制作的库。
在DirectX 11中引入的计算着色器是GPU计算的一种形式,因为它是一个不在图形管线中有固定位置的着色器。它与渲染过程密切相关,因为它是由图形API调用的。它与顶点、像素和其他着色器一起使用。它使用了与管线中使用的相同的统一着色处理器池。它是一个着色器,像其他的一样,因为它有一些的输入数据集,可以访问缓冲区(如纹理)的输入和输出。在计算着色器中,经线和线程更加明显。例如,每次调用都获得一个它可以访问的线程索引。还有线程组的概念,在DirectX 11中,线程组由1到1024个线程组成。这些线程组由x,y和z坐标指定,主要是为了在着色器代码中使用的简单性。每个线程组都有少量在线程之间共享的内存。在DirectX 11,有32 kB的容量。计算着色器由线程组执行,这样组中的所有线程都保证并发运行
计算着色器的一个重要优势是,它们可以访问在GPU上生成的数据。将数据从GPU发送到CPU会导致延迟,所以如果处理和结果能够保存在GPU上,性能可以得到提高。后处理,在某种程度上修改渲染的图像,是计算着色器的一个常见用途。共享内存意味着来自采样图像像素的中间结果可以与相邻线程共享。例如,使用计算着色器来确定图像的分布或平均亮度,其运行速度是在像素着色器上执行此操作的两倍。
计算着色器对于粒子系统、网格处理如面部动画、剔除、图像滤波、提高深度精度、阴影、景深以及其他任何需要使用GPU处理器的任务都很有用。Wihlidal讨论了计算着色器如何比细分壳着色器更有效。其他用途见图3.16。


这就结束了我们对GPU渲染管线实现的回顾。有很多方法可以使用和组合GPU功能来执行各种与渲染相关的过程。本书的中心主题是为利用这些功能而调整的相关理论和算法。我们的重点现在转移到转换和着色。
补充阅读和在线资源
Giesen的图形管道之旅详细讨论了GPU的许多方面,解释了元素为何以这种方式工作。Fatahalian和Bryant的课程用一系列详细的幻灯片讨论了GPU的并行性。虽然重点是使用CUDA的GPU计算,但Kirk和Hwa的书的引言部分讨论了GPU的进化和设计哲学。
学习shader编程的正式方面需要一些工作。像OpenGL Superbible和OpenGL Programming Guide这样的书籍包含了关于着色器编程的材料。老书OpenGL Shading Language没有涵盖更近期的着色器阶段,如几何和细分着色器,但特别关注与着色器相关的算法。查看这本书的网站获取最新的和推荐的书籍。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-25 22:30 , Processed in 0.096739 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表