2.4 一些容易困惑的地方

在读者学习Shader的过程中,会看到一些所谓的专业术语,这些术语的出现频率很高,以至于如果没有对其有基本的认识,会使得初学者总是感到非常困惑。本章的最后将阐述其中的一些术语。

2.4.1 什么是OpenGL/DirectX

只要读者接触过图像编程,就一定听说过OpenGL和DirectX,也一定知道这两者之间的竞争关系。OpenGL与DirectX之间的竞争以及它们与各个硬件生产商之间的纠葛历史很有趣,但很可惜这不在本书的讨论范围。本节的目的在于向读者尽可能通俗地解释,它们到底是什么,又和之前讲到的渲染管线、GPU有什么关系。

我们花了一整个章节的篇幅来讲述渲染的概念流水线以及GPU是如何实现这些流水线的,但如果要开发者直接访问GPU是一件非常麻烦的事情,我们可能需要和各种寄存器、显存打交道。而图像编程接口在这些硬件的基础上实现了一层抽象。

OpenGL和DirectX就是这些图像应用编程接口,这些接口用于渲染二维或三维图形。可以说,这些接口架起了上层应用程序和底层GPU的沟通桥梁。一个应用程序向这些接口发送渲染命令,而这些接口会依次向显卡驱动(Graphics Driver)发送渲染命令,这些显卡驱动是真正知道如何和GPU通信的角色,正是它们把OpenGL或者DirectX的函数调用翻译成了GPU能够听懂的语言,同时它们也负责把纹理等数据转换成GPU所支持的格式。一个比喻是,显卡驱动就是显卡的操作系统。图2.18显示了这样的关系。

▲图2.18 CPU、OpenGL/DirectX、显卡驱动和GPU之间的关系

概括来说,我们的应用程序运行在CPU上。应用程序可以通过调用OpenGL或DirectX的图形接口将渲染所需的数据,如顶点数据、纹理数据、材质参数等数据存储在显存中的特定区域。随后,开发者可以通过图像编程接口发出渲染命令,这些渲染命令也被称为Draw Call,它们将会被显卡驱动翻译成GPU能够理解的代码,进行真正的绘制。

由图2.18可以看出,一个显卡除了有图像处理单元GPU外,还拥有自己的内存,这个内存通常被称为显存Video Random Access Memory, VRAM)。GPU可以在显存中存储任何数据,但对于渲染来说一些数据类型是必需的,例如用于屏幕显示的图像缓冲、深度缓冲等。

因为显卡驱动的存在,几乎所有的GPU都既可以和OpenGL合作,也可以和DirectX一起工作。从显卡的角度出发,实际上它只需要和显卡驱动打交道就可以了。而显卡驱动就好像一个中介者,负责和两方(图像编程接口和GPU)打交道。因此,一个显卡制作商为了让他们的显卡可以同时和OpenGL、DirectX合作,就必须提供支持OpenGL和DirectX接口的显卡驱动。

2.4.2 什么是HLSL、GLSL、CG

我们上面讲到了很多可编程的着色器阶段,如顶点着色器、片元着色器等。这些着色器的可编程性在于,我们可以使用一种特定的语言来编写程序,就好比我们可以用C#来写游戏逻辑一样。

在可编程管线出现之前,为了编写着色器代码,开发者们学习汇编语言。为了给开发者们打开更方便的大门,就出现了更高级的着色语言(Shading Language)。着色语言是专门用于编写着色器的,常见的着色语言有DirectX的HLSL(High Level Shading Language)、OpenGL的GLSL(OpenGL Shading Language)以及NVIDIA的CG(C for Graphic)。HLSL、GLSL、CG都是“高级(High-Level)”语言,但这种高级是相对于汇编语言来说的,而不是像C#相对于C的高级那样。这些语言会被编译成与机器无关的汇编语言,也被称为中间语言(Intermediate Language, IL)。这些中间语言再交给显卡驱动来翻译成真正的机器语言,即GPU可以理解的语言。

对于一个初学者来说,一个最常见的问题就是,他应该选择哪种语言?

