光栅化渲染管线详解

芙莉德修女

光栅化渲染管线是学习图形学的基础,学习渲染管线流程时,如果对其中的各个关键步骤理解不够深入,可能会看得一头雾水。这篇文章并不会从一开始就列出渲染管线的各个步骤,而是先聚焦渲染流程中的几个重要话题,把这几个话题分析清楚之后,再将它们串联起来,分析相互之间的关联,数据在各个阶段的形式和流动,得到光栅化渲染管线的整个流程。如果你想直接看渲染流程,可以跳到文章第9节。

1. 画面和帧率

我们在玩游戏时,看到的是一个变化的,在“动”的连续画面,实际上我们都知道,所谓“动”的连续画面,是一系列静态画面,以一个较高的速度在切换,切换的速度超过了我们视觉感知的范围,所以我们看起来画面是连续的。正常情况下,人眼感知变化的极限值是 1/24 秒,也就是说,在1秒内平滑的切换 24 个以上的画面,人眼将感知到画面是连续的。
通常我们所说的 FPS(Frames Per Second)就是每秒的画面帧数,也就是说当游戏画面超过 24 FPS 时,人眼就不会感知到卡顿了。但是需要注意的是,并不是说超过 24 帧以后的画面人眼感知不到差别,24 FPS 和 60 FPS 除了输入响应速度上的差别之外,人眼感知的画面流畅度也是不一样的。
假设我们在玩的游戏能保持平稳 FPS 为 60,意思就是这个游戏每秒钟给我们展示了 60 个静态画面,而其中的每一个静态画面,都经历了一个渲染管线的全流程。
我们下面就来分析,渲染每一个静态画面,经历了哪些步骤。

2. 渲染管线的输入和输出

无论在何种显示设备上玩游戏,画面最终都会反映到屏幕上,画面的本质是屏幕像素的颜色集合,静态画面其实就是由屏幕所有像素及其颜色构成,也就是说,渲染管线的最终输出是屏幕上每个像素的颜色。
而渲染管线的输入数据包括但不限于以下内容

  • 将要被观察到的物体
    包括模型、位置、形状、材质等
  • 物体所处的环境
    包括灯光、天气、雾等等
  • 观察者属性
    摄像机的位置、角度等等
    那么,渲染管线就是准备物体的数据、环境数据和摄像机数据,然后根据这些数据,得到显示屏幕上每个像素该显示什么颜色的过程。通常游戏渲染管线都是基于光栅化的渲染管线。光线追踪是完全不同于光栅化的渲染方式,但在这里不会进行说明。

3. 光栅化

光栅化这个词经常见到,那么什么是光栅化呢?

光栅化的本质是连续几何数据的离散化

下面这个图表示了一个简单的连续数据离散化的场景,使用一系列离散的像素来表示一个连续的圆形。这里我们也可以考虑一个最简单的情况:给定一个线段的起始两个点坐标,如何针对该线段做光栅化处理?可以参考这篇文章:直线光栅化。

连续数据离散化

对物体的光栅化说白了就是确定这个物体覆盖到了哪些像素的过程,我们知道,通常使用三角形列表来描述我们需要渲染的物体模型,那么可以简化考虑,针对一个三角形,如何进行光栅化呢?如下图,我们假设一个个的格子就是像素,红点是像素中心,我们给定了三角形三个顶点的屏幕坐标,来看看光栅化是如何一步一步进行的。

屏幕上的三角形

  • 第一步,首先确定 BoundingBox,取三个顶点中最小的 x 值和 y 值,向下取整;取最大的 x 和 y 值,向上取整;四个值组成一个矩形,这个矩形是覆盖完整三角形的最少像素组成的矩形。
    包围盒确定
  • 第二步,逐行扫描 BoundingBox 的像素,依次判断像素中心是否在三角形内部,将中心在三角形内部的像素保留。关于如何判断点在三角形内部,可以参考点在三角形内部的判定。
    扫描第一行
  • BoundingBox 扫描完毕,得到像素列表,针对这个三角形的光栅化流程结束,整体流程大概如图所示:
    逐行扫描

    光栅化是怎么实现的呢?
    在渲染管线中,光栅化阶段是在 GPU 上执行的一个固定阶段,由 GPU 中专门的硬件模块Rezaster Engine光栅化引擎处理。

4. 坐标变换

在上面光栅化的解释部分,我们是根据三角形三个顶点的屏幕坐标来确定 BoundingBox 的,坐标值是怎么来的呢?我们提供的是模型数据。对于模型上的某个三角形来说,它的顶点在屏幕上的坐标由以下因素确定:

  • 顶点本身相对于模型的位置
  • 模型的位置、旋转角度和缩放系数
  • 摄像机的属性,包括位置、方向、角度
  • 屏幕的宽高
    我们下面就来说明,如何根据以上数据,来一步一步得到三角形顶点对应的屏幕坐标,这涉及到坐标变换,在坐标变换推导中有详细的关于从三角形局部坐标得到屏幕坐标的计算过程推导,而这里我们知道具体的步骤和结果就可以了。

