《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的发展时间轴

        2001年, NVIDIA的GeForce 3是一款支持可编程顶点着色的GPU,一开始只有DirectX 8.0支持,然后发展到OpenGL中。这些着色程序被驱动用类似汇编的语言编译成微代码。DirectX 8.0也加入了像素着色,但是这时候的像素着色并不是真正的可编程。基于 皮克斯工作室的发现, 渲染引擎叫RenderMan,随着纹理读取和浮点值的应用,真正的可编程出现了。
        这时的着色器还不允许有控制流,控制条件必须通过模拟得到 。DirectX定义了Shader Model来区分硬件的不同着色能力。2002年,directX 9.0 包含了Shader Model 2.0,拥有了真正的可编程顶点着色和像素着色。OpenGL也推出了相同的功能。着色资源的增强,让着色器可以实现更多复杂的效果。随着对控制流的支持,着色器程序的长度和复杂度的增加,让可编程模型越来越繁琐。幸运的,DirectX9.0 开始使用HLSL语言,由微软和英伟达一起联合开发的着色器语言,同时OpenGL也发布了GLSL语言。这些语言的语法和设计都受到了C语言的影响。
        2004年推出了Shader Model 3.0,添加了动态控制流,让着色器变得更强大,同时让一些可选的功能变成了固定流程,进一步加强了资源,给顶点着色添加了有限的支持纹理读取。2005年微软的xbox 360推出了支持Shader model 3.0的主机,2006年索尼同样推出了支持shader model3.0的主机。任天堂wii是最后一批只支持固定渲染管线的主机了,发布于2006年。大量使用和管理着色语言的工具产生了。图3.6就是一个使用Cook的着色树概念的工具的截图。
图3.6  一个用于着色设计的可视化着色图形系统。不同的功能被封装在左侧的功能盒中,选中后可以在右侧看到可调的参数。每个功能的输入输出只需要简单连线,如图中间所示。
 
        2006年末,Shader Model 4.0和DirectX 10的发布,加入了一些新的功能,例如几何着色和流输出。Shader Model 4.0 ,uniform输入可用于所有的着色器。资源得到更一步加强,增加了对整数型数据结构的支持。OpenGL 3.3使用了GLSL 3.3版本。
        2009年,DirectX11和Shader Model 5.0发布,添加了 曲面细分着色(tessellation shader)计算着色(compute shader,又称通用计算 DirectCompute)。这个版本里的CPU的多处理器更加高效。OpenGL则在4.0版本中支持了曲面细分着色,在4.3版本中支持了计算着色。DirectX和OpenGL发展的不同,两者不同的版本都需要特定硬件支持。微软拥有DirectX API,可以直接和硬件产商,例如AMD,NVIDIA,Intel,直接合作,也和游戏开发者和计算机辅助设计公司合作,一起推动了一些特色功能的发展。 OpenGL是由硬件和软件供应商组成的联盟开发的,由非盈利的Khronos组织管理。由于涉及到的公司非常多,一般在DirectX中发布的一些API特性,随后就会出现在OpenGL后面更新的版本中。但是OpenGL更容易扩展,支持更多地平台,这使得很多功能在官方发布之前就可以使用。
        下一代变革性API是AMD在2013年推出的Mantle API 与游戏开发商合作开发的电子游戏DICE, Mantle去掉了很多图形驱动程序的开销,并将这种控制直接交给了开发者。新的结构更好地支持多核CPU。新的API降低了CPU在驱动上的开销,使得多核CPU更加高效。微软紧随其后在2015年发布了DirectX12,设计思路都是一样的。注意DirectX12并不是聚焦在发布GPU的新特性上。两种API都可以把图像发送给虚拟现实系统,例如Oculus Rift 和 HTC Vive。然而DirectX 对API进行了重新设计,更符合现代GPU设计。降低CPU的驱动消耗,降低了CPU成为瓶颈的概率,也提高了多处理器处理图形的性能。移植到旧版本API中可能会有点困难。
        苹果在2014年发布了自己的低开销API——Metal。除了性能提高,降低了CPU的使用,可以保留更多地电量,对移动设备而言这点很重要。Metal拥有自己的着色语言。
        AMD随后把自己的Mantle工作交给了 Khronos组织, Khronos随后在2016年发布的新版中将其重命名为Vulkan。和OpenGL一样,Vulkan同样支持多平台。Vulkan使用一种新的高级中间语言(SPIR-V),可以同时被着色器和GPU计算使用,可以移植到很多平台上。Vulkan也可以用于非图形化的GPU计算,不需要一个展示窗口。Vulkan同其他的低驱动消耗的API的区别在于,Vulkan支持多平台,从工作站到移动设备。
        在移动设备上使用的OpenGL ES,“ES”表示的嵌入式系统,因为这个API是为移动设备开发的。当时标准的OpenGL对一些设备而言是繁重缓慢的,并且提供的功能很少。在2003年,OpenGL ES 1.0发布,是OpenGL 1.3的精简版,仅支持固定渲染管线。虽然DirectX当时发展很快,但是移动设备上并不快。例如在2010年发布的第一代iPad,应用的是OpenGL ES 1.1,虽然在2007就发布了OpenGL ES 2.0版本,已经可以支持可编程着色。OpenGL ES 3.0在2012年发布,支持了多渲染目标(multiple render target),纹理压缩,变换反馈(transform feedback),实例化(instancing),支持更多类型的纹理和模式,改进了着色编程语言。OpenGL ES 3.1 添加了计算着色,3.2 添加了几何着色和曲面细分着色等等。在23章将会详细讨论移动设备上的结构。
        作为OpenGL ES的分支 WebGL是应用在浏览器上。第一个版本发布在2011年,可以被应用到大部分移动设备上,功能相当于OpenGL ES 2.0。WebGL 2对应的是OpenGL 3.0。
        WebGL比较适合在课堂上教学使用:
        1、它是跨平台的,可以在所有的个人电脑和大部分移动设备上使用。
        2、由浏览器支持。也许一个浏览器不支持特定的GPU功能,但另外一个浏览器或许支持。
        3、 代码是interpreted,不需要编译,开发环境只需要一个文本编辑器。
        4、大部分浏览器是支持Debug的,代码可以运行在任何网站上。
        5、 程序可以通过上传到网站或者GitHub来部署。
    
        高级场景图和效果库,例如three.js 可以快速访问各种复杂效果代码,像阴影算法,后期处理,基于物理的渲染和延迟渲染。    
 

