..

Shader 101-01 - Pipeline

Intro

渲染管线是什么

游戏如何将图像渲染到屏幕上是一个看似简单实际上却非常复杂的事情,尤其是 3D 游戏。

如果说桌面应用只需要考虑如何用操作系统提供的 API 在窗口绘制一个个 2D 图像,那么对于一个游戏来说,它首先需要在电脑里通过游戏的数据构建出一个世界的的运行状态,再通过游戏的摄像机去获取该世界在某一帧的某一个角度的图像,然后分析这一个视角下游戏物体都会被哪些光照所影响,某个物体是否是半透明的,如果是半透明的那么后面的物体应该被透成什么颜色,最后再将这些数据汇总为一张画面,被展示到屏幕面前。

游戏数据一个阶段一个阶段变为屏幕图像的过程就是游戏的图像渲染流程。

图像的渲染流程用专业的术语来说就叫作渲染管线,是模型(或者说图形)信息到最终屏幕的过程。需要特别注意的是,在数据进入 GPU 之前,CPU 中的渲染逻辑的预先处理部分也包含在渲染管线的流程当中。

渲染管线并没有绝对统一的一个标准,不过大致的流程都是差不多的,而且随着技术的发展,流程也在持续变化,在比较权威的《Real-time Rendering》这本书中,渲染管线被划分为了 应用阶段、几何阶段、光栅化阶段以及最终的逐像素阶段。

flowchart LR

classDef canProgram fill:#b0fba5
classDef fixed fill:#a5fbe3
classDef soft fill:yellow

Application("Application"):::fixed
Geometry("Geometry Processing"):::canProgram
Rasterization("Rasterization"):::canProgram
Pixel("Pixel Processing"):::fixed

Application --> Geometry --> Rasterization --> Pixel

我们也可以用更简单的说,在图形处理中我们需要两种程序,一个代表几何阶段的程序叫作 Vertex Shader,另一个代表光栅化阶段的程序叫作 Fragment Shader。

Vertex Shader 负责获取处理的网格(Mesh)信息进行处理,网格中的像素经过处理之后被存储在 Fragments 中被传给 Fragment Shader。

当 Vertex Shader 处理顶点的时候,它会将平均每三个顶点组成一个 Fragment,最终整个被处理过的图形被称为 Fragments。

Q:既然Vertex Shader已经将网格的数据做好了空间转换,那这些信息不是已经可以用了吗?为什么还需要一个Fragment Shader呢?

A:在图像处理当中,我们并不一定需要用到所有的像素数据,因此有些数据需要被修改,而有些则直接弃之不用。 比如在我们在实作模糊效果时,其中一种思路便是通过把一个像素周边的像素合并成一个相同颜色的像素实现的,这些内容过于复杂,无法交由CPU来计算,只能由GPU代劳,因此也可以将Vertex Shader看作和CPU打交道的程序,而Fragment则是负责GPU的内容。 于是,在 Fragment Shader 中,有些像素被保留,而另一些则可能被丢掉,接下来 Fragment Shader 将处理好的内容传给颜色缓冲区(Color Buffer),结束了它的工作。

Shader 是什么

Shader 这个单词基于单词「Shade:给…遮挡;把…涂暗」,从字面上理解就是把什么东西涂暗,加上 er 后缀后,意思大致就可以理解为「遮挡器、涂暗器」。我们可以合理的猜测这个名字的来源可能是因为早年的时候 Shader 功能有限,只能调整图像的光暗变化,所以叫作 Shader。

当然,现在的 Shader 所能做的事情远远不只是遮蔽某个物体或是将某个物体涂暗,现在的它能够控制图形的颜色、光照、纹理和其他视觉效果,为了更好的描述它的功能,我们现在总是将其称为着色器。

着色器通常由着色器语言编写,着色器语言提供了指令和语法,用于编写描述光照、纹理映射、阴影、反射等图形外观的代码。目前比较常见的着色器语言有 DicrectX 的 HLSL (High Level Shading Language) 和 OpenGL 的 GLSL (OpenGL Shading Language)

渲染管线的各个阶段

在图像从游戏数据到实际的图像被渲染出来之间,有一些数据的处理部分是可以让我们使用程序进行一定程序的修改的,用于修改渲染内容的程序就被我们叫作着色器,也就是 Shader。

flowchart TD

classDef canProgram fill:#b0fba5
classDef fixed fill:#a5fbe3
classDef soft fill:yellow