4.1 模型变换

4.1.1 局部坐标系

我们知道模型文件中包含顶点数据,Unity 加载模型文件后,会为模型添加 MeshFilter 组件,该组件中封装了对模型网格数据的引用:

namespace UnityEngine
{
    //
    // 摘要:
    //     A class to access the Mesh of the.
    [NativeHeader("Runtime/Graphics/Mesh/MeshFilter.h")]
    [RequireComponent(typeof(Transform))]
    public sealed class MeshFilter : Component
    {
        public MeshFilter();

        //
        // 摘要:
        //     Returns the shared mesh of the mesh filter.
        public Mesh sharedMesh { get; set; }
        //
        // 摘要:
        //     Returns the instantiated Mesh assigned to the mesh filter.
        public Mesh mesh { get; set; }
    }
}

我们可以通过访问 MeshFilter 中的 mesh 数据来查看模型中存放的顶点数据是什么样的,为了简便,我们以包含顶点数量最少的 Unity 内置 Quad 为例来看,使用如下的工具方法将模型中的顶点数据打印出来

public class MeshUtils : MonoBehaviour
{
    public static void displayVertices(GameObject gameObject)
    {        
        MeshFilter filter = gameObject.GetComponent();
        if (filter != null)
        {
            Mesh mesh = filter.mesh;
            Debug.Log("顶点数:" + mesh.vertices.Length);
            for (int i = 0; i < mesh.vertices.Length; i ++)
            {
                Debug.Log(i + "," + mesh.vertices[i]);                
            }
        }        
    }
}

在场景中创建一个 Quad,然后附加脚本来调用 MeshUtils.displayVertices 方法,将 Quad 的顶点数据打印出来,结果如下

Quad的顶点数据

而且,当我们调整 Quad 的位置、旋转和缩放时,打印的结果都不变,于是我们知道,Mesh 中存放的顶点位置是顶点相对于 Quad 的坐标,也就是局部坐标,局部坐标以模型中心为原点,我们常说 Mesh 中存放的顶点数据在局部坐标系中Quad 中的4个顶点和坐标原点示意图
顶点和原点

4.1.2 世界坐标系

Unity 场景中的物体都处在世界坐标系中,考虑上面的那个Quad模型,我们把它放到场景的根节点下,它会有一个世界坐标,这个坐标反应在 Transform 组件的 Position 属性中,当然它的四个顶点,也都对应一个世界坐标,根据顶点的局部坐标,计算它世界坐标的过程,就叫做模型变换,所谓变换,其实就是将坐标值右乘一个矩阵,得到一个新的坐标值,而这个矩阵通常被叫做模型矩阵。这里需要注意的是,模型变换并没有改变顶点的绝对位置,只是把它的坐标系从模型坐标系改到了世界坐标系。

  • 模型变换与哪些因素有关
    当我们修改QuadPosition值时,Quad 的位置会发生变化,当然它的四个顶点在场景中的位置(世界坐标)也发生了变化,而且修改 Quad 的旋转Rotation和缩放Scale时,四个顶点的世界坐标也发生了变化,所以模型变换与模型的位置、旋转和缩放相关。
  • 同一个模型下的不同顶点,对应同一个模型矩阵
    通常的模型只有两层父子关系,顶点作为模型的直接根节点,所有顶点受模型的位置、旋转、缩放影响的规律是一样的,也就是说不同顶点使用同样的模型矩阵来进行变换。
4.1.3 模型变换矩阵

那么我们怎么得到这个矩阵呢?关于模型变换矩阵的推导请参考坐标变换推导,这里直接给出结论:

  • 根据父节点的 Position 为 得到平移矩阵
  • 根据父节点的 Rotation 为沿xyz三坐标轴旋转角度分别为,得到旋转矩阵
    3D旋转矩阵\ R_x(\theta)=\begin{bmatrix} 1&0&0&0\\0&\cos\theta&-\sin\theta&0\\0&\sin\theta&\cos\theta&0\\0&0&0&1 \end{bmatrix} R_y(\theta)=\begin{bmatrix} \cos\theta&0&\sin\theta&0\\0&1&0&0\\-\sin\theta&0&\cos\theta&0\\0&0&0&1 \end{bmatrix} R_z(\theta)=\begin{bmatrix} \cos\theta&-\sin\theta&0&0\\\sin\theta&\cos\theta&0&0\\0&0&1&0\\0&0&0&1 \end{bmatrix}
  • 根据父节点的 Scale 为得到缩放矩阵
  • 根据缩放、旋转、平移的顺序依次进行变换,反应在矩阵乘法上就是依次左乘,就得到模型变换矩阵了
    这里变换的顺序是不能调换的,必须先缩放、再旋转、最后平移。这也很好理解,一个顶点先沿某轴旋转再平移和先平移再沿某轴旋转,得到的结果是不一样的。因此变换的顺序是重要的。可以得到最终的模型变换矩阵为

