Unity Shader:渲染流水线

本文同时发布在我的个人博客上:https://dragon_boy.gitee.io

渲染流水线

渲染流水线的工作任务在于由一个三维场景出发、生成一张二维图像。换言之,计算机需要从一系列的顶点数据、纹理等信息出发,把这些信息最终转化成一张人眼可以看到的图像。这些工作由CPU和GPU共同完成。

一个渲染流程可以被粗略的分为3个阶段:应用阶段、几何阶段、光栅化阶段:


  • 应用阶段:
    这一阶段由CPU负责实现。开发者有三个任务:首先,我们需要准备好场景数据;其次,为提高渲染性能,需要粗略地将不可见地物体剔除;最后,设置好每个模型地渲染状态:如材质、纹理、使用的shader。这一阶段的输出主要是渲染所需的几何信息,即渲染图元,可以是点、线、三角面等。这些渲染图元传递到下一阶段——几何阶段。

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

几何阶段负责和每个渲染图元打交道,进行逐顶点、逐多边形的操作。几何阶段的主要任务是把顶点坐标变换到屏幕空间,再交给光栅器进行处理。通过对输入的渲染图元进行处理后,输出屏幕空间的二维顶点坐标、每个顶点的深度值、颜色信息等,然后传递到下一阶段。

  • 光栅化阶段
    这一阶段只用几何阶段输出的数据来产生屏幕上的像素,并渲染出最终的图像。这一阶段也是在GPU上进行的。光栅化的任务主要是决定每个渲染图元中的哪些像素应该被绘制在屏幕上。它需要对上一阶段得到的逐顶点数据进行插值,再进行逐像素处理。

CPU与GPU通信

渲染流水线的起点是应用阶段,细分为下面三个:
(1)把数据加载到显存中。
(2)设置渲染状态。
(3)调用Draw Call。

把数据加载到显存中

所有渲染所需的数据都需要从硬盘中加载到系统内存中。网格和纹理等数据又会被加载到显卡的存储空间,即显存中。显卡对于现存的访问速度更快,且大多数显卡对系统内存没有直接访问的权利。

把数据加载到显存中后,系统内存中的数据就可以移除了,除了一些数据(如碰撞检测相关的数据)。

在这之后,还需要通过CPU设置渲染状态。

设置渲染状态

渲染状态定义了场景中的网格是怎样被渲染的。例如。使用哪个顶点着色器\片元着色器、光源属性、材质等。如果没有更改渲染状态,那么所有的网格都将使用同一渲染状态。

在设置好渲染状态后,CPU就需要调用一个渲染命令来让GPU渲染,这个命令即Draw Call。

调用Draw Call

Draw Call命令由CPU发起,由GPU接收。这个命令指向一个被需要渲染的图元列表,不包含任何材质信息。

当调用一个Draw Call时,GPU就会根据渲染状态(如材质、材质、着色器等)和输入的顶点数据来进行计算,最终输出成屏幕上显示的图像。这个计算过程被称为GPU流水线。

GPU流水线

在渲染流程的几何阶段和光栅化阶段,对应的是GPU流水线。开发者无法拥有绝对的控制权,但可以控制其中的许多阶段。

GPU流水线可被细分为下面几个阶段:


GPU渲染流水线接受顶点数据作为输入,这些顶点数据由应用阶段加载到显存中,再由DrawCall指定。这些数据接着被传入顶点着色器。

顶点着色器是完全可编程的,在主流的现代API中,这个着色器必须由开发者提供。它通常用于实现顶点的空间变换,顶点着色等功能。

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

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

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

几何阶段的最后一个流程是屏幕映射,这一阶段不可配置和编程,它负责将每个图元的坐标转化到屏幕坐标系中。

接着来到光栅化阶段,首先是三角形设置和三角形遍历,这些是固定管线的阶段。

接着是片元着色器,完全可编程且必须,它用于实现逐片元的着色操作。

