# 深入 GPU 和渲染优化(基础篇)

# 基础概念

GPU架构概述
  • GPU 架构概述

    GPU,全称为 Graphics Processing Unit,即图形处理单元。

    GPU 主要包含控制模块,计算模块和输出模块。

    渲染输出单元,纹理映射单元和着色器处理单元 / 流处理器

  • 计算模块

    计算模块由通用计算单元组成,即 GPGPU,它适用于所有 shader 类型。

  • TPC

    通用计算单元中,最核心的计算模块,称为 Texture Process Cluster (TPC),它负责:

    (1)纹理采样

    (2)顶点插值,顶点剔除

    (3)shader 载入

    (4)shader 计算

  • 图元引擎(Primitive Engine, PE)

    在渲染管线中负责顶点数据读取,三角形剔除等操作。

  • 流 (多) 处理器 (Streaming Multi-processor, SM)

    **** TPC 中最核心的部件。包含独立的 Cache,Register,线程资源调度器,浮点运算核心,读写单元等。

  • 流处理器(Streaming-Processor,SP)

    SM 由多个 SP 和其它资源组成。

  • Warp

    每个 SP 分为多个 warp,它是流处理器调度的基础模块。通常来说,warp 可能包含 32 个 thread,它们将并行运行。每个 thread 有自己的寄存器和本地内存。

  • On Chip Memory

    片上存储器。是访问速度极快,尺寸极小的存储器。

  • 纹理映射单元(Texture Mapping Unit,TMP)

    着色器的一部分,能执行纹理采样。

  • 渲染输出单元(Render Ouput Unit, ROP)

    渲染流水线的最后一步,它将处理抗锯齿,并执行读取 / 写入缓冲区的操作。

    https://img-blog.csdnimg.cn/20201128160221505.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1pKVV9maXNoMTk5Ng==,size_16,color_FFFFFF,t_70

  • SoC(System on Chip)

    移动端将 CPU,GPU,内存,显存,GPS 等整合在一个芯片上的解决方案。

# 带宽

带宽是衡量显卡性能的一个重要指标,它反映了显存数据传输的能力 (IO)

显存位宽:显存在一个时钟周期传输数据的位数;

显存频率:显存的速度,为时钟周期的倒数;

显存带宽:显存频率 * 显存位宽 / 8,单位为字节每秒;

如果把数据读取传输看作水管传输的话,位宽代表了水管的半径,频率则反映了水流的速度,带宽即单位时间内能够传输的水量;位宽越大,频率越高,则带宽越大,传输数据的能力越强。

数据的频繁传输非常耗电,因此我们认为带宽是影响手机发热的重要因素之一。

# ALU

即 Arithmetic logic unit,算术逻辑单元,它是多类型计算电路的基本部件,计算电路包括中央处理单元(CPU)、浮点处理单元(FPU) 和图形处理单元(GPU)等。

通常而言,我们会在 shader 计算中涉及到算术指令,如按位运算 / 关系运算 / 浮点运算等。它是衡量 shader 复杂度的重要指标之一。

# SIMD

SIMD,即 Single Instruction Multiple Data,用一个指令并行地对多个数据进行运算。和 CPU 一样,GPU 同样有对应的 SIMD。

shader 中大量会涉及到向量和矩阵的运算,SIMD 的硬件设计有利于加速 GPU 计算。

# 显存

GPU 上内存称为显存,它的设计和 CPU 比较相似,都为多级缓存机制,包含 register,L1/L2 Cache 和 DRAM 等多个模块。因此,在 GPU 开发时,也需要考虑缓存命中的问题。

https://img-blog.csdnimg.cn/20201101191421252.png

和 CPU 不同的地方在于:

① GPU 的寄存器数量远多于 CPU

② warp 内部的 thread 有自己独立的寄存器和 Local Memory

③ SM 中有独立的用于贴图缓存的 L1 Cache,用于 shader 指令缓存的 L1 Cache,作为内部线程间 Shared Memory 的 L1 Cache 等

④ SM 有独立的常量区内存,用于存储 constant buffer 和 texture 等 shader 资源

⑤ 有全局空间的显存

⑥ SM 的 L2 缓存是公用的

在硬件设计上,PC 端的显存为 GPU 独立的可直接访问的内存,而 Mobile 上 CPU/GPU 使用的内存处于同一物理内存上,GPU 仅有逻辑上独立的内存区间,由 GPU 控制管理。

在内存分配上,显存需要直接操作内存,并且访问内存时也要求内存对齐。

在内存管理上,CPU 有缺页中断等机制,但 GPU 不支持页错误。

https://img-blog.csdnimg.cn/20201129162409741.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1pKVV9maXNoMTk5Ng==,size_16,color_FFFFFF,t_70

# 流程控制 (flow-control)

流程控制,通俗而言,就是计算过程中遇到的分支(if)。相比 CPU,GPU 具有较弱的流程控制能力。

对于 CPU 而言,它具有分支预测的功能,会根据原先的分支结果预测下一次分支走向,只有预测错误才会产生额外开销。

而对于 GPU 而言,它的流程控制基于 active mask 技术。假设 warp 中包含 32 个 thread,GPU 将使用一个 bit mask 判断 32 个 thread 的分支状态,对应位为 1 代表需要执行对应的分支。warp 执行分支时,将先执行所有 true(或 false)的分支,等所有分支执行完成后,再执行所有 false(或 true)的分支,整体执行时间为两者之和。这一过程将打断 warp 的并行化。

