在学习SubShader之前,我们有必要对 Render Pipeline (渲染流水线)和 GPU Pipeline (图形硬件流水线)有一个比较细致的了解。这是一篇干货,内容主要参考了《Unity Shader入门精要》、《Real-Time Rendering》以及众多博客,其中加入了一些个人的见解,里面涉及到的知识能够为我们以后的Shader编写提供指导。有错误的地方欢迎联系指正。
什么是流水线
在第0章我们简单地提到了渲染流水线的大概过程。但是,只知道大概过程会给我们以后的学习带来疑惑,所以我们还是要熟悉整个渲染的流程。在这之前,我们首先要清楚Pipeline(流水线)是什么。
我们都知道,工厂的生产都是基于流水线的,那为什么我们会选择使用这种模式呢?我们先回到传统模式,假设一件商品需要经过四道工序完成,而这四道工序都由同一个工人去完成。显然,工序是拓扑有序的,也就是说,我们必须严格按照1-2-3-4的顺序进行,在计算机上,这对应着串行计算,这也就意味着,上一道工序如果没有完成,我们便永远无法开展下一步的工作,这无疑是低效的。在注重效率的现代社会,最重要的协作方式肯定是各司其职,所有人在同一个时间段完成不同的工作,再把各自的阶段产物递交给下一道工序的执行者,这对应着计算机的并行计算。因为各自负责了自己擅长的工作,在时间上又是同时开展的,效率显然远远高过传统方式。值得庆幸的是,GPU(图形显卡)的特长正是并行计算。
渲染流水线是怎样运作的
了解了流水线模式的好处之后,可能会有这样的疑问:为什么渲染也需要用到流水线呢?这是因为渲染工作也是由若干阶段组成的。接下来我们将深入流水线中看看渲染的实质。在《Real-Time Rendering》一书中,作者把渲染流程分为了三个概念阶段,分别是Application Stage(应用阶段)、Geometry Stage(几何阶段)、Rasterizer Stage(光栅化阶段),这也是目前被广泛认可的一种描述。
应用阶段
在应用阶段,我们需要准备好场景数据,如视角位置,光照设置,但最重要的输出是Render Primitives(渲染图元),这一阶段在CPU中完成,对应到Unity中就是我们需要在场景中摆放Light,设置Main Camera,摆放游戏物体,设置好所有的参数。
几何阶段
从上一阶段获取到图元信息后,几何阶段会进行所有和几何相关的工作,决定哪些图元需要被绘制,需要怎样绘制,在哪里绘制。处理之后,我们会得到每个顶点在二维屏幕空间的坐标,以及各顶点的深度、颜色信息,这些会被传输到光栅化阶段。这一阶段通常在GPU上进行。
纯CPU的渲染流水线通常称为软渲染,即用软件模拟硬件进行渲染操作。
光栅化阶段
这一阶段通常也在GPU进行,这个时候,渲染已经接近尾声。利用上一阶段得到的数据,我们可以在GPU的插值寄存器中进行插值运算得到足够数量的像素信息,并最终确定逐像素确认,哪些像素应该显示在屏幕上。
CPU和GPU之间的通信
我们可以看到,应用阶段的数据在CPU中,这些数据是怎样传输给GPU进行几何阶段的操作呢?
在CPU中,所有和渲染有关的数据都会进入显存中,这是因为显卡对于显存的访问速度更快,随后,CPU会设置一些渲染状态,最后,CPU会调用Draw Call。Draw Call是一个CPU调度命令,它会指定那些需要被渲染的图元并通知GPU,这些被指定的数据会通过数据总线传输到GPU中。Draw Call其实就是调用图形编程语言(如DX,GL,Cg)的接口,通过这一层抽象与硬件层打交道。
数据总线是计算机内部各设备之间交换设备的一个通道,既然是通道,那么它肯定有传输速度的上限,频繁地提交Draw Call会导致CPU过载,这也是一个常见的性能瓶颈。
GPU流水线
应用阶段进行的计算都是为硬件层的渲染做准备,这个阶段结束后,就正式进入了GPU的流水线中。在一些比较老的GPU中采用的是固定渲染流水线,这也就意味着所有的操作都是受限的,我们只能做一些简单的配置。随着硬件设备的发展,现代的图形显卡几乎都支持可编程渲染流水线,定制化程度得到了提高。固定渲染流水线已经逐渐被淘汰了,这里不对其展开说明,下面主要了解一下可编程渲染管线的各个阶段:
Vertex Shader(顶点着色器)
顶点着色器是完全可编程的。输入其中的每一个顶点都会调用一次顶点着色器,它无法创建和销毁顶点,也无法获取顶点之间的关系,但这一特性适合用来进行高速的并行计算。输入顶点着色器的有顶点的位置信息,法线信息,切线信息等。它的主要工作是进行坐标变换和顶点光照计算最终得到Normalized Device Coordinates(NDC,归一化的设备坐标)。这个坐标通常会在光栅化后传递给片元着色器进行处理。但是顶点着色器的作用远不止于此,输入顶点的法线、切线等信息也会在这一步进行处理,比如生成副切线,把顶点转换到切线空间进行计算,或者进行法线外扩,实现描边效果。
涉及到坐标变换,就绕不开矩阵和线性代数,数学部分的内容可以参考《3D数学基础:图形与游戏开发》等图书。
Tessellation Shader(曲面细分着色器)& Geometry Shader(几何着色器)
顶点着色器为了追求速度不得不舍弃一些操作,但这些舍弃的操作会在一定程度上影响画面的美感。在硬件的支持下,便诞生了具有特异功能的曲面细分着色器和几何着色器。这两个着色器不可编程,但可以配置。
由于计算机的数据离散性,我们只能使用折线表示曲线,使用多平面表示曲面,如果粒度不够,曲线和曲面就显得没那么平滑。而曲面细分着色器的作用就是解决这个问题:生成新的顶点,“插入”到直线上或平面内,让曲线和曲面显得更圆滑。
而几何着色器的优势在于它可以创建和销毁顶点。但这些创建出来的顶点不是用于细分,而是用于扩展;同时,它也可以销毁那些我们不想输出到下一阶段的顶点。
由于Shaderlab的高度封装性,Unity对这两种着色器的支持度比较低。
经过若干着色器的计算筛选,顶点规模已经基本确定了,接下来对顶点做最后的处理。
Clipping(裁剪)
由于我们输出的不可能是整个空间,出于性能考虑,我们自然会想到,舍去那些不会出现在屏幕上的顶点,这也就是裁剪。在这一阶段,我们会把顶点变换到裁剪空间,裁剪空间是一个单位立方体空间,因此我们只需要判断哪些线、面在立方体内,哪些在立方体外,即可知道我们真正需要处理的是哪些。特殊情况是,如果有一条直线或者一个平面部分可见,那么裁剪操作会在立方体边界生成新的顶点,取代那些不会出现的顶点。裁剪操作虽然不可编程,但是我们可以定制裁剪视锥,远近平面,视角大小等信息控制裁剪范围。
Screen Mapping(屏幕映射)
现在我们已经得到了屏幕内的所有顶点信息,但它仍位于裁剪空间中,因此我们有必要把这些顶点映射到屏幕坐标系。映射过程中使用了两个维度的坐标信息,而我们知道空间坐标是一个三维信息,丢失的那一维我们并没有真正地舍弃,而是将他作为顶点的深度信息,为以后的片元操作提供依据。
至此,概念流水线的几何阶段工作就结束了,我们回顾一下,上述阶段,我们接收了顶点的原始信息,最终得到的是渲染所需的屏幕坐标,顶点深度值等信息。需要注意的是,在Shaderlab中编写的顶点着色器包含了裁剪部分,这是因为接下来这些顶点数据会被提交给光栅化阶段,这一阶段接受的输入是裁剪空间下的信息。接下来是光栅化阶段的工作了。
首先是光栅化以及插值过程,它包含了三角形设置和三角形遍历,目的是计算图元覆盖的像素。
Triangle Setup(三角形设置)
计算三角形网格表示的数据。
Triangle Traversal(三角形遍历)
这个阶段接收的数据仍是顶点级别。这里是真正栅格化数据的阶段,这个阶段会逐像素检查其是否有被三角形网格覆盖,如果有,就生成一个片元。因此,它也被称为扫描变换过程,覆盖信息计算完成后,整个覆盖区域会使用顶点信息进行插值,生成像素级别的数据,这些数据会传递到片元着色器中。
这些像素级别的数据仍然是以片元为载体的,并不真正对应屏幕上的像素。
接下来是片元着色器环节,也是第二个和最后一个完全可编程环节。
Fragment Shader(片元着色器)
在DirectX中,它也被称为Pixel Shader(像素着色器),但个人感觉片元是更适合的名称,因为这个阶段的输出并不会真正影响屏幕的像素颜色,接下来还有逐片元操作,对这些片元进行筛选,以确定最终显示的颜色。这一阶段最重要的技术是纹理采样。为了得到采样结果,我们通常会在顶点着色器中计算每个顶点的纹理坐标,采样器会根据这个坐标采样纹理数据。由于我们已经在顶点着色器中计算好了每个顶点的颜色信息,也在上一阶段得到了像素级别的插值颜色,因此现在我们只需要根据我们想实现的效果,做相应的颜色计算即可。
Per-Fragment Operations(逐片元操作)
在DirectX中也称为Output Merger(输出合并)。这一阶段具有高度的配置性。进行到这里,渲染工作也基本完成了。经过上一阶段,我们得到了许多色彩斑斓的片元,是时候进行最后的筛选了。在这一阶段,我们会对每一个片元都进行一系列的测试操作以及最终的混合操作,目的是确定这个片元是否可见,以及可见时它的颜色对应的权重。测试主要有Stencil Test(模板测试)和Depth Test(深度测试),在测试前,首先要判断这个片元是否开启了对应的测试操作。在测试中,我们会利用对应的缓冲和片元进行比较,对应的有Stencil Buffer(模板缓冲)和Depth Buffer(深度缓冲)。通过了模板测试的片元会被保留,同时更改模板缓冲区的值,随后进行深度测试(假设这个片元同时开启了两种测试)。深度测试具有更高的配置性。即使这个片元通过了深度测试,我们也可以关闭深度写入,让这个片元的深度值不影响深度缓冲区。透明效果的实现离不开深度测试的高配置性。
经过深度测试后我们会发现,我们舍弃了许多片元,这样也就意味着这些片元对应的片元着色环节所做的一切都是徒劳。自然我们会想,能否将深度测试提前?答案是肯定的,这项技术被称为Early-Z,它会在片元着色器之前进行深度测试,但这并不意味着我们可以舍弃真正的深度测试环节。如果我们把深度测试提前,这些检验结果可能会和片元着色器的某些操作发生冲突,这个时候我们就不得不放弃Early-Z,选择传统的深度测试。
通过测试的片元会来到最后一个环节,Blend(混合),与之对应的是颜色缓冲区。我们可以选择开启/关闭混合操作。对于不透明的物体,我们会关闭混合操作,这样,这个片元的颜色会完全覆盖掉颜色缓冲区的像素值;对于半透明物体,我们需要开启混合选项,以达到透明效果。这一阶段也是高度配置的,我们可以自定义混合的比例。Photoshop的图层混合模式的实现也是类似的操作。
GPU流水线之后
当所有片元都经过逐片元操作后,我们得到的颜色缓冲区就可以作为最终呈现在屏幕上的图像了。但一般来说,我们会把这个颜色缓冲区的内容输出到Frame Buffer(帧缓冲)。这是因为光栅化过程的进度是不可知的,也就是说有可能我们读取的屏幕图像还包含了部分上一帧的内容(这个转场很炫酷,但并不是我们希望看到的)。对应的解决方案是,使用Double Buffer(双缓冲)技术,我们准备两个缓冲区,一个用于当前屏幕图像显示,一个用于幕后渲染下一帧的图像。不仅如此,我们得到了帧缓冲后,还可以进行Post-Processing(屏幕后处理),实现更丰富的视觉效果。
最后
我们已经梳理了一遍GPU渲染的详细过程,但由于抽象层(Shader Language)提供给我们的接口的区别,以及GPU为我们做的额外优化,许多细节或者顺序可能不尽相同,但也足以让我们对计算机图形学和渲染有比较清晰的认识了,至少可以知道计算机,或者说硬件,在背后为我们做了些什么。
在Shaderlab中,顶点和片元着色器可以通过插入CG/GLSL代码块实现,剔除阶段和逐片元操作则可以通过Tags和CommonState实现高度配置。