UE5模拟交互篇|可交互流体风场实现

UE5模拟交互篇|可交互流体风场实现

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


一、原理介绍

1. 思路分析
先来说观察介质模拟的两种视角:拉格朗日视角和欧拉视角。

拉格朗日视角一般将介质视为粒子(或微小网格)的集合,粒子会随着介质一起移动,通过计算每个粒子的运动状态和受力情况来表现介质的变化。欧拉视角则通常将介质占有的空间切分为一个个小网格,对每个小格子计算格子内介质的状态、输入输出情况,以及对其他的格子的影响。

要计算某一位置点的风力,如果采用拉格朗日视角,就需要获得单位时间内经过该位置点的平均粒子速度,这样显然不太现实,因为单位时间内经过该点的粒子概率较低。如果采用欧拉视角,只需要查找该点所位于的格子位置,进而可以直接读出平均速度。

在2019年的GDC上《战神》的风场技术分享也是基于欧拉视角下实现的。


《战神》可交互风场分享


局部速度场

玩家的行为可以影响到速度场的变化,而速度场又可以给场景中的物体施加力的作用。

《战神》中的做法是划分32x32x16米的区域,精度为1米每个格子记录该格子内的平均速度,并且整个速度场会跟随角色移动,对场景交互产生的影响也只会发生在该区域范围内。超过范围内的直接采样噪声或者给一个默认速度,这样网格就不要遍布整个世界空间来达到节省开支。

既然知道了整个风场是建立在场的基础下,那么玩家是如何影响到场,而场又怎样进行符合现实中的物理规则的变化?这就不得不提这篇名为《Real-Time Fluid Dynamics for Games》的经典论文。

2. 解析论文


纳维-斯托克斯方程

Stable Fluids的基础是Navier-Stokes方程组,是作为流体模拟的首选。这里的流体是不可压缩(Incompressible)流体,也就是密度、温度不变。其中P是压力场(标量场),p表示密度,v表示粘滞系数(Viscosity),F是外力。

N-S通式(1)中可变形为

第一项F很好理解,表示外力。对应论文中的AddSource项,表示加在速度场中的外力,在游戏中流体交互主要体现在对外力的施加上。

第二项论文中的扩散项,可以简单理解成液体的粘稠性质,该项表示速度在自身二阶导下扩散。扩散越强烈,场就越平滑。欧拉视角下表示为每个单元和周围的邻域交换。理想情况下的交换可表示为:


理想情况下单元格流体之间交换

x = x0 + a*(sum_p – num*x0)*dt

其中x0表示先前状态,x为当前状态,a为扩散速率,num为相邻单元格数量,sum_p和sum_c分别为相邻单元格前一时刻的和后一时刻值的总和。

论文作者认为第二项直接求可能为负,会产生摇摆和发散,所以不这么做。因此他对式子进行如下变形:

x0 = x - a*(sum_c – num*x)*dt
x0= x – a*sum_c*dt + a*num*x*dt
(1 + a*num*dt)x = x0 + a*sum_c*dt
x = (x0 + a*sum_c*dt)/(1 + a*num*dt)

论文中认为对这个求解没必要进行求逆,而作者用到了Gauss-Seidel Relaxation方法,对方程组进行迭代多次,逐步逼近。

第三项对应论文中的对流项(Advect),速度会沿着自身的方向运动。可以理解成流体的速度会导致流体随流动一起传输物体、密度和其他量。这时可以把流体想象成一个个粒子随着速度运动,作者在论文中使用了半拉格朗日方法:


半拉格朗日法

把每个单元格中心视为一个粒子,沿着速度场回溯,在dt时刻之前位于哪个位置的粒子在会运动到当前的位置(s = so + v*dt, so = s - v*dt),线性插值出dt时间粒子,将此粒子视为当期时刻的粒子。

第四项对应论文中的投射项(Project),主要求解压力p,由于流体的分子可以相互移动,它们往往会“挤压”和“晃动”。当力施加到流体上时,它不会立即传播到整个体积。相反,靠近力的分子推到更远的分子上,压力就会增加。因为压力是单位面积的力,所以流体中的任何压力都会自然地导致加速度。这里作者在论文中拆分为三步去求解p:

第一步,求负的散度场,负的意义在解泊松方程,可以少一次乘以-1的操作:

-div = -0.5*(ui + 1 – ui - 1 + vi + 1 – vi - 1 + wi + 1 – wi - 1)/dx

第二步,这里使用到了亥姆霍兹-霍奇分解定理,即任何一个向量场都可以分解为一个无散向量场和梯度标量场。也就是求出什么场p的二阶导会造成这个散度场?这里用到了和之前求解扩散项相似的迭代法去求解。

