Unity 渲染原理 (一) 渲染流水线:从模型上的点到屏幕上的点

总述

本文大概总结下一些三维空间的坐标数据是如何通过渲染流水线变为屏幕上不同颜色的像素的,阅读之时,需要注意,图元,片元,和像素三者的区别。

下图是一个总体的流程图,具体细节可以看后面的解释。

什么是渲染流水线

渲染流水线的任务

渲染流水线的工作就是,从一个三维场景出发,渲染出一张二维图像。

也就是说,需要从一系列三维空间信息,如顶点数据,纹理等信息出发,把这些信息最终转化为一张人眼可以看到的二维图像。

而这个工作通常是由CPU和GPU共同完成的。

渲染流水线的三个概念阶段

注意,这三个阶段只是概念上的阶段,每个阶段内部还有很多步骤,通常也是一个流水线系统。

img

应用阶段

之所以叫应用阶段,是因为这个阶段是的工作是在我们应用中进行的,通常在CPU中执行,开发者对这个阶段有着绝对的控制权。

在这一阶段,开发者主要有三个任务:

  1. 准备好场景数据,例如摄像机的位置,视锥体,场景中的模型的点信息,光源的位置强度等信息。

  2. 有了以上信息之后,为了提高渲染性能,我们要做一个粗粒度的剔除(culling)工作,以把那些不可见的物体剔除出去,这样就不需要交给几何阶段处理了。

注意剔除不同于后面的裁剪,将完全不在视锥体中的物体信息完全剔除,直接将物体的信息丢弃就好,而后面讲的裁剪,指的是哪些一半在视锥体内,一半不在的,对于这种,我们在丢弃一部分点信息的同时,还要为物体和视锥体相交时的交点信息加上。

  1. 最后,我们要设置好每个模型的渲染状态,这些状态包括但不限于材质(漫反射颜色,高光反射颜色),纹理,Shader等。这一阶段最重要的是输出渲染所需的几何信息,即渲染图元(rendering primitives)。通俗来讲,渲染图元可以是点,线,三角面等。

应用阶段的结果就是所有需要被渲染的3D信息。

几何阶段

几何阶段用于处理所有和我们要绘制的几何相关的事情。例如,决定需要绘制的图元是什么,怎么绘制它们,在哪里绘制它们,这一阶段通常在GPU上进行

几何阶段负责和每个渲染图元打交道,然后进行逐顶点,逐多边形的操作,这个阶段可以进一步划分为更加小的流水线阶段。

几何阶段的一个重要任务就是把顶点坐标变换到屏幕坐标中,再交给光栅器进行处理。

通过对输入的图元进行多步处理之后,这一阶段将会输出屏幕空间的二维顶点坐标,每个顶点对应的深度值,着色等相关信息,并传递给下一个阶段。

光栅化阶段

这一阶段将会使用上个阶段传递的数据来产生屏幕上的像素,并渲染出最终的图像。

这一阶段也是在GPU上运行。

光栅化的任务主要是决定每个渲染图元的哪些像素应该被绘制在屏幕上,它需要对上一个阶段得到的逐顶点数据(如纹理坐标,顶点颜色)进行插值,然后进行逐像素处理。

上一步得到的结果的点,并不是和屏幕上的像素点一一对应的,比如假设屏幕上像素点的坐标都是整数的,而上一步计算的屏幕坐标很有可能是小数位的,我们不能直接简单粗暴的把这个点的值选择一个最近的像素点赋予,而是要进行插值计算。

CPU和GPU之间的通信

渲染流水线的起点是CPU,即应用阶段。应用阶段大致可分为三个阶段:

  1. 把数据加载到显存中。

  2. 设置渲染状态

  3. 调用Draw Call通知GPU。

把数据加载到显存中

所有渲染所需的数据都需要从硬盘中加载到系统内存。然后网格和纹理等数据又被加载到显卡上的存储空间——显存。这是因为,显卡对于显存的访问速度更快,而大多数显卡对于内存没有直接访问的权利。

当把数据加载到显存中后,RAM中的数据就可以移除了。但对于一些数据来说,CPU仍然需要访问它们(例如,我们希望CPU可以访问网格数据来检测碰撞),那么我们可能就不希望这些数据被移除。

设置渲染状态

什么是渲染状态?一个通俗的解释就是,这些状态定义了场景中的网格是怎么被渲染的,例如使用了哪个顶点着色器,片元着色器,光源属性,材质等。

如果我们没有更改渲染状态,那么所有网格都将使用同一种渲染状态。

img

调用Draw call