3.5 顶点着色

        如图3.2所示,顶点着色是GPU渲染管线的第一个阶段,同时也是第一个可编程阶段。注意,在顶点着色之前,还有一些其他的操作。在DirectX中称为输入汇编( input assembler ,将几个数据流汇合成发送给渲染管线的顶点数据和图元数据。举个例子,一个物体可以由一组坐标值和一组颜色值表示,输入汇编可以利用这些坐标值和颜色值创造出顶点,然后利用顶点构成三角形(或线或点)。输入汇编也支持实例化。一次Draw Call里,同一个物体可以实例化多次,每次的实例化的数据有些微不同(例如位置不同)。
        一个三角形网格可以由一组模型表面特定位置的顶点来表示。除了坐标位置之外,还有其他的 和顶点相关的 可选参数,例如颜色值或纹理坐标。网格的表面法线也被定义在网格顶点上。从数学上看,每个三角形都有一个明确的表面法线,并利用三角形的法线直接计算阴影似乎更有意义。然而,在渲染时,三角形网格经常表示的是下伏曲面,利用顶点的法线来表示表面的朝向,而不是利用三角形网格本身的法线。16.3.4章节会详细讨论计算顶点法线的方法。图3.7展示了两个三角形网格的侧视图,一个是光滑曲面,一个是有折痕的曲面。
图3.7 三角形网格的侧视图,黑色表示法线,红色表示曲面。左边表示的是光滑曲面的顶点法线,右边表示的是有折痕的曲面的顶点法线,中间顶点复制了一份,并且分别给了一个顶点法线。
 

        顶点着色是处理三角形网格的第一个阶段。用来描述如何构成三角形的数据并不适用于顶点着色。 顾名思义,顶点着色有方法可以修改新增或者忽略每个三角形的顶点数据,像颜色值,法线,纹理坐标和位置坐标。顶点着色程序一般会将顶点从模型空间转换到齐次裁剪空间,并且返回对应的坐标值。

        顶点着色和前面描述的统一着色器类似,输入顶点数据,然后输出通过插值得到的数据。顶点着色不能新增也不能销毁顶点,并且一个顶点生成的结果数据不能传给另外一个顶点使用。 由于每个顶点都是独立处理的,所以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生成公告板来展示闪电。右图是利用顶点着色和几何着色及流输出模拟布料。

        DirectX11支持了利用几何着色进行实例化,几何着色可以在任何图元上运行数次,在OpenGL4.0 中通过调用次数指定。几何着色最多输出4个数据流,其中一个发送给管线以进行进一步处理。所有数据流都可以有选择的发送给流输出渲染目标。
        几何着色的输出顺序是按输入顺序来的,因为如果几个着色核心是并行处理的,就需要保证输出结果是有序的。
        在一次DrawCall过程中,只有光栅化、曲面细分阶段和几何着色阶段是可以创建新的对象的。考虑到内存和资源,几何着色阶段是最不可预测的,因为它是完全可编程的。实际中几何着色用的很少,因为它没有很好地映射到GPU的强项。在一些移动设备中它在软件中实现的,所以不鼓励去使用它。
        

3.7.1 流输出

        在标准的GPU的管线中,数据从顶点着色阶段到光栅化阶段,然后交给像素着色阶段。以前,在这个过程中数据通过管线,中间生成的数据并不能被访问。因此,流输出的概念被提出来了,在Shader Model 4.0中。在顶点着色阶段处理完顶点后,处理后的结果可以形成一个流,例如一个有序的队列。 实际上,可以完全关闭栅格化,然后将管道纯粹用作非图形化流处理器。用这种方式处理数据可以通过管线返回,进而可以迭代处理。这种操作对模拟水或其他的粒子效果非常有用。也可以用来蒙皮一个物体,然后让这些顶点重复利用。
         流输出只以浮点数的形式返回数据,因此它会有显著的内存开销。流输出作用于图元,而不是顶点。如果网格输入到管线中,每个三角形的顶点会生成自己的顶点集合,原始网格中的共享顶点没有了。基于这个原因,顶点通常是作为一组点集图元发送给管线的。在OpenGL阶段,流输出阶段称为变换回馈( transform feedback ),变换顶点后返回以进一步处理。流输出的顶点顺序是保证顺序的。
 

3.8 像素着色

        光栅化阶段是一个相对固定的功能,不可编程但可配。遍历每个三角形,以确定它覆盖了哪些像素。光栅化还可以粗略计算三角形覆盖每个像素的面积,每个被完全覆盖或部分覆盖的像素被称为一个片元(fragment)
         三角形顶点的值,包括z缓冲区中使用的z值,在三角形 表面 的每个像素内插值,这些值发送给像素着色阶段进行片元处理。在OpenGL中像素处理称为片元着色( fragment shader ,这个名字或许更合适。点图元和线图元覆盖的像素同样是片元
         通常情况下,我们使用透视校正插值,使得像素表面位置之间的世界空间距离随着物体在距离上的后退而增加。 一个例子是渲染铁轨延伸到地平线。铁轨越远,枕木之间的间隔就越紧密,因为接近地平线的每一个连续像素都移动了更长的距离。 其他插值选项也有,比如屏幕空间插值,其中不考虑透视投影。 D irectx11提供了对何时以及如何执行插值的进一步控制。
        随着GPU的发展,越来越多的输入可以使用。例如,片元的屏幕空间坐标可以在Shader Model 3.0及以后版本中就可以使用,一个三角形的哪个面可见也是一种输入标志。这在一个pass中呈现一个三角形两面使用不同的材质很重要。
         通常像素着色就是计算并输出片元的颜色。它也能生成一个不透明度,并可选地修改其z-depth。在合并阶段,存储在像素上的值可以被修改。在光栅化阶段生成的深度值也可以被像素着色修改。模板缓冲的值通常是不能修改的,但是会传递到合并阶段。DirectX 11.3后允许着色器可以修改这一值。 在Shader Model 4.0中,fog计算和alpha测试等操作已经从合并阶段的操作转变为像素着色阶段的计算。
        像素着色可以丢弃片元,例如无输出,如图3.14所示。 裁剪 平面功能过去是固定功能管道中的一个可配置选项,现在出现在顶点着色器中。 有了片元丢弃,这个功能就可以在像素着色中以任何想要的方式实现。
图3.14 自定义裁剪平面。左图展示是一个水平裁剪平面裁剪后的结果。中间图展示的是一个嵌套球被三个裁剪平面裁剪的结果。右边图展示的是只有球表面在三个裁剪平面外才会被裁剪的结果。
 
        一开始,像素着色的输出结果只能提交给合并阶段,作为最终展示用。随着像素着色的发展,一个像素着色器可使用的指令数量增加了很多。这种增加催生了 多渲染目标(MRT,multiple render targets)的想法。 不同于将像素着色程序的结果仅仅发送到颜色缓冲和z缓冲区,每个片元可以生成多个值集,并保存到不同的缓冲中,每个缓冲称为一个render target可用的render target 的数量基于GPU不同,从4到8个不等。
        尽管有很多限制,但是MRT功能是很强大高效的。只需一次渲染,就可以在一个目标中生成一个颜色值图像,在另外一个目标中生成物体外形信息,然后在第三个目标中生成世界空间的距离信息。这种功能也催生了一种新的渲染管线类型,称为延迟渲染可分开进行可见性处理和着色处理。在第一个pass中存储物体的位置信息和每个像素的材质信息,然后在紧接的pass中进行光照或其他效果的处理。
         像素着色的限制是,不能把一个着色程序的输出结果直接发送到相邻像素上,也不能访问其他像素的变化,着色程序的结果只能影响自身像素。然而,这种限制也没想象中那么害怕。可以在一个pass中创建一种图像,用来保存像素着色需要的任何数据,然后在 随后的pass中像素着色器就可以访问它。这样就可以处理相邻的像素了。
       这个规则—— 像素着色不知道或不能影响相邻像素的处理结果——也可以被打破。 像素着色可以在计算梯度或导数信息时立即访问相邻片元的信息(尽管是间接的),例如沿着x轴或y轴逐像素计算插值变化,这些值对各种计算或者纹理寻址都很重要。 这些梯度值对于纹理过滤之类的操作特别重要。现代GPU用一个2x2的4个片元组成quad来实现这种功能的。当一个像素着色器需要一个梯度值的时候,就的需要知道相邻片元之间的差异,见图3.15。一个统一的处理器核心有这个能力访问相邻片元的数据,因为数据被保存在同一个warp中不同线程上,这样就可以在自身进行像素着色中用到了。但是在着色程序中有动态分支控制,则不能访问相邻片元的数据 组中的所有片元必须使用相同的指令集进行处理,这样所有四个像素的结果对于梯度计算才是有意义的。 这是一个基本的限制,即使在离线渲染系统中也存在。
图3.15 左图所示是一个三角形在quad(2x2个像素)里进行光栅化。图中用黑点标记的像素的梯度值计算过程如右图所示。右图中给出了四个像素各种的v值。注意其中三个像素虽然没被算入在三角形中,但是他们依然被GPU处理进行梯度值计算。 左下角像素在x和y屏幕方向上的梯度值是通过两个四邻像素计算出来的。
 
        DirectX11中使用了一种缓冲类型来写访问(write accsee)任何位置,叫 unordered access view(UAV)。一开始只能用于像素着色阶段和计算着色阶段,DirectX11.1之后所有的着色器都可以使用UAV。这种功能在OpenGL4.3 中称为  shader storage buffer object(SSBO)这两个名字都有各自的描述性。 像素着色器以任意顺序并行运行,并且在它们之间共享这个存储缓冲区。
        如果两个像素着色器试图在同一时间调用同一个索引值,则会出错。 两者都将原始的索引值,在本地修改,但是最后写入结果都将擦除另一个调用所生成的结果,最终只会生成一个结果。GPU通过使用专业原子单元来避免这个问题。然而,原子则意味着有一些着色器需要等待。
        虽然原子可以避免数据灾难,但是许多算法是需要以特定顺序来执行。例如,你想在一个蓝色透明的三角形上绘制一个红色半透明的三角形,把红色混合在蓝色上。这就需要在同一个像素上调用两次像素着色程序,每次绘制一个三角形,但是有可能是红色着色器完成在蓝色之前(例如红色物体在蓝色物体之前,但是先绘制红色物体然后再绘制的蓝色物体)。在标准渲染管线中,片元的值会存储然后发送到合并阶段进行处理。在DirectX11.3中引入了 Rasterizer order views(ROVs),可以保证是有序执行的。有点像UAVs(unordered),他们的读写方式一样,但是关键不同点是ROVs保证了是以正确的顺序进行数据访问。这大大增加了着色器可访问的缓冲的作用。例如,ROVs使得像素着色器可以写入自己的混合方法,因为它可以在ROV中直接访问和写入任何位置,这样就不需要合并阶段。 代价是,如果检测到无序访问,像素着色器调用可能会阻塞,直到之前绘制的三角形 处理完
 

3.9 合并阶段

        如2.5.2节说到的,合并阶段是每个片元各种深度值,颜色值和帧缓冲结合的地方。DirectX称这个阶段为输出混合(output merger),OpenGL称为每个样本操作( per-sample operations 。在大部分传统管线图表中,这个阶段就是模板缓冲和深度缓冲操作的地方。如果片元是可见的,那么颜色混合操作是需要进行的。对于不透明表面,其实没有进行真正的混合,因为片元的颜色被帧缓冲中存储的颜色简单取代了。事实上,片元的混合和颜色值的存储发生在半透明和合成操作中。
        想象一下,一个经过光栅化生成的片元在进行深度测试的时候被先前的片元遮挡住了。那么在像素着色阶段进行的所有操作都是无意义的。为避免这种浪费,许多GPU会在像素着色之前进行一些合并测试( merge testing 。片元的z值会被用来进行可见性测试,如果被隐藏的片元会被剔除。这个功能称为 early-z。像素着色可以改变片元深度值或完全丢弃该片元。如果在一个像素着色程序中检测到了这种操作, early-z则不能使用并且需要关掉,因为会降低管线的效率。 DirectX 11 和OpenGL 4.2允许在像素着色之前强制进行 early-z测试,虽然有着许多限制。
        虽然合并阶段不是可编程的,但是它高度可配。颜色混合有许多不同操作可配。最常见的是对颜色值和alpha值进行乘法,加法和减法,当然还有很多其他的操作,如取最大值,取最小值,按位逻辑运算等等。DirectX 10支持了帧缓冲和两个颜色值进行混合,称为 dual source-color  blending,但是不能用于多渲染目标(MRT)中。在DirectX 10.1中则引入了MRT每个独立的缓冲可以进行不同的混合操作。
        DirectX 11.3中利用ROVs可以让混合可编程,虽然有一定的性能消耗。ROVs和合并阶段都保证了绘制顺序,不管像素着色器输出结果的生成顺序,API都要求了这些数据需要被存储,然后按输入顺序传送到合并阶段。
 

3.10 计算着色阶段

        GPU实现的传统图形功能越来越多,也实现了一些非图形领域的应用,例如计算股票期权的预估 训练用于深度学习的神经网络。运用的是GPU的硬件计算力,称为 GPU computing
        在DirectX 11中,计算着色器是不在渲染管线中的着色器。它之所以和渲染相关,因为是利用图形API对它进行调用。它和用在渲染管线中的统一着色处理器在一个池中。和其他的着色器一样,有输入,可以访问缓冲。在计算着色器中使用warp和线程更为常见。DirectX 11中引入了线程组( thread group )概念 ,可包含1到1024个线程。这些线程用xyz坐标区分,是为了方便在着色器中使用。每组线程都共享一个小内存,在DirectX 11中,这个内存大小是32kB。每组线程内都是并发执行的。
        计算着色的一个重要优势是可以访问GPU生成的数据。发送GPU数据到CPU中是有一个小延迟的,如果数据的保存和处理在GPU完成,可以提高性能。后期处理经常用到计算着色。 共享内存意味着采样图像像素得到中间结果可以与相邻的线程共享。利用计算着色来决定一个图像的光照分布或平均亮度,已经发现要比在一个像素着色中性能快两倍。
        计算着色同样可以用在粒子系统( particle systems 网格处理( mesh processing )中,例如脸部动画( facial animation 裁剪( culling),图像过滤( image filtering),提高深度精度( improving depth precision),阴影(shadows),深度领域(depth of field 等等。如图3.16所示。
图3.16 计算着色的例子。左边图展示的是模拟风吹动头发的效果,头发是利用了曲面细分。中间图展示了一种快速模糊效果。右边图展示的是模拟海浪效果。
 
发布了7 篇原创文章 · 获赞 2 · 访问量 1197

猜你喜欢

转载自blog.csdn.net/xyxsuoer/article/details/103871944