https://img-blog.csdnimg.cn/20201117000001736.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1pKVV9maXNoMTk5Ng==,size_16,color_FFFFFF,t_70

active mask 技术

因此,我们应该尽可能避免流程控制。

# 像素填充率(pixel fillrate)

图形处理单元在每秒内渲染的像素数量。它也是衡量显卡性能的指标之一。

在实际计算中,分辨率、Overdraw 次数、Shader 计算复杂度和 Fill Rate 成正比。

# IMR 架构

IMR,即 Immediate Mode Rendering,是常用于 PC 端的渲染管线。它的架构设计比较简单清晰,整个管线是连续执行的,执行完上一个任务后,将立刻执行下一个任务,无需相互等待。

当接收到一个绘制指令后,这一绘制会立即开始执行,并且将依次顺序经过如下步骤:

(1)顶点处理(Vertex Processing):从内存读取顶点索引,并根据索引查找相关顶点缓冲区,加载顶点数据。顶点着色器加载到 SM 中并执行。

(2)裁剪和剔除(Clip & Cull):在这一过程中,PE 将剔除裁剪空间(clip space) 外的三角形,并且进行背面剔除操作。

(3)光栅化(Raster):执行光栅化,从几何转化为像素,像素打包成 warp,重新流入 SM,并根据重心坐标插值顶点属性。

(4)提前可见性测试(Early Visibility Test):对于没有 Alpha Test 的像素,由 ZROP 执行 early-Z test,通过后进入下一环节。

(5)纹理和着色(Texture & Shade):执行像素着色器。

(6)Alpha 测试(Alpha Test)

(7)可见性测试(Late Visibility Test):对于有 Alpha Test 的像素,由 ZROP 执行 late-Z test,并根据结果决定是否更新帧缓冲的颜色和深度。

(8)Alpha 混合(Alpha Blend):对于通过测试的像素,CROP 根据 blend 计算并更新颜色缓冲区。

# TBR/TDBR 架构

TBR,即 Tile-Based Rendering;TDBR,即 Tile-Deferred-Based Rendering,是常用于移动端的渲染管线。它的核心思想是牺牲执行效率,优化带宽消耗,以更好地适配移动端硬件。

不连续的 TBR/TBDR

如前文所提,对于 IMR 而言,管线是连续执行的。而对于 TBR/TBDR 而言,它的整个过程是不连续的,这一不连续具体体现在:

(1)提交 drawcall 阶段。得到绘制指令后,不会立即开始绘制操作,而是将所有绘制指令缓存起来,到最后才进行绘制;

(2)顶点着色阶段。对所有绘制指令执行 vs,并将绘制结果保存起来;

(3)像素着色阶段。绘制所有图元后,再将结果拷贝到 framebuffer 对应位置。

概括而言:IMR 就是单个指令依次连续执行,TBR/TDBR 则是所有指令完成一个步骤后再进入下一步骤。

TBR 和 TBDR 的区别在于,TBDR 会等待所有绘制指令的光栅化执行完成后,再进入像素着色器执行;而 TBR 中每个指令的光栅化和像素着色器是连续执行的。相当于:

TBR:drawcall - Wait - VS - Wait - RS - PS

TBDR : drawcall - Wait - VS - Wait - RS -Wait - PS

基于 tile 的 TBR/TBDR

TBR/TBDR 还有一个重要的特性,那就是它会将 frameBuffer 划分为多个 tile,以 tile 为单位进行渲染,这也正是它名字的来源。

当每个三角形都执行完 vs 阶段后,会进入 binning pass 阶段,此时 framebuffer 被划为多个 tile,并会去计算每个三角形所关联的 tile。最终,每个 tile 记录要渲染的三角形列表。

像素着色阶段,会以 tile 为单位依次进行绘制。根据 primitive list 判断当前 tile 包含哪些三角形以及对应的顶点属性,然后再绘制 tile 中每个三角形。绘制完成后,拷贝回 framebuffer 对应位置。

为什么说 TBR/TDBR 优化了带宽消耗

(1)批量读取 / 写入

对于 IMR 而言,依次连续执行指令,就类似于每次只读取一个数据,这样的操作执行 n 次;而对于 TBR/TDBR 而言,将所有指令执行完成后才进入下一阶段,就类似于一次性读取所有数据。这一设计是对带宽友好的。

(2)tile 低带宽消耗

读写深度缓冲 / 颜色缓冲是非常消耗带宽的操作,对于 IMR 架构而言,在做深度测试时,必须读 FrameBuffer,必要时会写入 FrameBuffer;在做 Blending 时,需要读写 FrameBuffer。(FrameBuffer 位于 Video Memory)

使用 TBR/TDBR 后,因为渲染被切分为 tile,而 tile 比较小,因此可以设计一种较快的内存,称为 on chip memory。可以先将数据存储在 tile 上的 on chip memory 上,提升了读写性能。等所有操作完成后再写入 FrameBuffer。

# 现代图形 API 与硬件

现代图形API与硬件

图形 API 的发展大致可以分为以下阶段:

(1)固定管线。代表:OGL

内置了渲染管线的实现,上层只能控制渲染状态切换,请求渲染绘制,或者修改特定的矩阵(如视图矩阵)等。

(2)可编程管线。代表:DX9,OpenGL ES

开放了渲染管线的部分模块,可通过可编程着色器语言控制,如顶点、像素等着色器。

(3)支持更多底层控制。代表:DX12,Vulkan

开放了 CPU-GPU 同步,资源 Barrier,显存管理等模块。

