查看原文
其他

大自然中最常见的生态之一,到了游戏里怎么就这么难做?

Byreave 腾讯GWB游戏无界 2023-10-16

引言 本文主要介绍一种基于浅水方程的水体交互算法,在基本保持水体交互效果的前提下,实现了一种极简的水面模拟和物体交互方法。

作者:Byreave
腾讯互动娱乐 工程师
(转载请征得同意。文章仅为作者观点,不代表GWB立场)

真实感的水体渲染在现今的游戏中越来越被需要,除了光照和波形渲染之外,水体交互也是描述水体功能的重要组成部分。作为一个游戏玩家,同时也作为一个游戏开发者,每到一款游戏中探索时,如果走到水中有较真实的交互效果,总会有种惊喜的感觉,并且也能提高游戏乐趣。玩水谁不爱呢。

水体交互其实也可以分成很多个部分,本文主要讨论的是水面交互,这种交互在游戏中比较常见,也可以说是整个水体渲染中比较低垂的果实。

1.1 一些水体交互的例子
下面列举了一些近年比较成功的真实感渲染游戏的水体交互例子。其中大部分都是粒子、纹理和物理结合的方式。

• 老头环 Elden Ring
• 刺客信条 英灵殿
• The Last of us Part I Remake
• The Last of us Part II
• 荒野大镖客2
• Hogwarts: Legacy
也有一些看起来是没有物理,仅凭借纹理+粒子实现的交互效果,多用于弹道射击水面等场景。在更久远年代的游戏中使用广泛。列举了一些近期的例子:

• Far Cry 6
• 荒野大镖客2
• 外卖模拟器 Death Stranding
可以发现,大部分水面交互都是大同小异,因为游戏对渲染实时性的要求,很多物理模拟步骤会被简化很多,并且会有粒子效果等的帮助,来实现水面交互的真实性。这些交互场景有一些共同的特点,

• 只和水体表面的交互,深度不重要
• 可交互区域没有明显高度差(水平)
• 交互范围有限,大部分围绕角色周围

这些特点基本忽略了三维的模拟,非常适合在游戏中出现较多的,把水体作为单层网格渲染的情况。

那么,有没有一种能够以很小渲染消耗来实现的物理模拟方法呢?本文接下来就会介绍一种基于浅水方程(Shallow Water/Wave Equation)的极简模拟方法,并且集成在了最新的UE5.1版本中。

1.2 基于浅水方程的水面模拟方法
话不多说,先看在UE5.1中,使用基本素材做出的交互效果:

水体的表达

对于被模拟的水面来说,游戏中一般可交互的水面都是由一层单面mesh渲染,平面比较多,有坡度也不会太大(比如瀑布就不行)。这种情况我们可以用一张高度图来表现模拟区域,其中水面高度细节可以通过高度图的像素值表达。这给我们在GPU上模拟水面物理带来了便利,可以用贴图(Texture)轻松的求解水面高度。

浅水方程

