工程向杂谈
需求总结
利用GPU Instance技术在游戏内大量绘制草海是一件既简单又复杂的事情,说它简单是因为所有问题的核心点只有一个,那就是GPU Instance 相关接口并不多,大体上渲染流程都一致:提交Mesh和实例参数,Pipeline帮你在某个时刻通过 vs 和 ps 展开每一个实例化的个体;说它复杂是因为我们的很多后续需求需要经得住这套渲染流程的考验,有时候一些看似简单的需求,很可能实现起来就束手束脚的。一般而言项目越大,需求种类越多越杂,这些累积在一起的工程向问题就越棘手,这主要是基于一点事实:移动设备资源有限,而需求之间必须精细协调资源分配,规划执行路线。那么我们会有哪些需求呢?简单的归类总结下,我个人的结论是这样的:
- 在平面上正确绘制草海本体(模型和世界空间<X,Z>坐标)
- 可以在错落有致的平面上绘制草海本体(高度信息*)
- 草随风缓慢摆动 (背景风噪)
- 草随风有方向的摆动 ,方向可调
- 草可以被角色影响:
- 角色站立处有压草效果
- 草可变色(烧灼后表现)
- 草可被切除(切削后表现)
- 模型草分层细节显示(LOD)
- 模型草变体和碎花*
- 分层草海*
让我对其中几个打上“星号”的部分稍微多做一些延伸:
首先是条目2,所谓错落有致是指的附带上高度信息后草模型在世界空间中可以表现出正确的位置,这很好理解,之所以把它作为工程向的一点技术问题单独拎出来是因为获取到“正确”的高度信息方法有很多,但是这部分信息对于开放世界来说,如果处理不善的话会造成很多性能和资源的浪费,需要小心应对;再一方面大家可能会注意到我对正确两个字打上了引号,这是因为不能简单的认为高度信息 = Unity Terrain 里导出的那张 Height map 所记录的高度数组。正确的高度值是在布置完场景后,通过由上而下的扫描来得到的,高度信息需要考虑到所有放置在地形上的石料,土坡,建筑等额外物件的影响——对于高度图生成的地形,最多可以表示下地表的简单起伏,想要表现局部复杂的几何细节,高度图并不适合。
然后是条目7中提到的模型变体和碎花,这些东西首先具有所有草海的性质(比如能被压弯,点燃,切除等),但是它们在视觉效果中并不算是普通草海,而是点缀在草海中的一些特例。举个《原神》中的例子,如图中箭头所示:
上方白色碎花和下方成片的红色碎花都属于模型草的变体,显然它们从Mesh角度来说是区别于基础款模型草的,而且数量一般不多,主要用于增加草海的“物种多样性”。如何将它们协调的渲染出来也是值得推敲的部分,如果将这些变体全部打包进基础款模型草Mesh然后一次提交给GPU,显然会让GPU负担爆炸般增加,所以比较合理的做法是分开批次走GPU Instance 接口提交,先渲染绿色草海,再渲染红色和/或白色花朵(如果它们Mesh一致只是颜色不同的话,可以合并成一次提交)。
还有是分层草海,为了便于理解还是先上图:
参考其中突出山崖部分,这种地形结构很容易造成同一个世界坐标<X,Z>下会取到2种不同高度的Y方向高度值,既一个点落在山崖上面,一个点落在山崖下方的地面上。那么在使用采样一张纹理来获取高度的前提下,我们就没有办法在这上下两处都记录上草海密度的,也就意味着要么山崖上没草,要么山崖下没草。当然要解决这个问题前最好确认下这是否是一个问题,如果场景美术能很好的处理山上山下一侧的地貌,那不解决也无妨;然而如果有类似在近地面的浮空岛这类地形,且岛上和岛下都需要覆盖草海时,我们就得通过摄像机位置来即时判断当前帧使用哪一张高度纹理和密度纹理,确保让摄像机附近的草能正确的显示出来。可以预见这样的需求势必增加我们对高度和密度信息的编码和存储方式,增加一些额外的复杂度。
量化(离散化)的世界
之前谈密度的时候我们说过一点,这里作为重要的技术细节,我愿意再次强调一下:我们在存储、加载密度块时,使用了离散的世界坐标系,任何坐标都是基础地砖长度的整数倍。在这里就是1米的整数倍,像
<x=1,z=1>,<x=-2,z=3>
这样的坐标是存在的,但是
<x=0.2,z=1.5>
这样的坐标就不存在。
当然如果嫌1米为基本单位太长,也可以使用0.5m作为基准,届时
<0.5,1.5>,<1,1>
就是存在的,但是
<0.2,1.5>
还是不存在,因为0.2不是0.5的整数倍。
这是一个非常基础的前提,为之后的一些算法和数据结构能够正确又高效的工作做了铺垫。
也许有人疑惑有什么样的便利?
打个比方,离散化地块后,我们能非常轻易的计算并定位任何一块地砖在世界空间中的准确位置,这为我们精确加载和释放资源提供了便利;再比如超大世界地图在远离坐标中心的地区,表示位置的整数部分会占用许多float数值的有效位,留给小数部分的精确度就会大幅下降,有了离散化坐标,我们可以精确定位任何一个地砖的四个端点位置,从而为后续坐标平移,消除精度损失提供便利。再比如,离散后的地砖也为编码四叉树提过了便利,一个地砖大小的面积自然就成了树的在理论上最小的叶子节点。
平移和补缺
前文介绍过,我们用一个矩形框来确定摄像机周围的一定范围,称之为视场,在程序里所有视场中的密度信息会被缓存和管理起来。如图:
当视场随着摄像机在世界空间中移动了一段距离后,自然会产生一个新的视场矩形,新旧相减一下就产生了如下图阴影部分的可复用区域,以及蓝色方块内的待加载区域。
那么这2块蓝色区域的密度信息从哪儿获取呢?这取决于我们怎么存储每块chunk(Terrain)上的密度:对于纹理格式,相当于二维数组,没什么难度;对于四叉树格式,就得开发一套接口去匹配整块读取数据的需求,这部分内容在上一篇接口类的分享中也提到过,我说到需要至少2个接口负责读取(Search)和写入(InsertRange)密度信息,接口中的入参里有一对 Min Max 数据结构,用来表示的正是我们感兴趣的蓝色区块。
假设上图区域R已经转化成了左下角(Min)和右上角(Max)2个点(都是离散化后的世界坐标点),如下图中标注出的样子:
假设发现R的覆盖面积需要用到6块chunk中存储的数据。那么每个chunk都需要贡献出一定范围的子区域上存储的信息,这些子区域的是我们感兴趣的地方,通过总结所有可能出现的覆盖形状,我发现了一些规律:
如图所示,一共存在16种不同的状态,简单分类下可以归结为1边相交,2边相交,3边相交和4边相交这样几种类型。
我们对四叉树通过输入切割边搜索数据的过程,就是在这16种状态中连续不断跳转的过程,虽然状态很多,但好在所有状态的跳转都是收束的!简单解释下:当遇到一个四叉树内的矩形块有3边相交时,所有它的子节点的状态只可能等于3边或小于3边,而不会跳转到4边及以上的状态。此外每种状态在四叉树的父节点只需要判断一次,其子节点可以继承父节点判断的结论,从而在一个较小的状态空间中进行后续判断和过滤。再者对于所有叶子节点所代表的区域,其中的密度自然都相等,可以很方便的搬运(Blit)到目标纹理中去。所以最终的结论是,在四叉树中搜索一片区域的数据可以很高效。
运用多线程
所有在CPU中进行的密度信息索引,拷贝和处理的任务,都是与Unity 自带函数无关的事情,完全可以脱离Unity的框架,丢给另一个线程去做,然后在合适的时机切回Unity的主线程。
我们以更新视距内密度信息为例:
蓝色主线程负责发起任务(Req),具体来说是请求一份当前帧时刻摄像机周围一定范围内的草海密度信息。而图中下方的绿色则代表被唤起的子线程,负责处理 Req 中所定义的需求,这里边包括搜索四叉树,提取四叉树各个相关叶子节点信息,甚至从磁盘读取序列化好的四叉树二进制并反序列化为C Sharp对象。
一次发自主线程的异步请求,为了简化并发逻辑,我们规定只有当子线程处理完毕且提交回主线程后,主线程这边才允许发起新一轮的异步请求。这样的好处是全程只有2个线程参与,且由于所有请求都来自主线程,所以只需要在主线程里设置一个状态位就能确保线程安全。唯一的问题是,当请求来的过快,就有可能出现这样一种状况:在2次成功的请求之间,可能存在一个或多个被阻塞并被抛弃的请求。形象的说明下,可以想象摄像机在高速移动并连续发起了视场位置更新的请求(req1,req2,req3...),当处于请求req1还未得到响应的情况时,表现层只能使用原有资源渲染草海(可以已经处于摄像机后方),此间发出的所有请求(req2)会被直接丢弃,直到req1返回后才能继续处理新来的请求 req3,以此类推。这到底是不是一个需要克服的问题应该视项目具体需求而定,如果确实有频繁的高速移动需求,且会出现明显的草海刷新间隔的段落感,那么就有必要在位移前做好预加载,或者利用虚化特效短暂模糊视角,这里就不多展开了。
九宫格和十六宫格
当使用9宫格来管理chunk的时候,我们会遇到一些麻烦的问题,比如下图:
玩家来回在A,B两处切换位置,为了满足摄像机始终处于9宫格中心的需求,就会频繁的加载和卸载图中黄色和绿色 2 块区域的chunk资源。我在这里提供一种比较简单的解决方案,并不唯一,主要是抛砖迎玉。
算法很简单:以少量的额外空间做一层缓存,实际可能使用到的格子数是9 + 7 = 16个,每当摄像机有跨越区域的移动时,算法会检查一下摄像机所在的当前地块附近,是否有1格以内的chunk没有加载,有则加载之,同时检查一下在所有已经加载完毕的chunk里,是否有距离摄像机所在地块 2 格以上的地块资源,有则卸载之。
好了,到目前为止关于绘制草海的基本概念和比较通用的算法及思路已经介绍完毕,都是我在设计项目过程中总结的一些共性的东西,虽然不涉及代码,也没有公式(什么?画草还需要公式?)但是应当有助于梳理流程,为后续的深挖和优化提供了框架基础。而且必须指出的是,这套流程只是草海的解决方案之一,是否基于密度,如何划分地块,如何获取高度等等方面都会产生众多的变体,所以只要适合项目,经得起实践检验,就是好的方案。
最后再开个坑,GPU渲染草海的很多技术细节也是值得深挖一下的,比如植被的风动效果就涉及到GPU中如何模拟自然风,如何模拟风与不同形态植被的作用,期间会涉及不少数学公式(好吧,真有公式);再比如利用GPGPU加速渲染,主要从GPU视椎裁剪以及Hi-Z剔除方面入手,并会利用到Unity包装起来的几个Indirect技术;还有是读取磁盘文件的一些新尝试(主要用来规避大量向Unity导入图片纹理带来的恼人的加载等待);亦或是基于虚拟纹理的全局高度图提取......