Unity shader入门精要读书笔记-第二章:渲染流水线

Unity系列文章目录

文章目录

  • Unity系列文章目录
  • 前言
  • 一、综述
  • 二、CPU 和GPU 之间的通信
    • 2.2.1 把数据加载到显存中
    • 2.2.2 设置渲染状态
    • 2.2.3 调用Draw Call
    • 2.3 GPU 流水线
    • 2.3.2 顶点着色器
    • 2.3.3 裁剪
    • 2.3.4 屏幕映射
    • 2.3.5 三角形设置
    • 2.3.6 三角形遍历
    • 2.3.7 片元着色器
    • 2.3.8 逐片元操作
  • 2.3.9 总结
  • 2.4 一些容易困惑的地方
  • 2.5 那么,你明白什么是Shader 了吗
  • 2.6 扩展阅读
  • 参考

前言

在开始一切学习之前,我们有必要了解什么是Shader,即着色器。与之关系非常紧密的就是
渲染流水线。可以说,如果你没有了解过渲染流水线的工作流程,就永远无法说自己对Shader 已
经入门。
渲染流水线的最终目的在于生成或者说是渲染一张二维纹理,即我们在电脑屏幕上看到的所
有效果。它的输入是一个虚拟摄像机、一些光源、一些Shader 以及纹理等。
本章将会给出渲染流水线的概览,同时会尽量避免数学上的计算,而仅仅提供一些全局上的
描述。本书给出的流水线不仅适用于Unity 平台,如果读者想要深入了解并学习着色器的话,会
发现下面的内容同样是非常重要和有价值的。

一、综述

要学会怎么使用Shader,我们首先要了解Shader 是怎么工作的。实际上,Shader 仅仅是渲染
流水线中的一个环节,想要让我们的Shader 发挥出它的作用,我们就需要知道它在渲染流水线中
扮演了怎样的角色。而本节会给出简化后的渲染流水线的工作流程。
2.1.1 什么是流水线
我们先来看一下真实生活中的流水线是什么。在工业上,流水线被广泛应用在装配线上。
我们来举一个例子。假设,老王有一个生产洋娃娃的工厂,一个洋娃娃的生产流程可以分为
4 个步骤:第1 步,制作洋娃娃的躯干;第2 步,缝上眼睛和嘴巴;第3 步,添加头发;第4 步,
给洋娃娃进行最后的产品包装。
在流水线出现之前,只有在每个洋娃娃完成了所有这4 个工序后才能开始制作下一个洋娃娃。
如果说每个步骤需要的时间是1 小时的话,那么每4 个小时才能生产一个洋娃娃。
但后来人们发现了一个更加有效的方法,即使用流水线。老王把流水线引入工厂之后,工厂
发生了很大的变化。虽然制作一个洋娃娃仍然需要4 个步骤,但不需要从头到尾完成全部步骤,
而是每个步骤由专人来完成,所有步骤并行进行。也就是说,当工序1 完成了制作躯干的任务并
把其交给工序2 时,工序1 又开始进行下一个洋娃娃的制作了。
使用流水线的好处在于可以提高单位时间的生产量。在洋娃娃的例子中,使用了流水线技术
后每1 个小时就可以生产一个洋娃娃。图2.1 显示了使用流水线前后生产效率的变化。
可以发现,流水线系统中决定最后生产速度的是最慢的工序所需的时间。例如,如果生产洋
娃娃的第二道工序需要的是两个小时,其他工序仍然需要1 个小时的话,那么平均每两个小时才
能生产出一个洋娃娃。即工序2 是性能的瓶颈(bottleneck)。
理想情况下,如果把一个非流水线系统分成n 个流水线阶段,且每个阶段耗费时间相同的话,
会使整个系统得到n 倍的速度提升。
Unity shader入门精要读书笔记-第二章:渲染流水线_第1张图片
2.2.2什么是渲染流水线
上面的关于流水线的概念同样适用于计算机的图像渲染中。渲染流水线的工作任务在于由一
个三维场景出发、生成(或者说渲染)一张二维图像。换句话说,计算机需要从一系列的顶点数
据、纹理等信息出发,把这些信息最终转换成一张人眼可以看到的图像。而这个工作通常是由CPU
和GPU 共同完成的。
《Real-Time Rendering, Third Edition》[1]一书中将一个渲染流程分成3 个阶段:应用阶段
(Application Stage)、几何阶段(Geometry Stage)、光栅化阶段(Rasterizer Stage)。
注意,这里仅仅是概念性阶段,每个阶段本身通常也是一个流水线系统,即包含了子流水线
阶段。图2.2 显示了3 个概念阶段之间的联系。
Unity shader入门精要读书笔记-第二章:渲染流水线_第2张图片
 应用阶段