准备好上述所有工作后,CPU就需要调用一个渲染命令来告诉GPU,让GPU按照设置好的渲染数据和渲染状态来工作。

当接收了一个Draw call时,GPU就会根据渲染状态(如材质,纹理,着色器等)和所有输入的顶点数据来进行计算,最终输出成屏幕上显示的像素。

GPU流水线

应用阶段结束后,CPU通过Draw call命令通知GPU根据CPU产生的数据进行渲染,接下来,就进入到了GPU流水线。

对于概念阶段的后两个阶段,即几何阶段和光栅化阶段,开发者无法拥有绝对的控制权,其实现的载体是GPU。GPU通过实现流水线化,大大加快了渲染速度。

虽然我们没办法完全控制这两个阶段的实现细节,但是GPU向开发者开放了很多控制权

img

从图中可以看出,GPU的渲染流水线接收顶点数据作为输入,。这些顶点数据是由应用阶段加载到显存中,再由Draw call指定,这些数据随后被传递给顶点着色器。

  • 顶点着色器。是完全可编程的,它通常用于实现顶点的空间变化,顶点着色等功能。

  • 曲面细分着色器。是一个可选的着色器,它用于细分图元。

  • 几何着色器。是一个可选的着色器,用于执行逐图元的着色操作,或者产生更多的图元。

  • 裁剪。这一阶段的目的是将那些不在摄像机视野内的顶点裁掉,并剔除某些三角图元的面片。这个阶段是可配置的,例如我们可以使用自定义的裁剪平面来配置裁剪区域,也可以通过指令控制裁剪三角形图元的正面还是背面。

  • 屏幕映射。不可配置和编程,负责把每个图元的坐标转换到屏幕坐标系中。

  • 三角形设置和三角形遍历。固定函数阶段

  • 片元着色器。逐片元着色操作。

  • 逐片元操作。负责执行很多重要操作,如修改颜色,深度缓冲,混合等,不可编程,但是可高度配置。

顶点着色器

流水线的第一个阶段,输入来自CPU,顶点着色器的处理单位是顶点。

也就是说,输入进来的每个顶点都会调用一次顶点着色器。

顶点着色器本身不可以创建或者销毁任何顶点,而且无法得到顶点与顶点之间的关系。例如我们无法得知两个顶点是否属于同一个三角网络。但正是因为这种独立性,GPU可以利用自身的特性进行并行化处理每一个顶点而不会被其他顶点阻塞。

顶点着色器的主要功能有:坐标变化与逐顶点光照。

当然,除了这两个主要任务,顶点着色器还可以输出后续阶段所需的数据

img

坐标转化。顾名思义,就是对顶点的坐标进行某种变换。顶点着色器可以在这一步中改变顶点的位置,这在顶点动画中非常有用。例如,我们可以通过改变顶点位置来模拟水面,布料等。但是需要注意的是,无论我们在顶点着色器中怎样改变顶点,一个顶点着色器必须完成的一个工作是:将顶点坐标从模型空间转换到齐次裁剪空间

我们经常可以在顶点着色器中看到如下代码:

o.pos = mul(UNITY_MVP, v.position);

这段代码的作用就是把顶点坐标转换到齐次裁剪坐标系下,接着通常再由硬件做透视除法后,最终得到归一化的设备坐标(Normalized Device Coordinate, NDC)

img

注意,上图给出的坐标范围是OpenGL同时也是Unity的NDC,它的z分量的范围是[-1,1]之间,而在DirectX中,NDC的z分量的范围是[0,1]。顶点着色器可以有不同的输出方式。

最常见的输出路径是经过光栅化后交给片元着色器进行处理。而在现代的Shader Model中,可以把数据发送给曲面细分着色器或几何着色器。

裁剪

由于我们的场景很大,而摄像机的视野范围很有可能不会覆盖所有的场景物体,一个很自然的想法就是那些不在视野范围的物体不需要被处理,而裁剪(Clipping)就是为了这个。

一个图元和摄像机视野的关系有三种:完全在视野内,部分在视野内,完全在视野外。

完全在视野内的就传给下一个流水线,完全在视野外的就不需要传递。

部分在视野内的就需要裁剪。

由于上一步我们已经将所有顶点归一化到了一个立方体内,因此裁剪就变得很简单:只需要将图元裁剪到单位立方体内:

img

和顶点着色器不同,这一步不可编程,即我们无法通过编程来控制裁剪的过程。

屏幕映射

这一步输入的坐标仍然是三维坐标系下的坐标(范围在单位立方体内)。屏幕映射的任务是将每个图元的x和y转换到屏幕坐标系下(二维坐标系),它和我们显示画面分辨率有很大关系

