《Unity Shader 入门精要》读书笔记 | 第 2 章 渲染流水线

发布于 2023-09-05  14 次阅读


开始之前

我在学习本书时使用的 Unity 版本为 2021.3.6f1。

我对于书中内容的注释会标明为 arimx 注,并使用斜体

更多内容

全部章节

综述

什么是流水线

使用流水线可以提高单位时间的生产量。

流水线系统中决定最后生产速度的是最慢的工序所需的时间,即瓶颈。

什么是渲染流水线

渲染流水线的工作任务是由一个三维场景出发,生成一张二维图像。这个工作通常是由 CPU 和 GPU 共同完成。

渲染流程的三个概念阶段:

  • 应用阶段
  • 几何阶段
  • 光栅化阶段

应用阶段

通常由 CPU 进行。

主要任务:

  • 准备场景数据,如摄像机位置、视锥体、场景模型、光源等
  • 粗粒度剔除(culling),剔除不可见物体
  • 设置每个模型的渲染状态,包括材质(漫反射颜色、高光反射颜色)、纹理、Shader 等

这一阶段输出渲染所需的几何信息,即渲染图元(rendering primitives),可以是点、线、三角面等。

几何阶段

通常由 GPU 进行。

处理绘制几何相关的事情。几何阶段的一个重要任务是把顶点坐标变换到屏幕空间中,再交给光栅器处理。

这一阶段输出屏幕空间的二维顶点坐标、每个顶点对应的深度值、着色等相关信息。

光栅化阶段

由 GPU 进行。

光栅化阶段对几何阶段得到的逐顶点数据进行插值,再进行逐像素处理,决定每个渲染图元中的哪些像素应该被绘制在屏幕上。

这个阶段会产生屏幕上的像素,渲染出最终的图像。

CPU 和 GPU 之间的通信

把数据加载到显存中

所有渲染所需的数据都需要从硬盘加载到系统内存中,然后网格和纹理等数据又被加载到显存中。

设置渲染状态

渲染状态定义了场景中的网格怎样被渲染,如使用哪个顶点着色器或片元着色器、光源属性、材质等。

调用 Draw Call

CPU 通过调用 Draw Call 来告诉 GPU 开始进行一个渲染过程。这个命令仅指向一个需要被渲染的图元列表,而不包含材质信息。

GPU 流水线

概述

顶点着色器

顶点着色器(Vertex Shader)是流水线的第一个阶段,它的输入来自 CPU。输入的每个顶点都会调用一次顶点着色器。顶点着色器本身不可以创建或者销毁任何顶点,而且无法得到顶点与顶点之间的关系。

顶点着色器主要完成坐标变换和逐顶点光照。顶点着色器把顶点坐标从模型空间转换到齐次裁剪空间。除此之外,坐标变换还可以用于顶点动画,通过改变顶点位置来模拟水面、布料等。

顶点着色器将顶点坐标转换到齐次裁剪空间后,通常会由硬件做透视除法得到归一化的设备坐标(Normalized Device Coordinates,NDC)。在 OpenGL 中,NDC 的 x、y、z 分量范围都是 $[-1, 1]$;而在 DirectX 中,NDC 的 z 分量范围为 $[0, 1]$。Unity 采用 OpenGL 的 NDC。

曲面细分着色器

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

几何着色器

几何着色器(Geometry Shader)也是一个可选的着色器,用于执行逐图元着色操作,或者用于产生更多的图元。

裁剪

裁剪(Clipping)可以将不在摄像机视野内的顶点去掉,并剔除某些三角图元的面片。

裁剪过程中,完全在视野内的图元会传递给下一个流水线阶段,完全在视野外的图元不会继续传递,而部分在视野内的图元则会进行裁剪处理,用视野边界上的新顶点代替视野外的顶点。

可以通过自定义裁剪操作进行配置。

屏幕映射

屏幕映射(Screen Mapping)将每个图元的 x 和 y 坐标转换到屏幕坐标系(Screen Coordinates)下。屏幕坐标和 z 坐标共同构成窗口坐标系(Window Coordinates),传递到光栅化阶段。

OpenGL 与 DirectX 屏幕坐标系不同,前者 $(0, 0)$ 点在左下角,后者的则在左上角。

三角形设置

几何阶段输出屏幕坐标系下的顶点位置以及相关额外信息,如深度值、法线方向、视角方向等。光栅化阶段计算每个图元覆盖了哪些像素,并为这些像素计算颜色。

三角形设置(Triangle Setup)是光栅化的第一个流水线阶段。这个阶段会计算光栅化一个三角网格所需的信息,为下一个阶段做准备。

三角形遍历

三角形遍历(Triangle Traversal)阶段,也称扫描变换(Scan Conversion),检查每个像素是否被一个三角网格所覆盖。如果是,则生成一个片元(fragment)。

三角形遍历阶段判断一个三角网格覆盖了哪些像素,并使用顶点信息对整个覆盖区域的像素进行插值。

这一阶段输出一个片元序列。片元不是真正意义上的像素,而是很多状态的集合,包括屏幕坐标、深度信息,法线、纹理坐标等。

片元着色器

片元着色器(Fragment Shader),在 DirectX 称为像素着色器(Pixel Shader),是另一个非常重要的可编程着色器阶段,输出一个或多个颜色值。

这一阶段可以完成很多重要的渲染技术,如纹理采样。

一般情况下片元着色器只能影响单个片元。有一个情况例外,就是片元着色器可以访问到导数信息。

逐片元操作

逐片元操作(Per-Fragment Operations),在 DirectX 称为输出合并阶段(Output-Merger),是渲染流水线的最后一步。这一阶段是高度可配置性的。

