《Real-Time Rendering 4th Edition》读书笔记--简单粗糙翻译 第三章 图形处理单元 The Graphics Processing Unit
写在前面的话:因为英语不好,所以看得慢,所以还不如索性按自己的理解简单粗糙翻译一遍,就当是自己的读书笔记了。不对之处甚多,以后理解深刻了,英语好了再回来修改。相信花在本书上的时间和精力是值得的。
————————————————————————————————
“计算机大部分时候就是你所看到的”--黄仁勋
图形加速一开始应用于扫描三角形时像素的插值计算,把像素值绘制在屏幕上,纹理访问图像数据并应用到表面上。后来又加了对深度值(z-depths)的插值和深度测试。由于使用的频繁,这些处理都被委托给专门的硬件来支持以提高性能。渲染管线的很多功能都是逐步迭代起来的。专门的图形加速硬件对于CPU唯一的优势就是处理速度,然而处理速度相当重要。
在过去的二十年,图形硬件经历了飞速发展。第一款有顶点处理功能的商用图形芯片(NVIDIA’s GeForce256)发布于1999年。英伟达为了区分GeForce256和以前的光栅化芯片,以GPU(graphics processing unit)来命名它,自此以后都开始叫GPU了。接下来几年里,GPU由复杂的固定渲染管线发展到了由开发者可编程实现。各种各样的可编程着色器是控制GPU的主要途径。为了效率,部分管线仍保持着可配,不可编程,但是都是朝着可编程和灵活性发展。
由于GPU是高度并行化处理任务,所以GPU有着很快的速度。它有专门组成部分来处理实现z-buffer,可快速访问纹理图像和其他的缓冲,可快速找到对应像素。23章将会详细讲解这些,这里主要讲的是为什么GPU拥有高度并行性。
3.3节解释了着色器的工作原理。目前,我们只需要知道着色器核心是一个小处理器,可用来处理相对独立的任务,例如将顶点由模型坐标转换到世界坐标,计算像素的颜色等等。由于每帧需要给屏幕提供成千上万个三角形,每秒有着几亿次的着色器调用(shader invocations)。
延迟是所有处理器都需要面对的问题。访问数据需要花费一定时间。如果数据信息到处理器的时间越长延迟就越大。章节23.3有详细介绍这个。访问存储器里的数据比访问寄存器里的数据需要更多的时间。章节18.4.1详细讲到了内存访问。等待检索数据会降低性能。
3.1 数据并行结构
为了提高速度,每种处理器都有对应的策略。CPU通常被用来处理各种各样的数据结构和大型代码块。CPU的多处理器,除了有限的单指令多数据数据结构(SIMD)向量处理外,他们都以串行的方式运行代码。为了减少延迟,大部分CPU芯片都有快速的局部缓存,存储着接下来有可能需要的数据。CPU同样有一些技巧减少延迟,例如分支预测,指令重排, 寄存器重命名和缓存预取。
GPU采用不一样的方法。大部分GPU芯片都是由几千个着色器组成。GPU是一个流处理器,相似的数据依次通过处理。正是由于相似性,GPU可以用大规模并行的方式处理这些数据。另外这些调用都是尽可能独立的,并不需要彼此之间的信息,没有共享内存。但是有时候这个规则会被打破,为了一些新的有用的功能,以牺牲一些性能为代价,不得不等待另外一个处理器完成工作。
GPU用吞吐量(throughput)来描述最大的数据处理速度。然而,这个快速处理有代价,由于很少芯片区域是可以缓存内存或控制逻辑,每个着色器的延迟通常要比CPU处理器的延迟高。
假设一个Mesh被光栅化之后有两千个片元需要被处理,像素着色程序需要被调用两千次。想象一下,如果只有一个着色器,性能肯定很糟糕。着色器每处理一个片元都需要在寄存器中完成一些数学操作。因为寄存器是局部的,快速的,所以访问很快。假如着色器需要访问一张纹理来知道mesh上像素的颜色。纹理是一个完全独立的资源,并不在像素着色程序的局部内存中,访问纹理是需要一定操作的。访问内存是需要成千上万个时钟周期的,而在这段时间内GPU是没有事情可做的。这时候着色器就在阻塞,等待着纹理颜色数据传过来。
为了让糟糕的GPU变得更好,可以给每个片元的局部寄存器一点存储空间。这样,与其在等待纹理数据,可以切换着色器去执行另外一个片元了。除了需要指出在第一个片元中执行的指令外,这个切换对两个片元的执行都没有影响,速度是很快的。和第一个片元一样,第二个片元同样有数学操作,然后获得纹理数据。第二个片元处理完,会紧接着处理第三个片元,直到两千个片元都以这种方式处理完。此时,着色器会回到第一个片元。到这时,所有的纹理数据都获取到了,等待着使用,这样着色程序可以继续执行。处理器会以这种方式处理下去,直到遇到下一个会阻塞,或者程序完成。单个片元的处理时间可能会增加,但是整体上片元处理时间大大减少。
在这个结构中,GPU由一开始的等待阻塞变成了去处理接下来的片元。GPU进一步设计将逻辑指令从数据中分离开,称为单指令多数据结构(SIMD),这种结构会在固定数量的着色器中以lock-setp的形式运行相同的指令。和运用单独的逻辑或调度单元器运行每个程序相比,SIMD的优势是可以用更少的硬件去处理数据和交互数据。将两千个片元处理例子用在现在GPU上,每个像素着色处理一个片元的过程叫一个线程(thread)。这个线程不同CPU中的线程,在着色器处理过程中的任何寄存器都需要一点内存来存储输入值。运行相同着色程序的线程被捆绑成一组,在NVIDIA中称为warps,在AMD中称为wavefronts。一个warps或wavefronts,是同时用8到64个GPU着色器运行SIMD处理过程。每一个线程都被映射到一个SIMD lane中。
如果我们有2000个片元需要操作,NVIDIA中有32个线程,那么每个线程就有2000/32 = 62.5个warps,这意味着需要63个warps,其中最后一个有一半是空的。warp的处理过程和单个GPU处理过程类似,32个处理单元在同一个lock-step上运行,一旦有一个执行拿取内存,其他的处理器都会同时执行相同操作,因为所有的处理器都是执行一样的指令。如果一个warp中拿取内存遇到了阻塞,后续的操作都需要等待它的数据,为了应对这种情况,我们可以将后续工作切换给另外一个warp。这个切换和我们单线程处理一样快,Wrap之间切换并无额外的开销,每个线程都有着自己的寄存器,每个warp都可以跟踪正在执行的指令。切换到一个新的warp只需要将一组核心指向另外一组核心,只有极小的开销。warp执行或切换直至全部工作完成。见图3.1。
图3.1 简化了的着色器处理例子。 一个三角形的全部片元,或者称为一个线程(threads),分成组warps。每个warp展示有4个线程,实际上有32个线程。这个着色程序有5个指令。GPU着色器执行这些指令从第一个warp开始,直到发现“txr”指令遇到了阻塞,需要时间去获取数据。第二个warp切换进来,着色器程序的前三个指令提交给的第二个warp,直到再遇到阻塞。紧接着第三个warp会切进来。在第三个warp遇到阻塞后,会切换到第一个warp,继续执行。如果这时候他的“txr”指令数据仍未拿到,执行真正的阻塞直到拿到所有的数据。每个warp依次完成。
在上面简单的例子中,warp的切换实际上是有一点开销的,尽管开销很小。尽管还有其他的技术来做优化执行效率,但是warp切换(warp-swapping)仍然是GPU降低延迟最主要的方法。还有几个因素影响处理过程的效率,例如有多少线程,多少warp可以被创建。
着色程序的结构同样是影响效率的重要因素。一个主要的因素是每个线程用到的寄存器的数量。在上面的例子里,我们假设GPU一次性需要处理两千个片元,每个线程需要的寄存器越多,GPU常驻线程就越少,warp也就越少。一旦warp少了,意味着遇到阻塞可切换的机会变少。warp都处在活跃中,活动的warp数量和最大数量的比值occupancy高(GPU占有率)。occupancy高意味着,更多地warp可用,所以空闲的处理器就很少。低occupancy则会经常导致低性能。获取内存的频率同样影响延迟。
另外一个因素影响整体效率的是动态分支(dynamic branching),由“if”语句和循环导致。假设在着色程序中碰到“if”语句。如果所有的线程都是一个分支里,warp不需要考虑到其他的分支执行,然而,在一些线程中,甚至只有一个线程,一旦有分支,warp必须两个分支都执行,然后通过特定的线程丢弃不需要的结果。这个问题称为,线程散度(thread divergence),warp中少量的线程需要执行循环迭代或者if分支,而warp中的其他线程不需要执行,则会导致这部分不需要执行的线程处于闲置阶段。
在接下来的章节中,我们将讨论GPU如何实现渲染管线,可编程着色器如何操作,每个GPU阶段的功能和延伸。
3.2 GPU渲染管线概述
GPU由几何处理阶段、光栅化阶段和像素处理阶段组成。而这些又被分成不同程度可配置或可编程的子阶段。图3.2展示了各种阶段,用颜色区分了是否可配可编程。注意这些物理阶段的划分可能跟第二章中的划分不同。
图3.2 GPU的渲染管线组成。 这些阶段按照颜色划分是否可编程可配置。绿色阶段是完全可编程的,虚线是可选阶段,黄色阶段是可配置的但不能编程的,例如在合并阶段的各种混合模式。蓝色阶段是固定功能。
GPU的逻辑模型,通过API暴露给开发者。正如18章和23章讨论的,逻辑管线(物理模型)的实现有硬件厂商提供。逻辑模型中的固定功能可以通过在相邻的可编程阶段添加指令执行。在渲染管线中一段程序可以被好几个子单元执行不同的代码段,也可以被一段特定的pass完整执行。逻辑模型会帮助你理解什么会影响性能,但是不应该被认为是GPU实现渲染管线的方式。
顶点着色,组成几何处理阶段的一部分,是一个完全可编程阶段。几何着色阶段同样是完全可编程阶段,用来处理图元的顶点,可以操作每个图元的着色,可销毁图元,可以创建新的图元。曲面细分和几何着色都是可选阶段,并不是所有的GPU都支持,特别是在移动设备中。
裁剪,三角形设置和三角形遍历都是硬件的固定功能。窗口和视口的设置会影响到屏幕映射。像素处理阶段是完全可编程的。尽管合并阶段不是可编程的,但是它高度可配,通过一系列参数设置。它的功能有改变颜色值,深度缓冲,混合,模板测试和其他缓冲等等。像素着色和合并一起组成了像素处理阶段。
随着时间推移,GPU管线从硬编码操作朝着越来越灵活越来越可控发展。其中可编程阶段是重要的一环。下一节,将会介绍各种可编程阶段的特征。
3.3 可编程着色阶段
现代着色程序采用统一的着色设计。这意味着顶点,像素,几何和曲面细分等相关着色处理器都采用了一个通用的编程模型。他们有着相同的指令系统体系结构(ISA, instruction set architecture)。由这种模型构成的处理器称为通用着色器核心,而有这样核心的GPU,就有着统一的着色结构。在这种结构后面的思想是,着色器处理是可以被各种角色使用的,并且GPU可以根据需求来对应分配。举个例子,一个拥有细小三角形构成的mesh要比两个三角形构成的大四边形需要更多顶点着色。一个分别拥有顶点着色核心池和像素着色核心池的GPU,理想的工作分配是保持让这些着色器核心有预测的处于忙碌中。GPU拥有统一着色器核心,就可以决定如何平衡这条路。
叙述整个着色器编程模型超出了本书的范围,有很多文档、书籍、网站都做了这件事。着色器编程使用了类C语言的着色器编程语言,DIrectX的语言称为高级着色语言(HLSL,High-Level Shading Language),而OpenGL的称为GLSL(OpenGL Shading Language)。DirectX的HLSL语言可以编译成虚拟机字节码,又称为中间语言(IL,DXIL,intermediate language),提供了硬件的独立性。中间表示允许着色程序可以被离线编译存储。中间语言被驱动转换成特定的GPU的指令系统体系结构ISA。控制台编程通常避免中间语言步骤,因为系统只有一个ISA。
单精度的浮点值标量和向量的基本数据类型是32位,虽然在着色器编程中经常用到矢量,但是以前32位并不被硬件支持。现在GPU已经支持32位整数和64位浮点数了。浮点值向量经常有坐标值(xyzw),法向量,矩阵的行,颜色值(rgba),或者纹理坐标(uvwq)。整数经常被用来表示计数,指数或位掩码。集合数据类型,例如结构体,数组,矩阵同样都是支持的。
一次绘制指令(Draw Call)会调用图形API绘制一组图元,这样会启动和运行图形管线中对应的着色器。每个可编程着色阶段有两种类型的输入:uniform输入,在一次Draw Call里不会变化(但是在不同Draw Call中是变化的),以及varying 输入,数据来自三角形的顶点或者来自光栅化。例如,在像素着色中,光源的颜色会是一个uniform 输入,而三角形表面的坐标是每个像素都改变的,所以是varying输入。纹理是一种特别的uniform输入,曾经总是给表面提供颜色值的图像,而今是可以作为存储各种大数据的列表。
底层的虚拟机给不同类型的输入输出提供了各种特定的寄存器。uniform类型的可用的常量寄存器数量要比varying输入和varying输出需要的常量寄存器数量大得多,这是因为在对每个顶点或者像素,varying类型的输入和输出中需要独自存储,所以这里的需要数量自然是有限的。而uniform输入是一次性存储的,在一次draw call 过程中,对所有的顶点和像素而言,数据可以重复被访问。虚拟机同时还有多用途的临时寄存器,用来暂存空间。 所有类型的寄存器都可以使用临时寄存器中的整数值进行数组索引。着色虚拟机中输入输出如图3.3所示。
图3.3 Shader Model 4.0 中的统一虚拟机架构和寄存器展示。每个资源旁边都给出了最大可用的数量。用斜杠分开的三个数字分别表示是顶点、几何和像素寄存器(从左到右)的最大数量。
在现代GPU中,一些常用的图形学计算都已经很高效了。这些计算有通过操作符表示的常用计算,例如*和+表示加法和乘法,有固定功能例如atan(),sqrt(),log(),还有些复杂的操作,例如矢量化法线,矢量化反射,叉乘计算,矩阵变换和行列式计算。
控制流(flow control)是利用分支指令来改变代码的执行流程。在HLSL中的控制流指令有“if”“case”语句及各种循环类型。着色器支持两种控制流。静态控制流(static flow control)是基于uniform 输入,这意味着控制流代码在本次draw call 过程中是常量。静态控制流的主要好处就是在不同情况下(例如不同数量的光源情况下)可以使用相同的着色器。不存在线程散度,因为所有的调用都是用的相同的代码路径。动态控制流(Dynamic flow control)是基于varying 输入的,意味着每个片元执行的代码可以不同。这比静态控制流更有用,但是性能消耗更多,特别在代码流在着色器调用中不定时改变流向。
3.4 可编程着色的环境和API
可编程着色的思想可追溯到1984年的Cook's shade tree。图3.4展示了一个简单的着色程序和其对应的着色树。在90年代,基于这个思想的强大的着色语言开始发展起来,直至今天在电影制作中仍有用到这个思想。
图3.4 一个简单的铜材质着色器和他对应的着色语言程序。
第一款商用级别的图形硬件由3dfx在1996年10月1号推出。图3.5展示了图形硬件发展的时间轴。3dfx的Voodoo图形卡渲染出来的游戏Quake 有着高质量和高性能,这款图形卡得到广泛的应用。这款硬件实现了固定渲染管线功能。在GPU支持可编程着色之前,曾多次尝试实现可编程着色。1999年Quake III采用的Arena scripting language是第一次得到成功广泛商用的着色语言。在一开始提到的 NVIDIA的 GeForce256,是第一款被称为GPU的图形硬件,虽然还不是可编程的,但是是可配置的。
图3.5 图形硬件版本和API的发展时间轴
3.5 顶点着色
顶点着色是处理三角形网格的第一个阶段。用来描述如何构成三角形的数据并不适用于顶点着色。 顾名思义,顶点着色有方法可以修改新增或者忽略每个三角形的顶点数据,像颜色值,法线,纹理坐标和位置坐标。顶点着色程序一般会将顶点从模型空间转换到齐次裁剪空间,并且返回对应的坐标值。
顶点着色和前面描述的统一着色器类似,输入顶点数据,然后输出通过插值得到的数据。顶点着色不能新增也不能销毁顶点,并且一个顶点生成的结果数据不能传给另外一个顶点使用。 由于每个顶点都是独立处理的,所以GPU上的任意数量的着色器处理器都可以并行应用于输入的顶点流。
在顶点着色之前,输入通常经过了处理,例如,模型会分成物理模型和逻辑模型,驱动会在创建顶点(物理上)前悄悄加入一些对应的指令(逻辑上),而这些对开发者都是不可见的。
接下来的章节介绍了一些顶点着色效果,例如动画关节的顶点绑定,轮廓绘制。还有:
· 生成新的对象。用顶点着色变换只会创建一次的mesh。
·利用Skining(蒙皮)和morphing(变形)技术制作人物动画和面部。
· 程序化变形,例如旗帜、衣服和水。
· 生成粒子。给管线传送没有渲染面积(no area)的mesh,然后给网格添加渲染面积。
· 光学变形,热扭曲,水波纹,翻书效果等等。把帧缓冲里的内容当做一张屏幕网格对齐的纹理,然后进行程序化变形。
· 地形高度场。
图3.8展示了顶点着色的一些变形例子。
图3.8 左边展示的是一个正常的茶壶。中间展示的是一个经过顶点着色简单剪切(shear)的茶壶。右边展示的是一个经过噪声变形的茶壶。
顶点着色的输出可以有好几种方式使用。通常是用来实例化图元,例如三角形,然后进行光栅化,找出需要进行像素着色的像素。在一些GPU中,顶点着色的输出还可以进行曲面细分或者几何着色或者存储起来。接下来章节会讨论这些操作。
3.6 曲面细分阶段
曲面细分阶段允许我们渲染曲面。GPU的一个任务是将每个表面用一组合适的三角形来表示。曲面细分阶段最早出现在DirectX 11中,随后被OpenGL 4.0 和OpenGL ES 3.2支持。
曲面细分有好几个优势。曲面( curved surface )和一组三角形来表示弯曲表面,曲面更简单。除了节省内存外,曲面可以避免从CPU到GPU的过程变成性能瓶颈,特别是在有角色动画和物体每帧有变形的时候。在特定视口给定合适数量的三角形来模拟表示表面。例如,如果一个球离摄像机很远,只需要很少的三角形就可以表示一个球,靠近后,需要几千个三角形才能表达的比较准确。这种控制LOD( level of detail)的能力同样可以用来控制性能。在一些差性能的GPU上用一些低精度的网格表示表面来维持帧率。模型通常是用一些精细的三角形来模拟平面和曲面,或者用不需要频繁进行着色计算的曲面细分来实现。
曲面细分阶段分三部分。在DirectX叫壳着色器( hull shader),细分器(tessellator)和域着色器( domain shader)。在OpenGL中壳着色器叫曲面细分控制着色器( tessellation control shader),域着色器叫曲面细分评估着色器(tessellation evaluation shader),虽然名字长,但更具体。而固定功能细分器称为图元生产者( primitive generator)。
在这我们对曲面细分每个阶段做一个简单总结。首先,壳着色器的输入是一项特殊的控制点(patch)图元。壳着色器有两个功能。第一,它告诉了细分器在不同配置下需要创建多少个三角形。第二,处理每个控制点。可以选择用壳着色器来修改patch的类型,按需添加或者移除控制点。壳着色器输出的一组控制点,交给域处理器进行处理。见图3.9。
图3.9 曲面细分阶段。一组patch输入给壳着色器,然后壳着色器将细分因素(TFs)和类型发送给固定功能细分器,然后按需变换这些控制点,然后和细分因素(TFs)和patch的相关参数一起发送给域着色器。细分器会按重心坐标生成一组新的顶点,然后传给域着色器,最后输出新的三角形Mesh。
在管线中,细分器(tessellator)是一个固定功能,由细分着色器执行。它的任务是给域着色器加入一些新的顶点,而壳着色器需要告诉细分器的是:曲面细分类型是什么——三角形(triangle),四边形(quadrilateral)或者等值线(isoline)。等值线是一组线段,经常被用来进行渲染毛发。另外一个重要的概念是细分因素(tessellation factors,在OpenGL叫tessellation levels ),有两种类型:内边缘和外边缘( inner and outer edge)。内部因素告诉三角形或者四边形内部需要细分的程度。而外部因素决定了外部边缘分割程度。图3.10给出了细分因素增加的例子。通过分别控制内外因素,我可以让相邻曲面的边缘细分合适,而不需管内部细分是否粗糙。重心坐标指定了表面上每个点的相对位置,所以新生成的顶点依照了重心坐标进行分布。
图3.10 改变细分因素的不同效果。茶壶有32个控制点(patches)。内细分因素和外细分因素从左到右分别是1,2,4,8。
壳着色器将patch变换后生成一组新的控制点。细分器将新生成的网格发送给域着色器。域着色器调用曲面的控制点来计算每个顶点的输出值。域着色器有着类似顶点着色的数据流模式,将细分器生成的顶点作为输入,生成合理的输出顶点,然后形成三角形传送给下一个管线阶段。
虽然这个系统听起来比较复杂,但是这种结构是高效的,每个着色器都是非常简单的。经过壳着色器的patch通过后并无改变,只是简单地传输了下所有patch的固定值,壳着色器可以利用patch间的距离或者屏幕大小来计算细分因素,例如地形渲染。细分器则生成了新的顶点,并告诉这些顶点的坐标以及需要组合的类型,是三角形还是线。域着色器则利用重心坐标生成每个点,然后将这些点进行计算生成坐标,法线,贴图坐标和其他需要的顶点信息。如图3.11所示。
图3.11 左边模型的三角形数大概是6000多个。右边是每个三角形经过PN三角细分后的模型。
3.7 几何着色
几何着色可以把图元转换成另外一种图元,而曲面细分没有这种能力。在DirectX10版本中加入了几何着色功能,在渲染管线中,它紧接着细分曲面着色器,并且它是可选的,而且要求Shader mode是4.0,早期版本不支持。OpenGL支持的版本是OpenGL3.2,OpenGL ES 3.2。
几何着色的输入是单个对象及其顶点。这些对象通常是三角形,线段或者点。几何着色可以定义和处理扩展图元。特别的,三角形外的三个附加顶点可以被传入进来,也可以利用与折线顶点相邻的两个顶点。见图3.12。在DirectX11 和Shader Model 5.0中,最多可以处理32个控制点,也就是说,在曲面细分阶段更合适生成patch。
图3.12 几何着色的输入都是一些简单类型:点,线段,三角形。右边第二个图元是线段和线段相邻的两个顶点,最右边的图元是三角形和三角线外的三个顶点。
顶点着色被设计用来修改输入数据和做有限的复制。例如,把一个面复制成6个面,然后变换渲染成一个立方体的6个面。 也可以用来创建高质量阴影的级联阴影贴图。几何着色可以利用点数据来创建粒子,毛皮渲染,为阴影算法找物体边缘。见图3.13.
图3.13 几何着色(GS)的使用案例。左图是利用GS进行元球等值面细分操作。中间图是利用GS和流输出对线段进行分形细分,然后利用GS生成公告板来展示闪电。右图是利用顶点着色和几何着色及流输出模拟布料。