什么是渲染流水线

渲染流水线的工作任务在于由一个三维场景出发﹑生成(或者说渲染)一张二维图像。换句话说,计算机需要从一系列的顶点数据﹑纹理等信息出发,把这些信息最终转换成一张人眼可以看到的图像。

《Real-Time Rendering,Third Edition》一书中将一个渲染流程分成 3 个阶段:应用阶段(Application Stage)﹑几何阶段(Geometry Stage)﹑光栅化阶段(Rasterizer Stage)

应用阶段

这个阶段是由我们的应用主导的,因此通常由 CPU 负责实现。换句话说,我们开发者具有这个阶段的绝对控制权。

在这个阶段中,开发者有 3 个主要任务:

首先,我们需要准备好场景数据,例如摄像机的位置﹑视锥体﹑场景中包含了哪些模型﹑使用了哪些光源等等;

其次,为了提高渲染性能,我们往往需要做一个粗粒度剔除(culling)工作,以把哪些看不见的物体剔除出去,这样就不需要再移交给几何阶段进行处理;

最后,我们需要设置好每个模型的渲染状态。这些渲染状态包括但不限于它使用的材质(漫反射颜色﹑高光反射颜色)﹑使用的纹理﹑使用的 Shader 等。

这个阶段最重要的输出是渲染所需的几何信息,即渲染图元(rendering primitives)。通俗来讲,渲染图元可以是点﹑线﹑三角面等,这些渲染图元将会被传递给下一个阶段---几何阶段。

几何阶段

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

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

光栅化阶段

这一阶段将会使用上个阶段传递的数据来产生屏幕上的像素,并渲染出最终的图像。这一阶段也是在 GPU 上进行。光栅化的任务主要是决定每个渲染图元的哪些像素应该被绘制在屏幕上。它需要对上一个阶段得到的逐顶点数据(例如纹理坐标﹑顶点颜色等)进行插值,然后再进行逐像素处理。

CPU 和 GPU 之间的通信

应用阶段大致可分为下面 3 个阶段:

(1)把数据加载到显存中。

(2)设置渲染状态。

(3)调用 Draw Call 。

把数据加载到显存中

所有渲染所需的数据都需要从硬盘(Hard Disk Drive ,HDD)中加载到系统内存(Random Access Memory,RAM)中。然后,网格和纹理等数据又被加载到显卡上的存储空间---显存(Video Random Access Memory,VRAM)中。这是因为,显卡对于显存的访问速度更快,而且大多数显卡对于 RAM 没有直接的访问权力。

需要注意的是,真是渲染中需要加载到显存中的数据往往复杂,例如,顶点的位置信息﹑法线方向﹑顶点颜色﹑纹理坐标等。

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

设置渲染状态

通俗的解释就是,这些状态定义了场景中的网格是怎样被渲染。例如,使用哪个顶点着色器(Vertex Shader)/片元着色器(Fragment Shader)﹑光源属性﹑材质等。

调用 Draw Call

Draw Call 就是一个命令,发起方是CPU 接收方是GPU 。这个命令仅仅会指向一个需要被渲染的图元列表,而不会包含任何材质信息--上个阶段已经完成。

当给定了一个 Draw Call 时,GPU 就会根据渲染状态(例如材质﹑纹理﹑着色器等)和所有输入的顶点数据来进行计算,最终输出成屏幕上显示的像素。这个计算过程就是GPU流水线。

GPU 流水线

顶点着色器(Vertex Shader)是完全可编程的,它通常用于实现顶点的空间变换﹑顶点着色等功能。

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

几何着色器(Geometry Shader)同样是一个可选的着色器,它可以被用于执行逐图元(Per-Primititive)的着色操作,或者被用于产生更多的图元。

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

屏幕映射(Screen Mapping)这个阶段是不可配置和编程的,它负责把每个图元的坐标转换到屏幕坐标系中。

光栅化阶段

三角形设置(Triangle Setup)三角形遍历(Triangle Traversal)阶段也都是固定函数的阶段。接下来是片元着色器(Fragment Shader),则是完全可编程的,它用于实现逐片元的着色操作。最后,逐片元操作(Per-Fragment Operations)阶段负责执行很多重要的操作,例如修改颜色﹑深度缓冲﹑进行混合等,它不是可编程的,但具有很高的可配置性。

---------------------

顶点着色器

顶点着色器(Vertex Shader)是流水线的第一阶段,它的输入来自于CPU。顶点着色器的处理单位是顶点,也就是说,输入进来的每个顶点都会调用一次顶点着色器。顶点着色器本身不可以创建或者销毁任何顶点,而且无法得到顶点与顶点之间的关系。我们无法得知两个顶点是否属于同一个三角网格。但正是因为这样的相互独立性,GPU可以利用本身的特性并行化处理每个顶点,这意味着这一阶段的处理速度会很快。

顶点着色器需要完成的工作主要有:坐标变换和逐顶点光照。还可以输出后续阶段所需的数据。

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