《小时百科:亥姆霍兹分解》

第三步,解出了p,我们再减去p的梯度场,求出速度,就满足了所谓投射,也是确保了稳定性。

u -=(p[i+1,,]-p[i-1,,])/2dx
v -=(p[,j+1,]-p[,j-1,])/2dx

二、实现过程

在UE中实现GPU流体模拟的方式有很多,例如比较出名的FluidNinja流体插件是通过在动态材质实例中计算输出到RT中实现的,本文主要在Niagara中GPU模拟阶段实现该过程。

1. 基础搭建


创建一个空发射器,Sim Target改成GPUCompute Sim,然后Fixed Bounds


在User Exposed中,创建一个Texture Render Target类型的变量,命名为WindFieldRT,作为输出调试的RT。创建一个int类型的变量,命名为resolution,作为到Render Target的分辨率。


在Emitter Attribute中,创建一个Grid3D Collection类型的变量,命名为VelocityGrid设置Num Attribute为3。创建一个Render Target 2D类型的变量,命名为RT_WindField,设置Over Render Target Format为RGBA 16f,Render Target User Parameter为刚刚创建的WindFieldRT


创建一个Niagara Module Script,命名为NMS_SetResolution,用于设置VelocityGrid的网格数以及RT_WindField分辨率。注意这里我们需要将3D的Grid写入到2D的RT中,采用Z轴切片然后平铺到Y轴的方式。所以Render Target分辨率为(x,y*z)。


创建一个Niagara Module Script,命名为NMS_ExportToRT,用于将Grid3D的信息写入到RT中。这个时候可以试下修改一下Grid3D中的值,如果RT也跟着变化,证明从Grid3D写入到RT成功。

注意接下来每一项的计算都需要在独立的Simulation Stages中进行,因为放在同一个Stages下可视为同时并行计算,输入的Grid为上一帧的计算结果。

创建一张Render Target,命名为RT_WindField。创建一个Actor蓝图,添加Niagara组件,将参数WindFieldRT设置为刚刚创建的RT_WindField,分辨率为128。将蓝图拖入到场景中。

2. AddSource(外力项)
这里的外力只考虑碰撞产生的作用力,这是外界影响流体的主要来源。要获取某一位置是否与角色发生碰撞需要先从Grid3D中的索引位置还原到世界位置。

其中输入项Grid为VelocityGrid,Size为自定义的风场模拟范围,WorldPosition为风场组件所在的世界位置。计算出每个单元格所在的世界位置。

cellWorldPosition = (float3(x,y,z) / res) * size + worldPosition;


新增输入项PhysicsAsset为碰撞检测源,dt为单位时间,这里我使用Engine Provided的DeltaTime

通过输入cellWorldPositionGet Closest Element节点获取到该位置点与PhysicsAsset的最近距离(Closest Distance)与最近位置点的速度(Closest Velocity)。这里需要做一次判断,当位置点PhysicsAsset内部时,Closest Distance的值为负数,我们只需要与PhysicsAsset相交部分的位置点的速度,其他位置速度则无意义。将该速度作为速度源最后与上一帧的速度叠加。

3. Diffuse(扩散项)

float a= dt * diff * N * N * N;

float VX_self;
Grid.GetGridValue(IndexX,IndexY,IndexZ,VectorIndex, VX_self);
float VX_right;
Grid.GetGridValue(IndexX+1,IndexY,IndexZ,VectorIndex, VX_right);
float VX_left;
Grid.GetGridValue(IndexX-1,IndexY,IndexZ,VectorIndex, VX_left);
float VX_up;
Grid.GetGridValue(IndexX,IndexY+1,IndexZ,VectorIndex, VX_up);
float VX_down;
Grid.GetGridValue(IndexX,IndexY-1,IndexZ,VectorIndex, VX_down);
float VX_front;
Grid.GetGridValue(IndexX,IndexY,IndexZ+1,VectorIndex, VX_front);
float VX_back;
Grid.GetGridValue(IndexX,IndexY,IndexZ-1,VectorIndex, VX_back);

float VY_self;
Grid.GetGridValue(IndexX,IndexY,IndexZ,VectorIndex+1, VY_self);
float VY_right;
Grid.GetGridValue(IndexX+1,IndexY,IndexZ,VectorIndex+1, VY_right);
float VY_left;
Grid.GetGridValue(IndexX-1,IndexY,IndexZ,VectorIndex+1, VY_left);
float VY_up;
Grid.GetGridValue(IndexX,IndexY+1,IndexZ,VectorIndex+1, VY_up);
float VY_down;
Grid.GetGridValue(IndexX,IndexY-1,IndexZ,VectorIndex+1, VY_down);
float VY_front;
Grid.GetGridValue(IndexX,IndexY,IndexZ+1,VectorIndex+1, VY_front);
float VY_back;
Grid.GetGridValue(IndexX,IndexY,IndexZ-1,VectorIndex+1, VY_back);