本章节的主要目的并不是介绍图形 API 的使用,而是结合现代图形 API 的设计,讨论一些偏向 GPU 底层模块的课题。

# 工作提交和命令队列

为了通知 GPU 以怎样的形式绘制怎样的物体,CPU 和 GPU 之间需要存在大量交互。其中,CPU 向 GPU 发送一个或多个命令(命令列表)的过程,也就是工作提交的过程。

CPU 和 GPU 之间存在一个命令队列,CPU 会不断地往队列中添加命令,而 GPU 会不断地从队列中取出指令并执行。这是一个典型的生产者消费者模型。也就是说,我们提交的工作往往并不是立即执行的,为了尽可能提升性能,我们应该尽可能使得 CPU/GPU 总是忙碌的。

命令队列类型

其中,命令包含多种类型:

(1)绘制命令。请求 GPU 绘制几何体。常称为 drawcall。

(2)设置状态命令。请求 GPU 切换 / 设置渲染状态。

(3)计算命令。请求 GPU 计算(compute shader)。

针对不同的命令,硬件底层实际上有独立的 GPU 引擎(Engine),而图形 API 也设计了对应类型的命令队列 (Queue)。一般而言,包含如下三种不同的硬件:

(1)3D(Graphics) Engine。

(2)Compute Engine。

(3)Copy Engine。

它们三者是超集关系,也就是说,3D Engine 也可以做 Compute 和 Copy 的工作,Compute Engine 也可以做 Copy 的工作,而 Copy Engine 就只能做 Copy 的工作。

Graphics Engine 完成绝大多数的操作,包括绘制,切换状态等。

Compute Engine 支持我们完成异步计算的操作(Aync Compute)。为了提高 GPU 的利用率,我们可以提交一些异步计算,完成模拟相关的工作,以利用空闲的 shader core。

Copy Engine 完成资源复制,纹理 / 模型流式加载等操作,它通常较早开始执行,避免图形引擎和计算引擎等待。

命令队列同步

既然命令队列是一个生产者 / 消费者模型,那么就不可避免的会涉及到同步的问题。

CPU 和 GPU 相互之间都可以进行同步,CPU 可以等待 GPU,GPU 也可以等待 GPU。它们之间的同步是通过信号量来完成的。

当 CPU 和 GPU 需要同步操作时,比如 CPU 需要等待 GPU 完成特定操作后才继续下一步,那么 CPU 这边将等待信号量达到某值,当 GPU 完成了特定操作后,它会将信号量更新到指定值(Signal)。CPU 检测到信号量变化后,继续接下来的操作。

命令队列内存管理

命令队列本质上是一段内存数据,对应的结构也就是我们所说的 Command Buffer,它和 Index Buffer/Vertex Buffer 一样,将由 CPU 构造完成后提交给 GPU。

命令队列一般会被设计为 Ring Buffer (环形缓冲区),它的容量通常是不可扩张的。一次提交的命令数量和策略以及队列的容量都有一定关联。

命令队列参考了单帧分配器模型(见深入内存管理一文),由于每个命令已经包含了执行所需的所有信息,无需维护状态,所以每帧分配点都会移动至开始,进行 Reset 操作。

命令队列调用

提交工作本质上是 I/O 操作,I/O 操作优化的关键在于减少调用,因此我们最好不要频繁提交小的命令,而是批量提交。确保每次调用能够对应一定时间的 GPU 工作,以隐藏调用的延迟。

# 资源创建和显存管理

旧的图形 API 只提供了显存分配的接口,具体的分配策略,分页,计数等操作都是由 driver 管理。

纹理 /buffer 资源

如果按照资源的类型分类,可按如下分类:

https://img-blog.csdnimg.cn/20201227143640599.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1pKVV9maXNoMTk5Ng==,size_16,color_FFFFFF,t_70

两者最大的区别就是它的内存排布不一致,缓冲区是线性排布的,而纹理通常会按照线性 (1D)、块状 (2D)、立方体 (3D) 排布。

纹理的具体排布方式和硬件实现有一定关系,可能的排布有行主序、块状布局(每块数据连续存储)等,一般来说,块状布局的使用更广泛。

除了纹理之外的资源都可以看作是 Buffer,比如常见的 Index Buffer,Vertex Buffer,Instance Buffer,Command Buffer, Constant Buffer 等。

如果按照资源对应的功能分类,可按如下分类:

https://img-blog.csdnimg.cn/20201128154419588.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1pKVV9maXNoMTk5Ng==,size_16,color_FFFFFF,t_70

不同 API 对于资源的描述会略有差异,总体而言,显存数据包含我们的 mesh 数据,传给 shader 的参数,以及 shader 执行后输出的一些结果。

# PSO / 状态量资源

**** 如果按照资源功能来分类,图形管线中还有一类特殊的数据,称为 PSO。

PSO 全称是 Pipleline State Object,它包含了整个 pipeline 的大部分状态,如下:

● shader 字节码

● 顶点格式

● 图元类型

● 混合 / 深度 / 模板的状态

● 深度 / 模板 / 渲染目标的格式和数量

● 多重采样参数

……

还有少量状态是未被 PSO 包含的,比如混合因子,视口等。它是通过提交命令的方式设置的。

状态量通常数量比较多,但是占用比较少,因此很适合打包成单一结构,统一提交。

# 资源的绑定

资源绑定也就是告知 GPU 当前使用哪个资源。可以从如下几个角度考虑资源绑定的性能:

(1)单独设置资源绑定相对于一次性设置资源绑定,会带来更大的性能消耗;

(2)时间上的连续性:连续帧的资源绑定有较大概率是一致的,最好能够做缓存;

(3)空间上的连续性:如果能够连续存储资源绑定,在频繁切换时能够更好地命中缓存;

# 资源的同步

****  因为 CPU 和 GPU 都能够对资源进行读写,所以我们对于资源也需要做同步管理,处理竞争的问题。

在进行图形编程的时候,我们遇到的同步问题可能有如下这些:

① 当 GPU 读取数据时,必须确保数据已经从内存上传到显存中;

② 下一 pass 需要使用上一 pass 写入的纹理数据作为输入时,必须确保纹理已经写入完毕;

③ CPU 回读显存数据时,必须确保显存数据已完全写入并在内存可读;

在旧的图形 API 中,我们无需手动管理同步,这是由于 API 封装了一些同步细节。

如果想要从 CPU 调用更新显存资源,旧的图形 API 会封装如下几种可能的操作:

① 同步更新。当我们请求更新时,该资源如果正在使用,会等待资源释放,block 住当前线程。

② 异步更新。先将数据拷贝到临时空间,等资源不再占用后再更新。

除了同步和异步,API 还隐藏了一些其它细节。比如资源究竟存储在 CPU mem 还是 GPU mem,它们什么时间进行数据同步等。因此当我们尝试读取显存资源时,它有可能不在 CPU mem 中,需要从 GPU 中拷贝;也有可能在 CPU 端存了备份,可以直接读取。

而在现代图形 API 中,时间和位置的同步操作都需要开发者实现。

为了描述一个资源的访问性,我们可用 CPU 读 / 写,GPU 读 / 写这四个属性来表达。

这些属性的不同组合得到的资源通常用于不同的场合,如:

① CPU 向 GPU 传输数据,此时 CPU 只写,GPU 只读;

② GPU 向 CPU 传输数据,满足 CPU 只读,GPU 只写;

③ 显存资源,CPU 不可读写,仅 GPU 可读写;

这里有一些细节值得注意:

大多数情况下,我们会借助一个中间的缓冲区来完成 CPU 和 GPU 之间的交互,先把数据从一端拷贝到中间缓冲区,再拷贝到另一端,而不是直接在两者都可访问的缓冲区上操作,这是出于性能考虑的,同时支持 CPU 和 GPU 的读写,访问速度会降低。而对于一些修改非常频繁的数据,拷贝的代价已经大于访问的代价,我们才会考虑后者。

另一方面,这一中间的缓冲区根据传输方向分为了两种不同的类型,这是从设计的层面避免同时写入的冲突。

目前,我们已经可以控制资源 CPU 和 GPU 端的传输,接下来我们就需要解决资源访问同步问题。

为了确保资源的有效性,通常使用资源屏障进行控制。屏障描述了资源状态的改变,记录了资源改变前后的访问状态。当状态发生改变时,屏障变得 “可通行”。

描述资源的状态包括:

● 当前资源已被映射到物理内存(虚拟内存)

● 当前资源失效(虚拟内存)

● 当前资源可用于着色器访问

● 当前资源可以拷贝 / 被拷贝

● 当前资源可作为渲染目标

● 当前资源可写入 / 读取深度

……

再回到开头的问题,为了确保着色器访问到的纹理资源是有效的,我们可以将数据借由中间缓冲区上传到显存中,然后添加一个资源屏障,从拷贝目标状态转换为着色器可读状态。

# 显存管理

由此可见,GPU 显存主要有两种类型的资源,一种是比较大的,像纹理这样的资源,一些是比较小的状态资源。如何有序地管理它们也是我们需要考虑的问题。

(1)显存堆

显存一般使用堆来维护,它的申请比较耗时,通常是在背景线程创建的。因为这个分配比较消耗性能,所以我们在显存分配中也使用了内存池的思路。

通常会创建大块连续显存,如 256M,然后通过 sub-allocate 的方式把已经创建好的显存分配给调用者。这里的分配 / 回收实际上只是找到 / 回收一块空闲的内存并返回指针,不存在分配 / 回收显存带来的系统开销。

通过 sub-allocate,我们可以减少分配的次数,并能够在此基础上设计一些分配器。

(2)虚拟内存

虚拟内存是我们比较熟悉的概念,分配虚拟内存意味着系统不一定分配了实际的物理地址空间,仅有需要的时候才去做分配的操作。

使用虚拟内存能够更好地进行显存的重用。

(3)显存的对齐

显存中也有对齐的概念,而且显存是强制对齐的,不进行对齐会崩溃。

当我们在堆上做 sub-allocate 的时候,我们需要手动计算分配起始位置,对齐后的分配字节数。

(4)  上层分配器

上文讨论的显存分配内存池是图形 API 底层提供的一些操作。但由于底层提供了足够方便的接口,我们还可以从上层分配做一些优化。

举例来说,在 CPU 内存分配中,由于我们可能分配的内存大小是任意的,所以像 buddy 这样一分为二,二分为四的分配器,会带来大量的内存碎片,所以它的实用性并不高。但对于纹理资源而言,由于大小一般都要求存储为 2 的幂次,它是非常适用于 buddy 算法的。

# 带宽优化

GPU架构概述

带宽优化实际上是一个非常宽泛的话题。因为本质上带宽是一种 I/O,而 I/O 在整个渲染过程中大量存在。

# 影响带宽的操作