o.pos = mul(UNITY_MVP,v.position);类似这代码,就是把顶点坐标转换到齐次裁剪坐标系下,接着通常再由硬件做透视除法后,最终得到归一化的设备(Normalized Device Coordinates,NDC)。

坐标范围是OpenGL同时也是Unity使用的NDC,它的z分量范围在[-1,1]之间,而在DirectX中,NDC的z分量范围是[0,1]。

裁剪

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

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

完全在视野内的图元就继续传递给下一个流水线阶段,完全在视野外的图元不会继续向下传递,因为它们不需要被渲染。而那些部分在视野内的图元要进行一个处理就是裁剪。例如。一条线段的一个顶点在视野内,而另一个顶点不在视野内,那么在视野外部的顶点应该使用一个新的顶点来代替,这个新的顶点位于这条线段和视野边界的交点处。

裁剪是不可编程的,而是硬件上的固定操作,但我们可以自定义一个裁剪操作对这一步进行配置。

屏幕映射

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

假设我们需要把场景渲染到一个窗口上,窗口的范围是从最小的窗口坐标(x1,y1)到最大的窗口坐标(x2,y2),其中 x1<x2且y1<y2。由于我们输入的坐标范围在-1到1,因此这个过程实际是一个缩放的过程。那么输入的z坐标会怎么样?屏幕映射不会对输入的z坐标做任何处理。实际上,屏幕坐标系和z坐标一起构成一个坐标系叫做窗口坐标系(Window Coordinates)。这些值会一起被传递到光栅化阶段。

屏幕映射得到的屏幕坐标决定了这个顶点对应屏幕上哪个像素以及距离这个像素有多远。

屏幕坐标系在OpenGL和DirectX之间的差异问题。OpenGL把屏幕的左下角当成最小的窗口坐标值,而DirectX则定义屏幕的左上角为最小的窗口坐标值。

如果发现得到的图像是倒转的,可能是这个原因。

光栅化阶段

三角形设置

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

三角形设置(Triangle Setup),这个阶段会计算光栅化一个三角网格所需的信息。具体来说,上一个阶段输出的都是三角网格的顶点,即我们得到的是三角网格每条边的俩个断点。但如果要得到整个三角网格对像素的覆盖情况,我们就必须计算每条边上的像素坐标。为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式。这样一个计算三角网格表示数据的过程就叫做三角形设置。它的输出是为了给下一个阶段做准备。

三角形遍历

三角形遍历(Triangle Traversal)阶段将会检查每个像素是否被一个三角网格所覆盖。如果被覆盖的话,就会生成一个片元(fragment)。而这样一个找到哪些像素被三角网格覆盖的过程就是三角形遍历,这个阶段也被称为扫描变换(Scan Conversion)

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

这一步的输出就是得到一个片元序列。需要注意的是,一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括了(但不限于)它的屏幕坐标﹑深度信息,以及其他从几何阶段输出的顶点信息,例如法线﹑纹理坐标等。

片元着色器

片元着色器(Fragment Shader)是另一个非常重要的可编程着色器阶段。在DirectX中,片元着色器也被称为像素着色器(Pixel Shader),但片元着色器是一个更合适的名字,因此此时的片元并不是一个真正意义上的像素。

前面的光栅化阶段实际上并不会影响屏幕上每个像素的颜色值,而是会产生一系列的数据信息,用来表述一个三角网格是怎样覆盖每个像素的。而每个片元就负责存储这样一系列数据。真正会对像素产生影响的阶段是下一个流水线阶段-逐片元操作。

片元着色器的输入是上一个阶段对顶点信息插值得到的结果,更具体来说,是根据哪些从顶点着色器中输出的数据插值得到的。而它的输出是一个或者多个颜色值。

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

虽然片元着色器可以完成很多重要效果,但是它的局限在于,它仅可以影响单个片元。也就是说,当执行片元着色器时,它不可以将自己的任何结果直接发送给它的邻居们。有一个情况例外,就是片元着色器可以访问到导数信息。

逐片元操作

渲染流水线的最后一步。逐片元操作(Per-Fragment Operations)是OpenGL中的说法,在DirectX中,这一阶段被称为输出合并阶段(Output-Merger)。

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

(1)决定每个片元的可见性。这涉及了很多测试工作,例如深度测试﹑模板测试等。

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

逐片元操作阶段是高度可配置性的。

这个阶段首先需要解决每个片元的可见性问题。如果片元没有通过其中某一个测试,之前为了产生这个片元所做的所有工作都是白费,这个片元会被舍弃掉。

这里给出两个最基本的测试---深度测试和模板测试的实现过程。

