ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Shader入门:光栅化都在做什么?

Shader入门:光栅化都在做什么?

渲染流水线总共有三大概念性阶段:应用阶段、几何阶段、光栅化阶段。上一篇已经讲了应用阶段和几何阶段,这一篇开始进入 GPU 主导的部分。几何阶段和光栅化阶段通常被叫做 GPU 流水线,它们各自又可以细分成多个子流水线阶段,如图 2 所示。

应用阶段和几何阶段已经在另两篇讲完,本篇主要讲解光栅化阶段。

建议读者先学习应用阶段和几何阶段再阅读本篇文章。

应用阶段

几何阶段

图2

光栅化阶段

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

  1. 计算每个图元覆盖了哪些像素

  2. 为这些像素计算它们的颜色

1.三角形相关

1.1输入

  • 屏幕映射阶段结束后,输出的是图元,该图元的每个顶点的位置信息已经变换到屏幕坐标中。
  • 但是图元只包含三角形的三个顶点信息,可以想到,实际上我们想要得到整个三角形对像素的覆盖情况,而不只是三角形三个顶点的像素覆盖情况。
  • 而这种求得三角形对像素的覆盖情况的过程,是三角形设置和遍历阶段,这里统称三角形相关。

1.2三角形遍历过程

  • 直接看光栅化阶段的伪代码:
// 定义片元结构体 struct Fragment { i32 x, y; // 屏幕像素坐标 f32 depth; // 插值后的深度 Vec2 uv; // 插值后的纹理坐标 Color vert_color; // 插值后的顶点色 }; void Rasterization( Vec2 a, Vec2 b, Vec2 c, Color ca, Color cb, Color cc, ......(传入三角形三个顶点的信息,包括坐标,颜色,法线,纹理坐标等等) ) { i32 min_x = min(a.x, b.x, c.x); i32 min_y = min(a.y, b.y, c.y); i32 max_x = max(a.x, b.x, c.x); i32 max_y = max(a.y, b.y, c.y); f32 total_area = SignedTriangleArea(a, b, c); for (i32 x = min_x; x <= max_x; x++) { for (i32 y = min_y; y <= max_y; y++) { f32 alpha = SignedTriangleArea(b, c, {x,y}) / total_area; f32 beta = SignedTriangleArea(c, a, {x,y}) / total_area; f32 gamma = SignedTriangleArea(a, b, {x,y}) / total_area; // 内外判定:三角形外的直接丢弃 if (alpha < 0 || beta < 0 || gamma < 0) continue; // 生成片元:用同一组重心坐标插值所有属性 Fragment frag; frag.x = x; frag.y = y;//屏幕像素坐标 frag.vert_color = alpha * ca + beta * cb + gamma * cc;//插值后的顶点色,深度、纹理坐标的插值同理 // 后面再单独执行:片元着色器 → 深度测试 → 混合 → 写入帧缓冲 } } }

代码解释:

1.开始遍历之前,实际上我们不可能遍历整个屏幕像素,这太消耗性能了,而是在遍历三角形之前生成一个包围盒,这会显著提高性能,如图:

求包围盒四个顶点的代码:

i32 min_x = min(a.x, b.x, c.x); i32 min_y = min(a.y, b.y, c.y); i32 max_x = max(a.x, b.x, c.x); i32 max_y = max(a.y, b.y, c.y);

2.如何确定一个点是否在三角形内部?通过判断该像素的重心坐标的其中一个坐标是否为负数,是则该点必定不在三角形内部。

重心坐标有三个点,每个点表示自己的三角形占总三角形面积的比例,比如如下图所示,A点的比例=三角形PBG/三角形总面积

所以我们可以看到代码中调用SignedTriangleArea(a, b, c)来求出重心坐标,然后在循环内部判断该点其中一个坐标是否为负数来确定该点是否在三角形内部,if (alpha < 0 || beta < 0 || gamma < 0) continue;

3.筛选出在三角形内部的像素后,但我们此时只有屏幕像素坐标这个信息,我们是不是还需要颜色等信息?拿颜色来举例,实则我们可以通过插值来计算该像素的颜色,它是基于该像素的重心坐标和原三个顶点的颜色信息计算得到。而用于存储这些插值后的数据以及其它数据的结构体,我们可以叫它片元。

// 生成片元:用同一组重心坐标插值所有属性 Fragment frag; frag.x = x; frag.y = y;//屏幕像素坐标 frag.vert_color = alpha * ca + beta * cb + gamma * cc;//插值后的顶点色,深度、纹理坐标的插值同理

1.3输出

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

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

  • 所以三角形遍历最终会输出片元,一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括了(但不限于):

    • 它的屏幕坐标

    • 深度信息

    • 以及其他从几何阶段输出的顶点信息经过插值处理,例如法线、纹理坐标,颜色等。

2.片元着色器

2.1输入输出

  • 片元着色器会选择性的读取片元携带的插值属性,比如 UV、法线、顶点色等;这些输入属性是只读的。着色器可以选择用哪些、怎么算,然后输出新的颜色、深度等到片元的输出值属性。

  • 下图显示了这个过程。

2.2主要工作

该阶段比较重要的技术是我们可以通过片元中经过光栅化插值处理的纹理坐标,进行纹理采样,输出纹理颜色。

再或者,我们可以通过片元中经过光栅化插值处理后的法线等信息,进行光照计算,输出光照颜色。

4.逐片元操作

