## 开篇:
大地形渲染的文章不少,但适合入门、相对基础且内容较全的指南并不多。这篇试图补上这个缺口,从基础概念和场景问题出发,把数据组织、网格划分、渲染优化等等实践的知识和暗坑串联起来。
在这次实践中,我们的最终目标是制作出一个满足下述两个需求的大世界地形:
* **地图尺度达到约16km \* 16km,水平几何精度级别为0.5m**
* **支持8种地形材质,地表精度约5 texel/cm,并尽量消除纹理平埔的重复感**
此外系列文章中也会介绍Hi-z、Adaptive Virtual Texture等优化技术的关键细节 本系列将分别从顶点几何及地表纹理两方面展开介绍,其中,顶点几何部分会从地形数据组织与网格划分讲起,逐步展开到 LOD、视锥裁剪、遮挡裁剪、Hi-Z 等渲染优化;地表纹理部分则围绕多材质混合、纹理采样精度、平铺重复感消除,以及 Adaptive Virtual Texture 等方案展开。希望这套内容既能帮助刚接触大世界地形的读者建立完整框架,也能为实际项目中的性能与效果权衡提供一些可落地的参考。
## 地形顶点几何的数据组织与网格划分:
### 数据压力
从几何角度考虑,如果期望顶点在平面上按照0.5m的密度排布,那么对于一个16,384m \* 16384m的世界而言,假定采用高度图方案,并按照float存储顶点高度,这最终需要的内存大小约为4GB。
无论从数据加载、运行内存还是显存大小等方面考虑如果直接加载这些数据都将会给系统带来较大的带宽压力,为了解决这个问题,推荐是以四叉树形式逐级划分地图。
### 地块划分和筛选方案
在这里,我采取的手段是限定四叉树各级的单个节点只能表述129 x 129个顶点;最小四叉树节点单边边长是64m,最大四叉树的单边边长是2048m,整个世界由64个大四叉树构成;然后从这些大四叉树节点开始遍历,根据视点距离筛选出需要请求的节点。
在实践中,我采取的方案如下:
* **限定距离玩家距离小于192m的四叉树节点会维持在64m x 64m的级别**
* **超过2km的节点则不进行细分判断,只取用最大的四叉树节点**
* **2km以内的大型根节点也要统计入内。**
* **如果一个节点的四个子节点资源没有全部加载到位,则这些子节点不应当参与渲染,父节点做为兜底显示**
****
统计后,最终tile可以控制在200个以内,实际上需要的资源内存可以控制在10MB以内,如果预载常驻64个最大层级的节点,那么每帧的加载压力就连6MB都不到了。此外,如果你期望进一步压缩顶点,你可以记录各个节点的高度上限与下限,然后选择使用单字节或双字节正整数通过插值的方式来记录这一地区的高度水平;如果一个区域的高度上限和下限相同,则可以不存储这里高度图,只存一个地区的水平高度。
### 缝边
然而这种划分四叉树的方案也会带来一个新的问题,即:
**如果两个节点相邻,且层级不同,则它们的邻边会因为顶点密度不均,位置不同,导致产生缝隙。** 而缝边问题是存在许多解决方案的,常见的方案包括了裙边、顶点对齐、顶点偏移、高度混合等方案。结合具体情况分析,我最终选择了顶点对齐。这是因为在我的生产过程中,我是先使用噪音生产出了最细一级的顶点数据,然后逐级以删除顶点的方式生产粗糙节点数据,在这个过程中粗糙节点保留下来的顶点坐标仍然可以在细致的节点中找到,因此只要让细致节点把相邻边上位置无法对齐的顶点坐标对齐到前一个可对齐的顶点上,就可以消除缝隙,这个方案也就是顶点偏移。
具体可参考如下例子:

* 假定AB两节点相邻,B比A细致
* B存在五个顶点,相邻边缘索引为0-4,坐标为p0,p1,p2,p3,p4
* A存在三个顶点, 相邻边缘坐标为p0,p2,p4
* 可对B做顶点预处理,对五个索引值先右移1位,再左移1位
* B索引处理后成为0 0 2 2 4 相邻边缘坐标为 p0,p0,p2,p2, p4

恭喜你,如果你可以看到这里,那么你已经了解了大地形的基本坐标处理方案。在上面的两个环节中,四叉树的筛选部分由于计算量小,且与资源加载强相关,因此暂时推荐在CPU端完成,后续我会介绍一种computer shader计算的方案,而后者的顶点坐标计算,数据量大,计算相对独立,在GPU上利用Computer Shader或者顶点函数来实现则是正确的选择。
### GPU-Driven和顶点簇
而实际上,我们还可以把优化再推一步,即利用Compute Shader和RHI的间接绘制API来让GPU独立完成后续的顶点计算和绘制命令提交功能。
间接绘制的API中一种常见的绘制Buffer通常由如下5项数据、二十个字节组成,即:

即利用某段索引,绘制若干实例,同时可以指定shader中的起始实例ID
然而直接使用这段Buffer去触发渲染并不会带来什么性能提升,无论是DrawInstance命令还是这段Buffer,它们占据的带宽压力都是微乎其微的,但是如果程序能够在Compute Shader而非CPU中计算好所有节点的顶点坐标,然后通过间接绘制触发渲染指令,则CPU就可以节省峰值三百多万次顶点坐标相关的计算,并且在执行这些计算的同时不阻塞CPU的相关工作。
更进一步的,buffer中的instanceCount和IndexCount是两个可变化的数据,结合GPU优秀的并行计算能力,Compute Shader结合间接绘制可以拥有比传统CPU方案更加灵活的对顶点分组处理的能力。
比如说可以在compute shader中把节点原本的129 x 129个顶点沿平面划分为32x32组顶点簇,每簇顶点拥有5\*5个顶点,32个三角图元,然后判断顶点簇的图元是否完全背面于角色实现,若否,则instanceOunt+1 ,若是,则不纳入最终绘制,这样就可以大幅度降低相关图片后续在光栅化中的性能消耗。
最终的顶点簇分布可参考下图

