GPU/渲染优化是引擎开发中非常重要的一个环节,实际上在工程中做任何渲染相关的内容,都需要考虑到背后的原理和性能。另一方面,GPU/渲染优化是一个非常宽泛的课题,它不仅涉及到针对底层GPU的架构做出的相关优化,图形API的设计和使用的优化,也会涉及到CPU中渲染数据准备的优化,或者shader编写的优化等多方面的内容。
[本文大纲] 基础概念 GPU架构概述 带宽 ALU SIMD 显存 流程控制 像素填充率 IMR架构 TBR/TDBR架构 现代图形API与硬件 工作提交和命令队列 命令队列类型 命令队列同步 命令队列内存管理 命令队列调用 资源创建和显存管理 纹理/buffer资源 PSO/状态量资源 资源的绑定 资源的同步 显存管理 带宽优化 影响带宽的操作 架构与带宽 纹理优化 纹理占用 纹理mipmap 纹理过滤 纹理采样和绑定 纹理与Buffer Drawcall优化 减少绘制物体的数量 视锥体剔除 小物件视距剔除 遮挡剔除 pass合并 合并drawcall 批处理 实例化 远景代理 合并和简化网格体 公告牌/烘焙 多线程生成drawcall GPUDriven Overdraw优化 不透明物体的overdraw Alpha Test的overdraw Alpha Blend的overdraw LOD设置 quad overdraw prepass Shader优化 ALU 分支 寄存器 |
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)
渲染流水线的最后一步,它将处理抗锯齿,并执行读取/写入缓冲区的操作。
SoC(System on Chip)
移动端将CPU,GPU,内存,显存,GPS等整合在一个芯片上的解决方案。
带宽是衡量显卡性能的一个重要指标,它反映了显存数据传输的能力(IO)
显存位宽:显存在一个时钟周期传输数据的位数;
显存频率:显存的速度,为时钟周期的倒数;
显存带宽:显存频率 * 显存位宽 / 8,单位为字节每秒;
如果把数据读取传输看作水管传输的话,位宽代表了水管的半径,频率则反映了水流的速度,带宽即单位时间内能够传输的水量;位宽越大,频率越高,则带宽越大,传输数据的能力越强。
数据的频繁传输非常耗电,因此我们认为带宽是影响手机发热的重要因素之一。
即Arithmetic logic unit,算术逻辑单元,它是多类型计算电路的基本部件,计算电路包括中央处理单元(CPU)、浮点处理单元(FPU)和图形处理单元(GPU)等。
通常而言,我们会在shader计算中涉及到算术指令,如按位运算/关系运算/浮点运算等。它是衡量shader复杂度的重要指标之一。
SIMD,即Single Instruction Multiple Data,用一个指令并行地对多个数据进行运算。和CPU一样,GPU同样有对应的SIMD。
shader中大量会涉及到向量和矩阵的运算,SIMD的硬件设计有利于加速GPU计算。
GPU上内存称为显存,它的设计和CPU比较相似,都为多级缓存机制,包含register,L1/L2 Cache和DRAM等多个模块。因此,在GPU开发时,也需要考虑缓存命中的问题。
和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不支持页错误。
流程控制,通俗而言,就是计算过程中遇到的分支(if)。相比CPU,GPU具有较弱的流程控制能力。
对于CPU而言,它具有分支预测的功能,会根据原先的分支结果预测下一次分支走向,只有预测错误才会产生额外开销。
而对于GPU而言,它的流程控制基于active mask技术。假设warp中包含32个thread,GPU将使用一个bit mask判断32个thread的分支状态,对应位为1代表需要执行对应的分支。warp执行分支时,将先执行所有true(或false)的分支,等所有分支执行完成后,再执行所有false(或true)的分支,整体执行时间为两者之和。这一过程将打断warp的并行化。
active mask技术因此,我们应该尽可能避免流程控制。
图形处理单元在每秒内渲染的像素数量。它也是衡量显卡性能的指标之一。
在实际计算中,分辨率、Overdraw次数、Shader计算复杂度和Fill Rate成正比。
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,即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的发展大致可以分为以下阶段:
(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资源
如果按照资源的类型分类,可按如下分类:
两者最大的区别就是它的内存排布不一致,缓冲区是线性排布的,而纹理通常会按照线性(1D)、块状(2D)、立方体(3D)排布。
纹理的具体排布方式和硬件实现有一定关系,可能的排布有行主序、块状布局(每块数据连续存储)等,一般来说,块状布局的使用更广泛。
除了纹理之外的资源都可以看作是Buffer,比如常见的Index Buffer,Vertex Buffer,Instance Buffer,Command Buffer, Constant Buffer等。
如果按照资源对应的功能分类,可按如下分类:
不同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算法的。
带宽优化实际上是一个非常宽泛的话题。因为本质上带宽是一种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编程中最常见的资源类型之一,因此针对纹理,我们可以有很多优化的方式。无论是哪种优化方式,都会基于以下核心思路:
(1)显存减少
(2)带宽降低
(3)缓存友好
(4)采样次数减少
以上几点并不是完全独立的概念。比如当我们降低贴图尺寸的时候,首先最直观的是我们降低了显存占用;当显存占用降低时,传输的数据量就会减少,带宽压力就会变小;此外,显存越小,缓存命中率也会更高。
另一方面,纹理有多种类型,在优化过程中,我们可以将其概述为两类,一类是美术资产,另一类是程序创建的纹理,针对这两种不同类型的纹理,我们有不同的优化策略。
纹理优化一方面的思路就是从纹理本身下手。
① 制定美术纹理资源大小格式标准。纹理分辨率并不是越大越好的,它受限于纹理采样的屏幕大小;
② 对效果影响不大的情况下,可以选择降低一些Render Target的分辨率,比如一些后处理或粒子绘制;
③ 对纹理进行压缩处理。对于美术纹理,一般会使用常见的几个块状压缩算法,通过CPU压缩,并在GPU中解压并读取,因为是按块压缩,所以解压时也只需要按所需块解压即可。这通常需要硬件支持解压。同时,硬件可能也会支持FrameBuffer的自动压缩。
从信号学的角度来看,为了不失真地恢复信号,采样频率应该大于模拟信号最高频率的2倍。纹理信息虽然本身并不是模拟信号,但纹理采样和信号采样也具有一定的相似之处。当我们以较低频率去采样高频纹理时,由于采样的像素是不连续的,就有可能出现失真的现象。
所谓的“较低频率”采样往往发生在离相机较远的物体上,采样的不连续可能会导致:
① 静态效果存在失真
② 运动时会发生闪烁
③ 缓存命中率低
为了解决这个问题,我们引入了mipmap的机制,即多级渐远纹理,也就是在原纹理的基础上,生成一系列纹理,每个纹理是前一个纹理大小的1/2,这些系列纹理一般是算法自动生成的。引入mipmap后:
① 内存占用会增加,且理论上不会超过原来的2倍,但通过纹理池流式加载优化后可以降低内存占用;
② 带宽压力理论上会减小,因为原本需要传输高精度纹理,现在只需要传输低精度纹理;
③ 采样纹理时会在一系列纹理中选择合适大小的纹理进行采样,选择的策略包含如下几种:
(1)使用最邻近的多级渐近纹理采样;
(2)在两个最接近像素大小的多级渐远纹理间进行线性插值;
选择线性插值的方式,运动时闪烁的现象就会得到优化,但采样次数会增加。
在生成mipmap纹理时,如果单纯使用简单的降采样,在效果上和不做mipmap就没有太多差距。一般而言,会有多种压缩方式,包括锐化/模糊多种不同类型的压缩。
因此,除了一些和相机位置无关的纹理贴图(比如天空盒),绝大多数纹理都应该默认生成mipmap。
纹理坐标是分布在(0,1) 之间的值。这是一个与分辨率无关的值,因此uv坐标和像素是无法一一对应的,我们面临着究竟应该采样哪个像素的问题。
通常有以下两种采样方式:
(1)邻近过滤
(2)线性过滤
在实际采样中,我们可能遇到屏幕纹理像素大于或者小于实际纹理像素的情况。我们可以为这两种不同的情况指定不同的纹理过滤形式。
当物体表面倾斜(三角形法线和视线接近垂直)时,由于uv坐标的变化率有较大差异,使用传统的双线性纹理采样会出现失真的现象。
为了解决这一问题,我们可以使用各向异性采样过滤技术,对最大变化方向会采样更多的纹理。我们可以指定采样的品质,品质越高,采样的次数也会越多。
当我们在shader中调用一次纹理采样的接口时,根据我们纹理过滤的策略不同,底层可能执行了不同次数的纹理采样。如下所示:
纹理过滤 | 采样次数 |
最邻近采样 | 1 |
双线性采样 | 4 |
三线性采样 | 8 |
各向异性采样 1X | 8 |
各向异性采样 2X | 16 |
各向异性采样 4X | 32 |
各向异性采样 8X | 64 |
各向异性采样 16X | 128 |
可以看到三线性采样和各向异性采样消耗非常大,如果不是特别有必要,应该尽量避免使用。
① 贴图合通道
为了尽可能减少采样次数和绑定次数,利用所有贴图通道,可以把把访问时具有关联性的数据存放到一起。
比如原先是1个2通道的纹理和2个1通道的纹理,可以合并为一张纹理。
法线通常压缩为2通道存储。
没有用到alpha通道的纹理存储为RGB,而不是RGBA。
② 使用Texture Array / Bindless Texture/Texture Altas
这两者主要是优化纹理的绑定次数,可以把多张贴图打包到一个图集,texturearray会限制大小和格式一致。但采样的时候还是需要单独采样的。
③ 减少贴图使用
一些可以用数学计算得到的效果可以不使用纹理;
如果一张纹理可以通过另一张纹理的简单计算得到,则不需要两张纹理;
如果可以用多张小纹理混合能够表达比较丰富的效果,就不要采样整体烘焙的形式,比如地形;
概括来说,就是尽可能使用程序化制作的思路去制作材质,而不是所有东西都靠美术去绘制。
④ 纹理采样的硬件优化
在像素着色器中,硬件会根据顶点传入的坐标值预加载贴图,因此读取贴图的效率会很高。但如果使用了其它uv坐标,就等于没有用到这个优化。
考虑到纹理通常是块状存储,Buffer通常是线性存储。为了能够更好地命中缓存,应该根据实际使用情况选择纹理或者Buffer来存储数据。
纹理通常用于存储美术纹理资产,Buffer通常用于存储不应压缩的数据信息。
drawcall,也就是CPU通过调用图形接口,向GPU请求绘制数据的过程。
CPU发出的请求会被封装成一个命令,并加入到命令队列。GPU执行完当前命令后,就会从命令队列再取一个命令执行。命令队列中,除了drawcall请求,还有状态切换的请求等。
drawcall优化对性能的影响主要体现在以下两个方面:
① 分批多次请求drawcall相比起一次请求drawcall,会多出一些接口调用的开销;
② 每次drawcall请求,会传输绘制网格数据和状态数据等,因此每次提交是比较耗时的。这种耗时主要体现在,CPU需要处理的事情太多,因此跟不上GPU的处理速度;
为了提升性能,我们应该尽可能减少drawcall的次数,或者加快渲染数据的准备。一般来说,我们有如下的优化思路:
视锥体剔除
CPU中,每帧在准备渲染所需数据之前,我们会对所有物体做一个遍历,判断它是否落在视锥体内,并剔除那些不在视锥体内的物体。减少最终提交到GPU的物体数量。
小物体视距剔除
对于一些距离较远的小物体,是否进行绘制对画面影响不大,比如远处某个角色身上的一颗扣子。
我们通常使用屏幕大小(screen size)来衡量是否为小物件,物体到相机的距离和物体原本大小都会影响物体最终的屏幕大小。
因此,我们通常会制定一套规则,即当视距达到特定距离时,将不绘制小于特定大小的物体,这一规则可以表达为一条曲线。
遮挡剔除
若一个物体完全遮挡了另一物体,被遮挡的物体应该被剔除。
pass合并
场景中的物体一般都是多pass的,每多一个pass意味着多一次drawcall调用。
因此,除非无法合并,我们会尽可能将计算放在一个pass中,而不是分开。比如阴影写深度需要不同的相机视图,因此无法合并。
其它
还有一些宏观上的策略,比如AOI,Streaming等一些策略,主要是对场景中对象的管理,它们对drawcall优化也有一定帮助。
接下来,我们考虑通过合并等方式减少drawcall的方式。
批处理
批处理也就是把相同材质/贴图/shader,仅顶点数据不同的物体合并到一起,仅进行一次drawcall。
为了能够通过批处理进行优化,首先在美术设计层面就要尽可能提升材质的复用性,否则即使引擎底层做了批处理的优化也是不起效果的。
批处理一般分为静态合并和动态合并。其中静态合并针对场景中静态物体,它会在编辑状态下预合并;而动态合并针对场景中的动态物体,它会在运行时动态检测引用相同材质的物体并合并,会带来一定的运行时性能消耗。
由于一次提交的数据是有上限的,所以合并也不是无限制的,对于无法一次合并的物体,会被拆分到多个drawcall中。
实例化
当我们需要绘制大量相同的顶点时,我们通常需要大量的drawcall请求,并且会传输大量重复数据。
对于大量重复物体,我们可以考虑使用图形接口提供的实例化渲染支持。绘制n个相同的物体,原本需要n次drawcall,使用实例化渲染后,只需一次drawcall。
顶点等数据只需有一份拷贝,每个物体各自的数据(如平移、旋转数据)按序存储在instancebuffer中。
最后,我们考虑对远景物体做一些简化,来达到减少drawcall的目的。
合并和简化网格体
对于远处的静态物体,我们可以把多个物体合并到一起,同时进行减面操作。合并和简化后,多个物体的结合体的顶点数理想中应该比原本单个物体还要少。
更进一步的,我们做LOD优化,考虑对不同视距的物体生成不同的合并网格体。
这一技术不仅可以减少drawcall,也可以降低带宽压力。
公告牌/烘焙
对于一些物体,我们可以把它LOD的最后一级直接替换为公告牌,再结合第一点合并的优化,带来的优化是非常显著的。
绘制到公告牌上相当于把3D物体2D化,这需要我们使用至少两个三角形来绘制。如果绘制的物体较小,我们还可以更进一步,把这两个三角形也省略了,直接把物体烘焙到地表。
为了加速CPU和GPU之间的交互,使CPU能够更快地准备渲染数据,引擎通常都会支持多线程渲染。具体而言,体现在以下几个方面:
① 包含多个线程。主线程负责调度和逻辑更新,渲染线程负责准备渲染数据,发起渲染请求;有些引擎还会给图形API设置新的线程;
② 多线程或异步发起drawcall,配合多个命令队列,在图形API层面实现多线程渲染;
目前渲染比较传统的做法是,CPU控制整个渲染流程,GPU负责执行CPU指令,概括而言,就是CPUDriven的意思。
这样的做法一是带来CPU和GPU通信的开销;二是由于CPU和GPU同步引起开销。因此,有一个想法是,可以直接由GPU来驱动整个渲染流程,这样就可以省去很多不必要的开销。
相关的网格体、纹理数据还是需要从CPU传往GPU,不过,此时,渲染命令主要通过GPU中的计算着色器控制。
overdraw也就是一个像素被多次绘制的现象。
对于不透明物体而言:
如果先绘制了物体A,再绘制物体B,对于某一像素而言,我们首先会填充A的颜色,但如果发现物体B的深度离相机更近,就会使用物体B的颜色覆盖A的颜色,此时,一个像素被绘制了多次。
但如果我们先绘制物体B,再绘制物体A,由于深度测试,A会被直接剔除,像素不会被多次绘制。
这里我们需要注意的是,上述讨论只是像素的绘制。但是无论是先绘制A还是B,它们都要经过深度测试才能确定是否保留当前像素。如果深度测试在ps计算后,那么总有像素的ps计算会被浪费。也就是说,通过绘制顺序的组织,我们只是影响了像素的重复绘制,但并没有影响像素的重复计算。
为了解决这一问题,我们考虑的是从硬件设计上将深度测试提前,称为early-z。
因此,我们对不透明物体从前往后进行深度排序,则可以减少overdraw。
但考虑到排序并不准确,并且也会造成一定的CPU消耗,对不透明物体排序通常是针对支持early-z的硬件来做的,否则它的优化可能提升并不明显。
对于需要alpha test的物体而言,它和不透明物体基本是一致的,除了它会在像素着色器里做discard操作。
之所以说alpha test的物体有性能问题,是因为它会影响early-z的逻辑,如果一个物体在ps中包含discard操作,那么硬件就不会对其执行early-z,因为在像素可能被丢弃的情况下,提前深度测试可能导致错误的结果。
对于半透明物体而言:
为了确保透明物体绘制正确,一个比较常用的方法是透明物体(根据到相机的距离)从后往前提交,并且应该关闭深度测试,这存在非常严重的overdraw。
半透明物体存在的情况下,一个像素的颜色可能是由多个物体贡献的,所以这往往是难以避免的。
一般来说,透明物体叠加越多,Overdraw就会越严重,比较常见的性能瓶颈就是透明粒子。
如果物体未设置合理LOD,导致物体离相机较远时,屏幕空间的顶点密度过高,可能有多个顶点落在同一个像素,导致overdraw。
如果多个三角形的边缘落在一个像素上,存在quad overdraw。
对于相邻的三角形,它们的邻边在光栅化时,很容易落入同一像素。
较小、较细长的三角形容易产生quad overdraw。
如果不选择使用排序的方式,渲染引擎中通常会添加prepass来减少overdraw带来的影响。
实现细节
在prepass中,我们关闭颜色写入,仅写入深度,得到深度图。
在basepass中,我们将深度测试比较公式修改为相等时写入。
也就是用较低成本的仅写入深度进行预处理,减轻overdraw写入的压力。
注意事项
① prepass会带来drawcall次数的提升
② prepass中绘制的物体所占的屏幕空间越大,就越有可能与更多的物体绘制区域重叠,也就越有可能通过深度比较来避免物体的overdraw。因此在prepass中绘制小物件的性价比不高。
仅针对mask材质做prepass
还有一个比较常见的思路是仅针对mask材质做prepass。
由于discard会破坏early-z,所以我们把比较麻烦的mask材质都放到prepass中,用一种更为廉价的方式获得它们的深度信息。
然后在basepass中,对mask材质不再进行discard操作,绘制深度测试相等的像素。对于不透明物体,则正常绘制。
当我们在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) 尽可能减少分支中代码的复杂度
代码越复杂,同步时可能产生的代价越大。尽可能减少代码复杂度,比如把一些可以不放在分支内的公共逻辑提取出来。
(2) 全量代码执行
全量代码的意思是,将两个分支的代码全部执行完,然后选择其中一个作为结果:
A = doA();
B = doB();
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的共享空间。