4.2 视图变换

4.2.1 视图变换与摄像机有关

视图变换的关键数据与摄像机有关,视图变换的过程可以这么理解:将摄像机移动到固定的位置(世界坐标原点),将摄像机的向上方向与 y 轴正方向重合,向前方向和 z 轴负方向重合,移动的过程中确保所有顶点做同样的变化,以确保摄像机看到的内容不发生改变。

视图变换

以摄像机坐标为原点、向上方向为 y 轴、向前方向为 -z 轴的坐标系也叫观察坐标系,视图变换就是将顶点的世界坐标系转换到观察坐标系的一个变换,很明显,视图变换矩阵与摄像机的位置和旋转角度相关,且不涉及缩放。视图变换可以分解为两步:

  • 平移摄像机到坐标原点
    假设摄像机的世界坐标为 ,可以很容易得到将它平移到世界坐标原点的平移矩阵 $M_{camera_translate}

  • 旋转摄像机
    假设摄像机沿xyz三坐标轴旋转角度分别为,那么旋转矩阵为

4.2.2 为什么要视图变换?

在世界坐标系中,通常摄像机坐标并不一定在原点,观察方向也不一定是 z 轴方向,对于后续的投影变换和其它一些操作来说,如果不满足这两个条件,计算会比较麻烦低效,我们通过视图变换满足了这两个条件,提高了后续投影变换时远近平面的确定等效率,减少了大量的计算量。
至于为什么摄像机的观察方向与 -z 轴重合,而不是与 z 轴重合,这是重要的,因为这样可以确保距离摄像机更近的顶点 z 值更大,而距离摄像机更远的顶点 z 值更小,我们可以直接根据 z 值来判断顶点距离摄像机的远近,而这个信息在渲染管线后续的深度测试步骤中非常重要。

4.3 投影变换

投影变换的关键数据也由摄像机定义,投影变换可以这么理解:将摄像机看到的空间(视椎体)转换成一个坐标范围都在 [-1, 1] 的立方体中,有透视投影正交投影两种投影方式。转换后的坐标系也可以称之为裁剪坐标系

正交投影和透视投影

  • 正交投影
    没有近大远小的特性,摄像机看到的部分是立方体,由六个平面定义。
  • 透视投影
    近大远小,摄像机看到的部分是椎体,由远近平面、摄像机 fov 定义。
4.3.1 为什么要做投影变换

投影变换的必要性在于:

  • 方便做基于视椎体的裁剪
    将视椎体变换到 [-1,1] 的3维度立体空间后,我们可以很方便的确定哪些顶点在视椎体外:若顶点的坐标分量中任意一个分量不在 [-1, 1] 取值范围,说明该顶点在视椎体外部,这样很容易做图元的裁剪,将摄像机看不到的部分剔除掉,不要参与后面的渲染计算
  • 方便计算归一化的设备坐标
    屏幕映射阶段确定每一个顶点在屏幕上的坐标,如果顶点的坐标范围在 [-1, 1],计算时会很方便。在本文后面屏幕映射部分会有更详细介绍。
4.3.2 正交投影变换

正交投影的可视空间由六个面来定义,这里就可以看出来我们之前做视图变换,将摄像机位置放到原点,摄像机观察方向和向上方向跟坐标轴重合的意义了,那就是定义这六个平面很方便,远近平面为 z=fz=n,左右平面是 x=lx=r,上下平面是 y=ty=b 。所以正交投影变换就是将这个立方体变换到 立方体,很明显,这里涉及到平移和缩放,不涉及旋转。

正交投影变换示意

  • 平移
    将立方体的中心 ((l + r)/2, (t + b)/2, (f + n)/2) 平移到 (0,0,0),矩阵为
  • 将 定义的立方体缩放到 立方体,矩阵为

    我们先平移再做缩放,得到的正交投影变换矩阵是
    M_{ortho-proj} = M_{box-s}\times M_{box-t}=\begin{bmatrix} 2/(r-l)&0&0&0\\0&2/(t-b)&0&0\\0&0&2/(n-f)&0\\0&0&0&1 \end{bmatrix} \times \begin{bmatrix} 1&0&0&-(r+l)/2\\0&1&0&-(t+b)/2\\0&0&1&-(n+f)/2\\0&0&0&1 \end{bmatrix}