float VZ_self;
Grid.GetGridValue(IndexX,IndexY,IndexZ,VectorIndex+2, VZ_self);
float VZ_right;
Grid.GetGridValue(IndexX+1,IndexY,IndexZ,VectorIndex+2, VZ_right);
float VZ_left;
Grid.GetGridValue(IndexX-1,IndexY,IndexZ,VectorIndex+2, VZ_left);
float VZ_up;
Grid.GetGridValue(IndexX,IndexY+1,IndexZ,VectorIndex+2, VZ_up);
float VZ_down;
Grid.GetGridValue(IndexX,IndexY-1,IndexZ,VectorIndex+2, VZ_down);
float VZ_front;
Grid.GetGridValue(IndexX,IndexY,IndexZ+1,VectorIndex+2, VZ_front);
float VZ_back;
Grid.GetGridValue(IndexX,IndexY,IndexZ-1,VectorIndex+2, VZ_back);

float3V_self= float3(VX_self,VY_self,VZ_self);
float3V_right= float3(VX_right,VY_right,VZ_right);
float3V_left= float3(VX_left,VY_left,VZ_left);
float3V_up= float3(VX_up,VY_up,VZ_up);
float3V_down= float3(VX_down,VY_down,VZ_down);
float3V_front= float3(VX_front,VY_front,VZ_front);
float3V_back= float3(VX_back,VY_back,VZ_back);

diffusion =(V_self + a *(V_right + V_left + V_up + V_down + V_front + V_back))/(1+6* a);

采样上文推导出的x = (x0 + a*sum_c*dt)/(1 + a*num*dt) 进行迭代(迭代次数在10次以上更佳),其中输入的diff 为扩散系数,可以在外部输入,值越大则单元格之间交换速度越大,效果上表现为流体越稀反之则越稠。

4. Advect(平流项)

float dt0= dt * N;
floati=IndexX;
floatj=IndexY;
floatk=IndexZ;

float U;
Grid.GetGridValue(IndexX,IndexY,IndexZ,VectorIndex, U);
float V;
Grid.GetGridValue(IndexX,IndexY,IndexZ,VectorIndex+1, V);
float W;
Grid.GetGridValue(IndexX,IndexY,IndexZ,VectorIndex+2, W);

floatx= i - dt0 * U;
floaty= j - dt0 * V;
floatz= k - dt0 * W;

x = clamp( x,0.5, N +0.5);
y = clamp( y,0.5, N +0.5);
z = clamp( z,0.5, N +0.5);

intx0=int(x);
inty0=int(y);
intz0=int(z);

floatx1= x0 +1;
floaty1= y0 +1;
floatz1= z0 +1;

floatxd=(x - x0)/(x1 - x0);
floatyd=(y - y0)/(y1 - y0);
floatzd=(z - z0)/(z1 - z0);

float VX000;
Grid.GetGridValue(x0, y0, z0,VectorIndex, VX000);
float VX100;
Grid.GetGridValue(x1, y0, z0,VectorIndex, VX100);
float VX010;
Grid.GetGridValue(x0, y1, z0,VectorIndex, VX010);
float VX001;
Grid.GetGridValue(x0, y0, z1,VectorIndex, VX001);
float VX101;
Grid.GetGridValue(x1, y0, z1,VectorIndex, VX101);
float VX011;
Grid.GetGridValue(x0, y1, z1,VectorIndex, VX011);
float VX110;
Grid.GetGridValue(x1, y1, z0,VectorIndex, VX110);
float VX111;
Grid.GetGridValue(x1, y1, z1,VectorIndex, VX111);

float VY000;
Grid.GetGridValue(x0, y0, z0,VectorIndex+1, VY000);
float VY100;
Grid.GetGridValue(x1, y0, z0,VectorIndex+1, VY100);
float VY010;
Grid.GetGridValue(x0, y1, z0,VectorIndex+1, VY010);
float VY001;
Grid.GetGridValue(x0, y0, z1,VectorIndex+1, VY001);
float VY101;
Grid.GetGridValue(x1, y0, z1,VectorIndex+1, VY101);
float VY011;
Grid.GetGridValue(x0, y1, z1,VectorIndex+1, VY011);
float VY110;
Grid.GetGridValue(x1, y1, z0,VectorIndex+1, VY110);
float VY111;
Grid.GetGridValue(x1, y1, z1,VectorIndex+1, VY111);