从名字我们可以看出,这个阶段是由我们的应用主导的,因此通常由CPU 负责实现。换句话
说,我们这些开发者具有这个阶段的绝对控制权。
在这一阶段中,开发者有3 个主要任务:首先,我们需要准备好场景数据,例如摄像机的位
置、视锥体、场景中包含了哪些模型、使用了哪些光源等等;其次,为了提高渲染性能,我们往
往需要做一个粗粒度剔除(culling)工作,以把那些不可见的物体剔除出去,这样就不需要再移
交给几何阶段进行处理;最后,我们需要设置好每个模型的渲染状态。这些渲染状态包括但不限
于它使用的材质(漫反射颜色、高光反射颜色)、使用的纹理、使用的Shader 等。这一阶段最重
要的输出是渲染所需的几何信息,即渲染图元(rendering primitives)。通俗来讲,渲染图元可以
是点、线、三角面等。这些渲染图元将会被传递给下一个阶段—几何阶段。
由于是由开发者主导这一阶段,因此应用阶段的流水线化是由开发者决定的。这不在本书的
范畴内,有兴趣的读者可以参考本章的扩展阅读部分。
 几何阶段
几何阶段用于处理所有和我们要绘制的几何相关的事情。例如,决定需要绘制的图元是什么,
怎样绘制它们,在哪里绘制它们。这一阶段通常在GPU 上进行。
几何阶段负责和每个渲染图元打交道,进行逐顶点、逐多边形的操作。这个阶段可以进一步
分成更小的流水线阶段,这在下一章中会讲到。几何阶段的一个重要任务就是把顶点坐标变换到
屏幕空间中,再交给光栅器进行处理。通过对输入的渲染图元进行多步处理后,这一阶段将会输
出屏幕空间的二维顶点坐标、每个顶点对应的深度值、着色等相关信息,并传递给下一个阶段。
 光栅化阶段
这一阶段将会使用上个阶段传递的数据来产生屏幕上的像素,并渲染出最终的图像。这一阶
段也是在GPU 上运行。光栅化的任务主要是决定每个渲染图元中的哪些像素应该被绘制在屏幕
上。它需要对上一个阶段得到的逐顶点数据(例如纹理坐标、顶点颜色等)进行插值,然后再进
行逐像素处理。和上一个阶段类似,光栅化阶段也可以分成更小的流水线阶段。提示
读者需要把上面的3 个流水线阶段和我们将要讲到的GPU 流水线阶段区分开来。
这里的流水线均是概念流水线,是我们为了给一个渲染流程进行基本的功能划分而提
出来的。下面要介绍的GPU 流水线,则是硬件真正用于实现上述概念的流水线。

二、CPU 和GPU 之间的通信

渲染流水线的起点是CPU,即应用阶段。应用阶段大致可分为下面3 个阶段:
(1)把数据加载到显存中。
(2)设置渲染状态。
(3)调用Draw Call(在本章的最后我们还会继续讨论它)。

2.2.1 把数据加载到显存中

所有渲染所需的数据都需要从硬盘(Hard Disk Drive,HDD)中加载到系统内存(Random
Access Memory,RAM)中。然后,网格和纹理等数据又被加载到显卡上的存储空间—显存(Video
Random Access Memory,VRAM)中。这是因为,显卡对于显存的访问速度更快,而且大多数显
卡对于RAM 没有直接的访问权利。图2.3 所示给出了这样一个例子。
Unity shader入门精要读书笔记-第二章:渲染流水线_第3张图片
需要注意的是,真实渲染中需要加载到显存中的数据往往比图2.3 所示复杂许多。例如,顶
点的位置信息、法线方向、顶点颜色、纹理坐标等。
当把数据加载到显存中后,RAM 中的数据就可以移除了。但对于一些数据来说,CPU 仍然
需要访问它们(例如,我们希望CPU 可以访问网格数据来进行碰撞检测),那么我们可能就不希
望这些数据被移除,因为从硬盘加载到RAM 的过程是十分耗时的。
在这之后,开发者还需要通过CPU 来设置渲染状态,从而“指导”GPU 如何进行渲染工作。

2.2.2 设置渲染状态

什么是渲染状态呢?一个通俗的解释就是,这些状态定义了场景中的网格是怎样被渲染的。
例如,使用哪个顶点着色器(Vertex Shader)/片元着色器(Fragment Shader)、光源属性、材质等。
如果我们没有更改渲染状态,那么所有的网格都将使用同一种渲染状态。图2.4 显示了当使用同
一种渲染状态时,渲染3 个不同网格的结果。
Unity shader入门精要读书笔记-第二章:渲染流水线_第4张图片在准备好上述所有工作后,CPU 就需要调用一个渲染命令来告诉GPU:“嘿!老兄,我都帮
你把数据准备好啦,你可以按照我的设置来开始渲染啦!”而这个渲染命令就是Draw Call。

2.2.3 调用Draw Call

相信接触过渲染优化的读者应该都听说过Draw Call。实际上,Draw Call 就是一个命令,它
的发起方是CPU,接收方是GPU。这个命令仅仅会指向一个需要被渲染的图元(primitives)列表,
而不会再包含任何材质信息—这是因为我们已经在上一个阶段中完成了!图2.5 形象化地阐释
了这个过程。
当给定了一个Draw Call 时,GPU 就会根据渲染状态(例如材质、纹理、着色器等)和所有
输入的顶点数据来进行计算,最终输出成屏幕上显示的那些漂亮的像素。而这个计算过程,就是
我们下一节要讲的GPU 流水线。
Unity shader入门精要读书笔记-第二章:渲染流水线_第5张图片