这一阶段的任务是:

  • 决定每个片元的可见性,涉及模板测试、深度测试等测试工作
  • 如果一个片元通过了所有的测试,就把片元的颜色值和已经存储在颜色缓冲区的颜色进行合并

首先是模板测试(Stencil Test)。如果开启了模板测试,GPU 会首先读取(使用读取掩码)模板缓冲区中该片元位置的模板值,然后将该值和读取(使用读取掩码)到的参考值(reference value)进行比较,这个比较函数可以是由开发者指定的。如果片元没有通过测试,该片元就会被舍弃。不管一个片元有没有通过模板测试,都可以根据模板测试和深度测试的结果来修改模板缓冲区,这个修改操作也是由开发者指定的。模板测试常用于限制渲染的区域,也可以用于渲染阴影、轮廓渲染等。

通过模板测试后,片元将会进行深度测试(Depth Test)。如果开启了深度测试,GPU 会把该片元的深度值和已经存在于深度缓冲区的深度值进行比较,这个比较函数也是由开发者设置的,通常为小于等于。如果片元没有通过深度测试,该片元就会被舍弃,也不能更改深度缓冲区中的值。如果片元通过了测试,开发者可以指定是否用该片元的深度值覆盖掉原有的深度值,这是通过开启或关闭深度写入做到的。透明效果和深度测试以及深度写入的关系密切。

一个片元通过所有的测试后,就会进行合并。对于半透明物体,需要使用混合(Blend)操作来让这个物体看起来是透明的;对于不透明物体,则可以关闭混合操作,用片元的颜色值直接覆盖颜色缓冲区的颜色值。

测试顺序不是唯一的。Early-Z 技术将深度测试提到片元着色器之前执行以提高性能。但提前测试可能导致检验结果与片元着色器中的操作冲突,如在片元着色器中进行透明度测试。现代 GPU 会判断是否发生冲突,若是,则禁用提前测试。这是透明度测试会导致性能下降的原因。

屏幕显示的就是颜色缓冲区中的颜色值。为了避免看到正在进行光栅化的图元,GPU 会使用双重缓冲(Double Buffering)策略。屏幕上的图像是前置缓冲(Front Buffer),而对场景的渲染则在后置缓冲(Back Buffer)中。一旦场景已经被渲染到后置缓冲中,GPU 就会交换后置缓冲和前置缓冲的内容。

一些容易困惑的地方

什么是 OpenGL/DirectX

OpenGL 和 DirectX 是图像应用编程接口。应用程序向这些接口发送渲染命令,而这些接口会依次向显卡驱动(Graphics Driver)发送渲染命令,显卡驱动则和 GPU 通信。

什么是 HLSL、GLSL、Cg

这些是着色语言(Shading Language),专门用于编写着色器。常见的着色语言有 DirectX 的 HLSL(High Level Shading Language)、OpenGL 的 GLSL(OpenGL Shading Language)以及 NVIDIA 的 Cg(C for Graphic)。

HLSL 由微软控制着色器的编译,支持平台相对有限,如 Windows、Xbox 360 等。这是因为其他平台上没有可以编译 HLSL 的编译器。

GLSL 可以在 Windows、Linux、Mac 以及移动平台等多种平台上工作,这是由于 OpenGL 没有提供着色器编译器,而是由显卡驱动来完成编译。换句话说,GLSL 依赖硬件,而不依赖操作系统。但这意味着 GLSL 的编译结果取决于硬件供应商。

Cg 是真正意义上的跨平台,它会根据平台的不同编译成相应的中间语言(Intermediate Language,IL)。Cg 语言的语法和 HLSL 非常相像。

Unity 平台上可以选择使用哪种语言,但不是真正意义上的对应的着色语言。

什么是 Draw Call

Draw Call 就是 CPU 调用图像编程接口,如 OpenGL 中的 glDrawElements 或者 DirectX 中的 DrawIndexedPrimitive,以命令 GPU 进行渲染的操作。

CPU 和 GPU 如何实现并行工作

解决方法是使用命令缓冲区(Command Buffer)。命令缓冲区包含了一个命令队列,由 CPU 向其中添加命令,而 GPU 从中读取命令,添加和读取的过程是独立的。当 CPU 需要渲染一些对象时,可以向命令缓冲区添加命令,而当 GPU 完成上一次的渲染任务后,它就可以从命令队列中再取出一个命令并执行它。命令缓冲区中的命令除了 Draw Call 外,还有其他命令,如改变渲染状态等。

为什么 Draw Call 多了会影响帧率

每次调用 Draw Call 前,CPU 需要向 GPU 发送很多内容,包括数据、状态和命令等。此时 CPU 需要完成很多工作,如检查渲染状态等。如果 Draw Call 的数量太多,CPU 就会把大量时间花费在提交 Draw Call 上,造成 CPU 过载。

如何减少 Draw Call

减少 Draw Call 的办法有很多,其中一种是批处理(Batching)。批处理将很多小的 Draw Call 合并成一个大的 Draw Call。由于需要在 CPU 内存中合并网格,而合并过程需要时间,批处理技术更适合静态的物体。如果用于动态物体,每一帧都需要重新合并再提交。

在游戏开发过程中,为了减少 Draw Call 的开销,需要注意:

  • 避免使用大量很小的网格
  • 避免使用过多的材质

什么是固定管线渲染

固定函数的流水线(Fixed-Function Pipeline),也称固定管线,通常是在较旧的 GPU 上实现的渲染流水线。这种流水线只给开发者提供一些配置操作,但开发者对流水线阶段没有完全控制权。

不建议使用固定管线的渲染方式。

什么是 Shader

  • GPU 流水线上一些可高度编程的阶段
  • 有一些特定类型的着色器
  • 依靠着色器可以控制流水线中的渲染细节