图形实战 - NiagaraSPH水体模拟

用UE4实现可互动水体

Posted Kongouuu's Blog on November 9, 2021

前言

前阵子在实习的时候有被要求做一个单人的mini,当时我想要做一个类似TRINE(三位一体)的横向卷轴交互解密游戏。并且我希望有元素的互动,比如说让水结冰啥的。做的过程其实本来里面大部分都是苦力活,没啥特别的,唯一有趣的就是中间摸了一下Niagara做小特效,不得不说这系统真的强。

当时就想,demo里需要有一个充满技术力的卖点吧,那当然 就是水体了!这是个很自然的想法,如果我能让水体动起来后结冰,那游戏不就会变得很有可能性吗。刚好之前摸过了Niagara,我深信他能用来做水体,于是就上网搜了,这时候就看到了下面这个视频:

UnrealOpenDay2020幻觉的艺术 - Niagara模拟框架一览

说实话 真的帅, 就试着做了一个乞丐版。

为了更了解这个技术,决定写下这个文章巩固一下知识。

Niagara

Niagara就是UE4的新的粒子系统,比起比较传统的只能在有限的选项下调整表现得系统来说,它可以提供更高的自定义性质。其实我也不是很懂旧版的粒子系统没用过,Unity的粒子也是简单用一下,所以不敢把话说死,这里简单提一下让自己很惊讶的几个功能吧。

1. 可以对粒子行为进行编程

我们可以对系统内的所有粒子进行编程来调整他们的行为。可以通过蓝图和hlsl来控制他们。

image-20211109164746674

2. 可以存储变量

Niagara可以在一个粒子系统内部储存多种不同的变量,甚至是Array都可以。 这一点在我们像上面进行编程的时候,可以有更加灵活的操作,比如说可以记录在上一轮模拟中的位置和速度等。当然变量不止可以在系统中储存,我们还可以从外部引入变量,换个说法就是把粒子系统内的变量Expose给外部让我们能在UE编辑器侧去调整我们的变量值。

3. Grid类型储存结构

Grid2d / Grid3d 也是Niagara里比较新的特性了,它实际上就是一个比较复合的Array结构,并且在这之上有许多跟空间相关的内置函数可以使用。

Grid3d来说,Grid3d在SPH模拟里起到的是一个空间划分的作用(就像八叉树一样),这样我们可以把模拟范围内的所有粒子分成不同的区块来加快计算。这里主要用到的是空间哈希,就是我们会把每个粒子通过内置函数,计算本地坐标对应的格子并且存进去。之后我们在计算粒子之间相互的力的时候就可以直接跟附近格子的粒子对比,从而加快计算。

Grid2d在使用中,因为是2d的关系,比起空间的管理更适合当作一个储存数据的贴图。这个神奇的地方就是,如果说我们设定了一个8*8的Grid2d网格,那么我们有64个槽位,每个槽位都可以储存无数个变量类型(比如float4)。这在SPH的时候我们可以做到在网格上把每个粒子的点进行光栅化,然后计算深度和位置等。

4. GPU计算

上面的所有编程啥的都是在GPU内算的,也就是说随便一台电脑跑几万个行为复杂的粒子都不成太大的问题。 当然,不止这个,就连碰撞也能通过GPU计算就让我觉得很厉害了。在Niagara可以设置你要用GPU还是CPU去算你的粒子,如果要碰撞的话GPU会使用SDF(Signed Distance Field)来计算碰撞。自己在写的时候虽然没用到内置的函数,不过能读到Distance Field就解决了内置碰撞的问题

5. 粒子同步

在用粒子做复杂的计算,比如说算碰撞的时候,可能会有并行计算上的问题。比如说有的粒子已经计算完了碰撞力开始移动,别的粒子才刚开始计算力。这样的不同步会导致最后的结果出现不理想的结果,我们希望的是所有粒子都计算完力之后一起移动。Niagara刚好有这个功能,叫Simulation Stages。系统里可以设定不同的stages,让粒子必须全部完成stage内的指令,才能一起进入下一个stage来达到同步效果

SPH

SPH简介

之前跟过一个基础的Physically-Based Dynamics的水体,就是让粒子自然下落然后根据碰撞调整位置的。不能说不好看,不过对这种“流体”的行为来说,表现得并不是很贴切。碰撞这种更像是散沙,而没有流体的一些特性。比如说碰撞不能解决下面两个问题。

  1. 水体在放入容器时会流动至表面高度一致
  2. 在少量水体(水滴)碰撞的情况下,比起弹开,水滴反而会粘起来。