接着是逐片元操作,会执行许多重要的操作,如修改颜色、深度缓冲、混合等。这一阶段不可编程,但高度可配置。

顶点着色器

顶点着色器是流水线的第一个阶段,它的输入来自于CPU。顶点着色器的处理单位是顶点,所以每一个顶点都会调用一次顶点着色器。

顶点着色器主要的任务是:坐标变换和逐顶点光照。除此之外,还可以计算后续阶段所需的数据。

  • 坐标变换
    顶点着色器可以在这一步中改变顶点的位置,这在顶点动画中非常有用。如,我们可以通过改变顶点位置来模拟水面、布料等。在这一步中,一个基本的顶点着色器必须完成这样的工作:将顶点坐标从模型空间转化到齐次裁剪空间。例如,在比较新的版本的unity中,会有这样的代码:
o.pos = mul(UNITY_MATRIX_MVP, v.position);

上面这句代码的功能,就是把顶点坐标转换到齐次裁剪坐标系下,接着再由硬件做透视除法后,得到归一化的设备坐标(NDC)。如下:


注意,在OpenGL中,NDC的z分量的范围是[-1,1],而DirectX中,范围是[0,1],Unity使用的前者。顶点着色器由不同的输出方式,最常见的是经过光栅化后传递给片元着色器,当然,也可以传递给曲面细分着色器或几何着色器。

裁剪

一个图元和摄像机视野的关系有三种:完全在视野内,部分在视野内,完全在视野外。完全在视野内和完全在视野外的图元很好理解,部分在视野内的图元就需要进行裁剪操作了。裁剪操作的主要原理是通过插值的方式在边界处添加新的顶点:


屏幕映射

这一步输入的坐标仍在NDC立方体范围内。屏幕映射的任务是把每个图元的x、y坐标转化到屏幕坐标系。屏幕坐标系与显示器的分辨率有关。

不同的API对屏幕坐标系的原点的定义不同。OpenGL为左下角,DirectX为左上角。

三角形设置

从这一步开始进入光栅化阶段。从上一阶段输出的信息是屏幕坐标系下的顶点位置以及和他们相关的额外信息,如深度值(z坐标)、法线方向、视角方向等。光栅化阶段有两个主要目标:计算每个图元覆盖了哪些像素,为这些像素计算颜色。

三角形设置阶段会计算光栅化一个三角网格所需的信息。上一阶段输出的都是三角形网格的顶点,未得到三角网格对像素的覆盖情况,我们需要计算每条边的像素坐标。为能够计算边界像素的坐标信息,我们需要得到三角形边界的表示方式,这样一个计算三角网格表示数据的过程叫做三角形设置。

三角形遍历

这一阶段会检查一个像素是否被一个三角网格所覆盖。如果被覆盖就会生成一个片元。找到三角形覆盖哪些像素的过程就是三角形遍历。

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


这一阶段的输出是一个片元序列,并不是真正意义上的像素,而是包含很多状态的集合,这些状态用于计算每个像素的最终颜色,包括但不限于屏幕坐标、深度信息,以及几何阶段输出的顶点信息。

片元着色器

片元着色器的输入是上一阶段对顶点信息插值的结果,输入是一个或多个颜色值。

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

逐片元操作

这一阶段有几个主要任务。
(1)决定每个片元的可见性。这涉及很多测试,如深度测试、模板测试等。
(2)如果一个片元通过了所有的测试,将片元的颜色与颜色缓冲中的颜色进行合并,即混合。

逐片元操作是高度可配置的,即每一步的细节我们都可以配置。

简化操作大致如下:


测试的过程比较复杂,其中最基本的深度测试和模板测试的实现过程如下:


首先是模板测试。开启模板测试后,GPU会读取模板缓冲区中该片元位置的模板值,然后将该值与读取到的参考值进行比较,这一比较所调用的函数由开发者决定,总之如果片元没有通过这个测试,片元就会被舍弃。不管片元有没有通过测试,我们都可以决定是否修改缓冲区中对应的值。模板测试通常用于限制渲染的区域,还可以用来渲染阴影,渲染轮廓。