带宽的占用和显存读写密切关联,从类型上来分,包括 CPU 内存和 GPU 显存的交互,以及 GPU 内部显存的读写。

一些可能和带宽相关的操作包含:

(1)CPU 向 GPU 提交命令,发送数据

(2)纹理采样 / 缓冲区读取数据

(3)颜色或其他数据写入 Render Target(Off-Screen Rendering)

(4)深度、颜色写入 framebuffer(On-Screen Rendering)

(5)深度测试(Alpha Test)从 framebuffer 读写数据

(6)混合(Blending)从 framebuffer 读写数据

如果纹理占用大,采样写入频繁,就会导致消耗带宽。

如果 overdraw 控制得不好,就会导致一帧中同一位置多次写入像素,消耗带宽。

drawcall / 状态切换等也会引发 CPU 到 GPU 的 I/O,但它又同时涉及到了 CPU 和 GPU 的同步问题,很多时候瓶颈可能在 CPU 上。因此带宽优化很少会讨论到这一点。

# 架构和带宽

在不考虑美术资产复杂度时,我们认为带宽的消耗和渲染架构或者说技术选型有一定关系。

延迟管线与带宽

到目前为止,市面上绝大部分移动端渲染都是前向管线。这是因为延迟管线一般都会写入至少 3 个 RT 的 GBuffer,这会带来非常严重的性能消耗。

后处理与带宽

后处理包括 MSAA,Bloom,DOF 等技术,需要对 FrameBuffer 进行读写。我们经常会说后处理会对性能造成影响,一方面是因为它要在一帧内触发多次 framebuffer 的切换,另一方面是它要频繁读写 framebuffer。

# 纹理优化

GPU架构概述

纹理是 GPU 编程中最常见的资源类型之一,因此针对纹理,我们可以有很多优化的方式。无论是哪种优化方式,都会基于以下核心思路:

(1)显存减少

(2)带宽降低

(3)缓存友好

(4)采样次数减少

以上几点并不是完全独立的概念。比如当我们降低贴图尺寸的时候,首先最直观的是我们降低了显存占用;当显存占用降低时,传输的数据量就会减少,带宽压力就会变小;此外,显存越小,缓存命中率也会更高。

另一方面,纹理有多种类型,在优化过程中,我们可以将其概述为两类,一类是美术资产,另一类是程序创建的纹理,针对这两种不同类型的纹理,我们有不同的优化策略。

# 纹理占用

纹理优化一方面的思路就是从纹理本身下手。

① 制定美术纹理资源大小格式标准。纹理分辨率并不是越大越好的,它受限于纹理采样的屏幕大小;

② 对效果影响不大的情况下,可以选择降低一些 Render Target 的分辨率,比如一些后处理或粒子绘制;

③ 对纹理进行压缩处理。对于美术纹理,一般会使用常见的几个块状压缩算法,通过 CPU 压缩,并在 GPU 中解压并读取,因为是按块压缩,所以解压时也只需要按所需块解压即可。这通常需要硬件支持解压。同时,硬件可能也会支持 FrameBuffer 的自动压缩。

# 纹理 mipmap

**** 从信号学的角度来看,为了不失真地恢复信号,采样频率应该大于模拟信号最高频率的 2 倍。纹理信息虽然本身并不是模拟信号,但纹理采样和信号采样也具有一定的相似之处。当我们以较低频率去采样高频纹理时,由于采样的像素是不连续的,就有可能出现失真的现象。

所谓的 “较低频率” 采样往往发生在离相机较远的物体上,采样的不连续可能会导致:

① 静态效果存在失真

② 运动时会发生闪烁

③ 缓存命中率低

为了解决这个问题,我们引入了 mipmap 的机制,即多级渐远纹理,也就是在原纹理的基础上,生成一系列纹理,每个纹理是前一个纹理大小的 1/2,这些系列纹理一般是算法自动生成的。引入 mipmap 后:

① 内存占用会增加,且理论上不会超过原来的 2 倍,但通过纹理池流式加载优化后可以降低内存占用;

② 带宽压力理论上会减小,因为原本需要传输高精度纹理,现在只需要传输低精度纹理;

③ 采样纹理时会在一系列纹理中选择合适大小的纹理进行采样,选择的策略包含如下几种:

(1)使用最邻近的多级渐近纹理采样;

(2)在两个最接近像素大小的多级渐远纹理间进行线性插值;

选择线性插值的方式,运动时闪烁的现象就会得到优化,但采样次数会增加。

在生成 mipmap 纹理时,如果单纯使用简单的降采样,在效果上和不做 mipmap 就没有太多差距。一般而言,会有多种压缩方式,包括锐化 / 模糊多种不同类型的压缩。

因此,除了一些和相机位置无关的纹理贴图(比如天空盒),绝大多数纹理都应该默认生成 mipmap。

# 纹理过滤

纹理坐标是分布在 (0,1) 之间的值。这是一个与分辨率无关的值,因此 uv 坐标和像素是无法一一对应的,我们面临着究竟应该采样哪个像素的问题。

通常有以下两种采样方式:

(1)邻近过滤

(2)线性过滤

在实际采样中,我们可能遇到屏幕纹理像素大于或者小于实际纹理像素的情况。我们可以为这两种不同的情况指定不同的纹理过滤形式。

当物体表面倾斜(三角形法线和视线接近垂直)时,由于 uv 坐标的变化率有较大差异,使用传统的双线性纹理采样会出现失真的现象。

为了解决这一问题,我们可以使用各向异性采样过滤技术,对最大变化方向会采样更多的纹理。我们可以指定采样的品质,品质越高,采样的次数也会越多。