SPH算法首先通过根据密度来决定速度方向,来解决了问题1,并且通过对粘度的形容来解决了问题2

太过科学的东西这里不讲,推荐一个参考文章写的真的很好 SPH算法简介: 对我的启蒙,下面我会用比较简单的语言去形容我对SPH的理解。

SPH公式读解

我们不考虑公式的详细展开,SPH主要就是为所有粒子计算以下三个力:

  1. Fexternal 外力:外力主要就是算每个粒子受到的重力,加上产生碰撞时的弹力。主要要注意的时跟环境碰撞时的力,这个力的设定决定了水体能有多稳定。

    这里我考虑的是本轮运动中碰撞应该使得我减少多少向碰撞物的力, 还有我反弹了多少力。比较随便的设置可能会让水体过于躁动,或者粒子会锁死在边界

  2. Fpressure 压力:压力主要就是根据我们期望的密度,去在光滑核半径内计算所有周围粒子对自己的影响。简单来说就是根据周围粒子的密度和自己的期望密度去计算当前粒子应该往哪里跑。我在看网上资料的时候发现很多人说自己照着SPH公式没有办法做出一个像水体的东西,那多半就是没有想好密度跟光滑核半径应该怎么选(当然还包含了Grid3d内可以选取到的粒子范围)。
  3. Fviscocity 粘度: 粘度的主要作用就是当我们很快的一个粒子在很慢的粒子附近的时候,两者的速度会相互影响(快的变慢慢的变快)。这个造就了我上面提过的水滴汇合的现象。这个粘度在我们水体模拟当中起到的一个最关键的作用就是对水体本身的稳定。假设我们不考虑速度之间的相互影响,那么会有许多粒子跑的过快让水体整体变得不稳定,如果有粘度的话,过快的粒子会被附近的粒子平均掉速度,从而保持整个水体的稳定性。

为了计算上面几个力我们需要在Niagara内部提前用Grid3d储存好附近的粒子,方便查找位置和速度来计算密度和力。当然,有的时候我们模拟的效果会很期待的差别很大,也就是说尽量要把压力,粘度等参数放在User Exposed上面这样我们可以方便测试值来逐个调整。

其实本身不太难理解,外力和压力都是特别清楚的,粘性的效果还是实际测试后更加的直观:

粘度系数为0
粘度系数为1.5

Niagara和SPH

简单提一下怎么在Niagara里完成SPH的计算:

Emitter Spawn
  1. 建立Grid3d用来空间划分
Emitter Update & Particle Spawn
  1. 选择要一次性弹出多个粒子(水体),还是像洗手台那样连续输出。并且选择好Spawn的范围
Particle Update (在这里的每个步骤都是一次Simulation Stage / 同步)
  1. 把粒子写进Grid3d

  2. 为所有粒子计算当前的密度和压力用于后面的计算。这里会使用Grid3d,可以直接查找当前和附近的格子有哪些粒子

  3. 添加SPH的力,也就是通过上面计算好的压力和密度把 压力 + 粘度 放到每个particle的自定义变量

  4. 添加碰撞力和重力,这里是跟distance field去查是否有碰撞,所以要把场景内部会碰撞的物体点算入dynamic distance field计算才行。

  5. 计算力对速度和位置的影响。 这里我们计算出来的力因为我把粒子设定重量为1,所以计算出来的力就是每个粒子的加速度。 加速度乘以Delta Time 就可以得到应该施加在原本速度上面的速度。

    \[Velocity_{final} = Velocity_{previous} + (Force * DeltaTime)\]

    因为我们是逐帧计算的,所以位移也要自己先计算掉。由于我们所有的力其实是在计算Delta Time之前的粒子状态,所以位置的变化应该是按照上面计算完了的数字去算

    \[Position_{final} = Position_{previous} + (Velocity_{final} * DeltaTime)\]

使用上面的公式就能完成3d部分粒子的计算啦!

Grid2d投影

为什么要做投影

我们上面已经把可以互动的水体粒子给做出来了,这时候因为UE4会生成动态的距离场所以我们拿箱子去碰水体就可以做到基础的互动,但这样的粒子他并不美观。如果我们比起Sprite Renderer,给这些粒子套上球形的Mesh来填充里面的空隙的话,确实能做出比较像流体的效果。不过这样并不会有任何合适的材质来让这一大堆的球体长得像水。 原因是水他是一个半透明的材质,并且会有适当的折射,这两个性质加起来在多个独立的球体上,本来就是不可能的。当然如果我们是渲染一些泡沫什么的非透明物体,倒是可以使用3d的粒子。因此,我们需要把水体变成一个整体,然后为这个整体套上Single Layer water材质来达到更好看的效果。