4.3.3 透视投影变换

透视投影相对于正交投影来说要复杂一些,与透视投影相关的参数包括摄像机的 fov (field of view) 以及 宽高比(aspect ratio)

透视投影相关参数

关于透视投影变换矩阵的推导比较复杂,可以参考文章坐标变换推导,一个取巧的方法是,先将透视摄像机的视椎体变换为正交投影的立方体,相当于保持视椎体的近平面保持不变,远平面z值保持不变,将远平面进行一个挤压,得到立方体,然后应用正交投影的变换矩阵就可以得到了。
挤压 frustum

最后得到的变换矩阵为:

4.4 视口变换(屏幕映射)

通过投影变换,我们已经将顶点坐标变换到裁剪坐标系,也就是在范围内,下一步就是确定每一个顶点在屏幕上的坐标了,也就是将顶点从裁剪坐标系变换到屏幕坐标系,这个过程叫做视口变换,也称为屏幕映射
在屏幕映射之前,需要做一次透视除法,裁剪坐标的每个分量除以 w分量,以确保 w分量 为1,得到归一化的设备坐标NDC,由于正交投影得到的裁剪坐标本身 w 分量已经是1了,所以透视除法主要针对透视投影变换。
视口变换是将3维坐标转换为2维坐标的步骤,视口变换矩阵与屏幕的宽高有关,假设屏幕宽高为 widthheight, 这里不考虑 z 坐标,只考虑 x 和 y 坐标,将 x 坐标从 [-1, 1] 映射到 [0, width], y 坐标从 [-1, 1] 映射到 [0, height],很容易写出视口变换矩阵

至此,我们已经完成了将顶点坐标转换为屏幕坐标的步骤了,已经具备了光栅化所需要的数据。

5. 着色器

着色器执行是渲染管线中的可编程部分,说白了就是一段在 GPU 上执行的程序,我们可以通过编写着色器代码来干预渲染管线的执行,渲染管线中最重要的着色器包括顶点着色器和片元着色器,关于着色器更多信息可以参考[OpenGL]着色器这篇文章。

5.1 顶点着色器

渲染管线执行时,会对每一个顶点调用一次顶点着色器,输入数据是图形 API 提供的顶点数据,输出数据会被插值处理后传递给片元着色器作为输入参数。在大多数的引擎实现中,顶点着色器会进行顶点的 MVP 变换,也就是模型-视图-投影一系列变换,得到裁剪坐标输出,顶点着色器的下一个固定管线阶段拿到裁剪坐标后进行透视除法和屏幕映射,进行后续操作。可以看看 Unity 中最简单的顶点着色器代码:

            float4 vert (float4 v : POSITION) : SV_POSITION         
            {
                return UnityObjectToClipPos(v);             
            }

作用就是将顶点数据中的局部坐标转换为裁剪坐标,它的内部实现应该是

mul(UNITY_MATRIX_MVP,  appdata.vertex);

就是用 MVP 矩阵去乘顶点坐标,这里的模型矩阵 M 视图矩阵 V 以及投影矩阵 P 都是在 CPU 端计算好,在 DrawCall 提交模型渲染数据的时候传递给着色器的。

你完全可以在 CPU 端先将坐标转换到裁剪坐标,直接提交给 GPU,顶点着色器中不需要再做处理。之所以提交局部坐标和变换矩阵,将变换放到 GPU,完全是因为 GPU 的并行处理架构更适合处理这样大量的矩阵运算,性能更高。

顶点着色器中可以做很多的操作,比如计算顶点颜色、处理顶点法线等等。
在顶点着色器之后还有两个可选的着色器阶段:

  • 曲面细分着色器Tessellation Shader
    用来细分图元,根据现有的图元列表,产生精细的更多图元,或减少图元,得到更粗略的效果

  • 几何着色器 Gemeotry Shader
    执行逐图元着色操作、或者产生更多的图元

5.2 片元着色器

顶点着色器将顶点坐标转换处理后,渲染管线的相关硬件模块会进行透视除法和屏幕映射,得到每个顶点的屏幕坐标,然后 GPU 的光栅化引擎依次对三角形进行光栅化,以确定覆盖到的像素,然后根据像素中心距离三角形顶点的距离,对顶点着色器输出的数据(位置、颜色、法线、深度等)进行插值,确保每一个片元(像素)对应一份数据,以这份数据作为输入调用片元着色器,片元着色器必须返回一个颜色值,得到该片元的颜色。
看一下 Unity 中最简单的片元着色器代码:

            fixed4 frag () : SV_Target
            {   
                return fixed4(1.0,0,0,1.0);             
            }

通常我们可以修改片元着色器代码来实现我们想要的渲染效果。

6. 裁剪