4.1输入输出

  • 光栅化产生的片元 F 一开始主要带着:屏幕位置、覆盖 mask、插值后的输入属性、深度等。

  • 它不一定一开始就有一个固定的“输出颜色属性”等着被赋值。

  • 片元着色器执行时声明输出,例如:

    out vec4 fragColor; void main() { fragColor = ...; }
  • 执行完成后,fragColor的值就作为这个片元的输出颜色/输出附件,交给逐片元操作。

  • 逐片元操作处理的还是同一个片元 F,只是现在它带有片元着色器写出的输出颜色,大致流程如下:

  • 光栅化生成片元 F:
    F = { 位置, 覆盖 mask, 插值输入属性, 深度 ... }

    片元着色器执行:
    读 F 的插值输入
    写 out 变量,例如 fragColor

    执行后:
    F 的输出颜色 = fragColor 的值

    逐片元操作:
    用 F 的输出颜色做混合/写入
    仍然用 F 的位置、覆盖、深度等

4.2主要任务

  1. 决定每个片元的可见性

    • 涉及很多测试工作,例如深度测试、模板测试等

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

  • 需要指明的是,逐片元操作阶段是高度可配置的,即我们可以设置每一步的操作细节

4.2.1决定片元可见性

一个片元只有通过了深度测试和模板测试,才能最终获得和颜色缓冲区进行合并的资格。而这些测试流程大致如下:

模板测试:本篇不讲解,感兴趣读者可以查阅相关资料。

深度测试:

深度可以简单理解为离摄像机的远近距离,默认情况下离摄像机越近值越低。

深度测试主要经历两个子阶段,深度测试和深度写入,深度写入我们可以选择是否开启。

通过深度测试可以确定出自己是否是最靠近摄像机的那个片元,而如果通过了深度测试,那么可以把自己的深度信息写到深度缓冲中,写入深度缓冲的操作叫深度写入,举个简单的例子说明深度测试和写入的作用:

假设A的深度:0.5 B的深度0.7
A和B都关闭了颜色混合和开启了深度写入
A和B都是不透明物体

那么,如果先渲染A→渲染B:

  1. A进行深度测试,发现深度缓冲中的值是1(默认值)而自己的深度值是0.5比1小,通过了深度测试,然后将自己的深度值0.5写入了深度缓冲中并把自己的颜色值写入颜色缓冲中
  2. B进行深度测试,发现深度缓冲中的值是0.5而自己的深度值是0.7比0.5大,没有经过深度测试,该片元B被舍弃
  3. 最终颜色缓冲区中是A的颜色,是完全合理的。
  4. 读者可以自行通过分析先渲染B→渲染A,发现最终还是A的颜色,所以你知道深度测试和深度写入有什么用了吗?

4.2.2合并

如果片元通过了深度测试,那么它将进行合并操作,该片元的颜色值可以选择覆盖颜色缓冲区的值或者与颜色缓冲区的值进行混合,通过是否开启混合操作来决定这种方式。

对于不透明物体和经过透明度测试的全透明物体,我们一般关闭混合,如4.2.2的例子所示。

而对于半透明物体来说,我们一般开启混合,并且需要设置正确的渲染顺序,下面举个例子说明设置正确渲染顺序的重要性:

假设A深度0.5 B深度0.7
A开启混合,B关闭混合
A是黄色半透明 B是不透明紫色

假设先渲染A→渲染B:

  1. A通过了深度测试,并进行深度写入,然后将自己的颜色和颜色缓冲区中的默认颜色混合成黄暗色半透明
  2. B发现自己没有通过深度测试
  3. 最终颜色是黄暗色半透明,错误的

假设先渲染B→渲染A:

  1. B通过了深度测试,并进行深度写入,然后将自己的颜色覆盖颜色缓冲区中的默认颜色变成紫色
  2. A发现自己通过深度测试,并进行深度写入,然后将自己的颜色和颜色缓冲区中的紫色颜色混合
  3. 最终颜色是黄色半透明和紫色的混合,结果正确

所以设置渲染顺序很重要,如果读者把上面的例子中A的深度写入关闭,在设置正确的渲染顺序下,也是可以达到正确的效果的,但是我建议还是关闭半透明物体的深度写入,我总结后发现半透明物体的几种情况:

  • 顺序正确 + ZWrite Off:理想简单情况下正确;复杂情况下(同一物体内部重叠、双面、粒子、相交)片元级顺序无法保证,混合顺序可能错,但所有层都参与了,视觉上通常可接受。

  • 顺序正确 + ZWrite On:理想简单情况下正确;复杂情况下,近处透明片元写入深度,远处透明片元被剔除,后面的透明层直接消失,透明感严重丢失。

  • 顺序错误 + 无论 ZWrite 开还是关:都错,只是错的样子不同。

所以,正确顺序通常是:先渲染不透明物体,开启深度测试 + 深度写入、关闭混合;再渲染半透明物体,开启深度测试、关闭深度写入、开启混合,并按从远到近排序。

4.3测试顺序与 Early-Z 技术

Early-Z 是一种 GPU 优化:尽量在片元着色器前完成深度/模板测试,提前丢弃不可见片元,避免无谓的片元着色器计算。

无法使用Early-Z技术的情况:

透明度测试里的clip让 GPU 无法提前判断片元是否会被丢弃,Early-Z 被迫禁用,片元着色器执行量增加,性能下降。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进