2.3 GPU 流水线

当GPU 从CPU 那里得到渲染命令后,就会进行一系列流水线操作,最终把图元渲染到屏幕上。
2.3.1 概述
在上一节中,我们解释了在应用阶段,CPU 是如何和GPU 通信,并通过调用Draw Call 来命
令GPU 进行渲染。GPU 渲染的过程就是GPU 流水线。
对于概念阶段的后两个阶段,即几何阶段和光栅化阶段,开发者无法拥有绝对的控制权,其
实现的载体是GPU。GPU 通过实现流水线化,大大加快了渲染速度。虽然我们无法完全控制这两
个阶段的实现细节,但GPU 向开发者开放了很多控制权。在这一节中,我们将具体了解GPU 是
如何实现这两个概念阶段的。
几何阶段和光栅化阶段可以分成若干更小的流水线阶段,这些流水线阶段由GPU 来实现,每
个阶段GPU 提供了不同的可配置性或可编程性。图2.6 中展示了不同的流水线阶段以及它们的可
配置性或可编程性。
Unity shader入门精要读书笔记-第二章:渲染流水线_第6张图片
从图中可以看出,GPU 的渲染流水线接收顶点数据作为输入。这些顶点数据是由应用阶段加
载到显存中,再由Draw Call 指定的。这些数据随后被传递给顶点着色器。
顶点着色器(Vertex Shader)是完全可编程的,它通常用于实现顶点的空间变换、顶点着色
等功能。曲面细分着色器(Tessellation Shader)是一个可选的着色器,它用于细分图元。几何着
色器(Geometry Shader)同样是一个可选的着色器,它可以被用于执行逐图元(Per-Primitive)
的着色操作,或者被用于产生更多的图元。下一个流水线阶段是裁剪(Clipping),这一阶段的目
的是将那些不在摄像机视野内的顶点裁剪掉,并剔除某些三角图元的面片。这个阶段是可配置的。
例如,我们可以使用自定义的裁剪平面来配置裁剪区域,也可以通过指令控制裁剪三角图元的正
面还是背面。几何概念阶段的最后一个流水线阶段是屏幕映射(Screen Mapping)。这一阶段是不
可配置和编程的,它负责把每个图元的坐标转换到屏幕坐标系中。
光栅化概念阶段中的三角形设置(Triangle Setup)和三角形遍历(Triangle Traversal)阶段
也都是固定函数(Fixed-Function)的阶段。接下来的片元着色器(Fragment Shader),则是完全
可编程的,它用于实现逐片元(Per-Fragment)的着色操作。最后,逐片元操作(Per-Fragment
Operations)阶段负责执行很多重要的操作,例如修改颜色、深度缓冲、进行混合等,它不是可
编程的,但具有很高的可配置性。
接下来,我们会对其中主要的流水线阶段进行更加详细的解释。

2.3.2 顶点着色器

顶点着色器(Vertex Shader)是流水线的第一个阶段,它的输入来自于CPU。顶点着色器的
处理单位是顶点,也就是说,输入进来的每个顶点都会调用一次顶点着色器。顶点着色器本身不
可以创建或者销毁任何顶点,而且无法得到顶点与顶点之间的关系。例如,我们无法得知两个顶
点是否属于同一个三角网格。但正是因为这样的相互独立性,GPU 可以利用本身的特性并行化处
理每一个顶点,这意味着这一阶段的处理速度会很快。
顶点着色器需要完成的工作主要有:坐标变换和逐顶点光照。当然,除了这两个主要任务外,
顶点着色器还可以输出后续阶段所需的数据。图2.7 展示了在顶点着色器中对顶点位置进行坐标
变换并计算顶点颜色的过程。
Unity shader入门精要读书笔记-第二章:渲染流水线_第7张图片
 坐标变换。顾名思义,就是对顶点的坐标(即位置)进行某种变换。顶点着色器可以在这
一步中改变顶点的位置,这在顶点动画中是非常有用的。例如,我们可以通过改变顶点位
置来模拟水面、布料等。但需要注意的是,无论我们在顶点着色器中怎样改变顶点的位置,
一个最基本的顶点着色器必须完成的一个工作是,把顶点坐标从模型空间转换到齐次裁剪
空间。想想看,我们在顶点着色器中是不是会看到类似下面的代码:
o.pos = mul(UNITY_MVP, v.position);
类似上面这句代码的功能,就是把顶点坐标转换到齐次裁剪坐标系下,接着通常再由硬件做
透视除法后,最终得到归一化的设备坐标(Normalized Device Coordinates ,NDC)。具体数学上
的实现细节我们会在第4 章中讲到。图2.8 展示了这样的一个转换过程。
Unity shader入门精要读书笔记-第二章:渲染流水线_第8张图片
需要注意的是,图2.8 给出的坐标范围是OpenGL 同时也是Unity 使用的NDC,它的z 分量
范围在[−1, 1]之间,而在DirectX 中,NDC 的z 分量范围是[0, 1]。顶点着色器可以有不同的输出
方式。最常见的输出路径是经光栅化后交给片元着色器进行处理。而在现代的Shader Model 中,
它还可以把数据发送给曲面细分着色器或几何着色器,感兴趣的读者可以自行了解。