当顶点经过顶点着色器处理后,就进入了裁剪阶段。裁剪的目的是去掉不在摄像机视野内的顶点,或者去掉三角形的背面等。一个三角形和摄像机视野的关系有这么几种,对应的处理方式也不一样。

  • 三角形顶点都在摄像机视野内
    不做裁剪,三个顶点都保留,继续传递到流水线下一阶段。
  • 三角形顶点都在摄像机视野外
    三个顶点全部丢弃,不会再进入流水线的下一阶段。
  • 部分顶点在摄像机视野内
    进行裁剪,将视野外的顶点抛弃,三角形的边与视野边缘相交的点作为新的顶点生成。
    裁剪示意图

    裁剪阶段不可编程,但是可配置,我们可以调用图形 API 进行一些裁剪方式的配置。裁剪由 GPU 的固定模块 Viewport Transform 来完成。

7. 逐片元操作

逐片元操作发生在片元着色器之后,这个阶段会针对片元做一系列的测试和操作。

  • 决定片元的可见性
    需要执行一些测试操作如深度测试、模板测试等来确定片元是否可见,是否要丢弃。
  • 混合片元的颜色
    若片元通过了所有测试,如何将片元的颜色和目前已经存储在颜色缓冲区中的颜色进行混合。
    需要开启哪些测试、如何进行颜色混合,都是通过图形 API 进行配置的。

7.1 模板测试

片元着色器处理完成后,首先会执行模板测试,与模板测试相关联的是一个模板缓冲区,Stencil Buffer,和颜色缓冲区一样,模板缓冲区也是一个 width * height 的内存区域,每个位置记录了一个模板掩码值,下图可以大概解释模板测试的工作过程,在渲染物体时,片元对应的模板缓冲区中模板值为0的片元被丢弃了,对应模板值为1的片元继续渲染:

模板测试

与模板缓冲区相关的操作包括

  • 启用模板测试
    图形接口的 API,比如 OpenGL 中的 glEnable(GL_STENCIL_TEST)
  • 渲染模板物体时,启用模板缓冲写入
    在渲染模板物体时,调用图形接口 API,如 glStencilMask(0xFF)
  • 渲染模板物体时,总是通过模板测试,且提供参考值来覆盖模板缓冲区的值
    模板本身不需要进行模板测试,但渲染时需要写入到模板缓冲区,表示“这个片元渲染了我”,调用的图形 API 比如 glStencilFunc(GL_AWALYS, 1, 0xFF) 总是通过模板测试并提供参考值1,glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE), 第三个参数 GL_REPLACE 表示使用之前提供的参考值来写入到模板缓冲区,也就是说模板物体覆盖到的所有片元,对应的模板缓冲中的值都是1。
  • 渲染物体时,指定模板测试比较类型和参考值
    会使用参考值和模板缓冲区中对应的值来进行比较,通过比较的片元就渲染,否则丢弃,比如 glStencilFunc(GL_NOTEQUAL, 1, 0xFF) 表示如果模板缓冲中的值非 1,丢弃该片元。
  • 渲染非模板物体时,通常要禁用模板写入
    调用图形 API 来禁用,如 glStencilMask(0x00)
    模板测试经常被用来实现 UI Mask 功能。

7.2 深度测试

深度测试对应一个深度缓冲,Depth Buffer,深度缓冲的尺寸也是 width * height,记录了每一个片元当前的深度值,在片元着色器处理完成且模板测试通过后,片元会进入深度测试阶段,深度测试的过程大概是:将当前片元的深度值和深度缓冲中对应位置的深度值作比较,比较方法可以通过图形 API 来设置,比较成功的片元会继续渲染,否则丢弃。
深度测试最重要的作用在多个物体覆盖同一个像素时,将那些距离摄像机更远的物体对应片元丢弃,该片元渲染距离摄像机最近的物体。

7.2.1 深度测试的操作

深度测试相关的操作包括:

  • 启用深度测试
    调用图形 API 来进行配置,如 glEnable(GL_DEPTH_TEST)
  • 设置深度测试函数
    例如 glDepthFunc(GL_LESS) 表示若当前片元的深度值小于缓冲区中的深度值,则通过测试,当前片元继续绘制,否则丢弃。
  • 设置是否写入深度
    在某些情况下你会需要对所有片段都执行深度测试并丢弃相应的片段,但不希望更新深度缓冲,所以需要禁用深度写入,例如 glDepthMask(GL_FALSE)
