【读书随笔】图像处理单元 GPU:《Real-Time Rendering》Fourth Edition Chapter 3

  • 本文总结了 Real-Time Rendering (第四版) 中第三章的内容
  • 本文内容结合 《Practical Rendering & Computation with Direct 3D 11》第三章以及网上内容

这里仅总结在书中自己认为或者不懂的要点并结合实际工作学习所需,若内容讲解不详细还请谅解


 随着市场对计算机图形渲染的需求越来越大,图像处理单元 GPU 产生了。因为图像渲染管线需要处理大量的,重复的简单计算,为了实现渲染效率,GPU 则被设计成高度并行式的计算模式。GPU用于在短时间进行大量的关于顶点,图元和像素的计算,GPU 的每一个硅晶体可以同时处理这些运算。

GPU 最开始是难以被编程的,但随着 GPU 的发展,GPU 在往灵活的,可编程的方式发展。其中,着色器(shader)就是进行 GPU 编程的主要形式。着色器可以进行顶点处理,几何坐标变换,像素颜色处理等。随着成千上万的图元被送入 GPU 中,每秒钟也会有无数的着色器结合渲染指令处理它们。

一个着色器处理一次图元的过程叫做 shader invocations(在这里是指一个片元在 shader 中从头到尾一次处理)其中一个 shader invocation 对片元(注意是片元 fragment)的处理叫一个线程(Thread),进行相同着色处理的线程叫做一个 Wrap (NVIDIA 术语,AMD术语叫做 wavefront)。在 NIVIDIA GPU 中,一个 wrap 有 32 个线程(32 的倍数可以方便的映射到硬件上从而提升效率 [3])。

  • Data-Parallel Architectures

与 CPU 能够处理多种数据结构和大块复杂代码的功能不同,GPU 只能处理固定的图像相关数据和着色器代码。但 GPU 在处理单指令多数据(SIMD)的效率则远远高于 CPU,这主要得益于 GPU 的并行设计模式:每一个处理单元都是独立的,且数据流会被分配到各个处理单元内并行处理,这种处理器也叫做流处理器(stream processor),其中 GPU 的每一个运算处理单元被叫作着色器核心(shader cores)。CPU 和 GPU 的硬件架构不同可见下图:

图 1:CPU vs GPU: 黄色为控制单元,橘色为存储单元(DRAM / Cache),绿色为算数逻辑单元(ALU)[t1] 

GPU 优化了吞吐量(throughput),也就是单次可处理的最大数据量。GPU 处理图元数据流的方式则是使用了 SIMD 的思想:将所有图元使用一个指令进行处理,然后再执行下一个指令处理所有的图元。这样可以有效的加速对所有图元的处理,并且避免了频繁切换指令而造成的效率损失。尽管看来处理单个图元的时间增长了,但整体效率却提高了。

相对于 CPU 的低延迟和低吞吐量,GPU 则采用了高延迟和高吞吐量的设计:因为要尽可能单次处理更多的数据,所以要尽可能保证一次处理的数据量能够充分的利用所有的计算资源。可以想象成一个罐头工厂,工厂生产一个罐头需要启动所有机器一次,工厂要生产一百个罐头也要启动所有机器一次(假设生产多个罐头同时完成),所以要尽可能地少开关机器,并一次能多生产罐头。

在处理每一个线程也是遵循同样的原理:如上面所说,所有执行相同着色操作的线程将会被封装成一个 wrap。其中,为了减少延迟,GPU 可以对每一个 wrap 进行一个或者一系列处理后切换到(swap)下一个 wrap:比如对于第一个 wrap 处理的最后一个步骤需要一定的时间去提取数据,在等待对第一个 wrap 处理的最后一步返还提取数据时,GPU 会对第二个 wrap 进行和对第一个 wrap 相同的处理。循环往复直到处理完所有的线程。这也是基于了 SIMD-processing 的思想以加快处理效率。书中给了一个简单的例子(见图 2):

图 2:处理多个指令,多个 wrap(假设能同时处理 32 个线程,即一个 wrap)

Shader 的结构也会影响 GPU 的效率。简而言之:越复杂的 shader 会使 GPU 效率降低,主要原因是复杂的 shader 将会占用大量的存储资源(在书中的体现是寄存器 register),当一个 shader 使用的 寄存器越多时,GPU 单次能处理的线程也就越少,这时线程的在 GPU 中的占有量(occupancy)也就越少。低占有量意味着 GPU 的效率越低,单次能处理的线程越少。

另一个原因是在 shader 中的动态分支(dynamic branching)造成的性能损耗,也就是 if statement 所造成的;如果一个 wrap 里面的数据有些在一个 if statement 判 true,有些判 false,那么这个 wrap则要被执行两次,且针对不同的 branch 只保留一半结果。 这个现象叫做 thread divergence

  • GPU 管线概述(GPU Pipeline Overview)