float VZ000;
Grid.GetGridValue(x0, y0, z0,VectorIndex+2, VZ000);
float VZ100;
Grid.GetGridValue(x1, y0, z0,VectorIndex+2, VZ100);
float VZ010;
Grid.GetGridValue(x0, y1, z0,VectorIndex+2, VZ010);
float VZ001;
Grid.GetGridValue(x0, y0, z1,VectorIndex+2, VZ001);
float VZ101;
Grid.GetGridValue(x1, y0, z1,VectorIndex+2, VZ101);
float VZ011;
Grid.GetGridValue(x0, y1, z1,VectorIndex+2, VZ011);
float VZ110;
Grid.GetGridValue(x1, y1, z0,VectorIndex+2, VZ110);
float VZ111;
Grid.GetGridValue(x1, y1, z1,VectorIndex+2, VZ111);

float3V000= float3(VX000,VY000,VZ000);
float3V100= float3(VX100,VY100,VZ100);
float3V010= float3(VX010,VY010,VZ010);
float3V001= float3(VX001,VY001,VZ001);
float3V101= float3(VX101,VY101,VZ101);
float3V011= float3(VX011,VY011,VZ011);
float3V110= float3(VX110,VY110,VZ110);
float3V111= float3(VX111,VY111,VZ111);

//三线性差值
Advect= V000 *(1- xd)*(1- yd)*(1- zd)+
V100 * xd *(1- yd)*(1- zd)+
V010 *(1- xd)* yd *(1- zd)+
V001 *(1- xd)*(1- yd)* zd +
V101 * xd *(1- yd)* zd +
V011 *(1- xd)* yd * zd +
V110 * xd * yd *(1- zd)+
V111 * xd * yd * zd;

如图所示(每个小格边长为0.25),当前帧单元格(黄色点位置)的速度为(2.75,1.25)通过回溯上一帧位置(蓝色点),采样该点相邻的单元格的值进行线性差值,得到结果作为下一帧的速度,这一步主要是半拉格朗日法的实现。

5. Project(投射项)

分别创建两张Grid2D,命名为DivergenceGridPressureGrid,用于计算散度和压力梯度。这里我们分成三步Simulation Stage去分别计算当前速度场的散度场,根据亥姆霍兹分解定理通过迭代的方式求出该速度场的梯度场,以及梯度场相减得到速度场。


计算散度


计算梯度压力


梯度相减得到速度

6. 边界问题
经过四项计算后我们可以得到较为真实的流体模拟效果,但是我们的模拟只能在模拟框范围内计算,当角色超出边界时则不再有新的外力进来。

这种问题最简单的解决方式是构建一个跟场景大小匹配的模拟框,以确保角色不会超出边界,但这样我们就需要定义一张包含整张地图的Grid,但我们需要的效果只是角色周围的一小范围内,这样显然不划算。

既然我们只需要距离角色一定范围内才有交互风的效果,参考《战神》中风场的做法,我们可以让风场模拟框跟随角色移动,风场信息在Grid上每一帧往角色运动反方向做偏移,这样只需要很小的固定消耗就能实现全地图的交互风场效果。

float x=IndexX+Veloctiy.x;
floaty=IndexY+Veloctiy.y;
floatz=IndexZ+Veloctiy.z;

intx0=int(x);
inty0=int(y);
intz0=int(z);

floatx1= x0 +1;
floaty1= y0 +1;
floatz1= z0 +1;

floatxd=(x - x0)/(x1 - x0);
floatyd=(y - y0)/(y1 - y0);
floatzd=(z - z0)/(z1 - z0);

float VX000;
Grid.GetGridValue(x0, y0, z0,VectorIndex, VX000);
float VX100;
Grid.GetGridValue(x1, y0, z0,VectorIndex, VX100);
float VX010;
Grid.GetGridValue(x0, y1, z0,VectorIndex, VX010);
float VX001;
Grid.GetGridValue(x0, y0, z1,VectorIndex, VX001);
float VX101;
Grid.GetGridValue(x1, y0, z1,VectorIndex, VX101);
float VX011;
Grid.GetGridValue(x0, y1, z1,VectorIndex, VX011);
float VX110;
Grid.GetGridValue(x1, y1, z0,VectorIndex, VX110);
float VX111;
Grid.GetGridValue(x1, y1, z1,VectorIndex, VX111);