7.2.2 片元深度值
  • 深度值数据怎么来的?
    每个片元对应一个深度值,那么这个深度值是怎么来的?回到我们的坐标变换部分,当我们将三角形顶点变换到裁剪坐标系 中之后,进行视口变换将顶点坐标的 x, y 值映射到屏幕坐标时,它的 z 分量被保留下来,后续在做光栅化时会插值得到每个片元的数据,其中就包含了插值之后的 z 分量,这就是深度值的来源。
  • 深度值的精度
    深度值取值范围通常在 [0.0, 1.0],考虑在观察空间下,z值可能是视椎体近平面(Near)和远平面(Far)之间的任何值。我们需要一种方式来将这些观察空间的z值变换到[0.0, 1.0],其中的一种方式就是做线性变换

    这样的线性变换,深度的变换曲线如下图,可以看到在距离摄像机远近的片元上,深度值的变换是平均的。
    线性深度值变化曲线

    事实上,我们需要一个非线性的深度方程,这个方程能够确保在距离摄像机更近时提供更高的精度,距离摄像机较远时精度较低,我们考虑如下的方程:

    它提供的深度精度曲线是这样的:
    深度值曲线

    使用这种方式来计算深度值,深度值很大一部分是由很小的z值所决定的,这给了近处的物体很大的深度精度。这个变换z值的方程是嵌入在投影矩阵中的,所以当我们想将一个顶点坐标从观察空间至裁剪空间的时候这个非线性方程就被应用了。
7.2.3 深度冲突

当两个三角形某个部分靠的很接近时,可能会出现深度冲突的问题,由于深度缓冲的精度限制,在某个位置,无法确定两个三角形的片元深度值 z 的大小,在做深度测试的时候无法确定丢弃哪一个片元,结果就是这两个形状不断地在切换前后顺序,这会导致一定程度的闪烁。这个现象叫做深度冲突(Z-fighting),因为它看起来像是这两个形状在争夺(Fight)谁该处于顶端。
如何防止深度冲突的产生呢?

  • 尽量不要让多个物体靠的太近,减少三角形的重叠。
  • 近可能将近平面放远一些,
    这样可以使得整个视椎体内有更大的精度,但这通常导致更近的物体会被裁剪掉。
  • 使用更高精度的深度缓冲
    大部分深度缓冲的精度都是24位的,但现在大部分的显卡都支持32位的深度缓冲,这将会极大地提高精度。所以,牺牲掉一些性能,你就能获得更高精度的深度测试,减少深度冲突。
7.2.4 Early-Z

深度测试发生在片元着色器之后,有可能在经历片元着色器中大量的计算后,发现这个片元根本无法通过测试,那么片元着色器中的消耗就白白浪费掉了,因此现在大部分的GPU都提供一个优化方法,叫做提前深度测试(Early Depth Testing),这是一个硬件层面的特性。提前深度测试允许深度测试在片段着色器之前运行。如果我们清楚一个片元永远不会是可见的(它在其他物体之后),我们就能提前丢弃这个片段,不会对该片元执行片元着色器。

片元着色器通常开销都是很大的,所以我们应该尽可能避免运行它们。当使用提前深度测试时,片段着色器中会存在一个限制,你不能在片段着色器中写入片段的深度值。如果一个片段着色器对它的深度值进行了写入,提前的深度测试就失效了,因为未执行片元着色器时它还不知道最终的深度。
GPU 会自动判断片元着色器中的操作是否会影响 Early-Z,如果会影响那么会禁用 Early-Z,造成了性能上的下降。

7.3 透明度混合

若片元通过了上述的模板测试和深度测试,那么将进入到混合阶段。

7.3.1 透明度混合

渲染过程是一个物体接一个物体的绘制到屏幕上,每个像素的颜色会被存储在颜色缓冲区中,当我们执行渲染时,颜色缓冲区中通常已经有了上次渲染之后的颜色,那么我们怎么处理当前片元的颜色和颜色缓冲区中的颜色呢?这就是混合解决的问题

  • 渲染不透明物体
    可以关闭混合,直接用当前片元的颜色值覆盖掉颜色缓冲区的颜色值。可以调用图形 API 关闭混合,例如 glDisable(GL_BLEND)
  • 渲染透明物体
    需要使用混合操作,结合缓冲区的颜色和当前片元颜色,使得物体看起来是透明效果,混合的过程就是根据当前片元的颜色和缓冲区中的颜色按照某个规则进行计算,得到的最终颜色写入到颜色缓冲区,通过图形 API 来指定计算规则,例如glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
7.3.2 绘制顺序

当我们绘制多个物体且包含透明和不透明物体时,绘制的顺序是重要的,否则可能会产生错误的绘制结果,整体来说要遵循这样的绘制顺序:

  • 先绘制所有不透明的物体
  • 对所有透明的物体排序
  • 按照从远到近的顺序绘制所有透明的物体
    如何确定透明物体的远近呢?一种方法是,从观察者视角获取物体的距离。这可以通过计算摄像机位置向量和物体的位置向量之间的距离所获得。