图 3:GPU 的渲染管线实现。其中绿色是可编程的,虚线部分是可选择实现部分,黄色部分是可调设置的,蓝色部分不可调节

GPU 渲染管线的实现基于实时渲染管线的架构(具体见这篇随笔)。随着开发需求越来越趋近追求灵活性,图形管线部分可编程或可调节的部分在逐渐变多,其中最为典型的就是着色器的使用。

下面将会讲解每一个可编程阶段,不可编程阶段详见我的上一篇博客

  • 可编程着色器阶段(The Programmable Shader Stage)

顶点,像素,几何结构等是构成渲染元素的核心,他们本身就有一套相似的指令集架构处理(被相同硬件处理,数据结构相似,可以使用相似的指令集进行处理)。现代渲染模型基于顶点,像素,几何结构等通用模型设计出一套通用着色器核心(common-shader core,DX术语)。基于这些通用核心,GPU 可以在渲染时将工作分为处理顶点,像素或其他种类,以此均衡计算资源的使用。也是基于这套核心,图形 API 设计不同的着色器语言来进行图像处理,比如 DirectX 的 HLSL(high level shading language)和 OpenGL 的 GLSL(OpenGL Shading Language)。

当 draw 指令被唤起,图形 API 会对图元开始绘制,这时着色器将会被启用,这个阶段既是着色器阶段(shader stage)。每个着色器阶段有两种输入:常量输入(uniform inputs)变量输入(varying inptus)。常量输入的值在每一次 draw 指令期间都不会变,但在不同的 draw 指令之间可以被改变。变量输入通常是来自于图元,比如顶点或者像素。举个例子:一个像素着色器中的光的颜色是常量输入,三角形顶点输入一直在变所以是变量输入。这里注意贴图是一个特别的常量输入,因为贴图会被视为一个很大的颜色数组。

着色器虚拟机(shader virtual machine)会为输入输出提供不同的寄存器,其中常量寄存器(constant registers)给常量输入输出的储存空间远远大于那些分配给变量的寄存器。这是因为常量寄存器需要储存会被重复使用的数据,而变量寄存器不需要。虚拟机也提供临时寄存器(temporary register)用于使用涂销空间(Scratch space),所有类型的寄存器都可以通过临时寄存器内的数组索引找到。着色器虚拟机的输入结构如下:

 

图 4:着色器虚拟机处理输入输出结构

 

 在着色器中需要进行流量控制(flow control,主要体现在分流,例如 if else)以处理不同类型的输入。着色器主要支持两种流量控制方式,一种是针对常量输入的静态流量控制(static flow control),静态流量控制中的代码在 draw call 中一直保持不变,它的主要优势是在于可以不同情况下使用同一个着色器,比如在多组光处理中使用相同的着色器,这并不会造成 thread divergence,因为所有的处理都使用了相同的代码。另一种是动态流量控制(Dynamic flow control),它基于变量输入的值,意味着每个片元都可以执行不同的代码。动态流量控制相比静态能适应更复杂的情况,但代价就是性能的损耗,尤其体现在在不同的 shader invocation 间来回切换。

  • 顶点着色器(The Vertex Shader)

在介绍顶点着色器之前,我们有必要先了解一下顶点:顶点(vertex)是模型中的最基本元素,一个顶点有它在模型空间内的具体位置。除了位置外,顶点还有一些可选的属性,比如颜色,贴图坐标以及顶点法线(注:文中提到过面法线,但在实际工作中的大部分使用的都是顶点法线,因为三角形面不一定是平的,详见下图)。

图 5:顶点法线(箭头)与三角形面(红色曲线),左边为平滑表面,右边为弯曲表面

然而在进行顶点着色之前,我们还需要先处理输入数据并将它们组合成顶点着色器能处理的形式,这个步骤叫做输入组装(input assembler,DX 术语)。输入组装将读入的原始数据组装成网格模型,输出一个或多个多边形网格(polygon mesh)模型,其中三角形网格(triangle mesh)模型是最常见的网格模型,其中一个模型由多个顶点组成。在组装完毕后发送给管线的第一步进行顶点着色处理,也就是顶点着色器。