假设,我们需要把场景渲染到一个窗口上,窗口的范围是从小的窗口坐标(x1,y1),到最大的窗口坐标(x2,y2),由于我们的输入坐标在-1到1,由此可以想象到,这个过程实际是一个缩放的过程,这个过程中z坐标保持不变。

屏幕映射不会对z坐标做出任何处理,而屏幕坐标系和z坐标一起构成一个新的坐标系,这个叫窗口坐标系,这些值一起被传递到光栅化阶段

img

三角形设置

从这一步就进入了光栅化阶段。

上一个阶段输出的信息是屏幕坐标系下的顶点位置以及和它们相关的额外信息,如深度值(z坐标),法线方向,视角方向等。

光栅化有两个最重要的目标:计算每个图元覆盖了哪些像素,以及为这些像素计算它们的颜色。

光栅化的第一个流水线阶段是三角形设置。这个阶段会计算光栅化一个三角形网格所需要的信息。

具体来讲,上一个阶段输出的都是三角网格的顶点,即我们得到的是三角网格每条边的两个顶点。但如果要得到整个三角网格对像素的覆盖情况,我们就必须计算每条边上的像素坐标。

为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式。

这样一个计算三角形网格表示数据的过程就叫做三角形设置。它的输出为下一个阶段做准备。

三角形遍历

会检查每个像素(屏幕上的像素)是否被一个三角形网格覆盖,如果被覆盖,就会产生一个片元。而这样一个找到哪些像素被三角形网格覆盖的过程就是三角形遍历,这个阶段也被称为扫描变换。

三角形遍历阶段会根据上一个阶段的结果来判断一个三角网格覆盖了哪些像素,并使用三角网格的三个顶点信息对整个覆盖区域的像素进行插值计算。

img

这一步的结果就是一个片元序列。

一个片元对应一个像素,但还不是真正意义上的一个像素,只是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括但不限于它的屏幕坐标,深度信息以及其他从几何阶段输出的顶点信息,如法线,纹理坐标等。

片元着色器

一个非常重要的可编程着色器阶段,也被称为像素着色器,但是片元着色器更合适,因为片元目前还不是一个真正意义上的像素。

前面的光栅化阶段并不会影响屏幕上的像素,而是会产生一系列数据,用来描述一个三角网格是如何覆盖每个像素的。而每个片元存放的就是这样的一系列数据。

真正对像素产生影响的是下一个流水线阶段——逐片元操作。

片元着色器的输入是上一个阶段对顶点信息差值得到的结果。

这一阶段可以完成很多重要的渲染技术,其中最重要的技术之一就是纹理采样,为了在片元着色器中进行纹理采样,我们通常会在顶点着色器中输出每个顶点的纹理坐标,然后经过光栅化阶段对三角网格三个顶点对应的纹理坐标进行插值,就可以得到其覆盖的片元的纹理坐标。

img

逐片元操作

这一步是渲染流水线的最后一步,也被称为输出合并阶段。

这一阶段有几个主要任务:

  • 决定每个片元的可见性,这涉及到很多测试工作,如深度测试,模板测试等。

  • 如果一个片元通过了所有测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区的颜色进行合并,也就是混合。

逐片元操作首先需要解决每个片元的可见性。这需要一系列测试,如果无法通过测试,该片元会被直接丢弃,前面的工作都会白费。

简单介绍下模版测试和深度测试

img

模版测试

与之相关的是模版缓冲,这个东西与颜色缓冲,或者深度缓冲一样,可以理解为操作系统中的寄存器之类的东西,用于临时存储。

开启了模版测试,GPU会先读取模版缓冲区中该片元位置的模版值(通过掩码方式,类似子网掩码),然后将该值和读取到的参考值比较。

这个比较函数可以由开发者指定,如小于时舍弃该片元,或者大于时舍弃

深度测试

如果开启深度测试,GPU会把该片元的深度值与深度缓冲区的深度值进行比较,这个比较与模版测试相同,也可以由开发者指定,通常这个比较函数是小于等于的关系,因为我们一般情况下只想显示离摄像机近的点。

通过了模版测试之后,开发者还可以指定是否用该值更新模版缓冲区内容。

合并

我们的渲染是一个物体接着一个物体画到屏幕上的,而每个像素的颜色值存储在颜色缓冲中。

当我们执行这次渲染的时候,颜色缓冲中往往有了上次的结果,那么我们是直接覆盖还是通过某种方式混合,这就是合并解决的问题。

对于不透明物体,直接关闭混合,覆盖就好。

但是对于半透明物体,我们就需要结合缓冲区的内容,进行混合。