今天分享给大家的是个人对腾讯光子技术中心在GDC 2023上关于水体的技术方案的学习心得,照例对要点做一个汇总:
- mesh部分
- 河流、湖泊用的是CDLOD数据结构,相关数据是基于节点烘焙的,支持运行时按需streaming,CDLOD多个节点的绘制采用了instancing,同一级LOD的节点通过一个DP完成
- 海洋部分近景用的也是CDLOD,远景使用CDLOD会存在密度过高问题(不可以通过调整算法降低吗?),可以更改成一个简单的低精度mesh
- wave部分
- 河流、湖泊用的是FBM方案
- 海洋用的是FFT
- 通过flowmap提供了局部水域的流动效果表现
- 通过material id实现了局部水域材质(含着色、流速等信息)的自由定制与丰富表现支持
- 支持游戏物件与水域的交互,交互的结果会作用到Displacement上
- 海洋的波形做了LOD处理,近景是FFT,远景是FBM,中景是二者的混合,超远部分没有displacement,只保留法线变化
- 做了屏幕空间的Tessellation,软件方案实现,通过Unity的Adaptive Subdivision逻辑完成,有额外的GPU、显存消耗(目前看来还不低)
过往的水体对硬件要求高,这里尝试实现一套可伸缩的方案,支持上至PC,下至移动端。
整套方案包括了水体的刷制工具、贴图的离线烘焙,运行时的流体模拟、浮力与船舶动力学,水下体积光以及自适应的曲面细分等。
分为模拟跟渲染两大块,先来看看模拟部分。
Flowmap是一项非常常用的技术,贴图通常是离线预计算的(Houdini),数据以2D的vector(3D数据)存储,不过之前的工具使用起来不太方便,且不支持turbulent flow(湍流)
这里的做法是基于lattice boltzmann(LBM)算法来完成对shallow water equation的求解,并将结果烘焙成一张物理驱动的flow map,LBM算法的优点是支持湍流,实现简单,可以并行计算(利用GPU的话,效率高),且结果是保守的
LBM其实是宏观Navier-Stokes方程的微观等价描述,这种方法会将由多个粒子组成的集合作为一个单元来考虑,之后将这些粒子的平均行为看成是当前集合的宏观行为。在这个方法中,会用f(x,v,t)来描述每个粒子的状态,x表示位置,v表示速度,t表示时间
基于Boltzmann transport equation(传播方程),可以得到一个新的公式
这个方程中,u是粒子的速度,omega是碰撞算子,这个算子会被用于控制粒子碰撞过程中的速度变化(分布)
这里还需要考虑一些外力的作用,比如浅水方程中假设出来的一些力
LBM使用包含9个速度的Cell来表征particle的motion,每个cell对应9个分布函数(?),每个函数都对应一个方向与一个权重(控制分布函数)
对公式做离散处理,这里可以将更新函数写成BGK碰撞算子的形式
LBM的更新是基于streaming(流动)来完成的,上图展示了某个cell的分布函数在stream的作用下相应的分布函数扩散到相邻的cell的过程
求解上述方程的话,整个算法分为四步:
- 更新平衡分布项
- 完成Cell的Streaming(流动)与碰撞
- 边界处理
- 计算宏观变量
先来看下局部平衡方程的更新:
- 首先基于当前的h, u, v等变量来完成对平衡分布项的更新,上图给出了具体的公式
- 公式的形式跟有很大关系,这里的指的是不同的扩散方向(一共有9个)
接下来看看相邻cell之间的流动与碰撞,基于这个公式我们可以得到新的分布函数(输入为当前的分布函数f,平衡分布函数以及relaxation因子)
这个公式中的最后一个项是浅水方程,包括摩擦力,其中i方向上的床剪切应力(bed shear stress )是按照平均深度的速度来给出的,这是一个静水压强项(水体不流动时的压强?)
边界的处理就是直接将速度在边界处做翻转,根据模式的不同,这里的处理也略有不同。
最后通过对9个方向上的局部数值进行累加来得到宏观变量的值,比如速度、深度等
LBM是数值求解方法的离散方程,自然会因为精度的问题导致结果的不稳定,为了保证结果的可用,这里增加了四项约束条件
借助compute shader,LBMSWE方案实现起来比较简单,这是一条河流的求解结果,正如前面所说,这个公式计算得到的flowmap可以实现:
- 水流碰撞石头后绕行的涡流效果
- 在地形曲折位置的涡流效果
- 水流碰到(小岛)地形后的流向变化效果
- 随着水深的变化而带来的流速的变化效果
这里还提供了工具,美术同学如果需要手动调整水流的方向,也会比较简单。
这是近距离的debug view的效果
对LBMSWE方案做一个总结,这种方法的优点是:
- 支持湍流、涡流
- 实现简单
- 计算并行化程度高,效率高
- 结果保守
方案的不足在于:
- 内存消耗高
- 不太可能用在实时
下面看下运行时流体模拟的一些工作
流体运行时模拟采用的方案是浅水方程(河流跟湖泊),这是一种基于高度场的2.5D流体模拟方案,这个方案是建立在两个守恒方程(质量守恒&冲量守恒)的基础上的
其中h是水面在该点的深度,H则是地表的高度,u是水平方向的流动速度。
数据是以交错网格的形式进行存储的,其中高度存储在cell的中心,而速度则存储在边上。
流体的时间积分算法是基于splitting scheme(细分策略)给出的,主要包括三个步骤:
- 高度的积分:基于上一帧的高度与速度完成积分得到当前帧的高度
- 速度的传导
- 水压的应用
基于质量守恒定律,我们可以基于速度来完成高度的更新。
再来看看速度的传导是如何实现的。这里考虑使用无条件稳定的Semi-Lagrangian advector来完成对速度的更新:
- 首先获取到当前位置的速度
- 其次基于当前速度,反向推测其前身所在的位置
- 最后在这个新的位置上,基于双线性插值,得到该点的速度并将之写入到原始点的位置
最后,我们再基于高度的梯度对速度做二次更新,因为计算过程需要获取到相邻cell的高度差,因此梯度的计算需要考虑地形的高度
这里还需要基于地形跟水体的高度来判断某个face是否是一堵墙,从而做一些边界的处理。如果是一堵墙,这里会将face_i+1,j设置为wall,并在每一次的计算结束后,将速度设置为0
这里是RTX 3080的模拟结果,整体的模拟网格分辨率为256x256,模拟消耗为0.09ms。
这里有一个地方需要关注,那就是SWE不支持地形的高低落差过大,比如瀑布以及breaking wave,对于这种情况,考虑基于粒子(特效)系统来实现覆盖。具体而言,就是当检测到地形的高低落差的时候,就需要将SWE转化为粒子数据,这些数据会带有对应高度场的质量、冲量,之后经过创建、更新、删除等几个步骤完成对应的效果。
首先,就是需要对网格中每个格子的边进行遍历来确定是否需要,以及需要创建多少粒子。粒子的数目可以基于穿过每个face的流量来确定,为了效果的自然,这里还可以对粒子的位置、数目以及其他属性等做一个抖动。
更新就是基于牛顿定律,在重力作用下,更新其位置即可
最后是删除,当粒子碰到地面或者水面后触发,当然,在删除之前,需要将相应的属性(速度等)传导给SWE Grid数据,这里给出了速度求解的公式:总冲量除以总质量。
速度分为UW两个方向,因此这个计算需要进行两遍。
由于速度跟高度是分开存储的,因此这里的累加是需要逐项进行,而不能一次性完成。计算的结果需要存储到5张贴图中,也就是上面标红的五个变量(去除重复的可以归纳为三个,不过由于这里是分开计算的,所以需要五个)
这里的优化方式是,假设粒子在冲刷水面的时候,会同时完成u/w/h等三个变量的更新处理,从而可以将速度跟高度耦合在一块处理,这样的话,就可以只根据速度来完成对应方向与高度上冲量的累加,贴图数目就可以减少到三张。之后再下一个pass更新速度的时候对总的质量进行复用
这里的贴图目前是基于全地图的,但其存储的是瀑布冲刷等场景中的局部数据(质量跟冲量),因此完全可以考虑将至转为局部的,甚至用其他格式来存储。
对比了效果,发现也没有太大的影响。
这里是最终的效果。
对算法做一次总结,总共8个步骤,前面加了水滴标记的为粒子专属的,对于每一步,这里还给出了输入输出项。
为了节省内存,这里用了double buffer的方式来实现数据的交换与更新,一个常驻的buffer与一个临时的buffer,临时buffer可以在每一帧中从texture pool中获取(相对于常驻的两张buffer贴图,消耗有了优化)。
这里列举了做的一些优化:
将32位浮点数转化为16位的整型,这个格式可以应用于4张(N+1) x (N+1)的贴图(速度、水体深度、boundary condition以及地形高度)
这里也假设最多需要65536个粒子,只需要3个buffer来装载相关的数据(位置、速度与生命周期)
这里还考虑将多个compute pass进行合并,比如前面说到的速度传导pass跟水压应用pass。
另一个优化点是通过working group(?)完成相邻的多个数据的获取,比如在高度积分pass中,需要获取相邻cell的速度与水体高度,这些数据就可以打包一次性获取。
水体粒子数目较多,内存占用高,为了优化这个问题,需要对粒子list进行压缩处理,但是压缩操作消耗比较高,这里就改成每五次迭代压缩一次,且压缩过程只对active的粒子生效。
为了减少对inactive的粒子更新的浪费,还借用了UE的indirect dispatch能力只对那些active的粒子进行处理。
这里还对不需要计算的cell做early exit处理
还从shader层面做了优化,包括指令级别的优化,以及寄存器的profile与算法调整优化
不同的模拟尺寸,耗时是不同的,1024的分辨率下耗时不超过0.3ms
在512(默认)分辨率下,各个环节的消耗也列在这里
SWE跟LBMSWE方案有各自的优劣,这里的做法就是将离线的LBM跟运行时的SWE方案结合起来相互补充。
接下来看看浮沫的模拟
浮沫对效果影响很大,如何动态的计算浮沫,才能使得效果更为逼真
这里采用的是一种基于网格的模拟方法,浮沫的产生这里总结为两点原因:
- 当前区域存在湍流,空气进入到水体中产生
基于这个考虑,这里尝试基于每个cell中的相对速度的幅度来控制浮沫的强度,跟前人的工作一样,这里会通过clamp函数来对结果进行控制
第二点则是当前区域的水体弯曲幅度过大,因为没法承受而破裂,从而产生浮沫。
针对这种情况,可以通过对水体的梯度的幅度进行调制来控制浮沫的强度。
浮沫的模拟跟欧拉网格流体模拟方案很相似,同样包含一个diffusion过程,浮沫的扩散会受到一个alpha的控制,这个alpha代表的是浮沫消散的速率(dissipation rate)。不过好在,这里不需要求解不可压缩速度场,而是可以直接复用前面SWE产生的速度场来完成浮沫的扩散。
在渲染上,就可以使用速度场作为flowmap来驱动浮沫贴图跟浮沫强度的变化。
接下来看看渲染部分。
先来介绍一下生产管线:
- Spline工具来生成河流跟湖泊
- 通过外部工具如Houdini来生成水体mesh数据
- 不管是哪种mesh,材质都可以手动指定
- 不管是哪种水体,最后都会烘焙成高度图
- 海洋的话,只需要设定海平面,同时划出排水区即可
有了水体覆盖范围之后,就会开始启动流体模拟(一键触发),模拟完成后会输出高度图、flowmap,材质ID map以及distance field数据,这个过程会迭代多次直到得到满意的结果。
生成的数据包括了如下的内容:
- 覆盖水体(河流跟湖泊)的场景高度(地形+物件)与水体的高度(河流可能会倾斜,因此需要拿到基础高度)
- 覆盖水体(河流跟湖泊)的flowmap数据跟Material ID Map数据
- 覆盖海岸线附近的Distance Field数据
河流跟湖泊的贴图(高度图、flowmap跟material map)都是以tile为单位进行存储的(后面说介绍,128x128分辨率的),离线计算完成,运行时可以streaming。
水体通过Material ID的方式来存储其材质,8位,共256种,其中0号赋值给了无水体的区域,1号赋值给了海洋,还剩下254种供河流跟湖泊使用。河流之所以需要多种材质,是因为方便为不同的区域调整其属性,包括水流速度、颜色、浮沫的计算逻辑等,有了Material ID,所有的水体就可以不受材质的限制,一次性绘制完成(当然,mesh分离导致的多个drawcall还是需要的)
为了避免单一的shader过于复杂,这里将水体渲染分成两个pass,第一个pass会处理displacement、normal以及foam。
这里给出每一帧河流跟湖泊(不包括海洋,河流跟湖泊采用的是相同的渲染逻辑,海洋单独一套)等水体渲染的各个步骤,先是Pre-pass,在这个pass中:
- 会执行一次depth only的渲染,mesh通过四叉树来管理,所以这里会基于四叉树的数据完成渲染,输入数据为水体的高度图
- 这个Pass的VS是简化版的,渲染一个flat water surface(比如倾斜的河流,就只绘制倾斜的外轮廓surface,当然是经过四叉树裁剪后的)
- 绘制时会用一个稍微大一点的FOV,以避免边缘部分被offset而穿帮
- 绘制完成后,我们会基于深度数据投影到世界空间来得到世界空间坐标
这里需要对CDLOD的绘制逻辑做一下简单介绍,CDLOD是按照四叉树组织的,对于每一层的节点,如果属于相同的LOD(没有细分的子节点),那就按照一个DrawCall(Instance Rendering)绘制即可,有子节点的就拆分到下一层进行绘制,而通常层数一般不会太多(超过十个),因此DrawCall这边也不会有太多的损耗。
FBM波形(海洋不用这个,用的是FFT):
- 使用FBM方法(这里的FBM的基本信号是美术同学通过各种工具如Houdini烘焙的一张displacement贴图)来模拟波形细节,通过调整振幅、频率以及速度等方式来实现水波的平静与波动效果
- FBM的波形参数是存储在水体材质的data buffer中的
- 之后通过世界坐标获取到对应位置的材质id,再从材质id map中拿到对应的材质(由于河流各个区段的效果可能会有不同,因此通过Material Map的方式来丰富细节表现),同时从材质data buffer中拿到材质的属性
- 最后再通过全局的flowmap来控制fbm波形的流动效果
这里计算Displacement、normal以及foam都是在屏幕空间完成的,displacement的执行是在屏幕空间的像素上进行,针对每个像素将之反投影到世界空间,之后基于坐标获取到对应的displacement写入到displacement buffer中,pre-pass的RT尺寸并不需要跟GBuffer的尺寸保持一致,因此对于低端机而言,我们可以通过控制RT的尺寸来控制计算消耗,因此这里可以根据性能以及项目需要控制屏幕空间的RT分辨率,以实现性能的把控。
Displacement数据也有两个来源,一个是FBM(或者FFT),另一个是Particle(Particle可以通过Niagara生成,也可以通过前面的动态SWE计算得到,SWE计算消耗太高,如果涉及到玩法,在移动端上就不太能跑起来)
- 在UE的Niagara粒子系统中,添加了一种新的材质类型
- 这种材质粒子在绘制的时候会将displacement数据写入到displacement buffer而非color buffer中
- 粒子的displacement会跟FBM波形的displacement结合在一起,组成最后的displacement
接着基于最终的displacement buffer生成法线数据:法线的计算需要先拿到Displacement,且数据有两个来源,一个是base mesh本身的transform(法线),另一个则是Displacement的法线。
foam的渲染跟前面的环节也是解耦的,会被绘制到一个单独的buffer中:Foam的计算逻辑也有两个分支,一个是湖泊跟河流,采用的是基于Flowmap(或动态SWE)的数据(在模拟的时候,通过对流速的差分来得到,Flowmap的RG通道存储2D的flow信息,B通道存储foam的intensity,材质可以基于这个数值来控制foam的具体表现),一个是海洋,基于FFT的雅可比矩阵来得到。
基于flowmap的foam数据只是一个静态值,要想让这个值动起来,需要跟flowmap一样,通过双重采样来实现。
最后在shading的时候,还需要再走一遍CDLOD的绘制(这里就是原始的RT分辨率了),这里对CDLOD的剔除还需要再做一遍(如果是在CPU遍历的话,那数据可以复用)
说到流程,这里也顺便介绍下海洋的波形是如何实现的,这里其实是做了LOD处理的:
- 近景:FFT
- 中景:FFT+FBM(三阶Octave,基于一张perlin noise得到)
- 远景:FBM(三阶Octave,基于一张perlin noise得到)
- 超远:没有displacement,只有normal等高光效果
Mesh采用的是CDLOD的方案,同时还添加了Tessellation逻辑,为了实现这套逻辑,还在引擎中单独增加了一套自定义的Vertex Factory。在VS中会对heightmap以及displacement map进行采样实现波形顶点的偏移。
在shading的时候,会再采样屏幕空间的法线以及foam数据以完成计算。而整套方案由于是基于UE原生的Single Layer Water改造而来的,因此可以直接复用UE的lumen光照与反射的结果。另外,如果不用lumen,开SurfelGI也是可以的。
CDLOD将数据按照四叉树进行组织,之后通过逐节点的剔除,将可见节点的mesh数据提交给GPU进行渲染
CDLOD的一个最大的特点就是不需要再想办法来处理多级LOD之间的衔接关系,相邻LOD的mesh会有一个自动的平滑过渡逻辑,可以随着距离的增加,自动完成从上级到下级的过渡
CDLOD的优点是不需要额外的逻辑来缝合多级LOD的裂缝,不足在于相邻节点最多只支持一级LOD的差距,因而LOD的下降速度相对较慢,在某些区域的mesh精度超出了需要的精度,但是如果就直接修改每一级LOD的覆盖范围的话,又可能导致近景的精度不足。
跟地形不同,水体是有覆盖范围的,需要将高出海平面的部分的水体剔除掉,这里会通过双线性滤波方式实现平滑的海岸线效果。这里的滤波是通过gather4手动完成的,之后对于不需要的顶点,则是通过除0得到NaN的报错来实现剔除(这个在移动端上也是可以跑的)。
为了避免低级别LOD的mesh在海岸线附近留下一个gap,这里还需要对水体mesh做一下膨胀处理
对于CDLOD四叉树的遍历是通过compute shader完成的,整个过程放在一个循环中执行,循环的每次执行会处理完当前四叉树的某个层级的所有节点。每一次的执行都会对当前处理节点的可见性进行判断,对于需要进一步处理的节点,会将信息写入到group shared memory buffer中等待循环的下一轮的迭代。等到所有节点都处理完之后,这个buffer就会被拷贝到output node buffer中,之后基于这个buffer完成indirect draw argument的更新
compute计算方法有如下的一些约束:
- 整个shader只有一个group,size为16x16(这里是线程数,线程数可能少于节点数,不过可以通过一个线程处理多个节点来跳过这个约束),shared memory的尺寸倒是一个硬伤,只能看看能不能通过数据压缩来优化
- 虽然每个线程都可以拿到所有的group shared memory的数据,但是硬件才能面对每个线程可以用的group memory做了约束,因此每个level(四叉树上的一层节点)可以写入到buffer中的最大节点数就不能太多
- 虽然在12k的地图中测试发现不会溢出,但还是做了一个fallback处理,如果溢出,下一帧就走CPU遍历
为了实现超大地图,这里将所有贴图数据(heightmap、flowmap、material id map等)塞入到一个超大的贴图中:
- 分割成128为单位的tile(为了双线性采样越界,其实还在旁边加了一个像素,构成一个130的贴图),方便做streaming
- 最高精度下,每个像素会覆盖一平方米的范围。
- 没有水体的tile会被跳过(不知道是否存储数据),而如果tile上的水体高度都是相同的,这里还会将高度数据压缩为一个float
运行时会给予到相机的距离来判断该选用哪一级的贴图,也就是面向水体的VT。
因为tile不是POT的,所以在贴图上还有部分空间没有用上,这里考虑将这块空间用来存储address table,这个表用于完成世界空间坐标VT Pool的贴图UV的映射。
这里是tile allocator的相关信息:
- We implement a very fast and simple tile allocator.
- It assumes there are up to 64x64 tiles.
- We use a 64x 64bits bit array to store if a tile is used or not
- And another uint64 is used to store the row bits.
- The bit array is initialized as all 1, which means free.
- The allocation always happens on the least significant non-zero bit,
- We can use 2 intrinsics like _BitScanForward64 to get the address quickly, then mark the bit as 0(occupied) and return the address
- Each allocation will only allocate one tile,
- By this way, we can guarantee all the allocations can find the first available tile.
- To Free the tile, just mark the bit to 1.
为了实现计算与运行时模拟,就需要拿到场景的高度数据,这里 没有直接用UE的SceneCapture功能,而是自己写了一套,因为UE的方案成本太高了:
- 会用俯视角完成水体跟地形的绘制
- 之后绘制与水体有交集的静态物体以实现对水体的挖洞效果(而如果只是俯视角的遮挡的话,桥体就会把其下的水体扣掉,那是不满足的)
- 这里会剔除掉水上的部分,并对物件采用back face render的方式来绘制(通过水上剔除+backface render拿到物件跟水体的横截面)
- 水下的物件会用一个单独的pass来处理
- 静态场景只需要绘制一次,动态物件则需要每帧捕获
动态物件的更新只会在需要做运行时模拟的tile上做(比如通过SWE模拟实现角色、船舶与水体的交互的,或者水流从无到有的)
SWE模拟所需四叉树的数据从烘焙后的四叉树(为啥要烘焙呢,因为离线需要获取到有哪些区域被水体覆盖,将这部分覆盖的区域烘焙成了离线的CDLOD数据,运行时新增的水体区域需要对这块数据做更新)来,不过会移除掉模拟区域内的节点,在更新的时候,会统计高度图上的像素来看看哪些节点需要更新,之后在GPU上完成四叉树数据的更新
高度图跟flowmap会直接被到处到VT pool中,由于VT的区块分配是发生在CPU上的,为了加速运算,这里会提前为模拟区域内的数据预先分配好对应的贴图空间,同时增加了第二个page table,有了这个,我们就可以在静态的水体跟动态的水体效果之间进行混合了。
所有的数据的更新都是在一个compute shade中完成的
高度图跟flowmap会被拷贝到staging贴图中并触发readback,供下一帧使用(以判断哪些没有水体的节点需要跳过)
这是整体的性能消耗数据
SWE可以在PC跟主机上跑起来,但是移动端可能就够呛,主要的想法是将耗时高的部分从SWE中移除出去,之后再完成方程的求解来得到可交互的波浪,以实现一个简易版的。
原本的算法是需要通过在一帧中进行多次递归迭代来得到稳定的结果,但是这种做法耗时比较久,因此尝试通过一次计算来完成模拟,但是这种做法会导致结果不稳定,为了解决这个问题,引入了一个damping值来避免高度的剧烈变化。
表面波形模拟是通过纽曼边界条件使得波形在遇到边界后反弹的,纽曼边界条件会给边界赋予一个微分数值,也就意味着在边界不会出现水体的的交换。在计算的时候,如果发现某个像素是超出边界的,就直接将数值设置为来避免水体交换。
这里实现了两种模式的水体模拟:
- fixed simulation,听起来像是不随视角而变化的,覆盖对应区域的模拟方式
- tiling simulation,模拟域挂在玩家身上,在运行时,只需要将玩家的位置通过tiling模式映射到simulation贴图上即可。
UE的single layer water在移动端上只支持透明模式,性能消耗过高,这里则在不透跟透明pass之间添加了一个不透明的水体渲染模式来优化性能,这种方式还不会破坏掉subpass的渲染模式。
下面看看针对水体的tessellation逻辑。
基于高度图的水体只支持单层的水体效果,不支持水上叠水,这里给出的解决方案是在屏幕空间内使用tessellation。
屏幕空间的tessellation主要是为了解决多层水体叠加(应用场景比较有限)的问题的,其渲染策略跟此前大块水体的渲染逻辑类似。不过有几点不一样的地方:1. 不再按照四叉树来绘制,在Prepass的时候直接绘制一个粗糙的mesh(比如一个quad),这个mesh之后的渲染逻辑(顶点的displacement计算等)就跟之前的逻辑一致了
- 为了区分下面一层的四叉树mesh的prepass数据跟粗糙mesh的prepass数据,这里还需要通过一个mash来对屏幕空间的位置进行标注
- 接着绘制一个屏幕空间的pass,这个pass中的mesh是对齐屏幕空间的,密度为4个像素一个quad,算是非常高了
- 在绘制屏幕空间的网格的时候,一方面会获取粗糙mesh的深度数据,从而拿到世界空间的坐标,之后通过屏幕空间的displacement buffer(前面流程中基于FBM+Particle计算得到的)拿到对应点的偏移数据,从而得到最终的世界坐标,之后完成PS的调用,得到最终的结果。
为了节省带宽,这里直接使用instance rendering的方式来完成绘制,其中每一行都是一个instance。grids中每个cell的X跟Y坐标可以通过PrimitiveID跟InstanceID拿到。
右上角的图是renderdoc截帧效果,每个cell的尺寸接近于一个像素。对于不需要的顶点,还是会采用前面说到的除零得NaN的方式来剔除掉,为了避免过多的浪费,这里还会有一个tile划分的策略,先整理出有水体的tile,之后对这些tile调用instance rendering(indirect draw)的方式完成绘制。
小尺寸水体可以通过屏幕空间的tessellation提高mesh精度,大尺寸水体如果也用同样的方法在海浪尺寸过大的时候就会导致瑕疵(比如海浪较大的时候,近景的像素可能就会直接连接到远处的像素,从而导致mesh扭曲),而如果直接提高CDLOD的精度,节点就会过多,导致性能下降(其实就正常使用的话,CDLOD远处的mesh精度依然过高,存在浪费,但是如果直接降低LOD精度,远处精度降下来就会导致近处精度也下降?这里应该可以做更精细的控制,比如调整各个LOD的距离来调整mesh的密度)
这里采取的方案是Siggraph 2019的自适应细分算法,这种算法通过对一个三角形进行二分来实现mesh的细分,这个细分过程可以表达为多个矩阵的相乘(每次细分就相当于对原始的parent三角形做一次变换,由于一个直角三角形只能变成上三角形跟下三角形两种细分的小三角形,因此可以认为一个三角形可以有两种细分方式,可以用01表示),而多次细分的过程可以组合成一个连续变换的key,通过对这个key进行逐位取矩阵做累乘就能得到对应的细分三角形。
之前还担心计算消耗过高,但由于细分是控制在一定的范围内的,所以整体的消耗还比较可控。同时也考虑过将矩阵累乘的结果i缓存,但发现消耗并没有太大变化,反而增加了内存消耗。
每一帧中,基于每个三角形到相机的距离来计算其应该有的level,第一帧的时候会将所有三角形的key塞入到一个output buffer中,这个buffer会在后面每一帧被处理,这个处理是一个循环操作(可以在GPU中并行完成),主要有三种情况:
- 当前三角形的精度过高需要合并,这时候就会跟其兄弟三角形一起,合并成parent,这里为了实现并行处理,永远只对尾号为0的三角形进行最后一位移除塞入output buffer操作,尾号为1的就直接移除了(需要保证不会因为轻微的差异而导致兄弟三角形的精度不一致,不然就会出现一个要合并,一个要保持甚至拆分。为了保证这个效果,就需要取两个三角形相交的边界的中心作为判断依据)
- 当前三角形精度过低,那就直接拆分成两个三角形
- 当前三角形精度不变,那就直接将原始的key写入output buffer。
在实现的过程中发现,当相机移动过快的时候,会导致顶点的闪烁,且由于buffer是逐帧复用的,严重的情况下甚至会留下常驻的空洞,通过debug排查到原因是前面担心过的,兄弟三角形得到的key等级存在不一致。
解决方案就是需要保证:
- 兄弟节点具有先沟通的level
- 兄弟节点的计算结果都是merge
才能触发merge,否则需要将merge改成keep
为了做到这一点,就需要能够拿到兄弟的信息,最好的方法就是保证兄弟节点在buffer中的位置是相邻的。
这里的解决方法是通过compute shader对key做一次排序,保证说兄弟三角形在buffer中的位置是相邻的,这里采用的是GPU中比较常用的Prefix sum算法,Unity 2021有一个talk,他们的地形采用的是同样的细分算法,不过这里没有采用他们的方法,而是直接用的prefix sum算法,因额外i已经足够快了,在PS5上只用不到0.1ms就行。
还是没明白,为啥需要排序。
buffer中的key用的是32位的格式(最多支持31层细分),且用了双buffer的结果,能够覆盖20km的区域,在这种配置下,内存消耗很快就过高了(为啥很快过高,应该从一开始就很高才对),解决方案有两个:
- 限制最大细分层级,从而调整key的格式,同时也能收缩buffer的尺寸
- 降低海洋的尺寸,也能约束key的格式与buffer的尺寸
通过将key的尺寸设置为20,整个mesh最多不超过100k个key(大概6.4M),在近景处的精度跟CDLOD一样,在远处的精度则可以更低。
如果分析下key的分布,或许可以考虑采用indirect index的方式对之做压缩,比如常用的几种细分,完全可以用另外的方式来存储
此外,还可以在key的基础上,对三角形做进一步的细分(2,4,8分),这样可以进一步提升密度又不会增加存储消耗。
这里还尝试了约束海洋的尺寸到10km,发现在尺寸远没有达到10km的时候,海洋就已经十分接近地平线了,也就是说,海洋中很大一块mesh其实只贡献了很小的一部分屏幕像素,但是这些像素又不能真的就剔除掉,否则会导致一个gap(空洞),这里尝试用屏幕空间的tessellation方案来填补孔洞,这里只需通过前面的tile划分的方式跳过已经被水体覆盖的区域即可,远处的水体不需要displacement,只用法线即可,结果还是挺不错的。
先对自适应细分算法做一个总结:相比于地形渲染,在水体上更适合:
- 不需要高度图
- 不需要为斜坡等场景做lod的手动调制
- 可以挂在人身上,需要绘制的mesh相比于地形要小很多。
- 其内存消耗、计算消耗都有点高
对比现有的几种细分算法:
- CDLOD绘制河流跟湖泊很快,但是远处密度过高的问题导致对于海洋等大尺寸水体就不合适了
- 屏幕空间的tessellation方案,在远距离大尺寸的海浪上存在问题,可以用于小尺寸水体以及海洋的远景渲染
- 自适应划分,适合海洋,但是需要在内存消耗跟mehs精度上找一个平衡。
后面了解到,CDLOD的远景可以用一个低精度的far mesh来覆盖,从而解决上面的问题,在这种设计下,比Adaptive的算法要好一些。
未来的工作:
- 希望提供更多的编辑工具
- 当前的SWE模拟精度为一米一个像素,尺寸小于一米的物件会被忽略,后面希望提高分辨率来得到更细节的海浪效果
- 尝试过将那个SWE跟烘焙的水体混合的方法,但是结果不太好,后面会尝试从烘焙的高度图以及flowmap中得到momentum来得到更为真实的效果
- 之前尝试过将water source一上一下移动来得到近岸海浪效果,但是还需要一些优化,以及尝试扩展到一个更大尺寸上。