怎么做投影(理论)

  1. 在粒子水体的中心建立一个垂直于相机朝向的平面。平面需要能覆盖所有的粒子。并且创建跟平面相同大小的Grid2d,以及两个Render Target(法线·深度)
  2. 把相机内部看到的所有粒子记录在网格里面,位置就是相机看到平面的对应位置。
  3. 把所有的粒子光栅化(也就是把粒子的点变成一个球写在平面上,并且记录球面每个点的深度)
  4. 由于是多个球体组成的,所以记录的深度会不平滑,所以对平面记录的深度进行一次双线性滤波
  5. 通过深度差能算出每个点的法线
  6. 建立一个材质,材质通过采样之前记录的深度Render Target做好遮罩,把非粒子的部分去除。
  7. 材质内部通过记录的深度去计算Pixel Depth Offset让我们的平面水体3d化

简单来说就是把摄像机看到的粒子信息记录下来,经过后处理后重新打在屏幕上面。由于Niagara系统本身的资源转递限制我们没有办像直接进行后处理一样在屏幕空间去处理粒子信息并光栅化,所以需要一个朝向我们摄像机方向的平面。不过粒子在平面上对应的位置只需要用内置的Grid2d相关函数就可以直接进行投影,并不需要额外计算。

要注意的是我们在进行整个粒子处理的时候,操作的是世界空间坐标,而不是NDC坐标。 我们在记录深度的时候并不需要把深度转换成NDC坐标,当然如果为了Debug是可以这么操作的。但我们尽量用一个高分辨率的Float格式的贴图去把深度存起来,这样后面我们套用材质时才可以正确的使用Pixel Depth Offset

关于投影的具体过程代码写起来还是挺又臭又长的这里就不放了

投影3d化

上面有提到通过Pixel Depth Offset可以让平面3d化,说起来比较简单,但实践的时候他并没有那么容易,有一个根本的原因,就是UE4为了GPU的渲染效率,禁用了负方向的offset,这导致我们没有办法把离摄像机更近的像素从平面拉过来。当然也不是不能把这个限制取消,但真的不值得。

我当时看到了另一个选项叫World Position Offset,我觉得它可以解决我的疑惑,但其实没有。这个贴图参数的作用只能对顶点发挥,也就是说我们如果要投射到一个平面上,那个平面只有4个顶点没有办法成功的offset

所有这里我想到了三个解决办法:

1. 继续使用Pixel Depth Offset(最简单)

其实这个想法很简单,只要我们的平面处于所有的粒子的前面就可以了。当然说起来容易但其实一般来说最重要的问题就是我们并不方便在系统里面生成一个坐标不一样的表格,目前grid系统只支持在Niagara系统的世界坐标上生成。但这要求比较坎坷,所有这个方法的前提是镜头尽量不要旋转。

进入正题,我们可以把Niagara System本身就设计成,粒子只会在它身后生存。当然如果要镜头旋转也不是不可能,我们只需要用一个蓝图包装好Niagara系统对我们摄像机的相对位置让他可以在旋转位移的同时挡住后面的粒子。不过旋转的时候画面回像掉帧一样不舒服,适合固定摄像机角度的情况使用。

2. 把水体贴图信息通过外部的蓝图变换

我们渲染的时候其实用到的只有我们得到的朝向摄像机的一个包含法线信息的贴图和一个包含深度信息的贴图。这两个贴图是完全可以从Niagara里导出来的。我们只需要把导出来的贴图信息,投影到外侧的一个自定义的平面就可以了,不过要注意的是深度和缩放的问题,也就是说当我们把贴图重新在一个新的平面上投影,这时候画出来的水体会比真实的要大得多,所以要应用一些变换。之后再套用Pixel Depth Offset 就可以了

3. 用叠满顶点的贴图+WPO

说实话,标题已经说明了一切。我们之所以需要像上面两种办法一样绞尽脑汁去做一些效率可能不太好或有限制的操作,根本原因就是因为用不了WPO。平面用不了WPO也只是单纯的因为顶点太少, 这个也不难解决,只要增加平面上的顶点数量就可以了。之后只需要像方法1一样去操作WPO就可以完美的做出前后位置都正常的水体了。

结果

实际效果

下面放一下做完的结果,其实整体效果并不太理想,我这里面做了许多错误的操作,这里并没有对投影3d化做上面的调整。球则是在平面后方才会让水体立体,之后会试一下方法3的。

结果的互动视频在这里