float VY000;
Grid.GetGridValue(x0, y0, z0,VectorIndex+1, VY000);
float VY100;
Grid.GetGridValue(x1, y0, z0,VectorIndex+1, VY100);
float VY010;
Grid.GetGridValue(x0, y1, z0,VectorIndex+1, VY010);
float VY001;
Grid.GetGridValue(x0, y0, z1,VectorIndex+1, VY001);
float VY101;
Grid.GetGridValue(x1, y0, z1,VectorIndex+1, VY101);
float VY011;
Grid.GetGridValue(x0, y1, z1,VectorIndex+1, VY011);
float VY110;
Grid.GetGridValue(x1, y1, z0,VectorIndex+1, VY110);
float VY111;
Grid.GetGridValue(x1, y1, z1,VectorIndex+1, VY111);

float VZ000;
Grid.GetGridValue(x0, y0, z0,VectorIndex+2, VZ000);
float VZ100;
Grid.GetGridValue(x1, y0, z0,VectorIndex+2, VZ100);
float VZ010;
Grid.GetGridValue(x0, y1, z0,VectorIndex+2, VZ010);
float VZ001;
Grid.GetGridValue(x0, y0, z1,VectorIndex+2, VZ001);
float VZ101;
Grid.GetGridValue(x1, y0, z1,VectorIndex+2, VZ101);
float VZ011;
Grid.GetGridValue(x0, y1, z1,VectorIndex+2, VZ011);
float VZ110;
Grid.GetGridValue(x1, y1, z0,VectorIndex+2, VZ110);
float VZ111;
Grid.GetGridValue(x1, y1, z1,VectorIndex+2, VZ111);

float3V000= float3(VX000,VY000,VZ000);
float3V100= float3(VX100,VY100,VZ100);
float3V010= float3(VX010,VY010,VZ010);
float3V001= float3(VX001,VY001,VZ001);
float3V101= float3(VX101,VY101,VZ101);
float3V011= float3(VX011,VY011,VZ011);
float3V110= float3(VX110,VY110,VZ110);
float3V111= float3(VX111,VY111,VZ111);

//三线性差值
value = V000 *(1- xd)*(1- yd)*(1- zd)+
V100 * xd *(1- yd)*(1- zd)+
V010 *(1- xd)* yd *(1- zd)+
V001 *(1- xd)*(1- yd)* zd +
V101 * xd *(1- yd)* zd +
V011 *(1- xd)* yd * zd +
V110 * xd * yd *(1- zd)+
V111 * xd * yd * zd;

还需要在蓝图中实时设置位置为角色位置,并设置WorldPosition(角色世界位置)、LastFramePosition(上一帧位置,用于计算移动偏移)、FieldSize(模拟框大小),三个参数实时更新到Niagara。在Niagara中根据当前位置跟上一帧位置的插值对风场进行相对偏移。

这一步其实跟求平流项的半拉格朗日法很像,只不过速度用的是相对整体偏移的速度。

这样我们就得到一个跟随角色移动的模拟框,并且计算消耗控制在模拟框范围内,超出则不参与计算。

7. 采样风场

再定义一张1x3大小的RT命名为RT_Data,这里RT初始化等操作与上述RT_WindField一致,可以参RT_WindField的设置,然后将风场组件位置(上述步骤中从蓝图传入的WorldPosition)、风场模拟框大小(FieldSize)以及分辨率分别写入到三个像素中,RT_Data可以理解成自定义的一种结构体,用于后续在其他粒子系统中采样风场提供实时更新的参数

有了RT_Data的写入,接下来就是读取,这里我定义了一个Niagara Function Script,命名为NFS_ParseWindFieldData,在里面只需要读取该RT三个像素每个像素中心点的值即可解析得到位置、大小和分辨率三个值。


创建一个Niagara Module Script,命名为NMS_WindForce,用于在其他粒子系统中采样风场RT的值作为外力来驱动粒子运动。在新的Niagara中创建一个Fountain粒子发射器,拖入NMS_WindForce模块,设置RT_Data、RT_WindField和分辨率。

可以发现粒子受到了风力的影响,会随着角色运动方向而被带动。

同样创建一个箭头粒子矩阵,将采样风场得到的结果作为箭头方向,可以直观的表示风向。


整体一览

由于篇幅过长,这里只是对交互风场做最简单的展示,风场的应用还包括风与植被,风与烟雾以及风与布料等交互。

参考:

Chapter 38. Fast Fluid Dynamics Simulation on the GPU

GAMES201:高级物理引擎实战指南2020_哔哩哔哩_bilibili

半拉格朗日格式求解对流方程

线性方程组-迭代法 2:Jacobi迭代和Gauss-Seidel迭代


这是侑虎科技第1723篇文章,感谢作者芝麻拌葱花供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/jucon-chen

再次感谢芝麻拌葱花的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)