2.3.3 裁剪

由于我们的场景可能会很大,而摄像机的视野范围很有可能不会覆盖所有的场景物体,一个
很自然的想法就是,那些不在摄像机视野范围的物体不需要被处理。而裁剪(Clipping)就是为
了完成这个目的而被提出来的。
一个图元和摄像机视野的关系有3 种:完全在视野内、部分在视野内、完全在视野外。完全
在视野内的图元就继续传递给下一个流水线阶段,完全在视野外的图元不会继续向下传递,因为
它们不需要被渲染。而那些部分在视野内的图元需要进行一个处理,这就是裁剪。例如,一条线
段的一个顶点在视野内,而另一个顶点不在视野内,那么在视野外部的顶点应该使用一个新的顶
点来代替,这个新的顶点位于这条线段和视野边界的交点处。
由于我们已知在NDC 下的顶点位置,即顶点位置在一个立方体内,因此裁剪就变得很简单:
只需要将图元裁剪到单位立方体内。图2.9 展示了这样的一个过程。
Unity shader入门精要读书笔记-第二章:渲染流水线_第9张图片
和顶点着色器不同,这一步是不可编程的,即我们无法通过编程来控制裁剪的过程,而是硬
件上的固定操作,但我们可以自定义一个裁剪操作来对这一步进行配置。

2.3.4 屏幕映射

这一步输入的坐标仍然是三维坐标系下的坐标(范围在单位立方体内)。屏幕映射(Screen Mapping)的任务是把每个图元的x 和y 坐标转换到屏幕坐标系(Screen Coordinates)下。屏幕
坐标系是一个二维坐标系,它和我们用于显示画面的分辨率有很大关系。
假设,我们需要把场景渲染到一个窗口上,窗口的范围是从最小的窗口坐标(x1,y1)到最大的窗
口坐标(x2,y2),其中x1< x2 且y1< y2。由于我们输入的坐标范围在−1 到1,因此可以想象到,这个
过程实际是一个缩放的过程,如图2.10 所示。你可能会问,那么输入的z 坐标会怎么样呢?屏幕
映射不会对输入的z 坐标做任何处理。实际上,屏幕坐标系和z 坐标一起构成了一个坐标系,叫
做窗口坐标系(Window Coordinates)。这些值会一起被传递到光栅化阶段。
Unity shader入门精要读书笔记-第二章:渲染流水线_第10张图片
屏幕映射得到的屏幕坐标决定了这个顶点对应屏幕上哪个像素以及距离这个像素有多远。
有一个需要引起注意的地方是,屏幕坐标系在OpenGL 和DirectX 之间的差异问题。OpenGL
把屏幕的左下角当成最小的窗口坐标值,而DirectX 则定义了屏幕的左上角为最小的窗口坐标值。
图2.11 显示了这样的差异。
Unity shader入门精要读书笔记-第二章:渲染流水线_第11张图片
产生这种差异的原因是,微软的窗口都使用了这样的坐标系统,因为这和我们的阅读方式是
一致的:从左到右、从上到下,并且很多图像文件也是按照这样的格式进行存储的。
不管原因如何,差异就这么造成了。留给我们开发者的就是,要时刻小心这样的差异,如果
你发现得到的图像是倒转的,那么很有可能就是这个原因造成的。

2.3.5 三角形设置

由这一步开始就进入了光栅化阶段。从上一个阶段输出的信息是屏幕坐标系下的顶点位置以
及和它们相关的额外信息,如深度值(z 坐标)、法线方向、视角方向等。光栅化阶段有两个最重
要的目标:计算每个图元覆盖了哪些像素,以及为这些像素计算它们的颜色。
光栅化的第一个流水线阶段是三角形设置(Triangle Setup)。这个阶段会计算光栅化一个三
角网格所需的信息。具体来说,上一个阶段输出的都是三角网格的顶点,即我们得到的是三角网
格每条边的两个端点。但如果要得到整个三角网格对像素的覆盖情况,我们就必须计算每条边上
的像素坐标。为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式。这样一个计算三角网格表示数据的过程就叫做三角形设置。它的输出是为了给下一个阶段做准备。

2.3.6 三角形遍历

三角形遍历(Triangle Traversal)阶段将会检查每个像素是否被一个三角网格所覆盖。如果
被覆盖的话,就会生成一个片元(fragment)。而这样一个找到哪些像素被三角网格覆盖的过程就
是三角形遍历,这个阶段也被称为扫描变换(Scan Conversion)。
三角形遍历阶段会根据上一个阶段的计算结果来判断一个三角网格覆盖了哪些像素,并使用
三角网格3 个顶点的顶点信息对整个覆盖区域的像素进行插值。图2.12 展示了三角形遍历阶段的
简化计算过程。
Unity shader入门精要读书笔记-第二章:渲染流水线_第12张图片
这一步的输出就是得到一个片元序列。需要注意的是,一个片元并不是真正意义上的像素,
而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括了(但不限
于)它的屏幕坐标、深度信息,以及其他从几何阶段输出的顶点信息,例如法线、纹理坐标等。

