Contents

GPU流水线——几何、光栅化阶段

GPU流水线

当GPU从CPU得到渲染命令后,就会进行一系列的流水线操作,最终把图元渲染到屏幕上。

对于概念阶段的最后两个阶段即几何和光栅阶段,开发者无法拥有绝对的控制权,其实现的载体是GPU。

虽然我们无法完全控制这两个阶段,但GPU向开发者开放了很多控制权。

几何阶段和光栅化阶段可以分成若干个更小的流水线阶段,每个阶段提供了不同的可配置性和可编程性。

/usr/uploads/2025/05/3281744393.png

绿色表示该流水线阶段是完全可编程控制的

黄色标识该流水线阶段可以配置 但是不可以编程

蓝色表示该流水线是由GPU固定实现的 开发者没有控制权

实线表示该shader必须由开发者编程实现,虚线则表示可选的


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

几何阶段:

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

  • **曲面细分着色器(Tessellation Shader)**可选着色器,用于图元细分。

  • **几何着色器(Geometry Shader)**可选着色器,用于执行逐图元的着色操作,或被用于生产产生图元。

  • **裁剪(Clipping)**这个阶段是可配置的,将不在摄像机视野内的顶点裁剪掉,并剔除某些三角图元片面。

    我们使用自定义的裁剪平面来配置裁剪区域,也可以通过指令控制裁剪三角图元的正面还是背面。

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

光栅化阶段

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

几何阶段

顶点着色器

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

也就是说,输入进来的每个顶点 都会调用一次顶点着色器,顶点着色器本身不可以创建或销毁任何顶点,也无法得到顶点和顶点之间的关系。

例如,我们无法得知两个顶点是否属于一个三角网络。

但正因为这样的相互独立性,GPU可以利用本身的特性进行优化处理每一个顶点,这意味着这一阶段的处理速度会很快。

顶点着色器需要完成的工作有:坐标变换和逐顶点光照。

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

/usr/uploads/2025/05/942983214.png

坐标变换

故名思意,就是对顶点的坐标 进行某种变换。顶点着色器可以在这一步中改变顶点的位置,这在顶点动画中是非常有用的。

例如,我们可以通过改变顶点位置来模拟水面,布料等。

但要注意的是,无论我们在顶点着色器中怎样改变顶点的位置,一个最基本的顶点着色器必须完成的工作是

把顶点坐标从模型空间转换到齐次裁剪空间。

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

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

/usr/uploads/2025/05/1542046430.png

需要注意的是 图中给出的坐标范围是OpenGL 同时也是Unity使用的NDC,他的Z分量范围在[-1,1]之间

在DirectX中,NDC的Z分量范围是[0,1]。

在现代的Shader Model中 他可还可以把数据发送给曲面细分着色器或几何着色器。


裁剪

由于我们的场景可能会很大,摄像机的视野范围不会覆盖所有的场景物体

一个很自然的想法就是,那些不在摄像机视野范围的物体 就不需要被处理

而裁剪就是为了这个目的而被提出来的

一个图元和摄像机视野的关系有三种

  • 完全在视野内
  • 部分在视野内
  • 完全在视野外

完全在视野内的图元就继续传递给下一个流水线阶段

完全在视野外的图元则不会继续向下传递 因为他们不需要被渲染

而那些部分在视野内的图元 需要进行要给处理 就是裁剪

由于我们已知在NDC下的顶点位置,即顶点位置在一个立方体内,因此裁切就变得简单:

只需要将图元裁剪到立方体内

/usr/uploads/2025/05/447423862.png

和顶点着色器不同 这一步是不可以编程的,即我们无法通过编程来控制裁剪的过程,而是硬件上的固定操作,但我们可以自定义一个裁剪操作来对这一步进行配置。


屏幕映射

这一步输入的坐标依然是三维坐标系下的坐标(范围在单位立方体内)。

屏幕映射的任务是把每个图元的x和y坐标 转换到屏幕坐标系下。

屏幕坐标系是一个二维坐标系,它和我们用于显示画面的分辨率有很大关系

由于我们的输入坐标范围在-1到1,因此可以想象到这个过程 实际上是一个缩放的过程。