当我们在 shader 中调用一次纹理采样的接口时,根据我们纹理过滤的策略不同,底层可能执行了不同次数的纹理采样。如下所示:

纹理过滤采样次数
最邻近采样1
双线性采样4
三线性采样8
各向异性采样 1X8
各向异性采样 2X16
各向异性采样 4X32
各向异性采样 8X64
各向异性采样 16X128

可以看到三线性采样和各向异性采样消耗非常大,如果不是特别有必要,应该尽量避免使用。

# 纹理采样和绑定

① 贴图合通道

为了尽可能减少采样次数和绑定次数,利用所有贴图通道,可以把把访问时具有关联性的数据存放到一起。

比如原先是 1 个 2 通道的纹理和 2 个 1 通道的纹理,可以合并为一张纹理。

法线通常压缩为 2 通道存储。

没有用到 alpha 通道的纹理存储为 RGB,而不是 RGBA。

② 使用 Texture Array / Bindless Texture/Texture Altas

这两者主要是优化纹理的绑定次数,可以把多张贴图打包到一个图集,texturearray 会限制大小和格式一致。但采样的时候还是需要单独采样的。

③ 减少贴图使用

一些可以用数学计算得到的效果可以不使用纹理;

如果一张纹理可以通过另一张纹理的简单计算得到,则不需要两张纹理;

如果可以用多张小纹理混合能够表达比较丰富的效果,就不要采样整体烘焙的形式,比如地形;

概括来说,就是尽可能使用程序化制作的思路去制作材质,而不是所有东西都靠美术去绘制。

④ 纹理采样的硬件优化

在像素着色器中,硬件会根据顶点传入的坐标值预加载贴图,因此读取贴图的效率会很高。但如果使用了其它 uv 坐标,就等于没有用到这个优化。

# 纹理与 Buffer

考虑到纹理通常是块状存储,Buffer 通常是线性存储。为了能够更好地命中缓存,应该根据实际使用情况选择纹理或者 Buffer 来存储数据。

纹理通常用于存储美术纹理资产,Buffer 通常用于存储不应压缩的数据信息。

# Drawcall 优化

GPU架构概述

drawcall,也就是 CPU 通过调用图形接口,向 GPU 请求绘制数据的过程。

CPU 发出的请求会被封装成一个命令,并加入到命令队列。GPU 执行完当前命令后,就会从命令队列再取一个命令执行。命令队列中,除了 drawcall 请求,还有状态切换的请求等。

drawcall 优化对性能的影响主要体现在以下两个方面:

① 分批多次请求 drawcall 相比起一次请求 drawcall,会多出一些接口调用的开销;

② 每次 drawcall 请求,会传输绘制网格数据和状态数据等,因此每次提交是比较耗时的。这种耗时主要体现在,CPU 需要处理的事情太多,因此跟不上 GPU 的处理速度;

为了提升性能,我们应该尽可能减少 drawcall 的次数,或者加快渲染数据的准备。一般来说,我们有如下的优化思路:

# 减少绘制物体数量

视锥体剔除

https://img-blog.csdnimg.cn/20201115233456622.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1pKVV9maXNoMTk5Ng==,size_16,color_FFFFFF,t_70

CPU 中,每帧在准备渲染所需数据之前,我们会对所有物体做一个遍历,判断它是否落在视锥体内,并剔除那些不在视锥体内的物体。减少最终提交到 GPU 的物体数量。

小物体视距剔除

对于一些距离较远的小物体,是否进行绘制对画面影响不大,比如远处某个角色身上的一颗扣子。

我们通常使用屏幕大小(screen size) 来衡量是否为小物件,物体到相机的距离和物体原本大小都会影响物体最终的屏幕大小。

因此,我们通常会制定一套规则,即当视距达到特定距离时,将不绘制小于特定大小的物体,这一规则可以表达为一条曲线。

遮挡剔除

若一个物体完全遮挡了另一物体,被遮挡的物体应该被剔除。

https://img-blog.csdnimg.cn/20201116000720108.png

pass 合并

场景中的物体一般都是多 pass 的,每多一个 pass 意味着多一次 drawcall 调用。

因此,除非无法合并,我们会尽可能将计算放在一个 pass 中,而不是分开。比如阴影写深度需要不同的相机视图,因此无法合并。

其它

还有一些宏观上的策略,比如 AOI,Streaming 等一些策略,主要是对场景中对象的管理,它们对 drawcall 优化也有一定帮助。

# 合并 drawcall

接下来,我们考虑通过合并等方式减少 drawcall 的方式。

批处理

批处理也就是把相同材质 / 贴图 /shader,仅顶点数据不同的物体合并到一起,仅进行一次 drawcall。

为了能够通过批处理进行优化,首先在美术设计层面就要尽可能提升材质的复用性,否则即使引擎底层做了批处理的优化也是不起效果的。

批处理一般分为静态合并和动态合并。其中静态合并针对场景中静态物体,它会在编辑状态下预合并;而动态合并针对场景中的动态物体,它会在运行时动态检测引用相同材质的物体并合并,会带来一定的运行时性能消耗。

由于一次提交的数据是有上限的,所以合并也不是无限制的,对于无法一次合并的物体,会被拆分到多个 drawcall 中。

实例化

当我们需要绘制大量相同的顶点时,我们通常需要大量的 drawcall 请求,并且会传输大量重复数据。

