|
需求背景
开放大世界渲染中.地形的渲染占比较重,包括开发投入 表现效果 性能开销等.而地形shader部分的性能优化已经做过多版了.但mesh的部分,还是老旧的unity内置技术.这种cpu创建,分块,lod,剔除,提交渲染等对于新设备,特别是高性能端游用户,非常不友好.这是因为cpu这些年单核提升幅度放缓,引擎的渲染部分很难抽出大量并行的多线程.而高端GPU的算力隔代提升幅度更大.所以这类本身也适合gpu计算的部分,就有迁移的趋势了.刺客信条 或 farcry系列 都有这个趋势的分享.所以决定 用gpudriven技术重写这套地形.
主要参考来自 farcry5 的一篇文章,但是他涉及流式加载等,感觉不够简单清晰.所以介绍下我自己的做法(85%一致),和他文章内没提到的细节处理.如果经验很丰富的开发者 可以直接看这篇就足够了.
因为技术细节比较多,所以先放下 最终收益图 加点掌握这套的动力.
9700+ 3080设备下 (1080p)
上:unity terrain,下:gpu terrain
改造前任务分配
改造后任务分配
参数与名称
精度为 0.5米一个单位,为了方便都不说实际距离(米),都用单位
- 场景为 8192x8192( 4096x4096米)
- 高度图为 8192x8192 16位精度
- 全图一个四叉树,不分多个四叉树(前面几级数量少浪费不了多少)
- Tile:每512x512一个Tile,Tile是 裁剪开始的等级,如果从四叉树根节点查询.层级太多性能不好.
- Sector:每128x128 一个Sector,Sector 是lod的计算的划分尺度.也就是同一个Sector内部 都相同lod等级.
- Patch:每16x16 一个Patch,Patch是最小渲染单位.就是少于16单位后不再细分 ,挨个判断子节点,直接作为渲染元素.
解释下 为什么需要Patch,我一开始自己想的一套是 用 一个正方形,4个顶点,来作为最小渲染单位的(DrawMeshInstancedIndirect api的 mesh).这样可以剔除的最干净.但是这样缺点非常多,比如 4个顶点 一个格子,顶点比例为4,如果采用 16x16,那么会有15x15格子.顶点比例接近1.这是因为同一个mesh内的顶点可以复用,mesh实例之间 顶点无法复用导致.另一个方面是如果四叉树需要细分到1x1 ,内存消耗非常大 ,大几百M,而选16x16 为最小节点,只要5M.具体可选8x8或16x16 根据具体项目平衡这个值.
四叉树只记录每个单位的 x,z 最小高度 最大高度 lodbias.因为computeshader传值最小用int 所以 xz可以写成一个index,但为了可读性 还是用x和z. 又因为采用完整四叉树所以 每个子对象都可以根据自己位置算出来.如果不想计算这个也可以直接c#算好.
循环裁剪
循环裁剪示意图
这部分完全采用 farcry5的文章做法.c#每次dispatch只裁剪一层四叉树.第一次是 所有的Tile(size=512),并发判断Tile 是否需要剔除(视锥 遮挡 距离等).如果不需要剔除计算下lod,看看自己是否需要细分为4个子节点(size=256).如果不需要细分了 那么进入 finalBuferr列表.如果需要细分 进入TempB.然后 c#交换设置下TempA和TempB 与ComputeShader的绑定 调用 下一次裁剪.这样computeshader就 可以每次都把TempA当输入列表. TempB与Final当输出列表.这个循环的代码大概这样,每次交换TempA与TempB但第一次用 Tile那层.dispatchCount,我这里用了 每级最高可能值来做.我见过另一种做法,是利用 实际tempA表长度来做,但是这样做性能并不好,因为这个数量不能回读cpu,而是用indirect方式 让gpu内部自己共享这个count.这样numthreads 必须为[1,1,1],因为引擎会调用count次 不会 调用count/64次(假如用numthreads为[64,1,1]),这样会导致性能差些.
视锥裁剪
hiz裁剪
高度图
高度图精度在大部分项目内是约定俗成的16bit.否则会不足或浪费.但是 unity 5.6 没有提供这个类型,于是要找一个容量一样的来存.就是 ARGB16,(api里叫r4g4b4a4).但是我们需要把一个高度float写入一个rgba4通道的图.那么 用 builtin的 EncodeFloatRGBA是不行的,他是假设r8g8的格式下正确处理.所以需要自己封装2个函数为r4g4b4a4用
高度float 与 r4g4b4a4 的转换
ARGB16 高度图存储方式
LOD计算
每个四叉树节点单独计算lod是不行的 这是因为不同lod衔接处要处理接缝. 这需要获取相邻的lod 四叉树多次查询性能不足.所以需要更快的查找周围的方式 ,那自然就是 把lod写成贴图,然后根据位置对应到uv直接查询了.
我先后尝试过2种实现方式性能差不多.
第一种是把所有sector 也就是四叉树节点的size为128的那一批,单独调用一个computeshader 写到rt.
大概长这样
但是这样会多一次 dispatch,而每次dispatch 都是有额外开销的 .
所以我采用不单独计算lod的方式,而是边裁剪边计算.因为node的size是从 TileSize开始的(Tile作为第一批裁剪的节点),所以只要裁剪的size==sectorSize时.写入下 lodRT即可.但是如果 还没到这个size,比如 Tile自己就被提前剔除了,这个lod留空这么办.这里不需要做任何处理,因为如果一个方块区域被剔除不可见,那么所有与他相链接的顶点也都是不可见的,否则就是剔除错了.既然那些点不可见就不处理了. 但如果 没有被剔除而是lod计算出来比较低,直接没降到sector大小就进入final作为渲染单位了呢? 这里就需要 用for写入多个像素了. 而比sectorSize更新size的node 就不需要再自己计算lod了 直接取LodRt图里 自己所在sector对应的lod.因为 一个sector 只能一个lod.
lod接缝问题
farcry5文章里 讲这个比较简单,把网格数量多的那个没对应顶点的 中间点 直接拉到 有对应点的地方 ,然后高度 用 新位置的 xz采样高度图高度.但是这样采样出来 还是会有接缝.因为 uv相同但lod不同 采样的高度图会得到不同的高度.就出现接缝.这里需要把处于边线的点,取max(蓝色块lod,红色块lod).但即便如此 还是有一个问题 文章也没提到.
角点接缝
就是mip0 与2个 mip1 衔接的边移动好了位置,并且也取了 max(0,1),用1来做高度图采样的lod了,但是 因为两个mip1 也用了同样的逻辑与修正 导致他们有2个边采用了 max(1,2)的2来作为lod采样了.这些处理各自不相干涉,但在 mip0 mip1 mip2 公共的那个点上 就出了问题了.那个位置的点 有的用lod1 有的用lod2 就又对不上了. 具体的解决办法有3个.
- 大家都采样高度图lod0,牺牲点远处的缓存,反正这是vs内场景 顶点又不会太挨着所以缓存本来也没多少命中.
- 除了记录边界lod也记录对角lod,取(自己,相邻,对角)最大值.
- 对高度图的mipmap做特殊生成方式,然采用子lod结果与lod0相同,效果与1一样只是多了缓存浪费了mipmap内存
关于HIZ剔除扩展部分
改造的hiz裁剪
普通的视锥剔除 hiz剔除,非常常见,我是不会再重复讲这部分的.但是关于我自己对于常规算法的改造 是比较想分享的.不熟悉hiz常规算法的需要先了解下.为了方便讲解这里假设 屏幕与hiz depth都是 1024x1024 正方形.
假设现在一个地形patch在屏幕上比较扁平(地形的特殊性导致 大量投影到屏幕后扁平) 比如 18x68像素.根据普通算法 需要找到 128x128 屏幕对于一个像素的 depth miplevel,连续的判断2x2个,也就是256x256像素内 都要遮挡 才会剔除.这个太过保守了.扁平的aabb 最后去找正方形的深度像素比对,等于浪费了短边的信息.和68x68大小的aabb结果无差了.所以思路是 避免扁平,避免的办法比较简单我想的是 在长的方向上切割成2个aabb.这里就是2个9x34的aabb了.这样来查询是分别寻找 2x2个64x64 ,这样是查找 2个 128x128 比一个 256x256少了一半.所以可以更多的剔除.这对于没有开发 prez的项目非常有用.因为这种gpu剔除的渲染方式 很难高性能做排序,如果重叠 ps的开销巨大 无法有效利用earlyz.如果强行要用gpu排序 这里经过测试 推荐 github上的双调排序.
拆分扁平aabb 分别判断遮挡 |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|