7.3.3 透明度测试

我们也可以在 片元着色器中针对片元的透明度值进行测试,当透明度小于某个值时,直接丢弃该片元,使用 discard 着色器语句来进行丢弃操作。这个过程可以称为透明度测试,但你需要注意它和深度测试以及模板测试的区别,它并不是通过图形 API 来开启的,而是在片元着色器中编码实现的。

8. 背面剔除

同一个三角形,根据三角形的环绕顺序,有正面和背面,如果我们将正面和背面都执行片元着色器,将会造成额外一倍的性能消耗,因为我们只能看到其中一个面,另外一个面完全可以不用执行片元着色器,GPU 通过图形 API 给我们提供了设置剔除的开关,相关操作:

  • 启用剔除选项
    告诉 GPU 要进行面的剔除操作,例如 glEnable(GL_CULL_FACE)
  • 设置需要剔除的面的类型
    告诉 GPU 要剔除正面、背面还是正面背面都剔除,例如 glCullFace(GL_FRONT),参数可用选项包括 GL_BACKGL_FRONT 以及 GL_FRONT_AND_BACK,默认情况下是 GL_BACK
  • 设置顶点环绕方向
    可以告诉 GPU 顶点的环绕方向来确定哪个是正面,哪个是反面,例如 glFrontFace(GL_CCW) 表示逆时针方向,三个顶点沿逆时针方向得到的面是正面。GL_CW 设置为顺时针方向。
    通常面剔除的工作是在顶点着色器之后,光栅化阶段进行的。

9. 渲染管线全流程

上面几个部分分别分析了渲染过程中几个关键的步骤,这一部分将串联起整个渲染的流程,这里将从准备渲染数据开始、一步步分析和跟踪这些数据的形态和流向,看看这些数据是如何得到最终的一副画面的。

9.1 数据准备

9.1.1 场景数据

通常游戏引擎是以场景为资源组织的单位,场景中通常保存的数据通常包括

  • 物体的引用
    场景中通常会有物件比如树木、石头、角色、怪物等等,在场景文件中,会保存这些物体的引用,通常是一个资源 id,而真正的物体是保存在另外的模型文件中的,当然也有相当部分的物体是运行时动态加载之后添加到场景中的
  • 物体的组织结构
    包括物体之间的父子关系、相对坐标、缩放系数、旋转角度等等
  • 摄像机
    包括摄像机的位置、旋转角度、fov(透视摄像机)、size(正交摄像机) 、cullMask 等
  • 其它场景数据
    如场景雾特效、灯光属性等
9.1.2 模型

场景中的物件一般由模型来表示,模型的初始形式是硬盘或安装包上的文件,一般由 3dMaxmaya 之类的建模软件生产,模型文件包括的数据有

  • 网格数据
    网格就是模型的顶点和三角面数据,顶点数据一般包括局部坐标、顶点颜色、uv 等
  • 动画
    通常模型保存的是动画的文件引用,动画信息由单独的文件保存,保存的是骨骼关键帧数据
  • 材质
    模型上保存的也是材质的文件引用,有单独的材质文件,材质文件保存了它所使用的的 Shader 文件引用、图片文件引用和相关的参数设置等
9.1.3 材质和纹理

关于材质和纹理,这里可以有一个不太严谨的表达:材质 = Shader + 纹理

  • Shader
    Shader 也就是着色器,本质上是一段 GPU 执行的代码,作用是描述物体如何被渲染,Unity 中使用 ShaderLab 进行了封装,引擎最后提交编译的是 HLSL 或 CG 语言
  • 纹理
    加载硬盘或安装包上的图片得到纹理,通常由专有的纹理格式,构建到安装包内的图片是纹理格式而不再是 png/jpg 这类的图片格式,关于纹理和图片的信息,可参考文章图片和纹理

9.2 应用程序阶段

引擎都会维护一个游戏主循环,每循环一次就是一个渲染管线的周期,这个主循环是由 CPU 端进行维护,每一个循环需要做的事情包括组织渲染数据、粗粒度的剔除、设置物体的渲染状态等

  • 组织渲染数据
    将读取到内存中的数据,包括摄像机、模型顶点数据、纹理数据等组织起来,提交到显存,创建 VAO、VBO 或 EBO 对象供 GPU 访问
  • 设置渲染状态
    指定需要渲染的模型使用什么着色器、纹理、着色器的 uniform 参数等,然后将渲染命令提交到 CommandBuffer 供 GPU 读取
  • 较粗粒度的剔除
    在应用程序阶段将场景中隐藏的物体或摄像机看不见的物体剔除掉,不提交给 GPU,从源头节省掉相关的渲染消耗

9.3 几何处理阶段