那么z坐标会怎么样呢?屏幕映射不会对输入的z坐标做任何处理

事实上,屏幕坐标系和z坐标一起构成了一个坐标系叫做窗口坐标系 这些值会被传递到光栅化阶段

/usr/uploads/2025/05/2766312428.png

有一个需要注意的地方是,屏幕坐标系在OpenGL和DirectX之间的差异问题

OpenGL把左下角当成最小的窗口坐标值

DirectX则是左上角

/usr/uploads/2025/05/2634712902.png

不管原因如何,要时刻小心这样的差异,如果发现得到的图像是倒转的,那么很有可能是这个原因造成的


光栅化阶段

从上一阶段输出的信息 是屏幕坐标系下的顶点位置 和他们相关的额外信息(深度值(Z坐标)、法线方向、视角方向)等。

光栅化阶段有两个最重要的目标

  • 计算每个图元 覆盖了那些像素
  • 为这些像素计算他们的颜色

三角形设置

光栅化的第一个阶段 这个阶段会计算光栅化一个三角形网络 所需的信息

具体来说,上一阶段输出的都是三角网络的顶点,即我们得到的是三角形网格 每条边的两个端点

但如果要得到整个三角网络对像素的覆盖情况,就必须计算每条边上的像素坐标

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

这样一个计算三角网络表示数据的过程就叫三角形设置,他的输出是为了给下一个阶段做准备。


三角形遍历

三角形遍历阶段 将会检查每个像素是否被一个三角网络所覆盖。

如果被覆盖就会生成一个片元(fragment)。

而这样一个找到那些像素被三角网络覆盖的过程 就是三角形遍历,也被成为扫描变换(Scan Conversion)

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

/usr/uploads/2025/05/63883339.png

这一步输出的是一个片元序列,需要注意的是 一个片元并不是真正意义上的像素,而是包含了多种状态的集合

这些状态用于计算每个像素的最终颜色

这些状态包括但不限于

  • 屏幕坐标
  • 深度信息
  • 其他几何阶段输出的顶点信息(法线、纹理坐标等)

片元着色器

片元着色器 是一个非常重要的可编程着色器阶段

在DirectX中 片元着色器被成为 像素着色器,但片元着色器是一个更合适的名字,因为此时的片元并不是一个真正意义上的像素。

前面的光栅化阶段 实际上不会影响屏幕上每个像素的颜色值,而是会产生一些列的数据信息 用来表述一个三角网络是如何覆盖像素的。

而每个片元就负责春初这样一系列数据,真正会对像素产生影像的阶段是下一个流水线阶段——逐片元操作

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

/usr/uploads/2025/05/3836403041.png

片元着色器阶段可以完成很多重要的渲染技术,其中之一就是纹理采样。

为了在片元着色器中进行纹理采样,我们通常会在顶点着色器阶段 输出 每个顶点对应的纹理坐标

然后经过光栅化阶段对三角网络的3个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理坐标了。

虽然片元着色器可以完成很多重要的效果,但他的局限在于,它仅可以影响单个片元

也就是说当执行片元着色器时,他不可以将自己的任何结果直接发送给它的邻居们。

有一个情况例外,就是片元着色器可以访问到导数信息。


逐片元操作

渲染流水线的最后一步,逐片元操作是OpenGL中的说法,在DirectX中被称为 输出合并阶段。

这一阶段有几个任务。

  1. 决定每个片源的可见性,这涉及到了很多测试工作,例如深度测试、模板测试等
  2. 如果一个偏远通过了所有的测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区的颜色进行合并,或者说混合。

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

/usr/uploads/2025/05/134780938.png

测试的过程实际上是比较复杂的过程,不同的图形接口(例如OpenGL和DirectX)的实现细节也不仅相同。


深度测试和模板测试的简化流程图:

/usr/uploads/2025/05/2481254905.png

模板测试

先来看模板测试(Stencil Test),与之相关的是模板缓冲(Stencil Buffer)。

模板缓冲和我们经常听到的颜色缓冲、深度缓冲几乎是一类东西。

如果开启了模板测试,GPU会首先读取(使用读取掩码)模板缓冲区中该片元位置的模板值,