Shallow Water Equation,也可以叫Shallow Wave Equation,中文浅水方程,是一种流体力学模型,常用于模拟海洋与大气层的流动,适用于分析水平方向的尺度远大于垂直方向的尺度的流体自由液面的流动。从之前的使用场景来看,大部分几乎忽略了“垂直方向的尺度”,经过简化后,非常适合在游戏中实时使用。数学复杂的推导可以参见Wikipedia页面
(https://en.wikipedia.org/wiki/Shallow_water_equations),简化过程可以在Games103课程
(https://www.bilibili.com/video/BV12Q4y1S73g/?p=10)中学到。我们先忽略数学上的复杂性,直接摆出写方程迭代代码需要的简化结果: 
β:ViscosityConstant, α: SWE constant, hN(t-1): Height sum of the nearest four points

可以看到,在简化过的模拟方程中,当前帧水面高度h(t)由前两帧的高度h(t-1)和h(t-2)得到。总体代码实现并不复杂,由于我们的简化加入了额外的Damping参数加快迭代,其它参数设置推荐β设置为1,α设置为0-0.5的值,Damping设置为0-1的值,以免出现模拟爆炸。

边界条件

水体模拟的区域往往是有边界的,无论是在小水洼的边界,还是无边界海洋中的石头,都可以作为模拟的边界信息。对于边界的处理往往有两种方式,一种是不处理,水波会根据阻力慢慢消失,这样以真实性为代价换来性能的提升,在开放的水域比较适用。第二种是把模拟的水波反弹回来,在游泳池之类的明显边界的水域比较适用。比如:

模拟方程中对于第二种边界处理方法的描述也相对简单,叫Neumann boundary condition
(https://en.wikipedia.org/wiki/Neumann_boundary_condition),简单来说就是描述边界上的导数,导数为0则在边界上不会有变化。我们可以看到上述方程中有hN(t-1)项,既当前模拟点,假设为X,周围4点的水面高度和。想要做到反弹水面,我们在查询周围4点水面高度时,如果4点中某一点Y在边界上,把Y的高度设置为和X点高度相同即可。物理意义上,就是指阻止边界点的水面高度交换,这样在模拟过程中,水面波形就可以产生反弹的效果。可以看看下面的伪代码。

// Suppose Y is the point up to X. YIndex = XIndex + (0, 1);

float XHeight = WaterPreHeight.Load(XIndex).x;

float YHeight = WaterPreHeight.Load(YIndex).x;

if (IsBoundary(YHeight))

{

YHeight = XHeight;

}


模拟步骤

本文介绍的模拟方法大致分为如下几步:

1. 收集和水面模拟区域产生交互的物体,并且渲染物体深度信息到贴图。
2. 叠加物体信息到水面高度图。
3. 模拟方程迭代。
4. 应用模拟结果的高度图来渲染水面。


收集物体信息

想要知道有哪些物体和水面交互有很多种办法。我们的方法可以只作为参考。从效率上考虑,可以为模拟区域设置一个碰撞体,通过碰撞系统从GameThread得到所有的相交物体,然后提交到渲染线程,获得物体的信息,交给模拟迭代。上面提到,我们可以通过贴图表达水面高度,我们也可以通过顶视图的深度信息来表达物体和水面交互的信息。现在比较棘手的问题是,如何高效的渲染物体的深度。

在UE引擎中,我们可以通过SceneCaptureComponent来获得水面物体深度,它支持只渲染场景中部分物体。但是在看过代码之后发现,SceneCapture需要走完整个渲染流程,哪怕我们只需要一个深度信息,也需要付出渲染整个场景的代价。SceneCapture也无法定制渲染所用的Shader,当然可以用自定义后处理材质,但是这样又会带来新的性能消耗,所以可以想象我们需要一个更加简单、高效的流程来完成物体信息的收集。

在我们的实现中,我们实现了一个自定义的深度渲染pass,用来渲染指定物体的深度信息。其原理和阴影贴图渲染(ShadowDepth)类似,根据UE官方文档(https://docs.unrealengine.com/5.1/en-US/mesh-drawing-pipeline-in-unreal-engine/),

通过添加自定义的FParallelMeshDrawCommandPass来把收集到的物体渲染到自定义的DepthBuffer中。这种办法的好处是可以自定义MeshProcessor,支持修改CullingMode和使用自定义的PS,这在后续获得精确深度的步骤中有很重要的作用。

在切换到自定义的Pass实现后,获取物体信息这一步的耗时也明显降低。从之前SceneRenderer使用的一整套渲染流程,变成了一些简单的深度DrawCall的代价。

在获取物体信息上,除了运动的物体信息,比如玩家角色、移动物体、子弹等,还需要绘制作为边界物体的信息。边界物体信息我们可以做进一步优化,比如每帧只渲染正在运动的物体,静止的物体根据设置,判断是否要渲染成边界,而边界信息不需要每一帧更新,只有在边界变化的时候按需求更新。边界的信息可以保存成一个顶视图深度图,在后续步骤中重复使用。

对于深度图,因为根据算法原因,我们对于物体浸入了水面多少其实不敏感(垂直方向尺度非常小,所以叫做“浅”水方程),这样就还可以进一步优化。添加一个ResolvePass,只使用R8格式存储深度贴图。这样有利于后续步骤中贴图读取的开销。 

在UE Insight中可以看到,同时渲染9个移动的物体和角色,加上ResolvePass的开销只需要0.08ms(虽然是在2080S上)。

叠加物体信息到水面高度图

为了使耗时较高的迭代Pass读取贴图次数减少,我们需要预处理我们的物体信息。其中,我们在获取物体信息阶段获得了2个R8的贴图(运动物体和边界物体信息)。我们需要将运动物体信息+边界信息整合进水面高度图。

对于运动物体信息,我们需要将有物体浸入的水面排出一些水,模拟水面交互的过程。对于水面高度减少的多少,我们做了进一步简化,只需要根据深度的减少就好(物理正确的结果需要迭代求解减少的高度,原理解释参见Games103课程
(https://www.bilibili.com/video/BV12Q4y1S73g/?p=10))。对于水面高度已经在物体深度之下的水面,我们不做处理。

对于边界物体信息,我们把水面高度设置成一个特殊值,比如float_max即可。伪代码如下:

if(bIsBoundary)

{

WaterHeight[SimulationIndex] = BOUNDARY_HEIGHT;

}

else

{

CapturedDepthVal = saturate(CapturedDepthVal);

float CurrentWaterHeight = WaterHeight[SimulationIndex];

// Object depth above water has no effect

WaterHeight[SimulationIndex] = CapturedDepthVal == 0.0f ? CurrentWaterHeight : min(CurrentWaterHeight, - CapturedDepthVal);

}

模拟方程迭代

有了上述物体信息,我们需要完成方程的迭代。在传统方法中,模拟方程并没有最外层的Damping参数,需要在同一帧多次迭代,求解收敛值。这里的多次迭代,包括了方程本身的迭代,和上文提到的对交互物体陷入水面后,水面高度应该变化多少的求解。方程本身的迭代我们可以通过多次运行CS求解,而水面高度变化因为不仅和物体陷入的深度相关,也和周围水面的高度相关,所以需要用到共轭梯度法(PCG)求解。具体算法可以参考Games103视频最后有关VirtualHeight的求解
(https://www.bilibili.com/video/BV12Q4y1S73g?t=4855.3&p=10)
,和文末参考信息中的原始论文。

在我们的实现中,简化了多次迭代的过程(参考WaterLinePro插件中的实现),每一帧只运行一次迭代,这样在保证结果真实性的情况下有最好的性能。一次迭代的缺点就是波纹传播的速度和游戏的帧率相关,可以想象目前每帧一次迭代,根据方程波纹每帧只能影响周围一个像素,水面波纹信息每帧只能传递一个像素。帧率较高的应用或者对于传播速度有调整,可以调整上述模拟方程中的α参数或者模拟范围和模拟分辨率的比例,来保证传播速度符合理想。

对于Shader中的实现,我们有了2张前一帧和前两帧的水面高度图作为输入,1张当前帧的水面高度图作为输出,就可以通过CS/全屏PS完成迭代。在实现过程中,可以通过ping-pong三张贴图的方式,这样不需要额外的复制操作就可以记录水面高度历史信息。

模拟迭代比较直接,下面是伪代码:

    // Boundary pixels.

    if(IsBoundary(PrevHeight))

    {

     // Skip iteration.

     return;

    }

    // SWE

    float NearHeight = UpPrevHeight + DownPrevHeight + LeftPrevHeight + RightPrevHeight;

    float OutHeight = Damping * (PrevHeight + (PrevHeight - PrevPrevHeight) + TravelSpeed * 0.5f * (NearHeight - PrevHeight * 4));


    // Store to output texture

    OutWaterHeight[CurrentIndex] = OutHeight;

应用模拟结果

完成每一帧的迭代后,我们就获得了可以使用的水面高度贴图。贴图预览结果如下: 

左边为移动物体信息,右边为迭代的水面高度

在我们的实现中,使用ENQUEUE_RENDER_COMMAND来完成对模拟步骤的提交,会在主渲染帧之前完成,这样既不会破坏现有的渲染管线,也可以把当前帧的模拟结果直接使用到水体材质中,完成水体的Normal/Displacement计算等操作。

有了高度图,我们可以在水体系统中得到Normal,并且应用到水体材质中。我们目前使用的自研PhotonWater系统通过提前的Normal/Displacement Pass来完成计算,结果很方便的就可以应用于水体。

值得注意的是,虽然在上述的游戏示例中,大部分水面都有位移计算,但是部分情况我们只需要计算Norma即可(尤其是手机端)。不过有了水面Mesh的位移的确可以提高真实性。PhotonWater实现的基于CDLOD的水体Mesh Tessellation和Screen-Space Displacement Mapping可以比较高效地应用各种位移贴图和波纹粒子效果。

获取准确的物体交互信息

至此,大部分水体模拟的实现细节就完成了。虽然还有很多可以改进的地方,我们还是可以先关注比较容易且效果好的改进。在上述示例的荒野大镖客2的例子中,我们可以看到水面的交互只和马的腿部产生。这种细节如果我们只用了正常渲染的顶视图深度是不够的,因为顶视图会认为马的身体遮挡了水面,从而把整个马作为深度信息传入,就无法做到上述细节。

比较直观的例子是一个倒立的圆锥体,如果圆锥体落入水面,我们只希望尖的地方和水体产生交互,而不做处理的方法会让交互范围变成圆锥的帽子,比如下图:

那么怎么做,才能让圆锥的尖角和水面“碰撞”呢?

解决方法其实有很多,其中比较精确的办法是通过自定义顶视图的深度渲染,通过渲染交互物体Mesh的反面,然后对水面深度进行Clip,就可以获得精确的反面深度信息。实现层面就是在BuildMeshDrawCommand之前手动反转CullMode。

ERasterizerCullMode MeshCullMode = ComputeMeshCullMode(MaterialResource, OverrideSettings);

// Always render back faces

MeshCullMode = MeshCullMode == CM_CCW ? CM_CW : CM_CCW;

BuildMeshDrawCommmands(

...,

MeshCullMode,

...

);

在深度渲染的PS中(注意一般的深度渲染Pass不需要PS,这也是我们上文提到的自定义深度渲染的便捷点之一),我们传入模拟位置的水面高度,不需要太精确,可以暂时假定模拟区域是一个平面即可。内容也可以非常简单。

// CustomCapturePS.usf

// Clip using water level, water level is already converted to device Z

clip(WaterLevelVal - SvPosition.z);

另外一种解决办法是使用一个替代(Proxy)Mesh渲染高度,而不是用真实物体渲染。部分游戏,比如Hogwarts: Legacy游泳的场景,细节要求不需要太高,可以不渲染整个角色Mesh,而是渲染一个替代品。比如一个简单的Sphere/Capsule代替人物,好处是显著减少三角面,缺点是需要Artists手动放置到角色上,以防止穿帮。在马匹的例子中,也可以用4个小球,Attach到马腿上,并且忽略马匹本身的Mesh,这样也可以完成交互细节的渲染。

显存和Shader消耗

本文介绍的方法使用了多个Buffer用于存储模拟数据。其中Buffer的大小可以由用户自定义,示例中使用的是1024x1024贴图,用来渲染4096x4096世界大小范围的水域。贴图分辨率可以根据性能需求调整,水域范围也可以由实际应用决定。

• 3个水面高度图 R16F * 3
• 深度信息和边界信息 R8 * 2
• 渲染深度所用的DS * 1
模拟流程上用了这些Pass:

• 渲染运动物体(如果有)-CustomCapturePass
• 渲染边界物体(如果有更新)
• Resolve深度信息-PhotonWaterCopyCaptureDepth
• 叠加深度信息到水体高度-ComputeWaves
• 迭代模拟方程-IterateSWE

在模拟1024X1024分辨率的贴图情况下,UnrealInsight显示的消耗如下(2080S): 

还有一些可以减少消耗的优化技巧,比如模拟区域没有物体更新,可以过一段时间暂停更新,等有交互出现的时候再启动,以免出现空转的情况。

移动端也能用

在移动端,模拟过程类似,模拟效果来看可以达到和PC/Console差不多的效果。
关于移动端的耗时,笔者没有统计各个Pass分开耗时,只能从开关模拟来看总体耗时,在三星S20手机上得到以下结果:

• 1024x1024的模拟分辨率,模拟开销大概3ms。
• 512x512的模拟分辨率,模拟开销大概在1ms以下。

在分辨率降低的情况下,只要模拟区域的世界大小也等比例降低,得到的波纹细节是一致的,所以可以根据使用场景合理调节。在不需要准确波纹细节的情况下,256x256模拟分辨率也是可以选择的。

>>参考
1. Games103(https://www.bilibili.com/video/BV12Q4y1S73g/?p=10) -关于Surface Waves(SWE)详细的简化过程。
2. Waterline Pro Plugin(https://www.unrealengine.com/marketplace/en-US/product/waterline) -插件中是纯蓝图实现,并且使用SceneCapture,不过参考了一帧只迭代一次的方法。
3. Kass, Michael, and Gavin Miller. "Rapid, stable fluid dynamics for computer graphics." Proceedings of the 17th annual conference on Computer graphics and interactive techniques. 1990.



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存