必须要说的是我这个完成度确实是挺低的,我下面会说一下需要注意的点

需要注意的点

3d部分
  1. UE4的单位是cm
  2. SPH计算的公式是不会出太大问题的。关于密度计算方面有人说用Ideal Gas的也可以用别的,这个其实影响真的不大因为在调整效果的时候会在整个密度公式前面加一个权值,你无论使用的是可压缩还是不可压缩的密度公式最后在调整权值后差别都不是很大
  3. 边界的力非常的关键,弄不好的话重力会把粒子压下去,不然就是会在角落疯狂的转动,或者是让整个水体很不稳定的抖动
  4. 民科:为了让粒子不抖动,同时没有任何浪费(指大量粒子沉在底下,或是边缘一个圈,这两个情况在网上别人的demo里挺常见的),首先是需要对的密度和光滑核半径。这个的话如果电脑能跑得动大量粒子尽量让密度高一些。我这里为了让粒子显得稳定同时可以互动,加了很多很民科的东西,比如调整重力,限制速度,或者限制整个力的总和上限。可能是其他的参数没调好才需要民科,不过效果好也没关系
  5. 民科2:虽然最后没有用到这个功能,但我当时为了让粒子稳定不抖动,且不乱流动,做了很多有趣的民科操作,记录一下挺有趣的。我先是根据密度改变(重力/速度),再来是截断导致抖动的速度等。虽然没用上吧
2d部分
  1. Grid2d最后要传递给材质的贴图数据(Render Target) 并不需要连接到外部的贴图,除非需要Debug。直接在粒子系统内部建立Render Target就行了。
  2. 记录的深度一定要是世界坐标的深度,而不是NDC范围的。
  3. 想要深度和法线变得平滑像水体一样单靠滤波不一定有效,最好是提高密度然后让光栅化的球体半径变小。
  4. 把粒子写入网格之后我们要对所有粒子光栅化,就是把点在贴图内画成球。这个步骤有一个难点就是我们虽然可以很简单的在网格上找到邻居网格让一个点变成一个球,但是我们很难去计算说算出来的新的点他的深度到底多少。因为网格上面的点之间的距离在不同深度下是不一样的,导致我们没有办法知道我们当前绘制的球体在世界空间中,半径到底多少。(这个我没有写,但我们有网格到世界的转换矩阵。我们知道网格深度多少,我们也知道要光栅化的采样的点深度多少,那么我们就能算出来每个球的半径已经投影出来的球面的点的深度

跟水体互动

这里单独分成了章节是因为当时实习的时候做的mini要求互动性。我想的就是让可互动的水体影响玩法,但这里很明显的一个事情就是,虽然粒子可以通过外界生成的距离长进行碰撞,但外界的东西没有办法被粒子影响。所以我想到的一点是使用结冰。

先看下成果图:

因为mini并没有拷贝出来,这里只有开发时没放Pixel Depth Offset并且使用了Debug用的深度图当水体材质的画面。当时发现Render Target可以从粒子系统里拉出来之后,觉得我可以把在3d网格中的每一列的最高的粒子的位置存在一个贴图里拿出来。因为我是横向卷轴游戏所以只需要中间一条线的最高高度,也就是说假设我的3d网格总共有32列,我只需要一个32*1的贴图就可以储存这个信息。

有了这个贴图我就可以在外侧用蓝图去生成对应的砖块,之后只需要把砖块隐藏,就会有“人物站在结冰的表面上”的错觉。

总结

用粒子系统做水体模拟确实是一个不错的经验,当时赶时间中间也菜了不少坑。其实粒子在3d部分挺简单的,用一个比较蠢的方式来说就是直接抄,调调参数就好了。不过参数这里还是要对整个SPH模型有一定的理解才能调出合适的参数。

这里做出来的跟Asher大佬演示的可以说是毫不相干的效果,太丑了。我并不是很理解为什么他的水体可以这么稳定,我的猜测是在边界的处理上比我写的更好,并且密度估计也抬的更高一些。后面偏特效的方向我就没有跟了因为本来就只是学习一下这个物理。

不得不说整个Niagara水体做下来,最具有挑战的部分还是那个投影到2d的环境,太厉害了这个思路。虽然我并没有严格遵守真实情况去给光栅化的球一些比较真实的深度,不过在提高密度和降低球半径后,滤波出来的深度和密度图都是可以看的。我在做mini时其实并没有考虑太多关于还原3d位置(Offset)方面的技术,因为时间比较赶,在写这篇文章时能想出来上面几个办法也算是有成长了。

大大小小还是有些问题吧,不过算是一次不错的经验了。