2.3.7 片元着色器

片元着色器(Fragment Shader)是另一个非常重要的可编程着色器阶段。在DirectX 中,片
元着色器被称为像素着色器(Pixel Shader),但片元着色器是一个更合适的名字,因为此时的片
元并不是一个真正意义上的像素。
前面的光栅化阶段实际上并不会影响屏幕上每个像素的颜色值,而是会产生一系列的数据信
息,用来表述一个三角网格是怎样覆盖每个像素的。而每个片元就负责存储这样一系列数据。真
正会对像素产生影响的阶段是下一个流水线阶段—逐片元操作(Per-Fragment Operations)。
我们随后就会讲到。
片元着色器的输入是上一个阶段对顶点信息插值得到的结果,更具体来说,是根据那些从顶点着
色器中输出的数据插值得到的。而它的输出是一个或者多个颜色值。图2.13 显示了这样一个过程。
这一阶段可以完成很多重要的渲染技术,其中最重要的技术之一就是纹理采样。为了在片元
着色器中进行纹理采样,我们通常会在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过
光栅化阶段对三角网格的3 个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理坐标了。

Unity shader入门精要读书笔记-第二章:渲染流水线_第13张图片
虽然片元着色器可以完成很多重要效果,但它的局限在于,它仅可以影响单个片元。也就是
说,当执行片元着色器时,它不可以将自己的任何结果直接发送给它的邻居们。有一个情况例外,
就是片元着色器可以访问到导数信息(gradient,或者说是derivative)。有兴趣的读者可以参考本
章的扩展阅读部分。

2.3.8 逐片元操作