Vertex("Vertex Shader
顶点着色器"):::canProgram

Tessellation("Tessellation Shader
曲面细分着色器"):::canProgram
GeometryShader("Geometry Shader
几何着色器"):::canProgram
Clipping("Clipping
裁剪"):::fixed
ScreenMap("Screen Mapping
屏幕映射"):::soft
PA("Primitive Assembly
图元组装"):::fixed
TT("Triangle Traversal
三角形遍历"):::fixed
FragmentShader("Fragment Shader
片元着色器"):::canProgram
Merger("Merger
输出合并"):::soft

subgraph Geometry
direction LR
Vertex --> Tessellation --> GeometryShader --> Clipping --> ScreenMap
end

subgraph Rasterization
direction LR
PA --> TT --> FragmentShader --> Merger
end

Geometry --> Rasterization

Vertex Shader:顶点着色器

这个阶段是 GPU 流水线的第一个阶段,也是必须的阶段,这个阶段可以完全由开发者控制。在顶点着色器中,我们无法创建或销毁任何一个顶点,也无法处理当前处理的这个顶点与其他顶点之间的关系。

Vertex Shader 能够进行坐标变换我们我们并不奇怪,但是对于更改顶点颜色这一点,我们并不理解原理。

对于初学者来说,尤其是自己写过一些 Shader 代码的人来说,经常会产生一个困惑,如果 Vertex Shader 只是处理三角形顶点的颜色,为什么整个三角形都被设置了颜色呢?甚至如果我们给每一个顶点都设置不同的颜色, Fragment 还会自动的进行颜色插值,显示出渐变的效果。

一个 Shader 的例子如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Pass
{
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag

    #include "UnityCG.cginc"

    struct appdata
    {
        float4 vertex : POSITION;
        float2 uv : TEXCOORD0;
    };

    struct v2f
    {
        float4 vertex : SV_POSITION;
        float2 uv : TEXCOORD1;
        float3 color : COLOR;
    };

    v2f vert(appdata v)
    {
        v2f o;
        o.vertex = UnityObjectToClipPos(v.vertex);
        o.uv = v.uv;

        if (v.uv.x == 0 && v.uv.y == 0) {
            o.color = float3(0, 0, 1); // Blue
        } else if (v.uv.x == 0 && v.uv.y == 1) {
            o.color = float3(1, 0, 0); // Red
        } else if (v.uv.x == 1 && v.uv.y == 0) {
            o.color = float3(0, 1, 0); // Green
        } else if (v.uv.x == 1 && v.uv.y == 1) {
            o.color = float3(1, 1, 0); // Yellow
        }

        return o;
    }

    float4 frag(v2f i) : SV_Target
    {
        return float4(i.color, 1);
    }
    ENDCG
}

Four Color

在计算机图形学中,当我们绘制一个三角形的时候我们可以给每个顶点设置一个颜色,比如左上角是红色,左下角是蓝色,右上角是黄色,右下角是绿色。计算机会自动为三角形内部的每个像素计算颜色,这个过程叫做线性插值。

具体来说,三角形内部的颜色会根据像素离三个顶点的距离进行混合,比如一个像素正好在红色和绿色的正中间,那么它的颜色就是黄色。通过这种方法,颜色在三角形内部会自然地渐变,最终由片段着色器为每个像素上色,这样整个三角形就看起来有一个平滑的渐变效果。

而这个线性插值的过程并不需要 Fragment Shader 的介入。

Tessellation Shader:曲面细分着色器

曲面细分着色器属于一个可选着色器,用于细分图元。主要是对三角面进行细分,以此来增加物体表面的三角面的数量。借助它可以实现细节层次 LOD(Level of Detail) 的机制,使得离摄像机越近的物体具有更加丰富的细节,而远离摄像机的物体具有较少的细节,是性能优化的一种方式。

在这个阶段里,程序员可以进行曲面细分操作,看起来就像在原有的图元内加入更多的顶点。对于一些有大量曲面的模型,进行曲面细分可以让曲面更加圆润。

曲面细分着色器内部还分为了 Hull-Shader Stage、Tessellation Stage 和 Domain-Shader Stage 三个阶段。

flowchart LR

classDef canProgram fill:#b0fba5
classDef fixed fill:#a5fbe3

Vertex("Vertex Shader"):::canProgram
Geometry("Geometry Shader"):::canProgram

HullShader("Hull-Shader Stage"):::canProgram
TessellationStage("Tessellation Stage"):::fixed
DomainShader("Domain-Shader Stage"):::canProgram

subgraph Tessellation
direction LR
HullShader --> TessellationStage --> DomainShader
end

Vertex --> Tessellation --> Geometry

Hull-Shader Stage(外壳着色器阶段):这个阶段是可编程的,开发者可以对顶点进行细分操作,但是这里不会进行真正的细分,只是进行标记。

Tessellation Stage(镶嵌器阶段):这个阶段是不可编程的,GPU 将根据 Hull-Shader Stage 阶段的标记进行细分。

Domain-Shader Stage(域着色器阶段):这个阶段是可编程的,开发者可以对细分的顶点进行坐标计算。

Geometry Shader:几何着色器

在几何着色器阶段,开发者可以对顶点进行增删改操作。

这里需要注意的是 Geometry Shader 并行调用硬件很困难,并行程度低,效率和顶点着色器有很大差距。如果不是需要做顶点的增删操作等只能用 Geometry Shader 实现的效果,更推荐使用 Vertex Shader。

Clipping: 裁剪

裁剪阶段会将所有不在摄像机视野内,以及部分在视野内的图元(点、线、三角形)剔除,使它们不被渲染到。

Screen Mapping:屏幕映射

把每一个图元的x和y坐标转换到屏幕坐标系下,而对输入的z坐标不做任何处理。

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

Primitive Assembly:图元组装

把顶点数据收集并组装为简单的基本体(点、线、三角形),通俗地说就是将相关的两个顶点进行连连看。

几何阶段输入到光栅化阶段的数据主要是三角形网格的顶点信息,我们得到的只是三角形网格每条边的两个端点信息。 如果想要得到整个三角形网格对像素的覆盖情况,就必须计算每条边上的像素坐标,为了能计算三角形边界像素的坐标信息,我们必须得到三角形边界的表示方式。 而 在图元组装这个小阶段,GPU主要做的事情就是计算三角形网格的表示数据。

Triangle Traversal:三角形遍历

Triangle Traversal 过程将检验屏幕上的某个像素是否被一个三角形网格所覆盖,被覆盖的区域将生成一个片元(Fragment)。

GPU 还将对覆盖区域的每个像素进行插值计算,因为在一开始我们只知道顶点的各项数据,中间各个片元的数据需要 GPU 自己通过插值生成。

在遍历过程中,不是所有的像素都会被一个三角形完整地覆盖,很多时候是一个像素块内只有一部分被三角形覆盖。

对于这种情况有三种解决方案:

  1. Standard Rasterization:中心点被覆盖即被划入片元
  2. Outer-conservative Rasterization:覆盖了一点即被划入片元
  3. Inner-conservative Rasterization:完全被覆盖才会被划入片元

片元并不是真正意义上的像素,而是包含了很多种状态的集合,如屏幕坐标、深度、法线和纹理等,这些状态用于最终计算出每个像素的颜色。

Fragment Shader:片元着色器

片元着色器主要完成对三角形遍历输入的片元序列中的每个片元(像素)的着色计算和属性处理。这里需要注意的是,虽然片元和像素比较接近,但是片元并不等同于像素。

片元着色器的工作如下所示:

  1. 光照计算 —— 计算片元的光照效果。
  2. 纹理映射 —— 根据片元在纹理中的位置,对纹理进行采样,将纹理颜色映射到片元上,实现表面贴图效果。
  3. 材质属性处理 —— 根据材质的属性,比如颜色、透明度、反射率等,计算片元的最终颜色和透明度。
  4. 阴影计算 —— 根据光源等信息,计算片元是否处于阴影中,影响其最终颜色。

片元着色器虽然能够实现很多效果,但它仅能影响单个片元,在执行片元着色器时,它无法将任何的结果发送给周围的片元。

Merger:输出合并

这个阶段需要对每个片元进行操作,将它们的颜色以某种形式合并,得到最终在屏幕像素上显示的颜色。

  1. 决定每个片元的可见性,比如深度测试、模板测试。
  2. 如果通过了所有测试,需要把片元的颜色值和已经存储在颜色缓冲区(屏幕显示的就是颜色缓冲区的颜色值)的颜色进行合并(混合)
flowchart LR

classDef canProgram fill:#b0fba5
classDef fixed fill:#a5fbe3
classDef soft fill:yellow

FragmentShader("Fragment Shader
片元着色器"):::canProgram

FramebufferOutput("Framebuffer Output
帧缓冲输出"):::fixed

DepthTest("Depth Test
深度测试"):::soft
StencilTest("Stencil Test
模板测试"):::soft
AlphaTest("Alpha Test
透明度测试"):::soft
ColorBlend("Color Blending
颜色混合"):::soft

subgraph Merger
    direction LR
    StencilTest --> DepthTest
    DepthTest --> AlphaTest
    AlphaTest --> ColorBlend
end

FragmentShader --> Merger
Merger --> FramebufferOutput
透明度测试

在这个阶段,只有透明度达到设置的阈值才可以被渲染出来。

深度测试

CPU 将读取片元的深度值,与缓冲区的深度值进行比较,比较哪些片元应该留下来。

深度测试的发展历史
flowchart LR

classDef ral_E0DBE3 fill:#E0DBE3,text-align:left
classDef ral_pink fill:#F8C1B8,text-align:left
classDef ral_green fill:#A8D19E,text-align:left
classDef ral_B9D9EB fill:#B9D9EB,text-align:left

Unit_1("控制渲染顺序
画家算法
Z-buffer 算法
"):::ral_green

Unit_2("控制 Z-Buffer 对深度的存储
Z Test
Z Write
"):::ral_pink

Unit_3("控制不同类型物体的渲染顺序
透明物体
不透明物体
渲染队列
"):::ral_B9D9EB

Unit_4("减少 overdraw
Early-Z
Z-cull
Z-check
"):::ral_E0DBE3

Unit_1 --> Unit_2 --> Unit_3 --> Unit_4
画家算法

要做到去除隐藏面的最简单的方法。如果一个场景中有许多物体,就是先画远的东西,再画近的东西。这样一来,近的东西自然就会盖住远的东西。

但是画家算法只能解决 简单场景 的消隐问题,一旦物体出现叠加,无法判断谁远谁近的情况,画家算法就失效了。

painter algo

Z-buffer算法

先将 Z 缓冲器中各单元的初始值置为最小值,当要改变某个像素的颜色值时,首先检查当前多边形的深度值是否大于该像素原来的深度值(保存在该像素所对应的 Z 缓冲器的单元中),如果大于原来的 Z 值,说明当前多边形更靠近观察点,用它的颜色替换原像素的颜色。

帧缓冲器对应 intensity(x,y) 属性数组(帧缓冲器):存储图像每个空间可见像素的光强或颜色。

深度缓冲器对应 depth(x,y) 深度数组(z-buffer):存放图像空间每个可见像素的z坐标。

假设 $xoy$ 面为投影面,$z$ 轴为观察方向,过屏幕上任意像素点 $(x,y)$ 做平行于 $z$ 轴的射线 $R$ ,与物体表面相交于 $p_1$ , $p_2$ 点, $p_1$ , $p_2$ 点的 $z$ 值称为该点的深度值,Z-buffer 算法比较 $p_1$ , $p_2$ 的 $z$ 值,将最大的 $z$ 值存入 $z$ 缓冲器中。

z-buffer_axis

优点缺点
1. Z-buffer算法比较简单,直观1. 占用空间大
2. 在像素上以近物取代远物,与物体在屏幕上的出现顺序无关,方便硬件实现2. 没有利用图形的相关性与连续性
3. 该算法是在像素级上的消隐算法
Z Test & Z Write

Z Test (深度测试)是一种测试机制,用于决定一个像素是否应该被绘制在屏幕上。它比较当前像素的深度值(Z 值)与深度缓冲区中已存储的深度值,以确定该像素是否比之前绘制的像素更靠近视点。

Z Write(深度写入)决定是否将当前像素的深度值写入深度缓冲区。如果 Z Write 被禁用,即使通过了 Z Test,深度缓冲区也不会更新。

在实际使用中,Z Test 与 Z Write 需要配合使用,以下是几个比较典型的使用场景。

  1. 渲染不透明物体:开启 Z Test(默认条件为 LESS)确保正确的遮挡关系。开启 Z Write 更新深度缓冲区
  2. 渲染透明物体:开启 Z Test,确保正确的遮挡关系。禁用 Z Write,避免覆盖深度缓冲区中已有的值,从而确保透明物体的后续像素混合正常。通常需要从后向前排序渲染透明物体,以保证混合效果正确。
  3. 渲染特效:根据需求选择性地开启或禁用 Z Test 和 Z Write。如果特效需要遮挡关系,可以开启 Z Test。如果特效需要叠加效果,则可能禁用 Z Test,以确保效果覆盖在其他物体之上。

Z Test 与 Z Write 相较于 Z-buffer 算法的改进

  1. Z Test 可以在像素着色阶段之前丢弃被遮挡的像素,从而减少无效计算。通过合理配置 Z Write,可以避免不必要的深度缓冲更新,提高效率。
  2. Z Test 提供多种比较条件(LESS、GREATER、ALWAYS 等),适应不同场景需求,Z Write 的开关可支持透明物体、特效等复杂渲染场景。
  3. 使用 Z Test 和深度偏移(Depth Bias)可以缓解 Z-fighting(深度冲突)问题。

Z-fighting 现象 是指当两个平面深度值非常接近时,可能会产生闪烁效果(Z-fighting)。解决方案是调整深度偏移、增加深度缓冲区精度(如使用 24 位深度缓冲)

Render Queue

渲染队列(Render Queue)是一种在渲染管线中组织和管理绘制顺序的技术,用于控制不同类型的物体在场景中的渲染顺序。通过划分和排序渲染队列,渲染引擎可以更高效地处理复杂场景,确保渲染结果正确,同时优化性能。

渲染队列的功能如下所示:

  1. 控制渲染顺序:在场景中,不同类型的物体(如不透明物体、透明物体)对渲染顺序有特定要求。渲染队列能确保这些物体按照预期顺序被绘制,避免视觉错误。
  2. 优化性能:通过对物体进行分类和排序,可以减少状态切换(如材质、光照设置的更改)和冗余计算(如透明物体排序),从而提高渲染效率。
  3. 支持复杂场景:渲染队列允许灵活分配不同的渲染策略,如处理特殊效果(粒子、特效)或后期处理(光晕、模糊)。

渲染队列通常根据物体的特性分为以下几类:

不透明队列(Opaque Queue)

  • 特点:用于渲染不透明物体,按从前到后的顺序渲染。
  • 原因:启用深度写入(Z Write)和深度测试(Z Test),可以通过深度缓冲快速丢弃被遮挡的片段。
  • 优先级:优先渲染(通常是最早的队列)。

透明队列(Transparent Queue)

  • 特点:用于渲染具有透明度的物体(如玻璃、水面、半透明纹理),按从远到近的顺序渲染。
  • 原因:透明物体需要禁用深度写入(Z Write),并正确叠加混合,保证视觉效果。
  • 排序:基于相机到物体的距离进行排序。

特效队列(Effects Queue)

  • 特点:处理粒子、特效等特殊物体,通常也需要透明度支持。
  • 排序:根据需求选择从远到近或特定逻辑排序

后期处理队列(Post-Processing Queue)

  • 特点:用于后期处理,如景深、HDR、色调映射等。
  • 顺序:在所有场景物体渲染完成后进行。

自定义队列(Custom Queue)

  • 特点:渲染引擎允许用户定义自己的队列,用于特定需求,如UI层、遮挡剔除特殊效果等。
  • 灵活性:开发者可调整队列优先级,以适应不同的场景需求。
Early-Z

在传统的渲染管线中,深度测试(Z Test)通常发生在混合阶段(Blending),即在片段着色器(Fragment Shader)运行之后。这种方式需要对所有对象的像素运行片段着色器,无论最终的片段是否会被遮挡。这导致了大量的无用计算,因为在许多像素点上,多个片段可能重叠,但最终只有最接近观察者的片段会被显示。

为了解决这一性能瓶颈,现代 GPU 引入了 Early-Z 技术。在光栅化之后、片段着色器运行之前,GPU 会先进行一次早期深度测试。如果一个片段未通过深度测试(例如,被其他片段遮挡),那么就直接丢弃这个片段,不再执行片段着色器的计算。这种优化显著减少了无用的计算量,带来了显著的性能提升。

需要注意的是,尽管有 Early-Z 优化,最终阶段仍需要进行一次传统的深度测试(通常称为 Late-Z),以确保所有的遮挡关系都正确无误。这是因为某些操作(例如片段着色器中的修改)可能会影响深度值,Early-Z 的结果可能不完全可靠。

可以搜索论文 《Applications of Explicit Early-Z Culling》了解更多细节。

Reference

知乎:Shader 从入门到跑路

知乎:技术美术(TA)入门系列教程

kio0o0:百人计划-图形-3.1 深度与模板测试

Pplm:消隐算法(一)——Z-buffer算法

博客园:Unity Shader-渲染队列,ZTest,ZWrite,Early-Z