对于大量重复物体,我们可以考虑使用图形接口提供的实例化渲染支持。绘制 n 个相同的物体,原本需要 n 次 drawcall,使用实例化渲染后,只需一次 drawcall。

顶点等数据只需有一份拷贝,每个物体各自的数据(如平移、旋转数据)按序存储在 instancebuffer 中。

# 远景代理

最后,我们考虑对远景物体做一些简化,来达到减少 drawcall 的目的。

合并和简化网格体

对于远处的静态物体,我们可以把多个物体合并到一起,同时进行减面操作。合并和简化后,多个物体的结合体的顶点数理想中应该比原本单个物体还要少。

更进一步的,我们做 LOD 优化,考虑对不同视距的物体生成不同的合并网格体。

这一技术不仅可以减少 drawcall,也可以降低带宽压力。

公告牌 / 烘焙

对于一些物体,我们可以把它 LOD 的最后一级直接替换为公告牌,再结合第一点合并的优化,带来的优化是非常显著的。

绘制到公告牌上相当于把 3D 物体 2D 化,这需要我们使用至少两个三角形来绘制。如果绘制的物体较小,我们还可以更进一步,把这两个三角形也省略了,直接把物体烘焙到地表。

# 多线程生成 drawcall

为了加速 CPU 和 GPU 之间的交互,使 CPU 能够更快地准备渲染数据,引擎通常都会支持多线程渲染。具体而言,体现在以下几个方面:

① 包含多个线程。主线程负责调度和逻辑更新,渲染线程负责准备渲染数据,发起渲染请求;有些引擎还会给图形 API 设置新的线程;

② 多线程或异步发起 drawcall,配合多个命令队列,在图形 API 层面实现多线程渲染;

# GPUDriven

目前渲染比较传统的做法是,CPU 控制整个渲染流程,GPU 负责执行 CPU 指令,概括而言,就是 CPUDriven 的意思。

这样的做法一是带来 CPU 和 GPU 通信的开销;二是由于 CPU 和 GPU 同步引起开销。因此,有一个想法是,可以直接由 GPU 来驱动整个渲染流程,这样就可以省去很多不必要的开销。

相关的网格体、纹理数据还是需要从 CPU 传往 GPU,不过,此时,渲染命令主要通过 GPU 中的计算着色器控制。

# Overdraw 优化

GPU架构概述

overdraw 也就是一个像素被多次绘制的现象。

# 不透明物体的 overdraw

对于不透明物体而言:

如果先绘制了物体 A,再绘制物体 B,对于某一像素而言,我们首先会填充 A 的颜色,但如果发现物体 B 的深度离相机更近,就会使用物体 B 的颜色覆盖 A 的颜色,此时,一个像素被绘制了多次。

但如果我们先绘制物体 B,再绘制物体 A,由于深度测试,A 会被直接剔除,像素不会被多次绘制。

https://img-blog.csdnimg.cn/20201116232746448.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1pKVV9maXNoMTk5Ng==,size_16,color_FFFFFF,t_70

这里我们需要注意的是,上述讨论只是像素的绘制。但是无论是先绘制 A 还是 B,它们都要经过深度测试才能确定是否保留当前像素。如果深度测试在 ps 计算后,那么总有像素的 ps 计算会被浪费。也就是说,通过绘制顺序的组织,我们只是影响了像素的重复绘制,但并没有影响像素的重复计算。

https://img-blog.csdnimg.cn/20201116232845876.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1pKVV9maXNoMTk5Ng==,size_16,color_FFFFFF,t_70

为了解决这一问题,我们考虑的是从硬件设计上将深度测试提前,称为 early-z。

https://img-blog.csdnimg.cn/20201116233227135.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1pKVV9maXNoMTk5Ng==,size_16,color_FFFFFF,t_70

https://img-blog.csdnimg.cn/20201116233319561.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1pKVV9maXNoMTk5Ng==,size_16,color_FFFFFF,t_70

因此,我们对不透明物体从前往后进行深度排序,则可以减少 overdraw。

但考虑到排序并不准确,并且也会造成一定的 CPU 消耗,对不透明物体排序通常是针对支持 early-z 的硬件来做的,否则它的优化可能提升并不明显。

# Alpha Test 的 overdraw

对于需要 alpha test 的物体而言,它和不透明物体基本是一致的,除了它会在像素着色器里做 discard 操作。

之所以说 alpha test 的物体有性能问题,是因为它会影响 early-z 的逻辑,如果一个物体在 ps 中包含 discard 操作,那么硬件就不会对其执行 early-z,因为在像素可能被丢弃的情况下,提前深度测试可能导致错误的结果。

# Alpha Blend 的 overdraw

对于半透明物体而言:

为了确保透明物体绘制正确,一个比较常用的方法是透明物体(根据到相机的距离)从后往前提交,并且应该关闭深度测试,这存在非常严重的 overdraw。

半透明物体存在的情况下,一个像素的颜色可能是由多个物体贡献的,所以这往往是难以避免的。

一般来说,透明物体叠加越多,Overdraw 就会越严重,比较常见的性能瓶颈就是透明粒子。

# LOD 设置

如果物体未设置合理 LOD,导致物体离相机较远时,屏幕空间的顶点密度过高,可能有多个顶点落在同一个像素,导致 overdraw。

# quad overdraw

如果多个三角形的边缘落在一个像素上,存在 quad overdraw。

对于相邻的三角形,它们的邻边在光栅化时,很容易落入同一像素。

较小、较细长的三角形容易产生 quad overdraw。

# prepass