终于到了渲染流水线的最后一步。逐片元操作(Per-Fragment Operations)是OpenGL 中的
说法,在DirectX 中,这一阶段被称为输出合并阶段(Output-Merger)。Merger 这个词可能更容
易让读者明白这一步骤的目的:合并。而OpenGL 中的名字可以让读者明白这个阶段的操作单位,
即是对每一个片元进行一些操作。那么问题来了,要合并哪些数据?又要进行哪些操作呢?
这一阶段有几个主要任务。
(1)决定每个片元的可见性。这涉及了很多测试工作,例如深度测试、模板测试等。
(2)如果一个片元通过了所有的测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区
中的颜色进行合并,或者说是混合。
需要指明的是,逐片元操作阶段是高度可配置性的,即我们可以设置每一步的操作细节。这
在后面会讲到。
这个阶段首先需要解决每个片元的可见性问题。这需要进行一系列测试。这就好比考试,一
个片元只有通过了所有的考试,才能最终获得和GPU 谈判的资格,这个资格指的是它可以和颜色
缓冲区进行合并。如果它没有通过其中的某一个测试,那么对不起,之前为了产生这个片元所做
的所有工作都是白费的,因为这个片元会被舍弃掉。Poor fragment!图2.14 给出了简化后的逐片
元操作所做的操作。
Unity shader入门精要读书笔记-第二章:渲染流水线_第14张图片
测试的过程实际上是个比较复杂的过程,而且不同的图形接口(例如OpenGL 和DirectX)的
实现细节也不尽相同。这里给出两个最基本的测试—深度测试和模板测试的实现过程。能否理
解这些测试过程将关乎读者是否可以理解本书后面章节中提到的渲染队列,尤其是处理透明效果
时出现的问题。图2.15 给出了深度测试和模板测试的简化流程图。
Unity shader入门精要读书笔记-第二章:渲染流水线_第15张图片
我们先来看模板测试(Stencil Test)。与之相关的是模板缓冲(Stencil Buffer)。实际上,模板
缓冲和我们经常听到的颜色缓冲、深度缓冲几乎是一类东西。如果开启了模板测试,GPU 会首先
读取(使用读取掩码)模板缓冲区中该片元位置的模板值,然后将该值和读取(使用读取掩码)
到的参考值(reference value)进行比较,这个比较函数可以是由开发者指定的,例如小于时舍弃
该片元,或者大于等于时舍弃该片元。如果这个片元没有通过这个测试,该片元就会被舍弃。不
管一个片元有没有通过模板测试,我们都可以根据模板测试和下面的深度测试结果来修改模板缓
冲区,这个修改操作也是由开发者指定的。开发者可以设置不同结果下的修改操作,例如,在失
败时模板缓冲区保持不变,通过时将模板缓冲区中对应位置的值加1 等。模板测试通常用于限制
渲染的区域。另外,模板测试还有一些更高级的用法,如渲染阴影、轮廓渲染等。
如果一个片元幸运地通过了模板测试,那么它会进行下一个测试—深度测试(Depth Test)。
相信很多读者都听到过这个测试。这个测试同样是可以高度配置的。如果开启了深度测试,GPU
会把该片元的深度值和已经存在于深度缓冲区中的深度值进行比较。这个比较函数也是可由开发
者设置的,例如小于时舍弃该片元,或者大于等于时舍弃该片元。通常这个比较函数是小于等于
的关系,即如果这个片元的深度值大于等于当前深度缓冲区中的值,那么就会舍弃它。这是因为,
我们总想只显示出离摄像机最近的物体,而那些被其他物体遮挡的就不需要出现在屏幕上。如果
这个片元没有通过这个测试,该片元就会被舍弃。和模板测试有些不同的是,如果一个片元没有
通过深度测试,它就没有权利更改深度缓冲区中的值。而如果它通过了测试,开发者还可以指定
是否要用这个片元的深度值覆盖掉原有的深度值,这是通过开启/关闭深度写入来做到的。我们在
后面的学习中会发现,透明效果和深度测试以及深度写入的关系非常密切。
如果一个幸运的片元通过了上面的所有测试,它就可以自豪地来到合并功能的面前。
为什么需要合并?我们要知道,这里所讨论的渲染过程是一个物体接着一个物体画到屏幕上
的。而每个像素的颜色信息被存储在一个名为颜色缓冲的地方。因此,当我们执行这次渲染时,
颜色缓冲中往往已经有了上次渲染之后的颜色结果,那么,我们是使用这次渲染得到的颜色完全
覆盖掉之前的结果,还是进行其他处理?这就是合并需要解决的问题。
对于不透明物体,开发者可以关闭混合(Blend)操作。这样片元着色器计算得到的颜色值就
会直接覆盖掉颜色缓冲区中的像素值。但对于半透明物体,我们就需要使用混合操作来让这个物
体看起来是透明的。图2.16 展示了一个简化版的混合操作的流程图。
Unity shader入门精要读书笔记-第二章:渲染流水线_第16张图片
从流程图中我们可以发现,混合操作也是可以高度配置的:开发者可以选择开启/关闭混合功
能。如果没有开启混合功能,就会直接使用片元的颜色覆盖掉颜色缓冲区中的颜色,而这也是很
多初学者发现无法得到透明效果的原因(没有开启混合功能)。如果开启了混合,GPU 会取出源
颜色和目标颜色,将两种颜色进行混合。源颜色指的是片元着色器得到的颜色值,而目标颜色则
是已经存在于颜色缓冲区中的颜色值。之后,就会使用一个混合函数来进行混合操作。这个混合
函数通常和透明通道息息相关,例如根据透明通道的值进行相加、相减、相乘等。混合很像
Photoshop 中对图层的操作:每一层图层可以选择混合模式,混合模式决定了该图层和下层图层的
混合结果,而我们看到的图片就是混合后的图片。
上面给出的测试顺序并不是唯一的,而且虽然从逻辑上来说这些测试是在片元着色器之后进
行的,但对于大多数GPU 来说,它们会尽可能在执行片元着色器之前就进行这些测试。这是可以
理解的,想象一下,当GPU 在片元着色器阶段花了很大力气终于计算出片元的颜色后,却发现这
个片元根本没有通过这些检验,也就是说这个片元还是被舍弃了,那之前花费的计算成本全都浪
费了!图2.17 给出了这样一个场景。
作为一个想充分提高性能的GPU,它会希望尽可
能早地知道哪些片元是会被舍弃的,对于这些片元就
不需要再使用片元着色器来计算它们的颜色。在
Unity 给出的渲染流水线中,我们也可以发现它给出
的深度测试是在片元着色器之前。这种将深度测试提
前执行的技术通常也被称为Early-Z 技术。希望读者
看到这里时不会因此感到困惑。在本书后面的章节
中,我们还会继续讨论这个问题。
但是,如果将这些测试提前的话,其检验结果可
能会与片元着色器中的一些操作冲突。例如,如果我
们在片元着色器进行了透明度测试(我们将在8.3 节
中具体讲到),而这个片元没有通过透明度测试,我
们会在着色器中调用API(例如clip 函数)来手动将
其舍弃掉。这就导致GPU 无法提前执行各种测试。
因此,现代的GPU 会判断片元着色器中的操作是否和提前测试发生冲突,如果有冲突,就会禁用
提前测试。但是,这样也会造成性能上的下降,因为有更多片元需要被处理了。这也是透明度测
试会导致性能下降的原因。
当模型的图元经过了上面层层计算和测试后,就会显示到我们的屏幕上。我们的屏幕显示的
就是颜色缓冲区中的颜色值。但是,为了避免我们看到那些正在进行光栅化的图元,GPU 会使用
双重缓冲(Double Buffering)的策略。这意味着,对场景的渲染是在幕后发生的,即在后置缓冲
(Back Buffer)中。一旦场景已经被渲染到了后置缓冲中,GPU 就会交换后置缓冲区和前置缓冲
(Front Buffer)中的内容,而前置缓冲区是之前显示在屏幕上的图像。由此,保证了我们看到的
图像总是连续的。
2.3.9 总结
虽然我们上面讲了很多,但其真正的实现过程远比上面讲到的要复杂。需要注意的是,读者
可能会发现这里给出的流水线名称、顺序可能和在一些资料上看到的不同。一个原因是由于图像
编程接口(如OpenGL 和DirectX)的实现不尽相同,另一个原因是GPU 在底层可能做了很多优
化,例如上面提到的会在片元着色器之前就进行深度测试,以避免无谓的计算。
Unity shader入门精要读书笔记-第二章:渲染流水线_第17张图片