GLSL的优点在于它的跨平台性,它可以在Windows、Linux、Mac甚至移动平台等多种平台上工作,但这种跨平台性是由于OpenGL没有提供着色器编译器,而是由显卡驱动来完成着色器的编译工作。也就是说,只要显卡驱动支持对GLSL的编译它就可以运行。这种做法的好处在于,由于供应商完全了解自己的硬件构造,他们知道怎样做可以发挥出最大的作用。换句话说,GLSL是依赖硬件,而非操作系统层级的。但这也意味着GLSL的编译结果将取决于硬件供应商。要知道,世界上有很多硬件供应商——NVIDIA、ATI等,他们对GLSL的编译实现不尽相同,这可能会造成编译结果不一致的情况,因为这完全取决于供应商的做法。

而对于HLSL,是由微软控制着色器的编译,就算使用了不同的硬件,同一个着色器的编译结果也是一样的(前提是版本相同)。但也因此支持HLSL的平台相对比较有限,几乎完全是微软自已的产品,如Windows、Xbox 360、PS3等。这是因为在其他平台上没有可以编译HLSL的编译器。

CG则是真正意义上的跨平台。它会根据平台的不同,编译成相应的中间语言。CG语言的跨平台性很大原因取决于与微软的合作,这也导致CG语言的语法和HLSL非常相像,CG语言可以无缝移植成HLSL代码。但缺点是可能无法完全发挥出OpenGL的最新特性。

对于Unity平台,我们同样可以选择使用哪种语言。在Unity Shader中,我们可以选择使用“CG/HLSL”或者“GLSL”。带引号是因为Unity里的这些着色语言并不是真正意义上的对应的着色语言,尽管它们的语法几乎一样。以Unity CG为例,你有时会发现有些CG语法在Unity Shader中是不支持的。关于Unity Shader和真正的CG/HLSL、GLSL之间的关系我们会在3.6节中讲到。

2.4.3 什么是Draw Call

在前面的章节中,我们已经了解了Draw Call的含义。Draw Call本身的含义很简单,就是CPU调用图像编程接口,如OpenGL中的glDrawElements命令或者DirectX中的DrawIndexedPrimitive命令,以命令GPU进行渲染的操作。

一个常见的误区是,Draw Call中造成性能问题的元凶是GPU,认为GPU上的状态切换是耗时的,其实不是的,真正“拖后腿”其实的是CPU。

在深入理解Draw Call之前,我们先来看一下CPU和GPU之间的流水线化是怎么实现的,即它们是如何相互独立一起工作的。

问题一:CPU和GPU是如何实现并行工作的?

如果没有流水线化,那么CPU需要等到GPU完成上一个渲染任务才能再次发送渲染命令。但这种方法显然会造成效率低下。因此,就像在本章一开头讲到的老王的洋娃娃工厂一样,我们需要让CPU和GPU可以并行工作。而解决方法就是使用一个命令缓冲区Command Buffer)。

命令缓冲区包含了一个命令队列,由CPU向其中添加命令,而由GPU从中读取命令,添加和读取的过程是互相独立的。命令缓冲区使得CPU和GPU可以相互独立工作。当CPU需要渲染一些对象时,它可以向命令缓冲区中添加命令,而当GPU完成了上一次的渲染任务后,它就可以从命令队列中再取出一个命令并执行它。

命令缓冲区中的命令有很多种类,而Draw Call是其中一种,其他命令还有改变渲染状态等(例如改变使用的着色器,使用不同的纹理等)。图2.19显示了这样一个例子。

▲图2.19 命令缓冲区。CPU通过图像编程接口向命令缓冲区中添加命令,而GPU从中读取命令并执行。黄色方框内的命令就是Draw Call,而红色方框内的命令用于改变渲染状态。我们使用红色方框来表示改变渲染状态的命令,是因为这些命令往往更加耗时

问题二:为什么Draw Call多了会影响帧率?

我们先来做一个实验:请创建10000个小文件,每个文件的大小为1KB,然后把它们从一个文件夹复制到另一个文件夹。你会发现,尽管这些文件的空间总和不超过10MB,但要花费很长时间。现在,我们再来创建一个单独的文件,它的大小是10MB,然后也把它从一个文件夹复制到另一个文件夹。而这次复制的时间却少很多!这是为什么呢?明明它们所包含的内容大小是一样的。原因在于,每一个复制动作需要很多额外的操作,例如分配内存、创建各种元数据等。如你所见,这些操作将造成很多额外的性能开销,如果我们复制了很多小文件,那么这个开销将会很大。