如果片元通过模板测试,那么就会进行深度测试。如果开启深度测试,就会将该片元的深度值与深度缓冲区中的深度值进行比较,比较的函数由开发者决定,如果测试失败,就是舍弃该片元。和模板测试不同,如果一个片元没有通过深度测试,就不能修改深度缓冲区中的值。

片元通过上述测试后,就会来到混合操作。

我们所讨论的渲染过程是一个物体接一个物体绘制到屏幕上,每个像素的颜色信息被存储在颜色缓冲中。当我们执行一次渲染时,颜色缓冲中已有上次渲染的颜色结果,那么覆盖掉还是混合,这就是这一阶段要处理的。

对于不透明的物体,开发者通常关闭混合操作,直接对颜色缓冲中的值进行覆盖操作。但对于半透明的物体,我们就必须使用混合操作来让物体看上去时透明的。下面是混合的操作:


如果开启混合,GPU就会取出源颜色和目标颜色,将两种颜色进行混合。原颜色是片元的颜色,目标颜色是颜色缓冲区中的颜色。之后会设定一个混合函数进行混合操作。

上述的测试顺序并不是唯一的,为了性能考虑,我们通常更希望在执行片元着色器前就进行测试,这样可以减少不必要的片元着色器的调用。例如深度测试,这种提前深度测试的技术也被称为Early-Z技术。

但如果提前测试的话,可能会与片元着色器的一些操作冲突,如透明度测试。如果一个片元没有通过透明度测试,我们就会手动舍弃这个片元,这样的话GPU就无法提前执行各种测试。因此,现代的GPU会片段片元着色器的操作是否和提前测试发生冲突,如果冲突就会禁用提前测试。

当模型的图元经过大量计算和测试后,就会显示到我们的屏幕上,即颜色缓冲中的值。为了防止我们看到正在光栅化的图元,GPU使用双缓冲来显示。场景的渲染在屏幕后发生,在后置缓冲中,一旦场景被渲染到了后置缓冲中,GPU就会交换后置缓冲和前置缓冲中的值,前置缓冲是之前显示在屏幕上的图像。这样,我们看到的图像就是连续不闪烁的。

额外的事项

Draw Call

常见的误区是,Draw Call中造成性能问题的是GPU,其实是CPU。

CPU和GPU并行工作

我们使用一个命令缓冲区来让CPU和GPU并行工作。

命令缓冲区包含一个命令队列,由CPU向其中添加命令,由GPU读取命令,这两个过程是相互独立的。

命令缓冲区的命令由很多种,Draw Call只是其中的一种,如下:


Draw Call调用次数过多影响帧率

在每次调用Draw Call之前,CPU需要像GPU发送许多内容,包括数据、状态和命令等。在这一阶段,CPU需要完成很多工作,例如检查渲染状态等。一旦CPU完成了这些准备工作,GPU就可以开始渲染。GPU的渲染能力很强,因此渲染速度往往快于CPU提交命令的速度。如果DrawCall的数量太多,CPU就会花费大量时间在提交Draw Call上,造成CPU过载。

减少Draw Call

减少Draw Call的方法有很多,这里介绍批处理的方法。

提交大量很小的Draw Call会造成CPU的性能瓶颈,即CPU把时间都花费在准备Draw Call的工作上,那么一个优化方法就是把很多小的Draw Call合并成一个大的Draw Call,这就是批处理。

由于我们在CPU的内存中合并网格,且合并的过程是需要消耗时间的,所以批处理适合那些静态的物体。

在游戏开发过程中,为了减少Draw Call的开销,需要注意以下两点:
(1)避免使用大量很小的网格。如果需要使用,可以考虑合并。
(2)必满使用过多的材质,尽量在不同的网格之间共用同一个材质。

你可能感兴趣的:(Unity Shader:渲染流水线)