当然,顶点簇也会带来一些问题,比如在上面的例子中每个顶点簇的最长边只有5个顶点,在做顶点偏移的接缝处理的时候,它最多兼容比自己粗糙两个lod层级的顶点簇,否则顶点就全部塌缩到 p0,因此在前期筛选渲染图块层级的时候就要记得保证相邻图块之间的层级差小于等于2。
对此,我的实现方案是准备一张256x256的lod纹理,每个像素是一个32位数字,记录的信息包含lod层级、高度图资源标号等信息。在筛选最终渲染图块的时候,我把这个过程交给GPU逐次执行,先处理2048x2048这一级最大的地块:
1. **确认渲染的在lod纹理上找到对应的32x32像素区域,记录当前图块的信息。**
2. **子节点资源准备充沛的,在下一批处理流程中处理**
3. **在下一批节点筛选之前,检查lod纹理,确认各个地块相邻的地块lod差距不大于2;检测出现问题的地块将放弃让子节点进入筛选流程**
4. **递归上述过程.....**
最终,我们可以顺利得到一个需要绘制地块lod层级不超过2的地块ID队列。此外,lodmap也可辅助后续的顶点函数快速采样,确认地块信息。这个环节也可以顺便做一次shi'zhui视锥剔除的初筛,为后续处理减少不必要的计算。

## Hiz方案
本次实践中,我们还借助顶点簇的优势,采用了Hi-z遮挡剔除方案。
Hi-z方案简单来说是一种遮挡剔除方案,主要需要输入的信息包括:
* **一张存有深度信息的深度纹理**
* **拍摄这张深度纹理所用的相机矩阵信息(有时候需要一部分额外信息)**
* **地形网格划分出的顶点簇包围盒信息**
输出信息包括:
* **通过遮挡剔除的顶点簇数目**
* **通过遮挡剔除的顶点簇的附加信息(如顶点簇位置、lod等)**
处理过程简单概括一下就是:
1. **取每个顶点簇的包围盒**
2. **利用拍摄深度纹理的相机矩阵计算出包围盒八个点的深度值和屏幕位置**
3. **拿这些值和深度纹理中的对应坐标的值做比较**
4. **如果包围盒距离屏幕更远,那么这个顶点簇就可以被剔除**
5. **否则要把顶点簇的信息纳入输出信息中。**
当然这里只是简单的过程解释,实际上做这个方案的时候我们还要考虑很多实现细节,这里主要涉及的核心问题有两个:
* **剔除要做保守剔除还是激进剔除?**
* **深度纹理从何而来,生产方式是什么?**
### 保守剔除的深度剔除
首先,按照包围盒替代物体轮廓参与遮挡情况的判断本身就是一种简化判断。在这种情况下进行剔除的结果一定是有偏差的,如果直接拿包围盒八个点投影后的相关像素做比对,最后的结果就一定会导致部分物体的遮挡情况判断失误;
如果物体实际上是被挡住的,但是强行渲染了,那么只是性能上有一些损失;这是可以接受的。
而如果物体实际上没有被遮挡,但是被强行隐藏了,那么就会导致渲染上的缺少,这是不能接受的。
综上,思考深度剔除方案时要沿着判断哪些顶点簇一定可以被剔除的思路去做,
也就是要筛选在包围盒的屏幕投影范围内,原本深度纹理的最远点也比包围盒计算出的最近点还远的顶点簇。
### 深度纹理的来源
深度纹理的来源主要有两种:
* **取上一帧实际的渲染纹理**
* **取地形的简化模型进行渲染,形成深度纹理**
第一种方案不需要新增渲染过程,不需要制作lod模型,程序人员自己就可以完成,缺点是上一帧的相机投影矩阵和当前帧的相机投影矩阵不同,如果不对包围盒的投影范围做加厚处理就很容易在镜头移动旋转过快的时候产生错误剔除。
第二个方案虽然新增了渲染过程,且剔除更加不精准,但是不需要额外的投影矩阵,保守但是不会出错。且部分场景下实际效果会更好。
这两种方案我觉得都不存在绝对的优劣,具体还是看项目的情况,我采取的是第一个方案
### 加速范围遍历
第一种方案也还会带来一个缺点,就是目前的游戏分辨率都会比较高,可能是2k甚至4k纹理,一个顶点簇遮挡情况的检查所要遍历的像素可能多达几百个。
为了降低遍历的像素数目,可以先对深度纹理做一次压缩处理,按照8\*8 或者4\*4的分布想把像素降采样到原本分辨率的1/8或者1/4。此外,降采样的过程不是单纯的把8\*8范围内像素的值做混合,而是需要筛选出一个压缩区域内距离屏幕坐标最远的深度值,取这个作为低分辨率纹理的像素值。这样包围盒需要访问的像素数目就会成倍减少。
再进一步的话,是在初次压缩后继续把降采样的纹理按照2\*2的尺度递归压缩,逐级保存,直到遍历到一个非常小的图片的时候(这时候分辨率一般是几百个像素),采样像素的时候,根据包围盒的最长边选择单像素覆盖范围最接近的深度纹理做范围采样,这样可以大幅度降低采样判断的像素数目。
综上,在完成上述任务后,我们可以大量减少绘制的图元。具体效果可参考下述二图
评论区
共 条评论热门最新