渲染的过程虽然和上面的实验有很大不同,但从感性角度上是很类似的。在每次调用Draw Call之前,CPU需要向GPU发送很多内容,包括数据、状态和命令等。在这一阶段,CPU需要完成很多工作,例如检查渲染状态等。而一旦CPU完成了这些准备工作,GPU就可以开始本次的渲染。GPU的渲染能力是很强的,渲染200个还是2000个三角网格通常没有什么区别,因此渲染速度往往快于CPU提交命令的速度。如果Draw Call的数量太多,CPU就会把大量时间花费在提交Draw Call上,造成CPU的过载。图2.20显示了这样一个例子。

▲图2.20 命令缓冲区中的虚线方框表示GPU已经完成的命令。此时,命令缓冲区中没有可以执行的命令了,GPU处于空闲状态,而CPU还没有准备好下一个渲染命令

问题三:如何减少Draw Call?

尽管减少Draw Call的方法有很多,但我们这里仅讨论使用批处理Batching)的方法。

我们讲过,提交大量很小的Draw Call会造成CPU的性能瓶颈,即CPU把时间都花费在准备Draw Call的工作上了。那么,一个很显然的优化想法就是把很多小的DrawCall合并成一个大的Draw Call,这就是批处理的思想。图2.21显示了批处理所做的工作。

▲图2.21 利用批处理,CPU在RAM把多个网格合并成一个更大的网格,再发送给GPU,然后在一个Draw Call中渲染它们。但要注意的是,使用批处理合并的网格将会使用同一种渲染状态。也就是说,如果网格之间需要使用不同的渲染状态,那么就无法使用批处理技术

需要注意的是,由于我们需要在CPU的内存中合并网格,而合并的过程是需要消耗时间的。因此,批处理技术更加适合于那些静态的物体,例如不会移动的大地、石头等,对于这些静态物体我们只需要合并一次即可。当然,我们也可以对动态物体进行批处理。但是,由于这些物体是不断运动的,因此每一帧都需要重新进行合并然后再发送给GPU,这对空间和时间都会造成一定的影响。

在游戏开发过程中,为了减少Draw Call的开销,有两点需要注意。

(1)避免使用大量很小的网格。当不可避免地需要使用很小的网格结构时,考虑是否可以合并它们。

(2)避免使用过多的材质。尽量在不同的网格之间共用同一个材质。

在本书的16.4节,我们会继续阐述如何在Unity中利用批处理技术来进行优化。

2.4.4 什么是固定管线渲染

固定函数的流水线Fixed-Function Pipeline),也简称为固定管线,通常是指在较旧的GPU上实现的渲染流水线。这种流水线只给开发者提供一些配置操作,但开发者没有对流水线阶段的完全控制权。

固定管线通常提供了一系列接口,这些接口包含了一个函数入口点(Function Entry Points)集合,这些函数入口点会匹配GPU上的一个特定的逻辑功能。开发者们通过这些接口来控制渲染流水线。换句话说,固定渲染管线是只可配置的管线。一个形象的比喻是,我们在使用固定管线进行渲染时,就好像在控制电路上的多个开关,我们可以选择打开或者关闭一个开关,但永远无法控制整个电路的排布。

随着时代的发展,GPU流水线越来越朝着更高的灵活性和可控性方向发展,可编程渲染管线应运而生。我们在上面看到了许多可编程的流水线阶段,如顶点着色器、片元着色器,这些可编程的着色器阶段可以说是GPU进化最重要的贡献。表2.1给出了3种最常见的图像接口从固定管线向可编程管线进化的版本。

表2.1 3种图像接口从固定管线向可编程管线进化的版本

在GPU发展的过程中,为了继续提供固定管线的接口抽象,一些显卡驱动的开发者们使用了更加通用的着色架构,即使用可编程的管线来模拟固定管线。这是为了在提供可编程渲染管线的同时,可以让那些已经熟悉了固定管线的开发者们继续使用固定管线进行渲染。例如,OpenGL 2.0在没有真正的固定管线的硬件支持下,依靠系统的可编程管线功能来模仿固定管线的处理过程。但随着GPU的发展,固定管线已经逐渐退出历史舞台。例如,OpenGL 3.0是最后既支持可编程管线又完全支持固定管线编程接口的版本,在OpenGL 3.2中,Core Profile就完全移除了固定管线的概念。

因此,如果读者不是为了对较旧的设备进行兼容,不建议继续使用固定管线的渲染方式。