然后将该值和读取到的参考值进行比较,

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

如果片元没有通过测试就会会被舍弃。

不管一个片元有没有通过模板测试,我们都可以根据模板测试和下面的深度测试结果来修改模板缓冲区,这个也是开发者指定的。

开发者可以设置不同结果下的修改操作,例如:

失败时模板缓冲区保持不变,通过时将模板缓冲区中的位置的值加1等。

模板测试通常用于限制渲染的区域。另外,模板测试还有一些更高级的用法,如渲染阴影,轮廓渲染等。

如果一个片元通过了模板测试,那么他将会进行下一个测试——深度测试


深度测试

这个测试同样可以高度破欸之的,如果开启了深度测试,

GPU会把该片元的深度值和已经存在于深度缓冲区中的深度值进行比较。

这个比较函数可以由开发者设置,例如小于时舍弃片元或者大于时舍弃片元。

通常这个比较函数是小于等于的关系,即如果这个片元的深度值大于等于当前的深度缓冲区的值则舍弃。

这是因为我们总想只显示出离摄像机最近的物体,而那些被其他物体遮挡的就不需要出现在屏幕上。

和模板测试不同的是,如果一个片元没有通过深度测试,他就没有权利更改深度缓冲区的值,

如果他通过了测试,开发者还可以指定是否用这个片元的深度值覆盖掉原有的深度值,

这是通过开启/关闭深度写入来做到的,

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

如果一个通过了深度测试,那么他就可以来到合并(混合)功能面前


混合

对于不透明物体,开发者可以关闭混合(Blend)操作。

这样片元着色器计算到的颜色值就可以直接覆盖掉颜色缓冲区中的像素值。

但对于半透明物体,我们就需要使用混合操作来让物体看起来是透明的。

/usr/uploads/2025/05/59794616.png

从流程图中可以发现,混合操作也是可以高度配置的。

开发者可以选择开启或关闭混合功能。

如果没有开启混合,就会使用片元的颜色直接覆盖掉颜色缓冲区的颜色,这也是无法得到透明效果的原因。

如果开启混合,GPU就会取出源颜色=和目标颜色,将两种颜色混合。源颜色指的是片元着色器得到的颜色值,而目标颜色这是已经存在于颜色缓冲区中的颜色值。之后就会使用要给混合函数来进行混合操作。

这个混合函数和透明通道息息相关,例如根据透明通道的值进行加减乘等等。

混合很想Photoshop中队图层的操作,我们可以选择一个混合模式,混合模式决定了该图层和下层图层的混合效果。


Early-Z

上面给出的测试顺序并不是唯一的,虽然从逻辑上来说这些测试是在片元着色器之后进行的,但是对于大多数GPU来说,他们会尽可能在执行片元着色器之前就进行这些测试,想象一下,当GPU在片元着色器阶段计算出片元的颜色后,却发现这个片元没有通过检验要被舍弃,那之前的计算就浪费了。

GPU会希望尽可能的知道那些片元是会被舍弃的,在Unity给出的渲染流水线中,也可以发现他给出的深度测试是在片元着色器前,这种深度测试提前执行的技术通常称为Early-Z技术。

但如果这些测试提前的话,其检验结果可能会与片元着色器中的一些操作冲突,现代的GPU会判断片元着色器中的操作是否和提前测试发生冲突,如果有就会禁用提前测试,但是,这样也会造成性能上的下降,因为有更多片元需要被处理了,这也是透明度测试会导致性能下降的原因。


当模型的图元经过了上面层层计算和测试后,就会显示到屏幕上,我们屏幕显示的就是颜色缓冲区中的颜色值,但为了避免我们看到那些正在进行光栅化的图元,GPU会使用双重缓冲(Double Buffering)的策略。

这意味着对场景的渲染是在幕后发生的,即在后置缓冲(Back Buffer)中。

一旦场景已经被渲染到了后置缓冲中,GPU就会交换后置缓冲区和前置缓冲(Front Buffer)中的内容。

而前置缓冲区是之前显示在屏幕上的图像,因此保证了我们看到的图像是连续的。


来源作者:《UnityShader入门精要》冯乐乐