2.3.9 总结

虽然我们上面讲了很多,但其真正的实现过程远比上面讲到的要复杂。需要注意的是,读者
可能会发现这里给出的流水线名称、顺序可能和在一些资料上看到的不同。一个原因是由于图像
编程接口(如OpenGL 和DirectX)的实现不尽相同,另一个原因是GPU 在底层可能做了很多优
化,例如上面提到的会在片元着色器之前就进行深度测试,以避免无谓的计算。

虽然渲染流水线比较复杂,但Unity 作为一个非常出色的平台为我们封装了很多功能。更多
时候,我们只需要在一个Unity Shader 设置一些输入、编写顶点着色器和片元着色器、设置一些
状态就可以达到大部分常见的屏幕效果。这是Unity 吸引人的魅力之处,但这样的缺点在于,封
装性会导致编程自由度下降,使很多初学者迷失方向,无法掌握其背后的原理,并在出现问题时,
往往无法找到错误原因,这是在学习Unity Shader 时普遍的遭遇。
渲染流水线几乎和本书所有章节都息息相关,如果读者此时仍然无法完全理解渲染流水线,
仍可以继续学习下去。但如果读者在学习过程中发现有些设置或代码无法理解,可以不断查阅本
章内容,相信会有更深的理解。

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 显示了这样的关系。
概括来说,我们的应用程序运行在CPU 上。应用程序可以通过调用OpenGL 或DirectX 的图
形接口将渲染所需的数据,如顶点数据、纹理数据、材质参数等数据存储在显存中的特定区域。
随后,开发者可以通过图像编程接口发出渲染命令,这些渲染命令也被称为Draw Call,它们将会
被显卡驱动翻译成GPU 能够理解的代码,进行真正的绘制。
由图2.18 可以看出,一个显卡除了有图像处理单元GPU 外,还拥有自己的内存,这个内存
通常被称为显存(Video Random Access Memory,VRAM)。GPU 可以在显存中存储任何数据,
但对于渲染来说一些数据类型是必需的,例如用于屏幕显示的图像缓冲、深度缓冲等。
因为显卡驱动的存在,几乎所有的GPU 都既可以和OpenGL 合作,也可以和DirectX 一起工
作。从显卡的角度出发,实际上它只需要和显卡驱动打交道就可以了。而显卡驱动就好像一个中
介者,负责和两方(图像编程接口和GPU)打交道。因此,一个显卡制作商为了让他们的显卡可
以同时和OpenGL、DirectX 合作,就必须提供支持OpenGL 和DirectX 接口的显卡驱动。
Unity shader入门精要读书笔记-第二章:渲染流水线_第18张图片
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 等。这是因为在其他平台上没有可以编译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 显示了这样一个例子。
Unity shader入门精要读书笔记-第二章:渲染流水线_第19张图片