顶点着色处理(vertex shading)是 GPU 渲染管线的第一步,它直接处理每个输入顶点,可以对每个顶点数据属性进行修改,创建或忽视顶点内数据,比如修改顶点的位置或颜色以达到期待的效果。顶点着色器最基本的功能是将模型空间中的顶点数据转换为齐次坐标(这里牵扯几何坐标的转换),但当曲面细分开启时,顶点着色器也可以只生成控制点(Control Point,控制几何形状,然后将控制点信息传递给曲面细分阶段 [5]

顶点着色器输出的数据可被后面多个阶段使用。注意每一个顶点着色器只能处理一个顶点而不是多个,处理多个顶点的情况将会交给后面的步骤。

  • 曲面细分阶段(The Tessellation Stage)

曲面细分阶段可以渲染曲面部分,在这个阶段 GPU 提取面的信息进而生成一系列表示曲面的三角形面,这里所指的”曲面“并不是字面意义上所指的弯曲的表面,而是更为细致的模型表现,这些模型表现都可以由曲面细分实现更多的三角形图元实现,比如基于曲面细分的位移贴图(displacement mapping)

然而为什么不直接制作精致的模型而是选择曲面细分呢?之所以不在模型中直接生成曲面而是用曲面细分的方式是有原因的,具体如下:

  1. 节省储存资源:试想一下如果游戏引擎每次都需要加载一个三角形面数极多的模型,那么内存需要在渲染时加载非常大的数据。然而这个过程可以在 GPU 中通过少量面数据进行计算得到需要的曲面效果,从而降低内存压力。
  2. 降低数据传输压力:理由同上,传递少量数据再在 GPU 内计算曲面可减少 CPU 与 GPU 之间总线传输压力。
  3. 维持帧率:当在不需要渲染太多细节时,停止曲面细分可以维持帧率。比如一个物体里镜头很远时,可以减少其三角形的面数来维持帧率,而当需要展示细节时则开启曲面细分或者恢复之前隐藏的面或顶点。这个操作叫做控制细节等级(level of detail,LOD)。

曲面细分阶段主要有三个子阶段:

  1. 细分控制着色器(OpenGL 叫做 tessellation control shader, DX 叫做 hull shader):接受细分曲面,处理控制点。输出一系列控制点到 domain shader,输出细分因素(tessellation factors)给细分器。细分因素决定细分方式。
  2. 细分器(OpenGL 叫做 primitive generator, DX 叫做 tessellator):增加新顶点,传递给 domain shader。

  3. 细分计算着色器(OpenGL 叫做 tessellation evaluation shader, DX 叫做 domain shader):计算新顶点位置,输出曲面细分后的模型。

 

这三个阶段由 1 到 3 传递并处理数据,其中 hull shader 和 domain shader 可被编程。过程如下:

 

图 6:曲面细分过程
  • 几何着色器(The Geometry Shader)

几何着色器基于现有图元生成其他图元,顶点着色和曲面细分无法实现这一点(曲面细分阶段只是生成新的三角形图元以体现曲面)。几何着色器可以实现诸如粒子特效,模型变形,精细阴影等效果。几何着色器接受一个或多个图元,并将他们转换成其他形状的图元,比如说三角形变圆形。由于影响几何着色器效率的因素有很多,且它的行为不好预测(尽管是可编程部分),所以在实际运用中,几何渲染器不经常被使用。

  • 像素着色器(Pixel Shader)

在进行完以上三个阶段后,数据在经过裁剪,屏幕映射和光栅插值后,将会被发送到最后一个可编程阶段:像素着色阶段(pixel shading stage)

由上一个阶段,经过光栅器基于输入的三角形顶点插值得出的需要渲染的像素以及这些像素的深度数据,会被传递给像素着色器(OpenGL 中叫做片元着色器)。像素着色器会对输入进来的像素数据处理得到像素的最终颜色。处理包括对像素的原始颜色,透明度,深度经由各项测试生成位于该位置的像素的最终颜色。

然而,像素着色器在处理像素时也有着自己的限制:首先,每一个像素着色处理只能一次处理单个像素数据,而无法读取或处理该像素的周边像素数据。对于这种问题,一种解决方式是使用新的数据结构。现代GPU 中引入了一种新的数据结构名为 quad,它是一种 2 x 2 的正方形,由 4 个像素组成,当一个像素需要临近像素的信息时,可以取得向四周像素的渐变信息来得到临近像素的信息。举例如下:

图 7:quad 取得临近像素渐变信息
  • 计算着色器(The Compute Shader)

尽管 GPU 最开始的目的时用于进行图像渲染计算,但随着人工智能和机器学习的发展,GPU 的计算优势逐渐体现出来(并行计算架构,高吞吐量等)。为了支持它们的计算需求,计算着色器(compute shader)被设计成使用 GPU 架构进行平行计算,而不需使用图形渲染管线。尽管计算着色器不能直接使用渲染管线功能,但他也可以帮助图形相关着色器优化功能。

  • 附加:

为了能够访问其他像素信息或计算结果,像素着色器和计算着色器都可以读写 UVA(DX 术语)。UVA 是一个随机顺序读写的缓存,用于不同的像素着色器和计算着色器获得其他线程的计算结果使用。GPU 底层硬件也为了防止多线程读写同一资源造成的数据冲突(data hazard)实现相应的解决方案。


 参考资料:

[1] Real-Time Rendering fourth Edition (大部分内容以及图片来自于此书)

[2] OmniSci --- CPU vs GPU

[3] CVbaby --- GPU中的几个基本概念

[4] 百度百科 --- 流处理器

posted @ 2020-10-30 19:00  DamienTian  阅读(383)  评论(0编辑  收藏  举报