本文同时发布在我的个人博客https://dragon_boy.gitee.io
本章目录概览
- 数据并行架构
- GPU管线概览
- 可编程着色器阶段
- 可编程着色器和API的发展
- 顶点着色器
- 细分曲面阶段
- 像素着色器
- 结合阶段
- 计算着色器
从历史观点来说,图形加速开始于对重合三角形的每个像素扫描线进行插值并显示这些值,将获取图像的能力包括在内允许纹理被应用于表面。为硬件添加插值和测试深度值的能力提供了内置的可见性检查。由于上述功能频繁使用,这些处理被提出用来提升硬件性能。随着渲染管线的部分增多,硬件的功能添加的越多。图形硬件在计算方面和CPU相比只有速度上的优势,但速度还是有局限的。
在过去20年,图形硬件经历了难以置信的变化。第一款包含硬件顶点处理的商业显卡在1999年发售(英伟达 GeForce256)。英伟达创造了名为GPU的术语,用来区分之前只能光栅化的显卡,而光栅化的显卡在当时已经很难提升性能。在接下来几年,GPU从固定管线发展到高度可编程的管线,多种可编程的着色器可以用来控制GPU的行为。出于效率方面的考虑,管线的部分阶段仍是可配置不可编程的,但趋势是可编程性和灵活度。
基于一系列有限的高并行任务的概念,GPU拥有极快的处理速度。GPU拥有自定义的芯片,致力于实现深度缓冲,快速获取纹理图片和其他缓冲,寻找哪些像素覆盖三角形等。
一个着色器核心是一个小的处理器,它进行一些单独的任务,例如将顶点进行坐标空间变换,或计算哪些像素覆盖三角形。每一帧有数千或数百万三角形送往屏幕,每秒会调用数十亿次着色器调用。
数据并行架构
不同的处理器架构使用不同的策略来避免失速。一个CPU针对处理大量不同的数据结构和大量代码进行了优化。CPU拥有多个处理器,但每个处理器以一种几乎连续的方式运行代码。为了降低延迟的影响,许多CPU都拥有快速本地缓存,内存会填充接下来可能会使用的数据。CPU为了避免失速也使用了许多其它策略,例如分支预测,指令重新排序,寄存器重命名和缓存获取。
GPU使用了不同的策略,大多数GPU的芯片区域被设计为大量处理器的集合,被称为着色器核心,数目往往是千级别的。GPU是一个流处理器,一系列相似的数据按指令轮流被处理。由于这种相似性——例如一系列顶点或像素,GPU可以以高度并行的方式处理这些数据。另一个重要的元素是这些调用是尽可能相互独立,这样的话就不需要邻近调用的数据或者共享可写内存地址。为了允许使用新的功能,这一规则有时会被打破,但这些例外会带来潜在的延迟问题,一个处理器可能会等待另一个处理器结束工作。
GPU针对吞吐量进行了优化,吞吐量定义为处理数据的最高速度。然而,这一快速处理是有代价的,由于使用更少的芯片区域来缓存内存和控制逻辑,每个着色器的延迟要远高于同级别的CPU处理器。
假设一个网格被光栅化,有2000个像素有片段要处理,一个像素着色器程序就被调用了2000次。想象一下一个世界上最垃圾的GPU,只有一个着色器处理器,它从2000个片段的第一个片段开始调用着色器程序。着色器处理器在寄存器中执行少量的算数计算,寄存器是本地的,可以快速访问,所以不会影响速度。接着着色器处理器得到类似获取纹理的指令,例如针对一个给定的表面位置,程序需要知道要赋予网格的图片像素颜色。一张纹理是完全独立的资源,不属于像素着色器程序的本地内存的一部分,所以纹理的获取会有些复杂。一片内存的获取会花费数百或数千时钟周期,在这段时间内GPU处理器不会做任何事情,这样的话,着色器处理器就会失速,等待纹理颜色值返回。
为了让这个糟糕的GPU的性能变得更好,给与每个片段少量的存储空间用于本地寄存器使用。现在,不再使用失速的纹理获取,着色器处理器被允许转换处理另外一个片段,即2000个片段中的第二个。这个转换非常迅速,远由于之前着色器进行等待的状态。现在第二个片段被处理,按照上述的规律,一直到2000个片段都被处理完毕。之后,纹理已经获取完毕,着色器处理器转到第一个片段,接下来就可以接着使用纹理颜色进行处理。着色器处理器按照这种方式处理直到另一个造成失速问题的指令传入,或者程序结束。如果一个着色器处理器持续处理一个片段,那么所花费的时间会更长,但针对全部片段的处理所需的时间会大幅下降。
在这个架构中,由于GPU持续切换着待处理的片段,延迟的问题被隐藏了起来。GPU通过将指令的执行逻辑从数据中分离出来来进一步优化这种设计,这种设计被称为单个指令,多重数据
即SIMD
。这种方式在一个固定数量的着色器程序上步调一致的执行相同的命令。SIMD的优点是更少的芯片需要被用来处理数据和切换片段。还记得上述的2000个片段的例子吗?我们将其转换为现代GPU术语的话,即针对每个片段的像素着色器的调用称为一个线程,这种线程不同于CPU的线程,它包含少量存储输入到着色器的内存,和着色器执行任务所需的寄存器。使用相同着色器程序的线程捆绑成组,英伟达将这个组称为warps
,(直译为经纱,即绞在一起的一团线,呈扭曲状态,或者翻译为扭曲空间),AMD将其称为wavefronts
(直译为波阵面,跟前面的意思有点像)。一个warp
通过一些数量的着色器核心来规划执行时间,使用SIMD处理。每个线程映射到一个SIMD lane
。
假如我们有2000个线程要处理,在英伟达GPU上的warps
包含32个线程,这将产出个warps
,这意味着有63个warps
被分配,其中一个warp
有一半是空的。一个warp
的执行和我们单个着色器处理器的GPU的例子很像,在所有的32个处理器上着色器程序步调一致地执行着,当一个内存获取地指令传入时,所有的线程都会同时传入相同指令,这是因为相同的指令要在所有的线程上都执行。上述指令会发出信号让所有线程都暂停,都等待着它们的结果。为了避免暂停,当前warp
会和另一个不同的拥有32线程的warp
交换,这会由32个核心执行。这一交换和我们的单处理器系统一样快,因为交换过程不会接触线程下的数据。每个线程有它自己的寄存器,每个warp
会追踪每个寄存器正在执行的指令,交换为一个新的warp
其实就是一系列核心换了一系列不同的线程来工作。warps
会执行任务或交换直到全部的warp
的相关任务都完成。
在我们的简单例子中,针对一张纹理的内存获取的延迟会导致warp
交换。实际上warp
交换可以在短暂延迟后执行,因为交换的时间花费很少。虽然针对执行任务的优化有一些其它的技术,但warp
交换仍是所有GPU使用的最主要的方式。一些因素会影响处理任务的效率,比如,如果有少量的线程,然后少量的warp
被创建,那么延迟隐藏就会很成问题。
着色器程序的结构是影响效率的重要因素,其中最主要的是每个线程使用的寄存器的数量。例如,假设有2000个线程同时存在于GPU上,每个线程所绑定的着色器程序需要的寄存器越多,线程数量就会越少,GPU上存在的warp
数量越少,而warp
数量的缺少意味着warp
交换次数减少,那么暂停问题就无法得到解决。在内存中的warp
是在工作中的,它的数量称为占有量。高占有量意味着有更多的warp
可以进行处理,那么限制的处理器就会更少,低占有量往往性能差。内存获取频率同样影响延迟隐藏所需要的程度。
另一个影响整体效率的因素是动态分支,即if
条件语句和循环。假如有一个if
语句出现在着色器程序中,如果所有线程求值并使用相同的分支,warp
就会不考虑其它分支并继续。然而,如果有些线程或者甚至只有一个线程,使用了条件选择路径,那么整个warp
就必须执行所有条件分支,得出每个特定线程所不需要的结果,这种问题称为线程差异,即少量线程需要使用条件分支和循环语句时,warp
中其它的线程不需要使用,在这期间会保持闲置状态。
所有GPU均实现了上述架构,结果就是限制大但计算效率高。
GPU管线概述
GPU实现了上一章的概念层面的几何处理,光栅化和像素处理等过程,它们按照不同程度的配置性或编程性被分为了几个硬件阶段:
我们在这里描述GPU的逻辑模型,它以API的形式展现在开发者的面前。对于逻辑管线的实现,也就是物理模型,它取决于供应商的硬件。一个在逻辑模型中为固定方法的阶段在GPU中执行的话可能是通过向邻近可编程阶段添加命令得到的。管线中的单个程序可能被分为多个元素通过独立的子单元执行,或者完全由一个独立的子过程执行。逻辑模型可以帮助我们了解影响性能的因素,但它不应该受GPU实际实现管线的方式的影响。
顶点着色器是一个完全可编程的阶段,它被用于实现顶点处理阶段。几何着色器是一个完全可编程的阶段,它操作基本体的顶点,它可以用来执行逐基本体着色的操纵,摧毁基本体或创建新的基本体。细分曲面阶段和几何着色器都是可选的,并不是所有的GPU都支持,尤其是移动设备的GPU。
裁剪,三角形准备,三角形遍历阶段由硬件的固定方法实现。屏幕映射受窗口和视窗设置的影响,内部实现是一个简单的缩放和坐标重置。像素着色器阶段是完全可编程的。结合阶段并不是可编程的,但高度可配置,并且可以执行大量的操作。它实现了结合这一功能性阶段,管理着修改颜色,深度缓冲,混合,模板缓冲和其它输出相关缓冲等功能。
可编程着色器阶段
现代着色器程序使用了一个统一的着色器设计模式,这意味着顶点、像素、几何和细分曲面相关的着色器共享一个相同的编程模型,在内部它们使用相同的指令集架构——ISA
。一个实现了这一模型的处理器称为一个公有着色器核心(DirectX
描述),使用了这些核心的GPU被认为拥有一个统一的着色器架构。在这种类型的架构的背后的理念是着色器处理器要在不同的场景下扮演有用的角色,GPU会根据不同的场景进行调整。例如,拥有小三角形的一系列网格需要的顶点着色器处理要多于由两个三角形组成的四边形集合。一个拥有独立的顶点着色器和像素着色器池的GPU意味着理论上需要快速预先决定所有的核心保持繁忙。通过使用统一着色器核心,GPU可以保持这一平衡。
描述完整的着色器编程模型在本书范围外。着色器使用C风格语言来编程,比如DX的HLSL
,OpenGL的GLSL
。HLSL
可以被编译为虚拟机器码,也被称为过渡语言IL
,使其不依赖于硬件。过渡描述也允许着色器程序离线编译和存储。这种过渡语言通过驱动转化为ISA的GPU描述。控制台编程往往避免这一过渡语言阶段,因为在系统中只有一个ISA。
基本的数据类型是32位的单精度浮点型标量和矢量。在现代GPU中,32位整型和64位浮点型也同样支持。浮点矢量通常存储下面的数据:位置(xyzw
),法线,矩阵行,颜色(rgba
)和纹理坐标(uvwq
)。整型数据经常用于存储计数、索引或位掩码。几何数据类型如结构体、数组和矩阵也同样支持。
一个绘制命令Draw Call
调用图形API绘制一组基本体,让图形管线执行和运行它的着色器。每个可编程着色器阶段有两种类型的输入:uniform
输入(统一输入),在一个Draw Call
中保持不变;varying
输入(变化输入),数据来自于顶点或光栅化。例如,一个像素着色器可能将灯光的颜色作为统一值,而三角形表面的位置逐像素改变,那么就是变化值。一张纹理是一种特殊的统一输入,它永远贴在表面上,但也可以被认为是大量数据的队列输入。
底层的虚拟机为不同的输入和输出提供了特殊的寄存器。针对统一变量的可获取静态寄存器的数量远大于变化变量的可获取寄存器数量。这是因为变化输入和输出需要每个顶点或像素单独存储,那么自然就会有一个限制,而统一输入只需存储一次,在一次绘制命令中所有的顶点和像素都可以重复利用。虚拟机也有针对一般目的使用的临时寄存器,它们用于暂存空间。所有类型的寄存器在临时寄存器中可以像数组下标一样使用值。
在图形计算中非常普遍的操作在现代GPU上非常有效率的执行着。着色器语言通过类似于*
和+
的符号显示这些常用的操作。其它的数学操作,如atan(),sqrt(),log()
等,GPU都针对性的进行了优化。同样还存在一些复杂的操作,如矢量标准化和反射,叉乘,矩阵转置和行列式计算等。
术语流程控制代表使用分支语句来改变代码执行的流程。流程控制的语句由if
和case
语句和其它的循环类型实现。着色器提供两种类型的流程控制,静态流程控制分支基于统一输入的值,意思是在整个绘制命令中代码的流程是不变的。静态流程控制的最重要的好处是允许相同的着色器在不同的情况下使用,不存在县城差异,因为所有的调用使用相同的代码路径。动态流程控制基于变化输入值,意思是所有的片段执行不同的代码,这相对于静态流程控制来说更强大但性能消耗更大,尤其在着色器调用间代码流程不规律的改变时。
可编程着色器和API的发展
可编程着色器的框架的理念可追溯到1984年Cook的着色器树。一个简单的着色器和它的着色器树在下图显示:
在上世纪80年代末RenderMan着色器语言根据上述理论被发明出来,这一语言至今仍用于电影渲染。
消费级别显卡硬件在1996年10月1日第一次由3dfx公司成功发售,他们的Voodoo显卡有能力高画质高性能地渲染《雷神之锤》这款游戏,这一显卡自始至终使用固定管线。在GPU支持可编程着色器之前,已经有一些方法通过多重渲染路径来实时实现可编程着色操作。《雷神之锤3》的Arena
(竞技场)脚本语言在1999年第一次被广泛使用。之后,英伟达的GeForce256
显卡首次引入GPU的概念,仍不可编程,但高度可配置。
在2001年早期,英伟达发布了首款支持可编程顶点着色器的显卡GeForce 3
,通过DirectX 8.0和OpenGL插件形式实现。这些着色器以一种汇编风格语言编写,并通过驱动快速转化为微型编码。像素着色器同样在DirectX 8.0中实现,但像素着色器并不能真正意义上的可编程,这种有限制的程序支持通过驱动被转化为纹理混合状态,依次通过寄存器结合器捆绑起来。这些程序不仅在长度上有限制,还缺乏重要的功能。
着色器在这时候并不允许分支控制,所以要使用条件语句需要通过计算两种项目并选择或插值得到模拟结果。DirectX定义了着色器模型(Shader Model
)的概念,用来区分使用不同着色器功能的硬件。在2002年,拥有着色器模型2.0的DirectX 9.0发行,它的特点是真正实现了可编程的顶点着色器和像素着色器,同样的功能OpenGL也通过插件实现。对任意的纹理读取和16位浮点数据存储的支持被添加。在着色器资源如指令、纹理和寄存器上的限制减少,所以着色器有能力处理更复杂的效果。对流程控制的支持也添加进来。着色器长度和复杂性的增加也让汇编编程模型变得极为复杂。幸运地是,DirectX 9.0也包含了HLSL,这一着色器语言由微软和英伟达合作开发。在同一时间,OpenGLABR发行了GLSL,一种针对OpenGL的类似的语言。这些语言的语法受C语言和RenderMan着色器语言的影响。
着色器模型3.0在2004年提出,添加了动态流程分支,让着色器的功能更加地强大,它也让可选的一些特性变为必须的,进一步的增加了资源的限制,在顶点着色器中添加了纹理读取的限制。在2005年末和2006年末,新生代游戏主机Xbox360和PS3发售,它们均使用了着色器模型3.0。任天堂的Wii是最后一个使用固定管线GPU的游戏主机,在2006年末发售,至此,完全的固定管线退出舞台。着色器语言也发展到了一定的阶段,有大量不同的工具用来创建和管理它们,现在的材质节点系统就是其中一种。
下一次在可编程性上的飞跃出现在2006年末尾,包含有着色器模型4.0的DirectX 10.0发行,有一些主要的功能,如几何着色器和流输出。着色器模型4.0有一个针对所有着色器的统一编程模型,资源限制进一步增加,针对整型数据(包括掩码操作)支持被添加,GLSL的3.30版本在OpenGL3.3中支持类似的着色器模型。
在2009年,DirectX 11.0和着色器模型5.0发行,添加了细分曲面阶段着色器和计算着色器,也成为DirectCompute
。这次发行也关注于让CPU的多重处理更加有效率。OpenGL在4.0版本添加了细分曲面,在4.3版本添加了计算着色器。DirectX和OpenGL的发展道路不同,两者都将一个特定阶段的硬件支持在一个特定的版本发行。微软控制DirectX API,它独立于硬件供应商,游戏开发者和影片制作商,微软自身决定显示什么特性。OpenGL由一个硬件和软件供应商的集团开发,由非盈利组织Khronos Group
管理,由于参与公司的数量,在OpenGL中的新特性的发行往往晚于DirectX的发行。然而,OpenGL允许插件,可以由供应商各自开发,这也让新特性可以在官方支持发布前提前体验到。
下一个API的重大改变是在2013年由AMD开发的MantleAPI,这一API由合伙公司DICE开发。Mantle的理念是忽略图形驱动的影响,将这一控制权交予开发者。这一重构的可能得益于对CPU多重处理的进一步支持。这一新型API关注于减少CPU花费在驱动上的时间,同时支持更有效的CPU多重处理器。这一在Mantle中的理念由微软学习并于2015年发行了DirectX 12.0,注意DirectX 12.0并不致力于表现GPU的新特性,他与DirectX 11.3的特性一致。两种API都可以在虚拟现实系统上使用。然而,DirectX 12是对API进行了彻底的重新设计,它更好的映射了现代GPU的架构。
苹果在2014年发行了自己的APIMetal
。Metal是第一个可以在移动设备上使用的API,例如iPhong 5S和iPad Air,新版的MAC在一年后可以通过OS X El Capitan系统使用。除了效率外,Metal减少了CPU的使用,节省了能耗,这在移动设备上非常重要。这一API也有自己的着色器语言。
AMD将Mantle的成果贡献给Khronos Group,在2016年发行了自己的API,Vulkan。和OpenGL一样,Vulkan可以跨平台工作。Vulkan使用一种新型的高级过渡语言SPIR-V,它用来表示着色器和GPU计算。
顶点着色器
顶点着色器是管线的第一个阶段,也是第一个可编程的阶段,开始之前需要一些数据操作。在DirectX中称为输入装配,一些数据流可以被捆绑在一起来组成一系列顶点和基本体并送往管线。例如,一个物体可以通过一个位置数组和一个颜色数组表示,输入装配器会通过创建有位置和颜色的顶点来创建物体的三角形,第二个物体也会使用相同的位置数组和一个不同的颜色数组来表示。同样也支持在输入装配中实现实例化,这允许一个物体可以每个实例使用不同的数据绘制多次,只使用一个绘制命令。
一个三角形网格被表示为一系列顶点,每个顶点于一个模型上的特定位置绑定。除了位置,还有其它可选的属性可以绑定在定点上,如颜色和纹理坐标。表面法线也可以在网格顶点上定义,但这看起来比较奇怪。数学上,每个三角形都有一条定义明确的表面法线,使用这表面法线来计算着色在理论上应该更准确,然而,在渲染时,三角形网格经常被使用来代表实际的曲面,顶点法线被用来代表这个曲面的朝向。下图显示了两种曲面和它们的顶点法线:
顶点着色器是第一个处理三角形网格的阶段,在顶点着色器中是无法获取什么样的三角形被构建了的数据。就像名字一样,顶点着色器是专门处理输入顶点的。顶点着色器提供了一种方式来修改,创建或忽略绑定在三角形顶点上的值,如颜色、法线、纹理坐标和位置。通常情况下,顶点着色器程序将顶点从模型空间转化到齐次裁剪空间,且顶点着色器必须至少输出顶点位置。
一个顶点着色器和之前描述的统一着色器很相似,每个传入的顶点被顶点着色器处理,然后输出一系列沿三角形或线擦绘制的数据。顶点着色器不能创建或销毁顶点,通过一个顶点生成的结果不能传输到另一个顶点。由于每个顶点被单独对待,针对输入顶点流,所有的GPU的着色器处理器都可以并行应用。
输入集合常常表示为一个在顶点着色器执行之前的一个处理过程。这是一个物理模型常常区别于逻辑模型的例子。物理上,获取数据来创建一个顶点可能发生在顶点着色器中,驱动会安静地为每个着色器预先准备适当地指令,这对开发者是不可见的。
接下来的章节会解释一些顶点着色器效果,例如针对动画骨骼的顶点混合和剪影渲染。其它着色器的应用包括:
- 物体生成,通过创建一次一个网格,并使用顶点着色器进行变形。
- 使用蒙皮和变形技术来驱动角色身体和脸部。
- 程序化变形,例如旗帜的移动、布料、水。
- 粒子创建,通过将没有区域的网格送往管线,并根据需要基于一片区域。
- 光学变形,热浪效果、水波纹、纸张卷曲和其它效果,使用屏幕帧缓冲变形。
- 使用顶点纹理来获得地形。
顶点着色器的输出可以在几个不同的方面被使用,最常见的方式是生成每个实例的基本体,然后光栅化,接着生成的每个单独的像素片段被送往像素着色器程序进行进一步处理。在一些GPU上也可以送往细分曲面阶段或几何着色器阶段或存储在内存上。
细分曲面阶段
细分曲面阶段允许我们渲染曲面。GPU的任务是获取每个面的描述并将其转化为一系列三角形的表示。这个阶段是可选的GPU特性,首次在DirectX 11.使用,OpenGL 4.0和OpenGL ES 3.2也同样支持。
使用细分曲面阶段有一些好处。曲面描述往往比提供相关的三角形要简洁,处了节省内存外,这个特性可以保持CPU和GPU之间的交流,这样的话对比如为角色和物体做动画时形状的每帧变换的性能瓶颈带来改善。曲面可以通过给定适当数量的三角形来渲染。例如,如果一个球体远离摄像机,只需少量的三角形即可满足要求,但越靠近摄像机,为了视觉效果可能就需要上千的三角形。控制细分级别的能力也允许应用程序控制它的性能,例如,在性能差的GPU上使用低质量的网格以保证帧率。通过平坦的曲面构成的模型可以被转化为三角形网格然后就可以按照预期扭曲,或者它们可以按顺序被细分来执行更低频率地花费高昂地着色器计算。
细分曲面阶段包含三个元素,使用DirectX的描述是:外壳着色器,镶嵌器和域着色器。
这里简短地介绍细分曲面阶段。开始,输入到外壳着色器的是一片特殊的基本体,这包含一些控制点,用来定义一个细分曲面,贝塞尔面片,或者其它类型的曲线物体。外壳着色器有两个方法,首先,它告诉镶嵌器要生成多少三角形,以何种配置生成,第二,它在每个控制点上执行一个过程。同样,作为可选项,外壳着色器还可以修改输入片面描述,添加或移除控制点。外壳着色器的输出是一系列控制点,以及细分曲面控制数据,送往域着色器。
镶嵌器是一个固定管线阶段,它的任务是为域着色器添加一些新的顶点来处理。外壳着色器告诉镶嵌器细分曲面的类型:三角形、四边形或等值线,等值线是一系列连续线段,有时用来渲染毛发。另一个由外壳着色器输出的重要值是细分曲面因数或细分曲面级别,有两种类型:内部和轮廓边,两个内部因数决定了在三角形或四边形内部细分曲面的程度,轮廓因数决定了每个边界边被分割的程度。
上面是一个增加细分曲面因数的例子。通过允许这样分离的控制,我们可以调整曲面的边界来契合细分曲面面,不管有多少内部面被细分。匹配边可以避免裂痕或者其它人工问题。顶点被绑定一个质心坐标,用来表明和周围顶点的关系。
外壳着色器总是输出一个片面,即一系列控制点的位置,然而,它可以通过输出零细分级别到镶嵌器来标记某一面片被忽略,否则,镶嵌器生成一个网格并送往域着色器。来自外壳着色器的曲面的控制点被每次域着色器的调用所使用,用来计算每个顶点的输出值。域着色器拥有类似于顶点着色器的数据流模式,每个来自于镶嵌器的输入顶点被处理和生成一个对应的输出顶点,组成的三角形被接着送往管线。
这一系统听起来很复杂,但这样设计结构是出于对相率的考虑,每个着色器都会很简单。传到外壳着色器的片面经常少量或不修改,着色器也可以使用片面的估计距离或屏幕尺寸来快速计算细分因数,例如地形渲染。外壳着色器可能简单是传递应用程序计算和提供的一系列值给所有片面。镶嵌器执行内置但固定的方法处理过程来生成顶点,给予它们位置,并明确它们组成的是三角形还是线。这一数据详述阶段是在着色器外进行的,出于计算效率的考虑。域着色器使用生成顶点的质心坐标,在片面的估计等式中生成位置、法线和纹理坐标等。
几何着色器
几何着色器可以将基本体转化为其它的基本体,这是细分曲面着色器做不到的。例如,一个三角形网格可以用线段表示,通过让每个三角形创建线段边,相反的,这些线段可以通过四边形替代,这样的话就可以渲染更厚的线段。几何着色器于2006年末在DirectX 10中添加。它在渲染管线中位于细分曲面阶段后,并且可选。
几何着色器的输入是一个简单的物体并它的顶点。物体典型地包含连续三角形,线段,或简单的点。扩展基本体可以在几何着色器中定义和处理。特别地,三个位于一个三角形外的顶点可以输入,两个邻近的在多边形线上的顶点可以被使用。在DirectX 11的着色器模型5.0中,我们可以传入更高级的片面,至多32个控制点。
几何着色器处理一个基本体,输入0个或多个顶点,它们被当作点、多边形线或连续三角形,注意空输出也可以由几何着色器生成。这样,一个网格可以有选择地通过编辑顶点,添加新基本体和移除物体来修改。
几何着色器被设计用来修改输入的数据或进行一定数量的复制。例如,一种使用方式是生成一份数据的经过6次变换的复制来模拟一个立方体,它也可以用来创建串联的阴影贴图,用于高精度阴影生成。另一些使用了几何着色器的优势的算法包括通过点数据创建不同大小的粒子,沿着剪影挤出鳍状体来渲染毛发,在阴影算法中寻找物体边缘。
DirectX 11为几何着色器添加了可以实例化的功能,即在任意基本体上几何着色器可以运行多次。在OpenGL 4.0中这一功能通过调用次数描述。几何着色器还可以输出至多4个流,每个流可以送往管线做更进一步处理,所有的流都可以有选择地送往流输出渲染目标。
几何着色器保证输出基本体的顺序是输入的顺序,这会影响性能,因为如果一些着色器核心并行运行,结果必须被存储和按顺序。几何着色器的特性让我们可以在一个绘制命令下就生成大量的几何体。
在一个绘制命令发布后,管线中只有三个位置的工作可以在GPU上创建:光栅化、细分曲面阶段和几何着色器阶段。当然,几何着色器的行为在需要大量资源和内存时是很难预测的,因为它是高度可编程的。实际中,几何着色器很少使用,因为它很难充分使用GPU的性能。在一些移动设备中,几何着色器在软件中被实现,它的使用很显然也多不起来。
流输出
GPU管线的标准使用方式是将数据送往顶点着色器,然后光栅化为结果三角形,然后在像素着色器中处理,往往这中间数据是无法获得的。流输出的理念在着色器模型4.0中被提出,在顶点被顶点着色器(可选地,细分曲面阶段和几何着色器)处理后,它们可以被输出到流中,例如,一个排序数组,额外送往光栅化阶段。光栅化实际上可以被关闭,这样管线可以被当作一个非图形流处理器来使用。数据处理器在这种方式下可以送往管线,因此允许交互式处理。这种类型的操作在模拟流水或其它粒子效果时非常有用。它还可以用来蒙皮模型并让这些顶点可以重复使用。
流输出返回的数据只有浮点类型的,所以会造成显而易见的内存消耗。流输出在基本体上工作,并不直接在顶点上。如果网格被送往管线,每个三角形生成它自己的三个输出顶点,而原始的三角形顶点会消失。根据这一理念,一个更典型的用法是将顶点作为一系列点基本体输入到管线。在OpenGL中,流输出阶段被称为变换反馈,由于重点是在于变化顶点并将它们返回做进一步处理。基本体被保证输入到流输出目标的顺序是它们输入时的顺序,即顶点顺序被保留。
像素着色器
在顶点,细分曲面和几何着色器执行完操作后,基本体被裁剪然后光栅化。
在三角形顶点中的值,包括用于深度缓冲的z值,会对三角形的每个像素进行沿三角形的插值。这些插值操作后的值被送往像素着色器,接着处理片段。
沿三角形插值的类型由像素着色器定义,通常使用的是透视矫正插值,一个例子是渲染沿视平线扩展的铁轨,铁轨的两条线在距离无限远处汇聚为一点,那么插值所需的像素越少。其它插值选项是可选的,如屏幕空间插值,透视不考虑在内。
在编程术语中,顶点着色器程序的输出,沿三角形插值后就作为了像素着色器的输入。随着GPU的发展,其它的输入也被显示出来。例如,在着色器模型3.0及以后片段的屏幕坐标是可获取的。同样,哪一边的三角形是可见的是一个输入标志,这对于三角形正反面使用不同材质来说很重要。
拥有输入后,像素着色器计算和输出片段颜色,它也可以产生一个不透明的值并可选则地修改它的深度值,在结合阶段,这些值用来修改像素存储着什么。在光栅化阶段生成的深度值也可以被像素着色器修改。模板缓冲值通常不可以修改,相反,它贯穿整个结合阶段,DirectX 11.3允许着色器改变这个值。例如雾计算和透明度测试的操作在着色器模型4.0中被转移到像素着色器中,以往是结合阶段。
像素着色器拥有一个特殊的能力——忽略输入的片段,例如生成空输出。裁剪平面的功能过去常是一个在固定管线中可配置的元素,之后在顶点着色器中实现。随着片段忽略的功能的存在,这一功能可以在像素着色器中的任何地方实现,例如布尔运算。
最初,像素着色器的输出只能送往结合阶段,用于最终的显示。像素着色器可执行指令的数量已经得到的快速地发展,这一数量增长让多重渲染目标的理念得以发展。我们不再将颜色和深度值作为像素着色器的结果输出,每个片段可以生成多个集合的数据并存储到不同的缓冲中,每一个都称为一个缓冲目标。缓冲目标通常有相同的x和y维数,一些API允许不同的大小,但渲染区域是最小的那个。一些架构要求渲染目标有相同位的深度值,甚至是相同的数据类型。取决于GPU,渲染目标的数量为4个或8个。
尽管有这些限制,MRT的功能还是很强大,在执行渲染算法时效率很高。单个渲染过程可以生成一张颜色图片到一个目标中,物体ID渲染到另一个目标中,世界空间坐标到第三个目标中。这一能力也让一种不同的渲染管线的到发展,即延后渲染,它的可见性和着色都在独立的渲染过程中,第一个过程将物体的位置和材质存储起来,接下来的过程使用这些存储起来的数据渲染光照和其它效果。
像素着色器的局限是它不能获取邻近像素的值,也不能将结果输出到邻近像素。准确地说,它只计算影响当前像素的结果。然而,这一限制并不像听起来那么严重,在一个渲染过程中创建的输出图像就可以应用到其它过程。
当然,上述规则也有例外,其中一个是像素着色器可以在计算导数或梯度时获取邻近像素信息。像素着色器被提供一定数量的经过插值的每个像素的xy屏幕坐标,这样的值在不同的计算和纹理取址时非常有用。这些梯度信息对例如纹理滤镜的操作非常重要,因为我们需要直到一张纹理覆盖一个像素的程度。所有的现代GPU通过将片段2*2成组来实现这一特点,这个组称为一个四边形。当一个像素着色器需要一个梯度值时,邻近片段的差值被返回。
一个统一核心拥有这种获取邻近元素数据的能力,这在相同
warp
的不同线程上是相同的,所以可以计算梯度供像素着色器使用。这一实现的结果是梯度信息不能在着色器的某些部分上获取,由于动态分支控制。在一组中的所有片段必须使用相同的指令集和来处理,这样所有四个像素的结果才能计算梯度。这是一个基本的限制问题。
DirectX 11介绍了一种缓冲类型,它允许写操作访问任何位置,即非排序访问视图(UAV
),初始版本只能在像素和计算着色器上使用,在DirectX 11.1后所有着色器都可以使用。OpenGL 4.3将这个缓冲称为着色器器存储缓冲对象(SSBO
)。
通常,一些机制需要用来避免数据竞争条件,即两个着色器程序都想影响相同的值,这会导致一些人工问题。例如,如果两个着色器想要同时添加相同的检索值,就会引发错误,两个着色器会检索相同的初始值,两者都想要本地修改它,那么可能会有一个着色器成功(它慢一点)。GPU通过使用着色器可访问的原子单元来避免这一问题,然而,这也就意味着,竞争时,会有一方暂停下来去等待另一方完成。
许多算法按要求一个特定的执行顺序。例如,我们想在一个红色半透明三角形后面绘制一个蓝色半透明三角形,将红色在上混合蓝色。针对这一个像素可以调用两次像素着色器,而且红色必须在蓝色前完成。但在标准管线中,片段的结果在结合阶段处理前便排序了。在DirectX 11.3中提出了光栅化顺序视图(ROV
),用来强制确定执行顺序,它们和UAV类似,可以被着色器读取和写入,区别在于ROV保证了数据会按照恰当的顺序访问,这也让这些着色器可访问的缓冲变得相当的有用,例如,ROV让像素着色器编写自己的混合方法成为了可能,因为它可以直接在ROV中的任何位置访问和写入,这样的话就不需要结合阶段了。代价是如果混乱的顺序被检测到,像素着色器的调用会暂停,直到三角形绘制命令被执行。
结合阶段
在之前说过,在结合阶段,每个单独片段的深度和颜色在帧缓冲中混合。DirectX将这一阶段成为输出混合器,OpenGL将这一阶段成为逐采样操作。在大多数传统管线图表中,这一阶段是模板缓冲和深度缓冲操作发生的地方。如果一个片段可见,另一个在这一阶段发生的操作是颜色混合。对于不透明表面没有实际混合操作,片段的颜色会直接进行替换,实际的片段混合和颜色存储通常在渲染半透明物体时使用。
想象以下一个光栅化产生的片段在像素着色器上运行,发现它被某一在它前面的片段遮挡着,那么接下来所有在该片段上的像素着色器调用都是毫无意义的。为了避免这一性能浪费,许多GPU在像素着色器调用前执行一些测试,片段的深度缓冲被用来测试可见性,如果片段被隐藏就会被剔除,这一功能称为提前深度测试(early-z
)。像素着色器拥有改变片段深度值或完全剔除片段的能力。如果上述两种操作在片元着色器中存在,那么提前深度测试就不能使用,而且会关闭。DirectX 11和OpenGL 4.2允许像素着色器强制提前深度测试开启,虽然会有一定数量的限制。
结合阶段在固定管线阶段占据中间立场,例如三角形准备和高度可编程的着色器阶段。尽管它不可编程,它的操作是高度可配置的,特别是颜色混合拥有大量不同的操作,最常见的是颜色和透明度值得相乘、相加和相减的结合,还有其它例如最大值最小值以及掩码逻辑运算等。DirectX10添加了将像素着色器颜色和帧缓冲颜色混合的功能,这一功能称为双重源颜色混合,这不允许和MRT同时使用。MRT同样支持混合,DirectX10.1介绍了在每个单独的缓冲上执行不同混合操作的能力。
在上面一节提过,DirectX 11.3提供了让混合可编程的ROV,尽管在性能上消耗大。ROV和结合阶段都要保证渲染顺序,或者说输出不变性。不管像素着色器生成结果的顺序怎样,作为API的实现,结果是要排序的并按输入的顺序送往结合阶段。
计算着色器
除了传统的图形管线,GPU还可以在其它方面使用。在不同领域GPU有许多非图形方面的使用,从模拟股票走向的值到训练深度学习的神经网络集,在这种方式下使用硬件被称为GPU计算。例如CUDA和OpenCL的平台被用来将GPU作为一个大型并行处理器来控制,不需要实际的图形功能。这些框架经常使用例如C或C++的语言实现,以及针对GPU设计的库。
在DirectX 11中,计算着色器是GPU计算的一种形式,它并不局限于渲染管线中的某一阶段,它紧密联系于渲染过程,通过图形API调用。它可以被顶点、像素和其它着色器调用,它也和其它使用统一着色器处理器池的着色器一样。它和其它着色器类似,因为它有一系列输入数据,可以访问缓冲作为输入和输出。warp
和线程的概念在计算着色器中更加清晰,例如,每次调用获取一个它可以访问的线程索引。同时存在线程组的概念,在DirectX 11中包含1至1024线程,这些线程组通过xyz坐标描述,这是在着色器代码中最方便的描述了。每个线程组有少量的内存与邻近线程共享,在DirectX 11中,大小是32kB。计算着色器由线程组执行,这样组中所有的线程都可以同时运行。
计算着色器的一大优势是它们可以访问在GPU上生成的数据。将数据从GPU送往CPU会带来延迟,所以如果处理过程和结果都发生在GPU上就可以保证性能。后期处理,即以某种方式修改渲染图像,是一种常使用的计算着色器。共享内存意味着从图像像素采样的过度数据可以和邻近线程共享。例如,使用一个计算着色器可以决定一张图片的亮度的分布或平均值。
计算着色器对粒子系统、网格处理(如面部动画)、消隐、图片滤镜、提升深度精度、阴影、景深、和其它GPU可以胜任的工作都非常有用。
一些想说的话
最近开始啃这本书,为了加深印象就自己尽可能地翻译了一遍。不得不说,这本书内容是真的多,但确实介绍了许多以往不曾了解过的细节。
这里补一下书中作者推荐的一些读物:
- 《A Trip Down the Graphics Pipeline》,作者Jim Blinn's Corner。这本书应该比较老了。
- 《OpenGL Programming Guide》,即“红宝书”,讲OpenGL编程的,建议了解了相关的图形学基础后再看,适合做项目的时候翻一翻。
- 《A Trip Through the Graphics Pipeline》,作者Giesen,详细地讨论了GPU的许多方面。
- Fatahalian和Bryant,他们的课程讨论了GPU并行的一些细节,在Google上可以搜到。
- Kirk和Hwa的书,使用CUDA来进行GPU计算,讨论了GPU的发展和一些设计理念,在Google上可以搜到。
- 《OpenGL Superbible》,即超级宝典,也是讲OpenGL的,不过以案例为主。
- 《OpenGL Shading Language》,这本书应该挺老的了,讲着色器语言的。
- 以及官网,上面有许多相关读物和论文推荐:http://realtimerendering.com/,不FAN QIANG貌似登不上。