实时渲染优化技术
前段时间初步完成了个人图形引擎IlumEngine的第一次性能优化。此次优化主要集中在几何渲染与纹理系统上,主要内容大致有:
- Vertex/Index Buffer Packing
- GPU Driven Rendering
- GPU Based Culling
- Frustum Culling
- Hierarchy Z Buffer Occlusion Culling
- Cone Back Face Culling
- Multi Draw Indirect
- Bindless Texture System
- GPU Based Culling
利用现代API灵活的可操作性,解决了几何阶段大量DrawCalls带来的CPU压力,以及提高顶点、索引数据的利用率,并且该架构也有利于后续集成实时光线追踪等功能
1. IlumEngine简介
IlumEngine是我目前正在开发的一个玩具图形引擎,名字取自《星球大战》中凯伯水晶的产地伊冷(后传中被第一军团改造为弑星者基地),引擎使用Vulkan作为图形API(后续或重构为RHI层以支持DX12甚至向下兼容OpenGL),目的是锻炼软件系统工程能力和作为学习图形学经典技术复现和前沿技术的实验平台,预期功能:
Render Graph高灵活度渲染管线架构
基于ImGui的交互友好的编辑器
异步资源加载系统(或进化至流式加载系统)
集成基本几何造型算法
- Bezier曲线
- 三次样条曲线
- B样条曲线
- 有理样条曲线
- 有理样条曲面等
集成基本数字几何处理算法
- 网格参数化
- 网格简化与细分
- 网格变形等
集成基本物理模拟算法
- 刚体模拟
- 布料模拟
- 柔性体模拟
- 流体模拟
集成基本光栅渲染算法
- Forward/Deferred/Tile Based渲染管线
- 实时阴影
- PCF、PCSS
- VSM
- CSM
- 环境光照:IBL、PRT
- 全局光照
- DDGI
- VXGI等
- 屏幕空间后处理
- Blooming
- SSGI
- SSR等
集成基本离线渲染算法
- PT
- PM
- BDPT等
目前已将基础的架构部分搭建完成,能够支持Disney PBR材质的延迟渲染管线:
接下来几节将从存储优化、CPU负载优化、GPU负载优化方面来介绍此次引擎优化的主要内容。
2. 几何缓存优化
在介绍引擎的几何缓存优化之前,先介绍一下目前引擎使用的场景图和几何模型存储结构。
2.1. 场景图
场景图是渲染引擎中重要的一个部分,通常采用树状结构(有向无环图)进行组织,IlumEngine中使用基于entt的实体组件系统ECS来实现场景图:
- 每个实体(Entity)作为场景图中的一个结点
- 每个实体可以挂上若干个组件(Component)
- 实体只是拓扑关系的结点,不存储实际数据
- 组件仅存储数据,而不存储逻辑(函数、方法)
- 组件中的数据由系统(System)使用,实现场景图的更新
想让一个实体拥有几何数据,则将该实体挂载上MeshRenderer
组件,在渲染循环系统中,将从所有实体的MeshRenderer
中获取渲染所需的几何数据,以完成几何阶段的渲染。
2.2. 几何模型存储结构
组件MeshRenderer
定义如下:
1 | struct MeshRenderer |
其中,
model
为模型数据的索引,这里使用模型文件所在位置的相对路径表示materials
为模型的材质,初始化时将拷贝为模型的默认材质,在编辑器中也可对某个实体的材质进行修改update
为全局静态更新变量,表示在某循环中与MeshRenderer
相关的更新
为得到实际的几何数据,我们还需要利用索引model
在资源管理器ResourceCache
中查询几何模型,ResourceCache
实现了模型与贴图的多线程异步加载和缓存查询等功能,这里不作展开。通过查询,将得到实际模型对象的引用ModelReference
:
1 | using ModelReference = std::reference_wrapper<Model>; |
而Model
便是我们实际存储几何数据的对象了,Model
中又有如下数据:
1 | struct Model |
其中,Submesh
为模型的子网格,为导入方便以及支持单个模型不同部分使用不同材质,IlumEngine采用了子网格的形式来组织大型模型,每个子网格拥有以下信息:
1 | struct Submesh |
其实和Model
是差不多的,Submesh
是目前几何数据渲染的最小独立单位。
2.3. 几何存储方案
上文已介绍了几何模型的一个存储结构,但是并未涉及具体的几何数据存储方案,所谓的几何数据存储方案,一个是CPU端的存储,即顶点和索引数据的存储;一个是GPU端的存储,即Vertex Buffer与Index Buffer的存储。对于静态网格模型而已,完全可以将几何数据送入GPU后删除CPU端的数据,但由于本引擎后续需要加入几何处理的功能,为了方便起见依旧全部保留CPU端的几何信息。
2.3.1. 极简方案
最简单的一种也是最直观的一种策略,便是每个子网格存一份位置的几何信息,即:
1 | struct Submesh |
这种方法最为简单直观,也方便编程,但在实际渲染过程中会有渲染状态频繁切换的问题。假设有$N$个模型,第$i$个模型具有$M_i$个子网格,那么在一个渲染循环内需要做以下操作:
遍历$N$个模型
遍历模型$i$中的$M_i$个子网格
绑定子网格对应的GPU资源
1
2vkCmdBindVertexBuffers(cmd_buffer, 0, 1, &vertex_buffer, 0);
vkCmdBindIndexBuffer(cmd_buffer, index_buffer, 0, VK_INDEX_TYPE_UINT32);执行渲染绘制操作
1
vkCmdDrawIndexed(cmd_buffer, index_count, 1, 0, 0, 0);
因此在一个渲染循环中需要切换绑定$N*M$次顶点/索引缓冲,当模型数量增加时会明显影响效率,而且多块小存储空间也不是一种好的存储分配策略,容易带来内存碎片等问题,同时,当模型具有多个重复的子网格时,这种存储策略将造成数据冗余,降低存储资源的利用率
2.3.2. 基于模型的优化方案
既然子网格存储所有的几何数据不太好,那我就每个模型存储一份几何数据,然后子网格只存偏移和长度咯。基于模型的优化方案也确实是这样的设计思路:
1 | struct Model |
模型中存储了所有子网格的几何数据,通过顶点索引的偏移offset
和顶点索引数量count
即可绘制出相应的子网格。由于目前的索引均从顶点缓冲的开头开始,因此暂不需要vertex_offset
的参与。
假设有$N$个模型,第$i$个模型具有$M_i$个子网格,那么在一个渲染循环内需要做以下操作:
遍历$N$个模型
绑定模型对应的GPU资源
1
2vkCmdBindVertexBuffers(cmd_buffer, 0, 1, &vertex_buffer, 0);
vkCmdBindIndexBuffer(cmd_buffer, index_buffer, 0, VK_INDEX_TYPE_UINT32);遍历模型$i$中的$M_i$个子网格
执行渲染绘制操作
1
vkCmdDrawIndexed(cmd_buffer, index_count, 1, index_offset, 0, 0);
因此在一个渲染循环中需要切换绑定$N$次顶点/索引缓冲,比前述的极简方案要好不少,同时模型存储也避免了多个重复子网格冗余的问题,相同的子网格只要有相同的索引偏移和数量即可。
2.3.3. 统一存储的优化方案
基于模型的方案在渲染每个模型时依然需要切换绑定顶点索引缓冲,在模型数量很多时同样可能带来瓶颈,同时也不利于我们后面进行GPU Driven Rendering的single drawcall设计。所以这次一劳永逸,分配一个大块的GPU显存资源来存储所有的顶点和索引缓冲,而CPU端的几何数据则仍按基于模型的方案设计。
1 | struct Model |
该方案的麻烦之处在于,每当有模型添加、修改或删除时需要对全局缓冲进行更新,同时也需要更新每个模型的偏移。下图为各个存储索引关系示例:
现在,假设有$N$个模型,第$i$个模型具有$M_i$个子网格,那么在一个渲染循环内需要做以下操作:
绑定几何数据对应的GPU资源
1
2vkCmdBindVertexBuffers(cmd_buffer, 0, 1, &vertex_buffer, 0);
vkCmdBindIndexBuffer(cmd_buffer, index_buffer, 0, VK_INDEX_TYPE_UINT32);遍历$N$个模型
遍历模型$i$中的$M_i$个子网格
执行渲染绘制操作
1
vkCmdDrawIndexed(cmd_buffer, index_count, 1, index_offset, vertex_offset, 0);
现在,我们彻底地将几何资源绑定次数降低至single bind,无论场景多大,模型数量多少我们均只需单次绑定开销,而在后续的GPU Driven Rendering的管线设计中,我们也会看到这种Vertex/Index Buffer packing的方法具有的巨大优势。
3. GPU驱动渲染管线
GPU Driven Rendering Pipelien的概念最早在Siggraph2015上由育碧Ubisoft提出[1],其相应技术也已在《刺客信条:大革命》中得以落地,在当时可以说是相当前卫的一种设计,但由于当年硬件条件所限,《刺客信条:大革命》却因为层出不穷的Bug被当时的玩家所诟病,一度将育碧和刺客信条系列推向低谷,不过回过头看,《刺客信条:大革命》确实在大型场景和复杂建筑、海量NPC、真实感渲染等方面都是前作所不能比拟的,可以算是3A大作进入画质内卷的一个分界线。
在IlumEngine中,我也尝试了使用GPU Driven Rendering Pipeline的思想,来对引擎进行性能调优。
3.1. 无绑定纹理
Bindless方法指不通过传统方法将资源通过bindTexture/bindBuffer的方式进行绑定,而是直接将Texture/Buffer等GPU资源的虚拟地址直接存储在Bindless Buffer中,在着色器中可以直接使用索引进行访问。Bindless技术最早来源于Nvidia提出的 AZDO(Approaching Zero Driver Overhead)技术框架,2008年Nvidia的Tesla架构就已经实现了Bindless Buffer,而在2012年的Kepler架构正式加入了Bindless Texture特性。
对于传统的绑定模型,我们往往需要在着色器中声明所需要的纹理/缓冲资源,并且分配相应的槽位(slot):
1 | layout (binding = 0) uniform sampler2D tex0; |
在CPU端,需要显式绑定所有纹理资源:
而使用Bindless绑定模型,在着色器中,我们相当于使用了一个无穷大的纹理数组:
1 | layout (binding = 0) uniform sampler2D textureArray[]; |
所有的纹理数据可以一次性全部灌入其中,需要用到时,我们只需要一个下标索引即可进行访问,而对于材质而言,也不再像下图那样的贴图绑定:
1 | layout (binding = 0) uniform sampler2D Albedo; |
而是使用一个结构体,存储所有的材质贴图索引:
1 | struct Material |
访问时只需:
1 | vec4 albedo = texture(textureArray[nonuniformEXT(material.Albedo)], inUV); |
即可。对于GLSL,记得开启扩展GL_EXT_nonuniform_qualifier
Bindless访问模型如下:
Bindless对GPU Driven Rendering Pipeline有至关重要的作用,它主要解决了传统API下绑定资源到管线的开销问题,同时突破了着色器的硬件访问限制,进一步降低CPU-GPU的交互,我们不需要在CPU端设置Bindless资源的绑定状态,是之后实现single drawcall for everything的基础。
Vulkan中与Bindless相关的技术叫descriptor_indexing
,在Vulkan 1.0属于EXT特性,但在Vulkan 1.2中已升为Core特性。在Logical Device的创建时指定:
1 | VkPhysicalDeviceVulkan12Features vulkan12_features = {}; |
其中,shaderSampledImageArrayNonUniformIndexing
、runtimeDescriptorArray
、descriptorBindingVariableDescriptorCount
指定开启descriptor_indexing
特性,descriptorBindingPartiallyBound
解决了缺省绑定的问题。
在创建Bindless Texture过程中,需要指定Bindless Texture的数组支持的最大容量,通常会指定为一个较大的数(如1024)以避免反复扩容,而大部分情况下场景中的纹理都不会填满最大容量,此时需要开启descriptorBindingPartiallyBound
支持缺省绑定,以防止出错。
Bindless Texture可视化:
至此,我们又将一个费时的操作从CPU端移走了。
3.2. 多重间接绘制
此前的一章一节中,我们将几何数据资源绑定的CPU开销降至最低,将纹理资源绑定的CPU开销给完全移走了,在本节中,我们将要把绘制开销降至最低,实现心心念念的single drawcall for everything。
在最开始的设计中,我以一种非常低效的方式进行几何阶段的渲染,流程如下:
- 绑定Pipeline、DescriptorSet、Vertex/Index Buffer
- 遍历模型
- 遍历子网格
- 收集材质信息,使用Push Constant操作将材质数据送往着色器
- 调用绘制命令
可以看到,每一个子网格都将贡献一次Push Constant开销和一次Drawcall的开销,更不用说其他的逻辑判断操作,结果可想而知,场景复杂度一上去,CPU开销裂开,非常不贴合现代图形API的设计初衷,我们需要更多类似Bindless Texture的低CPU开销设计。
好在现代图形API已经帮我们考虑好了,多重间接绘制Multi Draw Indirect能够完美地满足我们的需求。不同于显式调用绘制命令,Multi Draw Indirect允许我们实现将绘制命令预存在GPU的显存中,在需要绘制时调用:
1 | vkCmdDrawIndexedIndirect(cmd_buffer, draw_buffer, buffer_size, draw_count, sizeof(VkDrawIndexedIndirectCommand)); |
一个Drawcall即可完成所有的绘制指令提交。
下面介绍多重间接绘制相关的技术细节:
3.2.1. 指令缓冲
前述中,Multi Draw Indirect使用我们预存在GPU显存中的绘制命令进行提交,这些绘制命令存储在指令缓冲。在Vulkan中,有结构体VkDrawIndexedIndirectCommand
或VkDrawIndirectCommand
帮助我们指定指令缓冲中需要存哪些信息,一般来讲我们使用索引进行绘制,因此用的是VkDrawIndexedIndirectCommand
,其数据结构定义为:
1 | typedef struct VkDrawIndexedIndirectCommand { |
是不是和我们显式绘制指令的参数不能说很像,只能说一模一样?
1 | void vkCmdDrawIndexed( |
当然用法也一样,就是把之前要在渲染循环里指定的参数一一写入一个std::vector<VkDrawIndexedIndirectCommand>
容器里,然后在把里面的数据传到GPU显存中,使用其缓冲句柄即可调用vkCmdDrawIndexedIndirect
了。
3.2.2. 材质缓冲
前文提到过,在一开始的实现中,我们将材质信息使用Push Constant的方法在几何遍历时传到着色器中,而使用多重间接绘制时我们不再需要遍历几何体,没办法使用Push Constant方法传送逐子网格数据,因此我们需要一种新的传送材质数据的方法。
这里我也采用了一种比较暴力的方法,那就是将所有的材质数据都存在一个大的Storage Buffer,鉴于材质数据结构中只需存各个贴图的索引和一些简单的参数,需要的显存并不算多,在每帧循环时,根据需要进行更新。
由于材质信息是每个子网格拥有一份(不支持多材质、分层材质等),连同如预变换(Pre-Transform,与模型变换矩阵相乘构成世界变换矩阵)、包围盒等信息组成PerInstanceData
:
1 | struct PerInstanceData |
(变量顺序是为了内存对齐需要)
在着色器中,通过扩展GL_ARB_shader_draw_parameters
,能够获得当前绘制物体的索引gl_DrawIDARB
,由此来访问相应的PerInstanceData
。
这样一来,我们也顺利地使用一个DrawCall完成了所有的绘制指令提交,实际实验结果也很让人满意,CPU开销有了显著的降低,耗时仅为原来的十分之一不到,CPU也不再成为了渲染的瓶颈。
3.3. 基于GPU的剔除
在前面几节中,我们已经彻底完成了CPU端的瓶颈解除,但我们也不应止步于此,接下来将进行GPU端的性能优化。要想在不减少场景规模的前提下减少GPU的计算耗时,一个基本的想法就是告诉GPU哪些东西是需要渲染的、哪些东西是不需要渲染的,也就是本节的主角——剔除技术了。
剔除的本质是一种可见性测试,最常见的剔除有两种:视锥体剔除和遮挡剔除,这两种剔除方法能够排除大量不可见的可渲染物体,当然还有小片元剔除、背面剔除等其他方法。
在传统管线中,通常采用CPU进行剔除处理,通过SSIM等硬件加速手段提高求交检测来实现各种剔除技术。但在本GPU Driven Rendering Pipeline中,我们已经将所有渲染数据和参数放在显存上了,很自然地,我们将利用现代GPU的通用计算功能(GPGPU),使用计算着色器来帮助我们完成剔除的操作。
3.3.1. 视锥剔除
在学习计算机图形学基础时,我们都会接触到相机投影等相关知识,简单来讲,相机投影定义了一个裁剪空间,对于正交相机,其平截头体是一个长方体:
而对于透视相机,其平截头体是一个台体
在平截头体(或视锥体)之外的顶点将在裁剪阶段被渲染管线丢弃,尽管这些顶点不会参与最后的光栅化阶段,但还是会在顶点着色器中进行计算处理,造成不必要的性能浪费。通过视锥剔除计算,在不可见物体送入渲染管线前就进行排除,能够提高我们的计算效率和计算资源利用率。
视锥剔除的检测,即包围盒与视锥平面的求交检测,这里涉及两个步骤:视锥平面的求算与包围盒的求交。
视锥平面的求算
在IlumEngine中,视锥平面的计算使用了Gribb-Hartmann方法进行求解,其详细数学推导可参考[2]。
已知当前场景主摄像机的投影矩阵为$M_{P}$,视图矩阵为$M_V$,定义投影视图矩阵:
$$
M_{PV}=M_PM_V=\begin{bmatrix}
m_{11}&m_{12}&m_{13}&m_{14}\\
m_{21}&m_{22}&m_{23}&m_{24}\\
m_{31}&m_{32}&m_{33}&m_{34}\\
m_{41}&m_{42}&m_{43}&m_{44}
\end{bmatrix}
$$
则六个视锥面方程如下:
$$
\begin{aligned}
\begin{matrix}
\mathrm{Left: }&(m_{41}+m_{11})x+(m_{42}+m_{12})y+(m_{43}+m_{13})z+(m_{44}+m_{14})=0\\
\mathrm{Right: }&(m_{41}-m_{11})x+(m_{42}-m_{12})y+(m_{43}-m_{13})z+(m_{44}-m_{14})=0\\
\mathrm{Bottom: }&(m_{41}+m_{21})x+(m_{42}+m_{22})y+(m_{43}+m_{23})z+(m_{44}+m_{24})=0\\
\mathrm{Top: }&(m_{41}-m_{21})x+(m_{42}-m_{22})y+(m_{43}-m_{23})z+(m_{44}-m_{24})=0\\
\mathrm{Near: }&(m_{41}+m_{31})x+(m_{42}+m_{32})y+(m_{43}+m_{33})z+(m_{44}+m_{34})=0\\
\mathrm{Far: }&(m_{41}-m_{31})x+(m_{42}-m_{32})y+(m_{43}-m_{33})z+(m_{44}-m_{34})=0\\
\end{matrix}
\end{aligned}
$$
包围盒的求交
IlumEngine中使用了包围球和AABB包围盒两种包围结构,包围球具有旋转不变性、求交方便等优点,AABB包围盒的紧致性比包围球更胜一筹,可以提高更细粒度的剔除。
对于点$\pmb p(\pmb p_x, \pmb p_y,\pmb p_z)$,设视锥平面为$a_ix+b_iy+c_iz+d_i=0$,$(i=0,1,\cdots,6)$,则点$\pmb p$处于视锥体内,当且仅当:
$$
a_i\pmb p_x+b_i\pmb p_y+c_i\pmb p_z+d_i<0,\forall i=0,1,\cdots,6
$$
成立。
对于球心坐标为点$\pmb p(\pmb p_x, \pmb p_y,\pmb p_z)$,半径为$r$的包围球,当且仅当:
$$
a_i\pmb p_x+b_i\pmb p_y+c_i\pmb p_z+d_i+r<0,\forall i=0,1,\cdots,6
$$
成立时,物体不会被剔除,代码如下所示:
1 | bool checkSphere(vec3 pos, float radius) |
对于两端点为$\pmb p_{max}$和$\pmb p_{min}$的AABB包围盒,我们可以组合出八个包围盒顶点和视锥体的六个平面分别求交,但这样计算量太大,也没必要,对于每个视锥平面,我们只需挑选出其中离它最远的那个顶点进行判断即可,最远顶点可有下述公式决定:
$$
\begin{matrix}
\pmb p_x=\begin{cases}
p_{min_x}&a_i<0\\
p_{max_x}&\mathrm{otherwise}
\end{cases}
&
\pmb p_y=\begin{cases}
p_{min_y}&b_i<0\\
p_{max_y}&\mathrm{otherwise}
\end{cases}
&
\pmb p_z=\begin{cases}
p_{min_z}&c_i<0\\
p_{max_z}&\mathrm{otherwise}
\end{cases}
\end{matrix}
$$
使用该点进行判断即可。代码如下所示:
1 | bool checkAABB(vec3 min_val, vec3 max_val) |
3.3.2. Hierarchy Z-Buffer遮挡剔除
除了视锥剔除,遮挡剔除也是一种重要的剔除技术,当场景中有大量体积较大的遮挡物时有比较好的性能提升效果。遮挡剔除的实现手段有很多种,有使用硬件的遮挡查询策略,不过开销较大一般不建议使用;有手动指定Occlude和Occluder,使用CPU低分辨率软光栅进行剔除(参考Battlefield 3实现);也有通过离线烘培的方法来实现遮挡剔除。在IlumEngine中则利用帧间信息连续性的原理,使用前一帧的深度缓冲,利用计算着色器生成层级Mipmap,再通过屏幕空间包围结构在计算着色器中实现遮挡剔除。
Hierarchy Z-Buffer的生成
有了深度缓冲,要计算某物体是否被遮挡,一个直接的想法就是在深度图中采样该物体所在的像素位置,比较深度图采样值和该物体实际的深度值,若采样值小于深度值,则认为物体在该像素下被遮挡,当然物体的实际深度和像素位置在光栅化之前是很难算出来的,我们可以用简单的几何体例如包围结构来代替实际的物体,但即便如此,进行全分辨率的搜索和比较也是一个相当耗时的操作,这也是我们为什么需要层级深度缓冲的原因:通过包围结构在屏幕空间的投影大小,能够计算出相应的MipLevel,使得在该MipLevel下一个像素刚好能够覆盖全分辨率下包围结构的大小,这样一来,搜索和比较操作从原来的需要对包围结构所占像素逐个比对,优化至只需要搜索相应的MipLevel,通过一次采样即可完成比对。当然下采样也会带来一定的信息量损失,带来剔除精度的损失,但在剔除精度与开销之间的权衡,我们更倾向于后者。
在每一帧的几何阶段中,我们都将使用一个格式为VK_FORMAT_D32_SFLOAT_S8_UINT
的纹理来作为我们的Depth Stencil Buffer,由于深度图格式无法直接进行Mipmap操作,我们需要自己手动生成相应的Mipmap。
出于框架的局限性,需要在每个渲染流程的结尾将Deth Stencil Buffer拷贝到另外一张深度贴图Last_Frame.depth_buffer
中,在HizPass
中,首先我们需要准备好Last_Frame.hiz_buffer
的各个层级的VkImageView
,以方便后续数据的写入:
1 | m_views.resize(Renderer::instance()->Last_Frame.hiz_buffer->getMipLevelCount()); |
出于保守剔除策略,在下采样过程中,我们不应使用线性过滤等方法进行处理,而是考虑选择一个$4\times 4$ Texels中最大的值(不使用反向Z缓冲),在Vulkan中,可以使用Reduction Mode在VkSampler
创建时指定VK_SAMPLER_REDUCTION_MODE_MAX
:
1 | VkSamplerCreateInfo createInfo = {VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO}; |
接着生成好需要用到的descriptor_sets
:
1 | for (uint32_t level = 0; level < m_views.size(); level++) |
每个descriptor_set
规定了需要读取和写入的数据,在第一轮中,读取的应为上一帧的深度图,写入MipLevel为0的Hi-Z Buffer,而之后的每一轮都是读取MipLevel为$i$的Hi-Z Buffer,写入MipLevel为$i+1$的Hi-Z Buffer。
至此准备阶段已经结束了,在每一轮渲染循环中,需要进行以下更新:
1 | for (uint32_t level = 0; level < views.size(); level++) |
每一次计算循环开始,需要切换相应的读写状态、绑定相应的descriptor_set
,进行计算着色器的dispatch
,最后再进行下一轮的状态切换准备,直到填满Hi-Z Buffer的所有Mipmap层级为止。
屏幕空间包围结构的计算
有了Hi-Z Buffer,现在我们需要得到物体包围结构屏幕空间的投影,为了计算方便,只考虑包围球形式,使用的计算方法参考文献[3],该算法将世界空间的包围球变换为屏幕空间AABB包围盒。
首先我们回顾一下图形学基础中视图矩阵和投影矩阵的相关概念,视图矩阵$M_{view}$主要作用是将场景变换到相机空间中,所谓的相机空间,就是以相机为原点所定义的空间,视图矩阵可以由相机的模型矩阵求逆得到:$M_{view}=M_{camera\position}^{-1}$,当然一般很少通过这种方法来求取视图矩阵,因为不够直观,而是通过摄像机的朝向和摄像机的位置来进行求取:
$$
M{view}=\begin{bmatrix}
\pmb R_x&\pmb R_y&\pmb R_z&0\\
\pmb U_x&\pmb U_y&\pmb U_z&0\\
\pmb D_x&\pmb D_y&\pmb D_z&0\\
0&0&0&1
\end{bmatrix}
\ast
\begin{bmatrix}
1&0&0&-\pmb P_x\\
0&1&0&-\pmb P_y\\
0&0&1&-\pmb P_z\\
0&0&0&1
\end{bmatrix}
$$
其中,$\pmb R$为右向量,$\pmb U$为上向量,$\pmb D$为方向向量,$\pmb P$为摄像机的位置向量。
而投影矩阵则是实现将三维空间的物体投影到二维屏幕上,投影矩阵将相机空间中的顶点数据变换到裁剪空间中,最后通过透视除法变换到标准化设备坐标,这里以透视投影为例:
从两个方向观察,由三角形近似可得:
$$
\begin{align}
\dfrac{x_p}{x_e}&=\dfrac{-n}{z_e}\Rightarrow x_p=\dfrac{-n\cdot x_e}{z_e}=\dfrac{n\cdot x_e}{-z_e}\\
\dfrac{y_p}{y_e}&=\dfrac{-n}{z_e}\Rightarrow y_p=\dfrac{-n\cdot y_e}{z_e}=\dfrac{n\cdot y_e}{-z_e}
\end{align}
$$
注意到$x_p$和$y_p$的计算均需要除以一个$-z_e$,这与裁剪空间到NDC正则化的透视除法相对应:
$$
\begin{align}
\begin{pmatrix}
x_{clip}\\y_{clip}\\z_{clip}\\w_{clip}
\end{pmatrix}
&=\pmb M_{projection}\cdot
\begin{pmatrix}
x_{eye}\\y_{eye}\\z_{eye}\\w_{eye}
\end{pmatrix}\\
\begin{pmatrix}
x_{ndc}\\y_{ndc}\\z_{ndc}
\end{pmatrix}
&=
\begin{pmatrix}
x_{clip}/w_{clip}\\
y_{clip}/w_{clip}\\
z_{clip}/w_{clip}
\end{pmatrix}
\end{align}
$$
这里的$w_{clip}$便是$-z_e$了,因此透视投影矩阵有如下形式:
$$
\begin{align}
\begin{pmatrix}
x_c\\
y_c\\
z_c\\
w_c
\end{pmatrix}=
\begin{pmatrix}
\cdot & \cdot & \cdot & \cdot\\
\cdot & \cdot & \cdot & \cdot\\
\cdot & \cdot & \cdot & \cdot\\
0&0&-1&0
\end{pmatrix}
\begin{pmatrix}
x_e\\
y_e\\
z_e\\
w_e
\end{pmatrix}
\end{align}
$$
下面我们需要把近平面坐标点$x_p$和$y_p$线性映射到NDC坐标$x_n$和$y_n$:$[l,r]\Rightarrow [-1,1]$以及$[b,t]\Rightarrow [-1,1]$
$$
\begin{align}
\dfrac{x_n-(-1)}{1-(-1)}&=\dfrac{x_p-l}{r-l}\Rightarrow x_n=\dfrac{2x_p}{r-l}-\dfrac{r+l}{r-l}\\
\dfrac{y_n-(-1)}{1-(-1)}&=\dfrac{y_p-b}{t-b}\Rightarrow y_n=\dfrac{2y_p}{t-b}-\dfrac{t+b}{t-b}
\end{align}
$$
将$x_p$和$y_p$代入得
$$
\begin{align}
x_n=\Big(\dfrac{2n}{r-l}\cdot x_e+\dfrac{r+l}{r-l}\cdot z_e \Big)\Big/-z_e\\
y_n=\Big(\dfrac{2n}{t-b}\cdot y_e+\dfrac{t+b}{t-b}\cdot z_e \Big)\Big/-z_e
\end{align}
$$
可填入透视投影矩阵:
$$
\begin{align}
\begin{pmatrix}
x_c\\
y_c\\
z_c\\
w_c
\end{pmatrix}=
\begin{pmatrix}
\frac{2n}{r-l}&0&\frac{r+l}{r-l}&0\\
0&\frac{2n}{t-b}&\frac{t+b}{t-b}&0\\
\cdot & \cdot & \cdot & \cdot\\
0&0&-1&0
\end{pmatrix}
\begin{pmatrix}
x_e\\
y_e\\
z_e\\
w_e
\end{pmatrix}
\end{align}
$$
由于$z_c$不依赖于$x_e$与$y_e$且与$z_e$和$w_e$成线性关系,设
$$
\begin{align}
\begin{pmatrix}
x_c\\
y_c\\
z_c\\
w_c
\end{pmatrix}=
\begin{pmatrix}
\frac{2n}{r-l}&0&\frac{r+l}{r-l}&0\\
0&\frac{2n}{t-b}&\frac{t+b}{t-b}&0\\
0&0&A&B\\
0&0&-1&0
\end{pmatrix}
\begin{pmatrix}
x_e\\
y_e\\
z_e\\
w_e
\end{pmatrix}
\end{align}
$$
即
$$
z_n=z_c/w_c=\dfrac{Az_e+Bw_e}{-z_e}
$$
在视角空间中,$w_e=1$,因此$z_n=\frac{Az_e+B}{-z_e}$,利用边界关系:
$$
\begin{cases}
\dfrac{-An+B}{n}=-1\\
\dfrac{-Af+B}{f}=1
\end{cases}
\Rightarrow
\begin{cases}
A=-\dfrac{f+n}{f-n}\\
B=-\dfrac{2fn}{f-n}
\end{cases}
$$
因此完整的透视投影矩阵表示为:
$$
\begin{pmatrix}
\frac{2n}{r-l}&0&\frac{r+l}{r-l}&0\\
0&\frac{2n}{t-b}&\frac{t+b}{t-b}&0\\
0&0&\frac{-(f+n)}{f-n}&\frac{-2fn}{f-n}\\
0&0&-1&0
\end{pmatrix}
$$
如果视锥体对称,即$t=-b$和$l=-r$,则可简化为:
$$
\begin{pmatrix}
\frac{n}{r}&0&0&0\\
0&\frac{n}{t}&0&0\\
0&0&\frac{-(f+n)}{f-n}&\frac{-2fn}{f-n}\\
0&0&-1&0
\end{pmatrix}
$$
通常情况下我们会用参数$fovy$($y$轴方向的视域角)、$aspect$(屏幕宽高比)、$near$(近平面)以及$far$(远平面)来构造透视投影矩阵,相关关系如下:
$$
\begin{align}
r-l&=width=2nearaspecttan(fovy/2)\\
t-b&=height=2near*tan(fovy/2)
\end{align}
$$
而对于正交投影,只需对各个方向作正则化即可:
$$
\begin{cases}
\dfrac{x_n-(-1)}{1-(-1)}=\dfrac{x_e-l}{r-l}\\
\dfrac{y_n-(-1)}{1-(-1)}=\dfrac{y_e-b}{t-b}\\
\dfrac{z_n-(-1)}{1-(-1)}=\dfrac{z_e-n}{f-n}
\end{cases}
$$
得到正交投影矩阵:
$$
\begin{pmatrix}
\frac{2}{r-l}&0&0&-\frac{r+l}{r-l}\\
0&\frac{2}{t-b}&0&-\frac{t+b}{t-b}\\
0&0&\frac{-2}{f-n}&-\frac{f+n}{f-n}\\
0&0&0&1
\end{pmatrix}
$$
复习完视图矩阵和投影矩阵的相关概念后,我们正式计算世界空间中的包围球到屏幕空间AABB包围盒的投影。
首先,需要将包围球投影到相机空间,这一步简单地乘上一个视图矩阵即可。
求屏幕空间包围盒,即求包围球在投影面上的最大和最小坐标,从单方向看,如上图所示,若$\hat a$表示$x$轴,欲求取点$T$的坐标,连线$OT$与圆$C$相切,在该二维平面上,有球心坐标$C(C_x,C_z)$,设$\vec c=(C_x,C_y)$,$c=\sqrt{C_x^2+C_y^2}$,则从相机到$T$的单位向量可由旋转得到:
$$
\hat\omega =\begin{bmatrix}\cos\theta&\sin\theta\\-\sin\theta&\cos\theta\end{bmatrix}\frac{\vec c}{|\vec c|}
$$
而$T$到相机的距离也很容易求得:$d=\sqrt{c^2-r^2}$,且$\cos\theta = \frac{d}{c}$,$\sin\theta=\frac{r}{c}$
解得$T=O+\hat \omega d$,同理可求得点$B$的坐标,令$\tilde \theta = -\theta$即可。
点$B$和$T$即视图空间中,包围球在$x$轴方向上的最左点和最右点,我们还需要将其变换到裁剪空间中,进行归一化处理。
由前述推导可知,透视投影过程可表示为如下形式:
$$
\begin{pmatrix}
P_{00}&0&0&0\\
0&P_{11}&0&0\\
0&0&P_{22}&P_{23}\\
0&0&-1&0
\end{pmatrix}
\begin{pmatrix}
x_e\\y_e\\z_e\\1
\end{pmatrix}=
\begin{pmatrix}
P_{00}x_e\\P_{11}y_e\\P_{22}z_e+P_{23}\\-z_e
\end{pmatrix}
$$
通过透视除法投影到NDC空间中:
$$
\begin{aligned}
x_n &= \frac{Ax_e}{-z_e}\\
y_n &= \frac{By_e}{-z_e}
\end{aligned}
$$
最后通过简单的线性变换可以从NDC空间$[-1,1]$变换到UV空间$[0,1]$方便后续处理。
GLSL实现如下:
1 | bool projectSphere(vec3 C, float r, float znear, float P00, float P11, out vec4 aabb) |
深度值搜索与比较
得到Hi-Z Buffer和屏幕空间包围盒后,便可开始最后的计算环节。首先通过屏幕空间包围盒的大小获得需要索引的MipLevel:
1 | float width = (aabb.z - aabb.x) * cullData.zbuffer_width; |
通过包围盒中心位置来确定需要采样的UV坐标,为了保证不会误剔除,在其周围多采样几个像素:
1 | vec2 uv = (aabb.xy + aabb.zw) * 0.5; |
选择深度最大的那个深度值,保守剔除策略:
1 | float depth = textureLod(hiz_buffer, uv, mip_level).r; |
还需要将深度值通过逆透视除法还原到裁剪空间中,以避免浮点精度误差带来的剔除准确性降低,设采样得到的深度值为$z_n$,其裁剪空间对应的深度值为:
$$
z_c=\frac{2f\cdot n}{(f-n)\cdot z_n-(f+n)}
$$
其中,$f$为远裁剪面距离,$n$为近裁剪面距离,注意这里得到的$z_c$为负,需要取反。
在实际实验中,在摄像机远离物体的过程中可能会出现误剔除的情况,这是由于我们利用了上一帧的深度信息,而这一帧由于远离物体,深度变大,会导致自遮挡的现象出现,解决办法也很简单,我们需要保留上一帧的相机视图-投影矩阵,在计算用于比较的实际深度值时,应使用上一帧的相机参数,以解决自遮挡问题。
完整的遮挡剔除代码如下所示:
1 | float LinearizeDepth(float depth) |
3.4. 基于Meshlet的渲染优化
前文中我们已经实现了基本的基于GPU的剔除优化技术,在多数场景下都能带来一定的性能增益,但目前我们还只是以子网格为单位进行的剔除与提交,当子网格较大时仍存在不少计算资源的浪费,由于我们已经使用了GPGPU技术来帮助我们完成剔除与绘制的操作,模型的数量和Drawcall已不再是我们的性能瓶颈,一个很自然的想法便是能否将一个大的网格切分为诸多一定规则的小网格,从而提高剔除的粒度,同时由于我们使用了GPU强大的并行处理能力进行剔除,对它们进行处理也不将成为问题,因此,我们引入了Mesh Shader中Meshlet的概念,只是我们为了平台灵活性,不打算用Mesh Shader进行处理,而是使用计算着色器来帮助我们完成相同的功能。
Meshlet是网格划分为小块的单位,是Mesh Shader处理的基本单元,传统的顶点着色器是逐顶点处理模型的,而Mesh Shader则支持逐Meshlet处理模型。通常来讲,每个Meshlet具有相同的顶点数以及支持的最大三角形数,NVIDIA建议选取顶点数为64,三角形数为124的Meshlet进行处理,Meshlet的生成需要通过离线工具构建,IlumEngine中使用了开源库meshoptimizer进行处理,meshoptimizer
使用非常方便,文档齐全,这里不多讲其使用。如下封面图所示,我们已经成功将整个场景分成了大量小网格块:
加入了Meshlet支持的模型存储方式与之前也基本一致,区别在于每个Model
对象需要维护其所有的Meshlet,每个子网格需要存储其拥有的Meshlet的偏移和数量,Meshlet结构体定义如下:
1 | struct Meshlet |
meshopt_Bounds
为meshoptimizer
的包围体结构,使用的是包围球结构以及用于锥体背面剔除的相关参数。
现在的Model
和Submesh
结构体定义如下:
1 | struct Model |
现在我们已经将最小渲染单位从子网格换成了Meshlet,原先的间接绘制、GPU剔除等操作照样进行。
3.4.1. Meshlet渲染性能调优1:压缩合批
由于我们将每个网格都分成了众多Meshlet,在提高剔除粒度的同时也增加了渲染物的数量,同时用于间接绘制所需要的Draw_Buffer
也会相应的变大,在之前的实现中,我们通过设置VkDrawIndexedIndirectCommand
中的instanceCount
参数来决定是否进行绘制,设为0时则表示被剔除。但在直接从子网格处理转换到Meshlet处理时,却发现当场景规模很大时,渲染效率反而没有原来的高,经过Profile发现瓶颈出在了几何阶段的GPU开销上,尽管有了一系列CPU优化和GPU剔除,CPU已经基本不需要花什么时间了,但绘制过程中的GPU用时居高不下,经分析是由于Draw_Buffer
过大造成,光是遍历里头每一条渲染指令就已经花了GPU很多时间了。这个问题的解决方法也很简单:只提交需要进行绘制的指令。
在之前的实现中,我们是在CPU端事先设置好所有的VkDrawIndexedIndirectCommand
数组,传到GPU中,通过GPU可见性判断来设置instanceCount
参数决定是否绘制该物体,这种方法造成了最后进行提交的绘制缓冲中有效指令和无效指令相互混叠,容易产生流水线气泡,如果能够对绘制缓冲中的指令进行排序,将有效指令移至渲染队列的头部,将绘制指令数量设置为有效指令的数量,则可以大幅提升性能。但在着色器中实现排序操作似乎不是一件简单的操作,因此我们另辟蹊径,在剔除管线中不仅设置可见性,我们直接在里头设置整个渲染指令。
为方便渲染队列的构建和有效指令数量的跟踪,我们使用了一个Count_Buffer
来记录有效绘制指令的数量,在计算着色器中,通过原子加法操作,实现类似push_back
的功能:
1 | if (visible) |
然而,在开发过程中,又遇到一个问题,如何获取Count_Buffer
中的计数数据?使用GPU-CPU回读?那样在 一个渲染循环中只能获取到上一帧的计数结果。通过查阅文档发现,Vulkan开发者已经考虑过这个问题了,提供了vkCmdDrawIndexedIndirectCount
函数,让我们能够直接使用Count_Buffer
作为参数向GPU指定渲染指令数量。
至此,Meshlet渲染带来的合批过大问题已被完美解决。
3.4.2. Meshlet渲染性能调优2:层次剔除
简单使用Meshlet进行渲染,除了有合批过大的问题外,还有剔除开销的问题,当场景面数一多,Meshlet数量一大,剔除阶段的计算量也是不可忽视的。这里IlumEngine采用的解决方案是分层次进行剔除,其实也就是先对实例(子网格)进行剔除,再进行Meshlet进行剔除。已经在实例剔除阶段剔除的实例,其包含的Meshlet也就不用再进行剔除了,减少了剔除用时。事实上,层次剔除还可以更进一步的,通过构建不同层级的Meshlet BVH,进行高效地索引需要剔除的层级,能够实现更加高效的剔除策略,和LOD方法相结合,也就是虚幻引擎5中Nanite虚拟几何体的处理方法了。
4. 结果
场景总览
渲染管线
无优化
仅视锥剔除
仅背面剔除
仅遮挡剔除
使用所有剔除
演示Demo
可以看到由于使用了帧间连续性的原因,在某些地方仍会有部分闪烁,后续引入TAA等帧间累积方法可以一定程度上解决这个问题,但在帧率上确实有了实质性的提升。