在几何阶段,GPU 读取到显存数据和渲染命令后,对顶点数据进行几何处理,输出顶点的屏幕坐标、深度值等等。一个顶点数据在几何阶段需要进行 MVP 变换,然后经过透视除法和屏幕映射之后,得到对应的屏幕坐标。下图标识了顶点坐标的变换过程


坐标变换过程
  • 提交顶点数据
    CPU 端通过诸如 glGenBuffersglBindBufferglBufferData 类的图形 API,组织顶点数据并提交到显存的 VBO(顶点缓冲对象)中,通过 glVertexAttribPointerglEnableVertexAttribArray等 API 来提交顶点数据的解析规则,并绑定到 VAO(顶点数组对象)中供 GPU 访问。
  • 模型变换
    CPU 根据模型的世界坐标、缩放系数、旋转角度等数据,计算出该模型的模型矩阵,通过诸如 glUniform4f 类的接口将模型矩阵提交给顶点着色器,通常每个模型对应一个模型矩阵,该模型的所有顶点都使用同一个模型矩阵进行模型变换,模型变换得到顶点的世界坐标
  • 视图变换
    CPU 根据 摄像机的坐标和旋转角度,计算出该摄像机对应的视图矩阵,通过诸如 glUniform4f 类的接口将视图矩阵提交给顶点着色器,通常每个摄像机对应一个视图矩阵,该摄像机所渲染的所有物体的顶点都使用同一个视图矩阵进行视图变换,视图变换得到顶点的窗口坐标,也叫观察坐标
  • 投影变换
    CPU 根据摄像机的类型(正交、透视)和对应的参数,正交摄像机的参数包括六个面对应的坐标、透视摄像机包括 fov、aspect ratio 等数据,计算出投影矩阵,同样通过 glUniform4f 一类的 API 进行提交,经过投影变换后,得到顶点的裁剪坐标
  • 顶点着色器
    CPU 将以上的三个变换矩阵提交到顶点着色器之后,顶点着色器将输入的顶点右乘以上矩阵,完成了上述的三个变换,得到了顶点的裁剪坐标。在实际的实现中,你完全可以在 CPU 端计算出裁剪坐标提交给顶点着色器,着色器中不再进行变换,而是直接输出到下一阶段。
  • 透视除法
    GPU 中固定的模块Viewport Transform,将顶点着色器输出的裁剪坐标进行处理,统一除以 w 分量,确保坐标值都在 [-1, 1] 范围内,得到顶点的NDC(标准化设备坐标), 的立方体定义了摄像机的视口立方体,坐标不在该立方体内的顶点将被裁剪。
  • 屏幕映射
    屏幕映射也就是视口变换,CPU 通过 glViewport 接口告诉 GPU 窗口的宽高,GPU 中的 Viewport Transform模块 根据宽高数据,将顶点的 NDC 映射到窗口中,得到顶点的屏幕坐标,这一步将3维左边转换为2维坐标,针对 x 和 y 分量进行了处理,而 z 分量被保留下来作为顶点的深度,在后续步骤中使用。

9.3 光栅化阶段

几何处理阶段得到顶点的屏幕坐标后,GPU 中的固定模块光栅化引擎取得数据,进行光栅化,光栅化的过程得到了三角形所覆盖的所有像素,同时会进行背面剔除和 Early-Z 裁剪。并通过 GPU 的Attribute Setup模块 对数据进行插值,插值后的数据存放在 GPU 的 L1 或 L2 缓存中,供片元着色器读取。
关于光栅化的原理,在前面第3节已经有详细说明,此处不再赘述。

9.4 片元着色器

光栅化得到片元信息后,AttributeSetup 组装片元信息并存放到 GPU 缓存中,片元着色器读取对应的片元数据,开始执行。片元着色器的输入是顶点着色器输出的数据经过插值得到,而片元着色器的输出是该片元的颜色。
片元着色器的逻辑是完全可编程的。通过 glCreateProgram 创建着色器、glLinkProgram 链接着色器,glUseProgram 可以指定使用的片元着色器。

9.5 逐片元操作

片元着色器返回颜色后,成为候选像素,这些片元被输入到 GPU 的渲染输出单元ROP(Render Output Unit),一个ROP内部有很多ROP单元,在ROP单元中处理模板测试、深度测试,透明度混合等,测试和混合必须是原子操作,否则两个不同的三角形在同一个像素点就会有冲突和错误。
关于模板测试、深度测试和透明度混合,我们在之前的第7节有详细说明,此处不再赘述。
逐片元操作完成,通过测试的片元颜色值被写入到 FrameBuffer 中,本次渲染命令全部处理完成后,FrameBuffer 被交换到前台,反应到设备屏幕上,一帧的渲染流程就完成了。

你可能感兴趣的:(光栅化渲染管线详解)