模板测试(Stencil Test)如果开启了模板测试。GPU会首先读取(使用读取掩码)模板缓冲区中该片元位置的模板值,然后将该值和读取到的参考值进行比较,这个比较函数可以是由开发者指定的,例如小于时舍弃该片元,或者大于等于时舍弃该片元。如果这个片元没有通过这个测试,该片元就会被舍弃。不管一个片元有没有通过模板测试,我们都可以根据模板测试和下面的深度测试结果来修改模板缓冲区,这个修改操作也是由开发者指定的。开发者可以设置不同结果下的修改操作,例如,在失败时模板缓冲区保持不变,通过时将模板缓冲区中对应位置的值加1等。模板测试通常用于限制渲染的区域。另外,模板测试还有一些更高级的用法,如渲染阴影﹑轮廓渲染等。

如果一个片元通过了模板测试,那么它会进行下一个测试---深度测试(Depth Test)。这个测试同样是可以高度配置的。如果开启了深度测试,GPU会把该片元的深度值和已经存在深度缓冲区中的深度值进行比较。这个比较函数也是可由开发者设置的,例如小于时舍弃该片元,或者大于等于时舍弃该片元。通常这个比较函数是小于等于的关系,即如果这个片元的深度值大于等于当前深度缓冲区中的值,那么就会舍弃它。因为我们总想显示出离摄像机最近的物体,而哪些被其他物体遮挡的就不许出现在屏幕上。如果这个片元没有通过这个测试,该片元就会被舍弃。和模板测试有些不同,如果一个片元没有通过深度测试,它就没有权力更改深度缓冲区中的值。而如果它通过了测试,开发者还可以指定是否用这个片元的深度值覆盖掉原有的深度值,这是通过开启/关闭深度写入来做到的。

透明效果和深度测试以及深度写入的关系非常密切。

如果一个片元通过了上面所有的测试,就来到合并功能。

为什么需要合并?我们要知道,这里说的渲染过程是一个物体接着一个物体画到屏幕上的。而每个像素的颜色信息被存储在一个名为颜色缓冲区的地方。因此,当我们执行这次渲染时,颜色缓冲中往往已经有了上次渲染之后的颜色结果,那么,我们是使用这次渲染得到的颜色完全覆盖掉之前的结果,还是进行其他操作?这就是合并需要解决的问题。

对于不透明物体,开发者可以关闭混合(Blend)操作。这样片元着色器计算得到的颜色值就会直接覆盖掉颜色缓冲区中的像素值。但是对于半透明物体,我们就需要使用混合操作来让这个物体看起来是透明的。

混合操作也是高度配置的:开发者可以选择开启/关闭混合功能。如果没有开启混合功能,就会直接使用片元的颜色覆盖掉颜色缓冲区中的颜色,而这也是很多初学者无法得到透明效果的原因(没有开启混合功能)。如果开启了混合而目标颜色则是已经存于颜色缓冲区中的颜色值。之后,就会使用一个混合函数来进行混合操作。这个混合函数通常和透明通道息息相关,例如根据透明通道的值进行相加﹑相减﹑相乘等。混合很想PS中对图层的操作:每一层图层可以选择混合模式,混合模式决定了该图元和下层图层的混合效果,我们看到的图片就是混合后的图片。

上面给出的测试顺序并不是唯一的,而且虽然从逻辑上来说这些测试是在片元着色器之后进行的,但对于大多数GPU来说,它们会尽可能在执行片元着色器之前进行这些测试。因为,当GPU在片元着色器阶段花了很大力气终于计算出片元颜色后,却发现这个片元没有通过这些检验,片元被舍弃,那之前花费的计算成本全浪费了。

作为一个想充分提高性能的GPU,它会希望尽可能早的知道哪些片元会被舍弃,对于这些片元就不需再使用片元着色器来计算它们的颜色了。在Unity给出的渲染流水线中,我们也可以发现它给出的深度测试是在片元着色器之前的。这种将深度测试提前执行的技术通常也被称为Early-Z技术。

但是如果将这些测试提前的话,其检验结果可能会与片元着色器中的一些操作冲突。例如,如果我们在片元着色器进行了透明度测试,而这个片元没有通过测试,我们会手动舍弃该片元。这就导致GPU无法提前执行各种测试。因此,现代的GPU会判断片元着色器中的操作是否和提前测试发生冲突,如果有冲突,就会禁用提前测试,这样也会造成性能上的下降,因为有更多的片元需要被处理。这也是透明度测试会导致性能下降的原因。

当模型的图元经过了上面的层层计算和测试后,就会显示到我们的屏幕上。我们屏幕显示的就是颜色缓冲区中的颜色值。但是为了避免我们看到哪些正在进行光栅化的图元,GPU会使用双重缓冲(Double Buffering)的策略。这意味着,对场景的渲染是在幕后发生的,即在后置缓冲(Back Buffer)中。一旦场景已经被渲染到了后置缓冲中,GPU就会交换后置缓冲去和前置缓冲(Front Buffer)中的内容,而前置缓冲区是之前在屏幕上的图像。由此,保证了我们看到的图像总是连续的。

参考我买的 Unity Shader入门精要

  • 2
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值