问题二:为什么Draw Call 多了会影响帧率?
我们先来做一个实验:请创建10 000 个小文件,每个文件的大小为1KB,然后把它们从一个
文件夹复制到另一个文件夹。你会发现,尽管这些文件的空间总和不超过10MB,但要花费很长
时间。现在,我们再来创建一个单独的文件,它的大小是10MB,然后也把它从一个文件夹复制
到另一个文件夹。而这次复制的时间却少很多!这是为什么呢?明明它们所包含的内容大小是一
样的。原因在于,每一个复制动作需要很多额外的操作,例如分配内存、创建各种元数据等。如
你所见,这些操作将造成很多额外的性能开销,如果我们复制了很多小文件,那么这个开销将会
很大。
渲染的过程虽然和上面的实验有很大不同,但从感性角度上是很类似的。在每次调用Draw
Call 之前,CPU 需要向GPU 发送很多内容,包括数据、状态和命令等。在这一阶段,CPU 需要
完成很多工作,例如检查渲染状态等。而一旦CPU 完成了这些准备工作,GPU 就可以开始本次
的渲染。GPU 的渲染能力是很强的,渲染200 个还是2 000 个三角网格通常没有什么区别,因此
渲染速度往往快于CPU 提交命令的速度。如果Draw Call 的数量太多,CPU 就会把大量时间花费
在提交Draw Call 上,造成CPU 的过载。图2.20 显示了这样一个例子。
Unity shader入门精要读书笔记-第二章:渲染流水线_第20张图片
问题三:如何减少Draw Call?
尽管减少Draw Call 的方法有很多,但我们这里仅讨论使用批处理(Batching)的方法。
我们讲过,提交大量很小的Draw Call 会造成CPU 的性能瓶颈,即CPU 把时间都花费在准
备Draw Call 的工作上了。那么,一个很显然的优化想法就是把很多小的DrawCall 合并成一个大
的Draw Call,这就是批处理的思想。图2.21 显示了批处理所做的工作。
需要注意的是,由于我们需要在CPU 的内存中合并网格,而合并的过程是需要消耗时间的。
因此,批处理技术更加适合于那些静态的物体,例如不会移动的大地、石头等,对于这些静态物
体我们只需要合并一次即可。当然,我们也可以对动态物体进行批处理。但是,由于这些物体是
不断运动的,因此每一帧都需要重新进行合并然后再发送给GPU,这对空间和时间都会造成一定
的影响。
Unity shader入门精要读书笔记-第二章:渲染流水线_第21张图片
在游戏开发过程中,为了减少Draw Call 的开销,有两点需要注意。
(1)避免使用大量很小的网格。当不可避免地需要使用很小的网格结构时,考虑是否可以合并
它们。
(2)避免使用过多的材质。尽量在不同的网格之间共用同一个材质。
在本书的16.4 节,我们会继续阐述如何在Unity 中利用批处理技术来进行优化。
2.4.4 什么是固定管线渲染
固定函数的流水线(Fixed-Function Pipeline),也简称为固定管线,通常是指在较旧的GPU
上实现的渲染流水线。这种流水线只给开发者提供一些配置操作,但开发者没有对流水线阶段的
完全控制权。
固定管线通常提供了一系列接口,这些接口包含了一个函数入口点(Function Entry Points)
集合,这些函数入口点会匹配GPU 上的一个特定的逻辑功能。开发者们通过这些接口来控制渲染
流水线。换句话说,固定渲染管线是只可配置的管线。一个形象的比喻是,我们在使用固定管线
进行渲染时,就好像在控制电路上的多个开关,我们可以选择打开或者关闭一个开关,但永远无
法控制整个电路的排布。
随着时代的发展,GPU 流水线越来越朝着更高的灵活性和可控性方向发展,可编程渲染管线
应运而生。我们在上面看到了许多可编程的流水线阶段,如顶点着色器、片元着色器,这些可编
程的着色器阶段可以说是GPU 进化最重要的贡献。表2.1 给出了3 种最常见的图像接口从固定管
线向可编程管线进化的版本。
Unity shader入门精要读书笔记-第二章:渲染流水线_第22张图片
在GPU 发展的过程中,为了继续提供固定管线的接口抽象,一些显卡驱动的开发者们使用了
更加通用的着色架构,即使用可编程的管线来模拟固定管线。这是为了在提供可编程渲染管线的
同时,可以让那些已经熟悉了固定管线的开发者们继续使用固定管线进行渲染。例如,OpenGL 2.0
在没有真正的固定管线的硬件支持下,依靠系统的可编程管线功能来模仿固定管线的处理过程。
但随着GPU 的发展,固定管线已经逐渐退出历史舞台。例如,OpenGL 3.0 是最后既支持可编程
管线又完全支持固定管线编程接口的版本,在OpenGL 3.2 中,Core Profile 就完全移除了固定管
线的概念。
因此,如果读者不是为了对较旧的设备进行兼容,不建议继续使用固定管线的渲染方式。

2.5 那么,你明白什么是Shader 了吗

我们之所以要花很大篇幅来讲述GPU 的渲染流水线,是因为Shader 所在的阶段就是渲染流
水线的一部分,更具体来说,Shader 就是:
 GPU 流水线上一些可高度编程的阶段,而由着色器编译出来的最终代码是会在GPU 上运
行的(对于固定管线的渲染来说,着色器有时等同于一些特定的渲染设置);
 有一些特定类型的着色器,如顶点着色器、片元着色器等;
 依靠着色器我们可以控制流水线中的渲染细节,例如用顶点着色器来进行顶点变换以及传
递数据,用片元着色器来进行逐像素的渲染。
但同时,我们也要明白,要得到出色的游戏画面是需要包括Shader 在内的所有渲染流水线阶
段的共同参与才可完成:设置适当的渲染状态,使用合适的混合函数,开启还是关闭深度测试/
深度写入等。
Unity 作为一个出色的编辑工具,为我们提供了一个既可以方便地编写着色器 ,同时又可设
置渲染状态的地方:Unity Shader。在下一章中,我们将真正走进Unity Shader 的世界。

2.6 扩展阅读

如果读者对渲染流水线的细节感兴趣,可以阅读更多的资料。托马斯在他们的著作[1]中给出
了很多有关实时渲染的内容,这本书被誉为图形学中的圣经。如果你仍然觉得本书讲解的Draw
Call 不够形象生动,西蒙在他的文章中给出了很多动态的演示效果,而且值得注意的是,西蒙
本人是一位美术工作者。为什么需要批处理,什么时候需要批处理等更多关于批处理的内容,
可以在NVIDIA 所做的一次报告[2]中找到更多的答案。如果读者对OpenGL 和DirectX 的渲染流水线
的实现细节感兴趣,那么阅读它们的文档(https://www. opengl.org/wiki/Rendering_Pipeline_Overview,
https://msdn.microsoft.com/en-us/ library/windows/ desktop/ff476882(v=vs.85).aspx)是一个非常好的途径。
[1] Akenine-Möller T, Haines E, Hoffman N. Real-time rendering[M]. CRC Press, 2008.
[2] Wloka M. Batch, Batch, Batch: What does it really mean?[C]//Presentation at game developers
conference. 2003.

参考

Unity Shader入门精要
作者:冯乐乐

你可能感兴趣的:(Unity,openGL,unity渲染管线)