一文读懂什么是渲染管线(7k字)
渲染(Render)定义
渲染在电脑绘图中是指软件从模型生成图像的过程,通俗讲就是在计算机里面给虚拟世界"拍照"。渲染主要分为两种,一种是预渲染(pre-rendering
),它的计算强度很大,通常用于电影制作;另一种是实时渲染(real-time rendering
),多用于三维游戏,并且依靠显卡完成渲染过程.
渲染管线
渲染是一个非常复杂的过程,它需要从一系列的顶点、纹理等信息出发,把这些信息最终转换成一张人眼可以看到的图像,在这个过程中,通常是需要CPU和GPU密切配合,渲染管线具体的实现细节会严重依赖于所使用的软件和硬件,因此并不存在所谓通用的渲染管线。
tips💁♂:虽然管线的划分粒度不一样,但是每个阶段的具体功能其实是差不多的,原理也是一样的,并没有太大的差异。
以Unity内置渲染管线(Unity Build-in Rendering Pipeline)
为例,可以将其分为CPU应用程序端渲染逻辑和GPU渲染管线,下面是它的完整流程图,其中蓝色区域是CPU端要做的工作,绿色部分是GPU端需要处理的内容。
这些流程看起来很复杂,其实我们可以类比成现实中的拍照。电视剧《隐秘的生活》中,张东升老师带着他的岳父岳母去爬山,到山顶的时候说要给他们拍照留念,拍照分为三部分,首先是找到要拍照的人摆好POS,然后摄像机找好角度,对准焦距,最后按下快门,其中按快门这个操作对应的是Unity摄像机调用Render()
方法的过程 。在拍照的时候,如果物体超出了摄像机视野范围或者被其他物体给挡住了,在图像中都不会显示,这也就是剔除;背后的山在人的后面,这就对应不同的渲染顺序;岳父岳母的位置、穿什么衣服、太阳光照等信息都会被摄像机捕获,这些就是要打包的数据。最后调用SetPassCall、DrawCall,将渲染图元传递给GPU渲染管线进行处理。比喻并非十分恰当,主要还是希望能够加深印象。
剔除、设置渲染顺序等操作我们已经记住了,但这些具体是什么意思呢?
CPU渲染逻辑
该阶段通常是由CPU负责实现,作为开发人员,可以对这个阶段进行控制。在这个阶段主要包含以下几个步骤:
-
进行剔除(Culling)工作:剔除主要分为三类,分别是
视锥体剔除(Frustum Culling):如果场景中的物体和在视锥体外部,那么说明物体不可见,不需要对其进行渲染.。在Unity中可以通过设置
Camera
的Field of view
,Clipping Planes
等属性修改视锥体属性。层级剔除(Layer Culling Mask):通过给物体设置不同的层级,让摄像机不渲染某一层,在Unity中可以通过
Culling Mask
属性设置层级可见性遮挡剔除(Occlusion Culling):当一个物体被其他物体遮挡而不在摄像机的可视范围内时不对其进行渲染
-
设置渲染顺序:渲染顺序主要由渲染队列(Render Queue)的值决定的,不透明队列(
RenderQueue < 2500
),根据摄像机距离从前往后排序,这样先渲染离摄像机近的物体,远处的物体被遮挡剔除;半透明队列(RenderQueue > 2500
),根据摄像机距离从后往前排序,这是为了保证渲染正确性,例如半透明黄色和蓝色物体,不同的渲染顺序会出现不一样的颜色 。 -
打包数据: 将数据提交打包准备发送给GPU,这些数据主要包含三部分,分别是
模型信息:顶点坐标、法线、UV、切线、顶点颜色、索引列表...
变换矩阵:世界变换矩阵、VP矩阵(根据摄像机位置和fov等参数构建)
灯光、材质参数:shader、材质参数、灯光信息
-
调用SetPass Call, Draw Call:
SetPass Call: Shader脚本中一个Pass语义块就是一个完整的渲染流程,一个着色器可以包含多个Pass语义块,每当GPU运行一个Pass之前,就会产生一个SetPassCall,所以可以理解为调用一个完整渲染流程。
DrawCall:CPU每次调用图像编程接口命令GPU渲染的操作称为一次Draw Call。Draw Call就是一次渲染命令的调用,它指向一个需要被渲染的图元(primitive)列表,不包含任何材质信息。GPU收到指令就会根据渲染状态(例如材质、纹理、着色器等)和所有输入的顶点数据来进行计算,最终输出成屏幕上显示的那些漂亮的像素。
💁♂
CPU渲染阶段最重要的输出是渲染所需的几何信息,即渲染图元(rendering primitives),通俗来讲,渲染图元可以是点、线、三角面等,这些信息会传递给GPU渲染管线处理。
GPU渲染管线
GPU渲染管线由许多步骤组成,比如顶点处理、图元装配及光栅化、片元处理、输出合并等
顶点处理
-
顶点着色器(Vertex Shader)
顶点着色器的处理单位是顶点,也就是说,输入进来的每个顶点都会调用一次顶点着色器。它主要执行坐标转换和逐顶点光照的任务,坐标转换是将顶点坐标从模型空间转换到齐次裁剪空间中,它是通过MVP(Model、View、Projection)转换得到的,在shader代码中,可以使用
UnityObjectToClipPos()
函数来实现。逐顶点光照得到的光照结果比较不自然,所以一般是在片元着色器中进行光照计算。 -
曲面细分着色器 (Tessellation Shader)
这是一个可选的着色器,主要是对三角面进行细分,以此来增加物体表面的三角面的数量。借助它可以实现细节层次(
LOD,Level-of-
Detail
)的机制,使得离摄像机越近的物体具有更加丰富的细节,而远离摄像机的物体具有较少的细节,如下图所示。 -
几何着色器(Geometry Shader)
它也是一个可选的着色器,它以完整的图元(比如,点)作为输入数据,输出可以是一个或多个其他的图元(比如,三角面),或者不输出任何的图元。几何着色器的拿手好戏就是将输入的点或线扩展成多边形。下图展示了几何着色器如何将点扩展成多边形。
图元装配(Primitive Assembly)
经过顶点处理阶段,我们已经知道了顶点在裁剪空间的位置,接下来可以在裁剪空间中进行裁剪、背面剔除、屏幕映射等操作。
-
裁剪(Clipping)
一些图元,它可能一部分位于摄像机视野内,另一部分在摄像机视野外部,外面这部分不需要进行渲染,可以将它裁剪掉. 例如,线段的两个顶点一个位于视椎体内而另一个位于视椎体外,那么位于外部的顶点将被裁剪掉,而且在视椎体与线段的交界处产生新的顶点来代替视野外部的顶点(在裁剪空间中进行)。
-
标准化设备坐标(Normalized Device Coordinates,NDC)
在裁剪空间的基础上,进行透视除法
(perspective division)
后得到的坐标叫做NDC坐标,将坐标从裁剪空间的(-w,-w,w)变换为(-1,-1,1),即除 w,获得NDC坐标是为了实现屏幕坐标的转换与硬件无关。 -
背面剔除(Back-Face Culling)
背对摄像机的三角面剔除,上面我们讲到过模型数据中含有索引列表,列表中的三个点组成一个三角片,如果这三个点是顺时针排列的,认为是背面,否则认为是正面。
上图中的图元 t1,它的三个顶点v1,v2,v3很明显是逆时针(count-clockwise,CCW)排列的,因此认为它是正面,t2则是顺时针(clockwise,CW)排列,为背面。我们可以使用行列式
(determinant)
来确定投影后的2D三角形到底是CW还是CCW顺序。行列式的第一行由顶点v1和v2坐标确定,而第二行由顶点v1和v3坐标确定。如果行列式的值为负数,那么该三角面是背面朝向;如果为正数,则是正面朝向。 -
屏幕映射(ScreenMapping)
屏幕映射(ScreenMapping)的任务是把每个图元的x和y坐标转换到屏幕坐标系(Screen Coordinates)下。屏幕坐标系是一个二维坐标系,它和我们用于显示画面的分辨率有很大关系。
假设,我们需要把场景渲染到一个窗口上,窗口的范围是从最小的窗口坐标(x1,y1)到最大的窗口坐标(x2,y2),其中x1< x2且y1< y2。由于我们输入的坐标范围在-1到1,因此可以想象到,这个过程实际是一个缩放的过程,如图2.10所示。你可能会问,那么输入的z坐标会怎么样呢?屏幕映射不会对输入的z坐标做任何处理。实际上,屏幕坐标系和z坐标一起构成了一个坐标系,叫做窗口坐标系(WindowCoordinates)。这些值会一起被传递到光栅化阶段。
光栅化(Rasterization)
该阶段主要是将变换到屏幕空间的图元离散化为片元的过程。
-
三角形设置(Triangle Setup)
我们从上一个阶段获得图元的顶点信息,也就是三角面每条边的两个端点,但如果要得到整个三角网格对像素的覆盖情况,我们就必须计算每条边上的像素坐标。为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式。这样一个计算三角网格表示数据的过程就叫做三角形设置。
-
三角形遍历(Triangle Traversal)
三角形遍历阶段会根据上一个阶段的计算结果来判断一个三角网格覆盖了哪些像素,并使用三角网格3个顶点的顶点信息对整个覆盖区域的像素进行插值。
这一步的输出就是得到一个片元序列。需要注意的是,一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括了(但不限于)它的屏幕坐标、深度值Z、顶点颜色,以及其他从几何阶段输出的顶点信息,例如法线、纹理坐标等。
片元着色器(Fragment Shader)
它最主要的任务就是着色,光栅化阶段实际上并不会影响屏幕上每个像素的颜色值,而是会产生一系列的数据信息,用来表述一个三角网格是怎样覆盖每个像素的。而每个片元就负责存储这样一系列数据。着色有两种最常见的技术,分别是纹理贴图和光照技术。
纹理贴图(Textures)
纹理贴图也称为纹理映射,是将图像信息映射到三角形网格上的技术,以此来增加物体表面的细节,令物体更具有真实感。纹理技术有很多,常见的是凹凸贴图(bump mapping)、法线贴图(normal mapping)、高度纹理(height mapping)、阴影贴图(shadowmap)等。例如图中左边地球仪是一个球形,但我们也可以将地图绘制在右图一张二维的平面上,那么它们之间就存在着纹理映射的关系,我们想要获取地球仪上任意一点的信息,都可以从贴图中寻找。
纹理贴图是片段着色器的主要操作,通过贴图技术可以实现很多高级的效果。我们将贴图上的每个像素称为纹素(texel,纹理像素texture pixel的意思,用于和像素进行区分),纹理映射其实就是进行纹素和像素对应的过程。
在上图右边是一副32*32的贴图,它由一格一格的像素组成,每个像素都有一个地址,这个地址就叫做纹素地址。纹素地址可以使用一个二维数组来存储,这个二维数组就称为纹素数组。
我们一般使用一个二维的坐标(u,v)来表示纹理坐标,其中u是横坐标,v是纵坐标,因此纹理坐标一般也被称为UV坐标。UV坐标一般被归一化到[0,1]之间,但是如果UV超出这个范围,我们就需要指定纹理坐标的寻址方式,也叫作平铺方式。常见的寻址方式有:重复寻址(repeat)、边缘钳制寻址(clamp,拉伸纹理边缘)和镜像寻址(mirror)。在Unity中,可以通过设置贴图的Wrap Mode来修改,其中per-axis可以单独控制 Unity 如何在 U 轴和 V 轴上包裹纹理。下图展示了Unity3d中纹理的重复寻址和钳制寻址方式。
纹理采样是指给定一个坐标,去寻找它在纹素数组中的值。由于纹素和像素通常不是一 一对应的(例如将10x10的图片映射到50x50的屏幕中),所以我们需要决定像素所对应的纹素信息时,需要用到纹理的滤波方式。
Unity中的滤波主要有三种,可以通过Filter Mode进行设置,
-
Pointer,点过滤,纹理在靠近时变为块状,会产生较为明显的失真。
-
Bilinear,双线性过滤,pixel对应的纹理坐标为中心,采该纹理坐标周围4个texel的像素,再取平均,以平均值作为采样值。
-
Trilinear,三线性过滤,以双线性过滤为基础。会对pixel大小与texel大小最接近的两层Mipmap level分别进行双线性过滤,然后再对两层得到的结果进生线性插值。
如果我们要把一个很大的贴图映射到很小的一块区域里面,可以想象效果肯定会很差,这时可以使用Mipmap纹理链,也就是根据原图生成很多个大小不同的图片,然后根据映射区域的大小,选择使用哪一张图片。
在Unity中可以通过勾选Generate Mip Maps属性来启用Mipmap,由于生成多张图片,这会占用一定的内存。勾选后可以通过滑动条来预览不同等级的贴图。
光照计算(Lighting)
光照由直接光和间接光组成,计算光照最常用的就是phong模型了,它是一个经验模型,参数信息都是经验得到的,并没有实际的物理意义,所以利用Phong模型会出现违背物理规则的时候。Phong模型将物体光照分为三个部分进行计算,分别是:漫反射、镜面反射和环境光。
-
漫反射Difuse
漫反射是投射在粗糙表面上的光向各个方向反射的现象。当一束平行的入射光线射到粗糙的表面时,表面会把光线向着四面八方反射,所以入射线虽然互相平行,由于各点的法线方向不一致,造成反射光线向不同的方向无规则地反射。在漫反射中,视角的位置是不重要的,因为反射是完全随机的,因此可以认为在任何反射方向上的分布都是一样的。但是,入射光线的角度很重要。
-
镜面发射Specular
镜面反射是指若反射面比较光滑,当平行入射的光线射到这个反射面时,仍会平行地向一个方向反射出来。
-
环境光Ambient
环境光分量是用来模拟全局光照效果的,其实就是在物体光照信息基础上叠加上一个较小的光照常量,用来表示场景中其他物体反射的间接光照。
光照模型计划在下一节进行详细讲解,并通过代码实现。
输出合并(Output-Merger)
终于到了渲染流水线的最后一步,在DirectX
中,该阶段被称为输出合并阶段,而OpenGL
将其称为逐片元操作(Per-Fragment Operations
),从称呼中就可以看出,这个阶段主要是对每一个片元进行一些输出合并操作,包括Alpha测试、模板测试、深度测试和混合,它有一下几个主要任务:
-
决定每个片元的可见性。这涉及了很多测试工作,例如深度测试、模板测试等。为什么要进行测试呢?因为屏幕上的一个像素可能存在多个片元进行竞争,通过测试等规则,可以决定哪个片元最终能够渲染到屏幕上
-
如果一个片元通过了所有的测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区中的颜色进行合并,或者说是混合。
Alpha测试
通过片元数据,可以获取该片元的alpha值,如果alpha值小于某个数的话,则直接将该片元丢弃,不进行渲染,这是非常“粗暴”的(即只渲染透明度在某一范围内的片元),可以用来做一些树叶镂空的效果。
模板测试(Stencil Test)
模板测试默认是不开启的,我们可以通过glEnable(GL_STENCIL_TEST)
指令将其打开,这是一个开发者可以高度配置的阶段。如果开启了模板测试,GPU会首先读取模板缓冲区中该片元位置的模板值,然后将该值和读取到的参考值进行比较,这个比较函数可以是由开发者指定的,例如小于时舍弃该片元,或者大于等于时舍弃该片元。如果这个片元没有通过这个测试,该片元就会被舍弃。
深度测试(Depth Test)
根据日常经验,近处的物体会遮挡远处的物体,这种效果我们可以通过深度测试来模拟实现。它通过将深度缓存中的值和当前片元的深度进行比较,计算是否需要更新深度缓存和颜色缓存,如果不需要则将该片元丢弃,这于模板测试比较类似。我们在渲染半透明物体时, 需要开启深度测试而关闭深度写入功能。
混合(Blend)
一个片元经过层层测试,总算来到了混合功能面前,对于不透明物体,开发者可以关闭混合(Blend)操作。这样片元着色器计算得到的颜色值就会直接覆盖掉颜色缓冲区中的像素值。但对于半透明物体,我们就需要使用混合操作来让这个物体看起来是透明的。下面是一个简化版的混合操作流程图。
这个阶段也是高度可配置的,开发者可以选择是否开启混合功能。如果没有开启混合功能,就会直接使用片元的颜色覆盖掉颜色缓冲区中的颜色,因此是无法得到透明效果的。
提前深度测试(Early-Z
)
由于深度/模板测试是在片段着色器之后进行的,所以导致着色器计算资源的浪费,因为这些被遮挡的片段对我们终的画面是没有任何贡献的,而我们还花费了大量的资源对它们进行了复杂的光照等一系列计算。Early-Z Culling
正是在这种情况下出现的,它发生在顶点着色器和片元着色器之间。不过我们需要注意的是Early-Z Culling
本不是管线标准,只是硬件厂商用来加速渲染的一种优化手段,所以在不同的硬件上会有不同的实现,而且Early-Z Culling
并不保证一定有效,它需要硬件的支持。
帧缓存(Frame Buffer)
可以简单理解为一个临时画布,GPU渲染完成的信息会存放在帧缓存区,等待使用,上述各种测试也是在帧缓冲区进行的
帧缓冲区主要包含颜色缓冲区(Color Buffer)和深度缓冲区(Depth Buffer),假设下面蓝色线框是一个帧缓冲区,需要对蓝色和红色三角形的片元进行渲染,红色片元的深度值是0.8,蓝色片元的深度值是0.5。渲染红色片元时,由于它的深度值小于帧缓冲区的初始深度值,所以它的z-buffer值和color buffer值会覆盖帧缓冲区对于位置的值。渲染蓝色片元时,由于它的深度值比帧缓冲区的深度值都要小,所以可以覆盖缓冲区中的内容。
当模型的图元经过了上面层层计算和测试后,就会显示到我们的屏幕上。我们的屏幕显示的就是颜色缓冲区中的颜色值。但是,为了避免我们看到那些正在进行光栅化的图元,GPU会使用双重缓冲(Double Buffering)
的策略。这意味着,对场景的渲染是在幕后发生的,即在后置缓冲(Back Buffer)
中。一旦场景已经被渲染到了后置缓冲中,GPU就会交换后置缓冲区和前置缓冲(Front Buffer)
中的内容,而前置缓冲区是之前显示在屏幕上的图像。由此,保证了我们看到的图像总是连续的。
总结
下一节开始实战,计划使用Unity Shader
实现经典的phong
光照模型。
引用
[1] UnityShader入门精要
[2]
[3]