如果不选择使用排序的方式,渲染引擎中通常会添加 prepass 来减少 overdraw 带来的影响。

实现细节

在 prepass 中,我们关闭颜色写入,仅写入深度,得到深度图。

在 basepass 中,我们将深度测试比较公式修改为相等时写入。

也就是用较低成本的仅写入深度进行预处理,减轻 overdraw 写入的压力。

注意事项

① prepass 会带来 drawcall 次数的提升

② prepass 中绘制的物体所占的屏幕空间越大,就越有可能与更多的物体绘制区域重叠,也就越有可能通过深度比较来避免物体的 overdraw。因此在 prepass 中绘制小物件的性价比不高。

仅针对 mask 材质做 prepass

还有一个比较常见的思路是仅针对 mask 材质做 prepass。

由于 discard 会破坏 early-z,所以我们把比较麻烦的 mask 材质都放到 prepass 中,用一种更为廉价的方式获得它们的深度信息。

然后在 basepass 中,对 mask 材质不再进行 discard 操作,绘制深度测试相等的像素。对于不透明物体,则正常绘制。

# Shader 优化

GPU架构概述

# ALU

当我们在 shader 中做各种复杂的数学计算时,就会对应着多条算术指令。

一般来说,计算越复杂,指令越多,shader 便越消耗性能。

考虑到硬件底层会做一些指令上的优化,比如把一些常用的运算,如三角函数,简化为一条指令,所以我们较难直观地去统计具体的指令数,而通常借助于一些第三方工具进行指令数的分析。

针对着色器计算过于复杂的情况,可以考虑如下优化建议:

(1) 如果不需要准确的结果,可以用近似公式来代替计算;

(2) 如果半浮点数就已经满足计算的精度 / 范围,使用半浮点数来替代浮点数;

(3) 使用查找表 / 纹理来代替复杂的计算;

(4) 尽量避免 int 和 float 的混合计算,直接使用 float 计算,因为 int 到 float 的转换会消耗一定性能;

(5) 考虑逐顶点计算取代逐像素;

# 分支

if 判断或者 for 循环有可能会打断 GPU 内部 warp 的并行化,产生同步操作,导致效率变低。

是否会产生同步操作,取决于 if 判断的条件是静态或是动态的,以及 for 循环的次数是静态或是动态的。

分支类型

(1)静态分支

分为常数和 uniform 参数两种类型,编译器较容易判断并优化。

编译器优化的情况下,在同一时间下,所有 GPU 并行的计算都只会进入同一分支,因此并没有打断并行化。这一情况下,性能上几乎是无损耗的。

(2)动态分支

判断条件是动态的,比如 a > 1 这样的判断条件,或者贴图采样的结果。

动态分支效率也有差异。由于相邻块的数据会在同一 warp 中,如果分支的条件是位置相关的,同一 warp 中的计算会进入同一分支,那么也不会打断并行化;条件的分布在空间上较随机,对性能影响较大。

为什么说 if 可能是低效的

在 GPU 中的 warp 中,当我们执行 shader 逻辑时,指令集基本是相同的,只是数据有所不同,因此可以很好地做并行操作。

但如果出现了分支,意味着 warp 内部的线程可能需要执行不同的逻辑,指令集发生了变化,此时将需要同步操作,串行执行两段不同指令集的内容。

优化办法

1. 尽可能减少分支中代码的复杂度

代码越复杂,同步时可能产生的代价越大。尽可能减少代码复杂度,比如把一些可以不放在分支内的公共逻辑提取出来。

  1. 全量代码执行

全量代码的意思是,将两个分支的代码全部执行完,然后选择其中一个作为结果:
1.A = doA() 2.B = doB() 3. result = (1 - x) * A + x * B; *// 等价于lerp or mix函数*

其中 x 根据条件赋值为 0 或 1,也就是说,x 为 0 时,取 A 为结果;x 为 1 时,取 B 为结果。

有些着色器内有 BRANCH, FLATTEN, UNROLL, LOOP 这样的关键字,这样可以很方便地控制是否产生全量代码,它们的具体含义是:

if 语句:

BRANCH 只执行当前情况的代码,产生同步;

FLATTEN 执行全部情况的代码,不产生同步

for 语句:

UNROLL 展开,不产生同步;

LOOP 不展开,产生同步

哪种结果执行的更快取决于同步的时间和全量执行的时间哪个成为了当前的瓶颈,一些较为简单的分支计算可能全量执行会更快。

# 寄存器

GPU 中的寄存器数量是有限的。

一般分为两类,一部分是每个 thread 自己的寄存器,主要用于存储 shader 中临时变量,另一个是所有线程的共享内存,存储一些 shader 的输入,比如 uniform 数据或其它管线的输入。

shader 执行前需要为其分配对应的寄存器,在有限的寄存器下,每个 shader 占用的寄存器越少,能够并行执行的基本单位可能就越多。

因此,为了提升性能,尽可能提高并行的数量,有两个优化的方向:

一个是尽可能减少寄存器的使用,对于 thread 的寄存器而言,就是减少临时变量的使用;对于共享的寄存器而言,就是减少绑定到 shader 的参数。

另一个是保证两种不同类型的寄存器数量的平衡,由于短板效应,如果其中一个寄存器使用非常多,另一个相对少,那么使用较多的寄存器就会成为瓶颈,这意味着使用较少的寄存器会空置。

如果 thread 的寄存器成为瓶颈,可以考虑将一些常数以表格的形式记录到 GPU 的共享空间。