这篇笔记主要关于 Unity 的图形渲染管线以及顶点、片元着色器的基本信息。
基本渲染管线的抽象
基本的渲染流程我们可以分为三个阶段,如下图所示
应用阶段:发生在 CPU。
几何阶段:发生在 GPU。
光栅化阶段:发生在 GPU。
CPU 阶段
在 Unity 中,CPU 阶段发生的事情主要发生在 Camera 组件上。Camera 组件会在渲染时调用 Render() 函数,这个函数能够将足够的信息打包给 GPU,从而让 GPU 在几何阶段知道自己要怎么渲染。
有一些信息是由 CPU 进行处理并打包的,这些数据包括:
剔除 Cull
剔除决定有哪些东西可以不被渲染,比如说相机看不到的东西(视锥体之外的),遮挡关系,以及看不到的物体的背面,还有一些被手动设置渲染 Layer 的物体。
渲染顺序 Render Queue
决定对象被渲染的顺序。在 Unity 中,不透明的通道 (Geometry)默认在2000,可用范围为<2500。透明通道 (Transparent)默认在3000,可用范围 > 2500。这个数值越小,就说明被越先渲染。
打包数据
比如顶点位置、顶点法线方向、顶点颜色、纹理坐标之类的信息。我们可以观察一下 (.obj) 格式导出的信息,其中就包括顶点信息、顶点法线、uv、三角面信息。
SetPassCall() 与 DrawCall()
SetPassCall() 告诉引擎用什么 Shader 来渲染。
DrawCall() 告诉引擎 CPU 这边已经万事俱备,下面就交由 GPU 开始负责画的过程。
GPU 阶段
在 GPU 阶段,开发者能做的配置有限。以比较基础的情况来说,基本上可以分为以下几个步骤。
顶点着色器 Vertex Shader
我们需要知道这么几个事情:
顶点着色器的操作是逐顶点的:它的输入来自于 CPU 在上一阶段打包出来的顶点,每个顶点都会调用一次顶点着色器。
顶点着色器本身不添加、减少顶点,也并不知道顶点之间的关系(例如是否在一条 edge 上,是否组成一个面等)。
那么为什么我们会需要一个顶点着色器?在流水线上,顶点着色器的主要任务是变换坐标、计算光照。后续,片元着色器需要的一些数据也由顶点着色器提供,这些数据我们一般会在 Shader 文件中,用 v2f 结构体来存储这些信息。
顶点着色器的第一个任务是把顶点坐标从模型空间转换到裁剪空间。很好理解这很有必要,CPU 传入的(例如.obj)文件中,所有的坐标信息都是关于原模型设置的中心进行计算的。具体的转换过程我们可以去数学基础篇的笔记。
裁剪 Clip
在 CPU 阶段,我们已经做过了第一次粗粒度的剔除处理。当时,我们手动地阻止了一些图元被传递进 GPU,以提高渲染的性能。
在 GPU 阶段,我们要针对没有被剔除的剩下的图元进行进一步的裁剪处理——所谓的裁剪,指的是将不在 Camera 可视范围内的物体处理清楚。
图元与摄像机无非就三种关系:完全不可视、部分可视、完全可视。完全的两个很好处理,不可视就不传递,可视就传递到流水线下一个阶段。裁剪处理的是部分可视的那部分图元。
从模型空间到裁剪空间的过程与拍照的过程类似。这个部分会在数学基础部分详细描述,但是目前大概做以下理解:如果我们要描述一个相机在空间中的位置,我们基本上需要它的以下信息:
位置(Position):相机本身在空间中的位置,我们用 Position 向量表示。
相机顶指向的方向(Up):用来描述相机的方向,用单位向量 Up 表示。
相机朝向的方向(Look):用来描述相机对准的方向,用单位向量 Look 表示。
相机右边的方向(Right):用来描述相机的旋转,用单位向量 Right 表示。
有了这4个向量,我们就可以很精准地描述各个空间的转化。
1. 模型空间到世界空间:左乘 Model Matrix。这一步之后,我们就可以把原来以模型中的原点作为原点的坐标系转化到以世界原点作为原点的坐标系下。
2. 世界空间到相机空间:左乘 View Matrix。
3. 相机空间到裁剪空间:左乘 Projection Matrix。这里的 FOV 指的是 Field of View,也就是视角。aspect_ratio 指的是相机的宽度与长度的比值。Zn 与 Zf 分别是 near 和 far 的裁剪平面的 z 坐标。
在 Unity 中,我们可以用自带的 MVP 矩阵一步完成从模型空间到裁剪空间的坐标转换。
裁剪会在归一化设备坐标(NDC)下进行。
屏幕映射 Screen Mapping
在 NDC 的单位立方体内、被裁剪过的待渲染对象的三维坐标到目前为止应该是很清楚了。下一步任务就应该是把图元的坐标转换到屏幕坐标系上。
基本上我们可以认为在这一步是将原来在 [-1, 1] 上的坐标映射到屏幕分辨率的坐标范围。但是屏幕是 2D 的,所以这一步不处理 z 坐标。
在 OpenGL 和 DirectX 上,y 轴的正方向不同。这一点在未来写 Shader 的时候可能会导致输出图像在 y 轴方向上颠倒。遇到这个问题的时候,简单地 *= -1就可以了。
光栅化阶段
几何阶段我们处理好了顶点信息,到了这一步我们的目标就是算好图元覆盖的像素,以及这些像素的颜色。把每个像素应该显示出来的颜色计算出来的过程叫做光栅化(Rasterization)。
图元装配
这一步的目的是为了检查哪些像素会被三角面覆盖。在像素网格中绘制三角形的过程在这里不予赘述,概括而言就是看三角形的边界经过了哪些像素,再对内部像素利用三个顶点的信息进行线性插值。诚然,并不是每一个像素上都有来自顶点的信息——顶点是个点,它最终只会坐落于一个像素内。所以,三角面上的其他所有点的各个信息我们都需要通过插值(Interpolation)得到。所谓的插值,就是找到某一种合适的算法,在两端的数值已知的情况下,插入中间的数值。
如果一个像素被三角形覆盖,这个像素上就会形成一个片元(Fragment)。片元并不是像素,因为片元会包含很多额外的信息,比如屏幕坐标、深度信息、法线坐标等。
图元装配的结果就是现在 GPU 知道有哪些像素需要被着色了。在这一步,我们会输出一整个片元序列。这个序列中的每一个成员的颜色交由下一步片元着色器来处理。
片元着色器 Fragment Shader
之前所做的所有步骤我们实际上都没有做真正的 Shade 操作,也就是上色。顶点着色器虽然叫着色器,但实际上也只是提供了顶点的所有信息,并进行了坐标转化操作。到了片元着色器部分,我们就真的要开始考虑每一个像素上要显示什么颜色了。
插值所得的结果我们也已经在上一步图元装配完成了,这一部分也是片元着色器的输入值。换言之,现在每一个片元都知道自己所对应的坐标、法线等各种信息了,我们需要根据这些信息开始计算它们的显示颜色,这也就是片元着色器的输出值。
逐片元操作 Per-fragment Operations
片元着色器的局限性在于它还是只管一个片元的事,只管这一个像素上的片元能发生什么情况。
逐片元操作的目的是为了确定一个片元最终是否可见。一个片元可能会因为被遮挡、被蒙版等一系列原因导致其最终不可见。所以这个片元要经过一系列的测试。如果它没有通过其中的任何一个,它就会被直接舍弃掉。
模板测试 (Stencil Test):这个阶段的测试只是用来比较片元上的某个值和开发者设定的某个参考值的关系的。如果未能达成开发者所需要的目的(比如大于这个参考值),就将其舍弃。
深度测试(Depth Test):比较该片元的深度值和已经存在于缓冲区中的深度值比较。一个没有通过深度测试的片元无权修改当前深度缓冲区内的数值,但是一个通过了的片元也可以选择不刷新深度缓冲区的数值,这一步是由开发者决定的。很多透明效果需要用到深度测试中开启/关闭写入。
颜色混合(Blend):如果通过了所有的测试,那么最终就会来到颜色混合的过程。如果是非透明物体,我们可以直接用其中的某一个颜色替代掉颜色缓冲区内的其他颜色(当然也可以有其他的处理方法,这主要要看要什么效果)。对于透明物体,则可能需要考虑到更多更复杂的混合算法,比如正片叠底、叠加之类的 Photoshop 经典图层混合效果。
参考资料:
World, View and Projection Matrix Internals, http://gsteph.blogspot.com/2012/05/world-view-and-projection-matrix.html
Unity Shader 入门精要,人民邮电出版社,2016年6月第一版,冯乐乐著
陶老师nb🐄