UE5 UV邻接 Niagara潜水模拟 学习笔记

复制代码
1. 当前执行到 UVGrid 的某个格子
   IndexX, IndexY

2. 把格子中心转成 UV
   UV = ExecutionIndexToUnit()

3. 判断这个 UV 是否落在 SkeletalMesh 的某个 UV 三角形内

4. 如果是有效 UV:
   AdjacencyUV = UV
   IslandMask = 1
   ValidMask = 1

5. 如果不是有效 UV:
   在周围 Steps = 64 的范围里找最近的有效 UV 点

6. 找到最近有效 UV 后,会得到它所在的三角形 Coord.Tri
   以及它在三角形里的重心坐标 Coord.BaryCoord

7. 根据重心坐标,优先检查这个点最靠近的三角形边

8. 用这条边去问 Skeletal Mesh:
   当前三角形这条边旁边,模型拓扑上连着哪个三角形?

9. 如果邻居三角形的边顶点不是普通反向共享关系,
   就认为这里是 UV seam / UV 岛断开处

10. 取当前边的两个 UV:
    UVA -> UVB

11. 取邻接边的两个 UV:
    NUVB -> NUVA

12. 把当前 UV 相对旧边的位置:
    沿边距离 + 垂直距离

    映射到另一条 UV 边上,得到 P'

13. 写入:
    AdjacencyUV = P'

float ClosestUVDistSq = 2.0f;
float Tolerance = 0.0001;
FindIslandNeighbor = false;
FindClosestCoord = false;
AdjacencyUV = float2(0,0);

int NumCellsX = 1;
int NumCellsY = 1;
Grid.GetNumCells(NumCellsX, NumCellsY);

int IndexX = 0;
int IndexY = 0;
Grid.ExecutionIndexToGridIndex(IndexX, IndexY);
float2 UV;
Grid.ExecutionIndexToUnit(UV);

MeshTriCoordinate Coord;
Mesh.GetTriangleCoordAtUV(true, UV, Tolerance, Coord, Inside);
if(Inside)
{
    AdjacencyUV = UV;
}
else
{
    float CellSize = 1.0/float(NumCellsX);
    float MaxRadius = Steps*CellSize;

    for (int i = max(0, IndexX-Steps); i <= min(NumCellsX-1, IndexX+Steps); i++)
    {
        for (int j = max(0, IndexY-Steps); j <= min(NumCellsY-1, IndexY+Steps); j++)
        {    
            float2 CurUV;
            CurUV.x = (i+0.5)/float(NumCellsX);
            CurUV.y = (j+0.5)/float(NumCellsY);
            float2 D = CurUV - UV;
            float DistSq = dot(D, D);
            if (DistSq > MaxRadius * MaxRadius) continue;
            if (DistSq >= ClosestUVDistSq) continue; 
            MeshTriCoordinate DummyCoord;
            bool Valid;
            Mesh.GetTriangleCoordAtUV(true, CurUV, Tolerance, DummyCoord, Valid);
            if (Valid)
            {
                FindClosestCoord = true;
                ClosestUVDistSq = DistSq;
                Coord = DummyCoord;
            }
        }
    }

    if (FindClosestCoord)
    {                                                                                                                           
        int VA, VB, NVA, NVB;
        int NeighborIslandTri = -1;                                                                                       
        const int MaxQ = 16;
        int   Queue[16];
        float QDist[16];
        bool  QDone[16];
        int   QSize = 0;

        Queue[0] = Coord.Tri; QDist[0] = 0.0; QDone[0] = false;
        QSize = 1;

        float3 Bary = Coord.BaryCoord;
        int3 SortedEdges;
        if (Bary.x <= Bary.y && Bary.x <= Bary.z)
        {
            SortedEdges.x = 0;
            SortedEdges.yz = (Bary.y <= Bary.z) ? int2(1,2) : int2(2,1);
        }
        else if (Bary.y <= Bary.z)
        {
            SortedEdges.x = 1;
            SortedEdges.yz = (Bary.x <= Bary.z) ? int2(0,2) : int2(2,0);
        }
        else
        {
            SortedEdges.x = 2;
            SortedEdges.yz = (Bary.x <= Bary.y) ? int2(0,1) : int2(1,0);
        }

        for (int iter = 0; iter < MaxQ && NeighborIslandTri == -1; iter++)
        {
            int BestIdx = -1;
            float BestDist = 1e10;
            for (int k = 0; k < QSize; k++)
            {
                if (!QDone[k] && QDist[k] < BestDist)
                {
                    BestDist = QDist[k];
                    BestIdx = k;
                }
            }
            if (BestIdx == -1) break;
            QDone[BestIdx] = true;
            int CurTri = Queue[BestIdx];

            int CV0, CV1, CV2;
            Mesh.GetTriangleIndices(CurTri, CV0, CV1, CV2);
            int3 CurVerts = int3(CV0, CV1, CV2);

            int3 EdgeOrder = (CurTri == Coord.Tri) ? SortedEdges : int3(0, 1, 2);

            for (int ei = 0; ei < 3; ei++)
            {
                int e = EdgeOrder[ei];
                int NeighborTri, NeighborEdge;
                bool IsValid;
                Mesh.GetTriangleNeighbor(CurTri, e, NeighborTri, NeighborEdge, IsValid);

                if (!IsValid) continue;

                VA = CurVerts[(e + 1) % 3];
                VB = CurVerts[(e + 2) % 3];

                int NTV0, NTV1, NTV2;
                Mesh.GetTriangleIndices(NeighborTri, NTV0, NTV1, NTV2);
                int3 NVerts = int3(NTV0, NTV1, NTV2);

                NVA = NVerts[(NeighborEdge + 1) % 3];
                NVB = NVerts[(NeighborEdge + 2) % 3];

                if (VA != NVB || VB != NVA)
                {
                    NeighborIslandTri = NeighborTri;
                    break;
                }

                if (QSize < MaxQ)
                {
                    bool InQueue = false;
                    for (int k = 0; k < QSize; k++)
                        if (Queue[k] == NeighborTri) { InQueue = true; break; }

                    if (!InQueue)
                    {
                        float2 NUV0, NUV1, NUV2;
                        Mesh.GetVertexUV(NTV0, UVSet, NUV0);
                        Mesh.GetVertexUV(NTV1, UVSet, NUV1);
                        Mesh.GetVertexUV(NTV2, UVSet, NUV2);
                        float2 Centroid = (NUV0 + NUV1 + NUV2) / 3.0;
                        float2 Diff = Centroid - UV;

                        Queue[QSize] = NeighborTri;
                        QDist[QSize] = dot(Diff, Diff);
                        QDone[QSize] = false;
                        QSize++;
                    }
                }
            }
        }

        if (NeighborIslandTri != -1)
        {
            FindIslandNeighbor = true;
            float3 UVA, UVB, NUVA, NUVB;
            float2 DummyUV;
            Mesh.GetVertexUV(VA, UVSet, DummyUV);
            UVA = float3(DummyUV.x, DummyUV.y, 0.0);
            Mesh.GetVertexUV(VB, UVSet, DummyUV);
            UVB = float3(DummyUV.x, DummyUV.y, 0.0);
            Mesh.GetVertexUV(NVA, UVSet, DummyUV);
            NUVA = float3(DummyUV.x, DummyUV.y, 0.0);
            Mesh.GetVertexUV(NVB, UVSet, DummyUV);
            NUVB = float3(DummyUV.x, DummyUV.y, 0.0);
            float3 OrigUV = float3(UV.x, UV.y, 0.0);

            float3 UP = float3(0,0,1);
            float3 Dir = UVB - UVA;
            float3 NDir = NUVA - NUVB;
            float3 Dir1 = OrigUV - UVA;

            float Scale = length(NDir) / length(Dir);

            Dir = normalize(Dir);
            NDir = normalize(NDir);
            
            float AlongEdge = dot(Dir1, Dir) * Scale;
            float PerpEdge  = dot(Dir1, cross(UP, Dir)) * Scale;

            float3 NewDir = NDir * AlongEdge + cross(UP, NDir) * PerpEdge;
            AdjacencyUV = (NUVB + NewDir).xy;
        }
    }
}

它们合起来是一套 UV 空间浅水模拟

1. FindAdjacencyUV

作用:建立 UV seam 的"跳转表"。

它遍历 UVGrid 每个格子,把格子中心转成 UV。

如果这个 UV 在 SkeletalMesh 的有效 UV 三角形里:

复制代码
AdjacencyUV = 自己
IslandMask = 1
ValidMask = 1

如果这个 UV 在 UV 岛外/seam 附近,它会找附近有效三角形,再沿模型三角形邻接关系找到 seam 对面的 UV 岛,把当前位置映射到对面的 UV 坐标:

复制代码
AdjacencyUV = seam 对面的有效 UV

所以它最终输出的是:

复制代码
每个格子应该去哪里采样模型/水高/速度

2. Hit

作用:在点击/碰撞点附近加水。

它读取当前格子的 AdjacencyUV,用它查 SkeletalMesh,得到这个格子对应的世界坐标 Position

然后算:

复制代码
距离 = length(User.HitPos - Position)

距离越近,加水越多:

复制代码
5 以内满强度
5 到 6 衰减
6 以外不加

最后写:

复制代码
WaterGrid.Height += Strength * DeltaTime * 10

但不超过:

复制代码
Emitter.MaxDepth

它只直接写 Height,不直接写速度。速度是后面根据水高差和重力产生的。

3. GSW Extrapolate Velocity

作用:把水边缘的速度扩展到附近干格子。

如果当前格子水高几乎为 0:

复制代码
Height < eps

它就在周围 5x5 范围内找最近的、有水、有速度的格子,然后复制它的速度:

复制代码
当前干格子 Velocity = 最近有水格子的 Velocity

它不是平均多个格子,只取最近的一个。

目的:让水边界附近的速度场连续,后面的平流不会卡住或断掉。

4. GSW Advect

作用:根据速度场搬运水高和速度,也就是让水真正"流起来"。

对当前格子 C = (IndexX, IndexY),它用速度反推来源位置:

复制代码
SampleIndex = C - Scale * Velocity

然后从上一阶段的 SampleIndex 位置采样:

复制代码
新的 Height = PreviousWaterGrid.Sample(SampleIndex)
新的 Velocity = PreviousVelocityGrid.Sample(SampleIndex)

最后写回当前格子:

复制代码
WaterGrid[C].Height = Out_WaterHeight
VelocityGrid[C].Velocity = float2(Out_VelocityX, Out_VelocityY)

你问到的 Out_VelocityX / Out_VelocityY / Out_WaterHeight,它们就是:

复制代码
当前格子 C 的新计算结果

不是别的 index 的值。

这个模块里 Velocity.xVelocity.y 分开算,是因为它把速度当成类似交错网格处理:

复制代码
Height 在 cell 中心
Velocity.x 更像在横向速度采样点
Velocity.y 更像在纵向速度采样点

所以算 Velocity.x 时:

复制代码
x 用自己
y 从附近 y 平均出来

Velocity.y 时:

复制代码
y 用自己
x 从附近 x 平均出来

然后分别反推采样。

整体顺序可以这样记:

复制代码
FindAdjacencyUV
    先解决 UV seam:每个格子该去哪里采样
Hit
根据世界命中点,在角色表面对应位置加水高
Extrapolate Velocity
把水边缘速度补到干区域一小圈
Advect
用速度搬运 Height 和 Velocity,让水流动

一句话总览:

FindAdjacencyUV 解决 UV 断裂,Hit 负责加水,Extrapolate Velocity 补边界速度,Advect 用速度把水和速度往前搬。

第一个是处理边界外边没有水的情况去搜索补水,第二个是水内部的流动,这对应的是浅水方程(Shallow Water Equations)的核心平流过程。在 GSW Advect 模块中,水内部的流动通过以下物理和数值机制实现:

水内部流动的物理基础

浅水模拟中,水内部的流动遵循质量守恒和动量守恒:

  • 质量守恒:水高的变化等于流入和流出的净流量
  • 动量守恒:速度的变化由压力梯度(水高差)、重力和粘性力决定

数值实现细节

在 GSW Advect 中,水内部流动的具体实现包括:

1. 半拉格朗日平流(Semi-Lagrangian Advection)
复制代码
// 对每个格子 C = (i, j)
float2 Velocity = VelocityGrid[C];
float2 SamplePos = float2(i, j) - DeltaTime * Velocity * InvCellSize;
// 双线性插值采样上一帧的水高和速度
float Height = Texture.SampleBilinear(PreviousHeightTex, SamplePos);
float2 NewVelocity = Texture.SampleBilinear(PreviousVelocityTex, SamplePos);
WaterGrid[C].Height = Height;
VelocityGrid[C] = NewVelocity;

这种方法无条件稳定,允许较大的时间步长,但会引入数值耗散。

2. 交错网格(Staggered Grid)处理

如原文所述,速度分量在交错网格上存储:

  • Velocity.x 存储在格子右面中心
  • Velocity.y 存储在格子上面中心
  • 水高 Height 存储在格子中心

这种布局需要特殊的插值策略:计算 Velocity.x 时,x 分量从自身位置采样,y 分量从上下邻居平均;计算 Velocity.y 时则相反。

3. 边界条件处理

水内部流动需要正确处理各种边界:

  • 固壁边界:速度在边界处法向分量为零
  • 自由流出边界:允许水流出计算域
  • 周期性边界:用于无限或循环域
  • UV seam 边界:通过 FindAdjacencyUV 建立的跳转表处理

流动稳定性保障

为确保水内部流动的数值稳定性,实现中通常包含:

1. CFL 条件检查
复制代码
float CFL = max(abs(Velocity.x), abs(Velocity.y)) * DeltaTime / CellSize;
if (CFL > 0.5) {
    // 需要减小时间步长或使用更稳定的平流方案
    DeltaTime = 0.5 * CellSize / max(abs(Velocity.x), abs(Velocity.y));
}
2. 人工粘性(Artificial Viscosity)

为抑制数值振荡,可添加少量人工粘性:

复制代码
float2 VelocityLaplacian = Laplacian(VelocityGrid, i, j);
VelocityGrid[C] += ViscosityCoeff * VelocityLaplacian * DeltaTime;
3. 保形限制器(Slope Limiters)

对于高阶平流方案,使用限制器防止过冲和欠冲:

复制代码
float HeightLeft = WaterGrid[i-1, j].Height;
float HeightRight = WaterGrid[i+1, j].Height;
float HeightCenter = WaterGrid[i, j].Height;
// Minmod 限制器
float Slope = 0.5 * minmod(HeightCenter - HeightLeft, HeightRight - HeightCenter);

与外部模块的耦合

水内部流动不是孤立存在的,它与其他模块紧密耦合:

1. 与 Hit 模块的耦合

Hit 模块添加的水高会成为平流的源项,影响局部流动模式。

2. 与 Extrapolate Velocity 的耦合

Extrapolate Velocity 确保水边界处的速度场连续,为平流提供合理的边界值。

3. 与压力投影的耦合

完整的浅水模拟通常还包括压力投影步骤:

复制代码
// 1. 平流(Advect) - 水内部流动
Advect(Height, Velocity);
// 2. 添加外力(重力、风、源项)
ApplyForces(Height, Velocity);
// 3. 压力投影(确保无散度)
Project(Height, Velocity);
// 4. 边界条件应用
ApplyBoundaryConditions(Height, Velocity);

性能优化考虑

在实时应用中,水内部流动计算需要优化:

  • 平流纹理:使用 GPU 纹理采样实现高效插值
  • 计算域裁剪:只在水存在的区域进行计算
  • 多分辨率:远处使用低分辨率,近处使用高分辨率
  • LOD 过渡:不同分辨率区域间的平滑过渡

视觉特效增强

为增强水内部流动的视觉效果,可添加:

  • 涡度约束(Vorticity Confinement):增强小尺度涡旋细节
  • 泡沫生成:基于速度梯度和曲率生成泡沫粒子
  • 法线计算:从水高场计算法线用于光照
  • 折射/反射:基于流动模式的动态折射效果

总结来说,水内部的流动是浅水模拟中最核心的动力学过程,它通过平流算子将水高和速度场在时空上推进,同时需要与边界处理、稳定性控制、视觉增强等多个方面协同工作,才能产生既物理合理又视觉逼真的水流效果。

GSW Update Velocity

作用:更新速度,让水因为重力、坡度、水高差产生新的流动速度。

它读取当前格子的:

复制代码
VelocityGrid.Velocity
WaterGrid.Height
UVGrid.AdjacencyUV

然后通过 AdjacencyUV 查 SkeletalMesh,拿到:

复制代码
Normal
Tangent
Binormal

再做两件事:

复制代码
1. 把世界重力投影到模型表面的 Tangent / Binormal 上
   加到 Velocity.x / Velocity.y

2. 根据当前水高和左/下格子的水高差
   更新 Velocity.x / Velocity.y

它只看左/下,是因为当前 index 的:

复制代码
Velocity.x 表示当前格子左边界速度
Velocity.y 表示当前格子下边界速度

右/上边界速度会在相邻 index 里算。

最后它会:

复制代码
速度 *= 0.995 阻尼
速度 clamp 到 MaxVelocity

一句话:

复制代码
Update Velocity = 根据坡度、重力、水高差,生成/调整水流速度

GWS Update Height

作用:根据四条边的速度,更新当前格子的水高。

它读取当前格子的:

复制代码
Height
左/右/上/下 Height
左边界速度 v_x
右边界速度 v_x_right
下边界速度 v_y
上边界速度 v_y_up

然后判断水是从哪边流入/流出,用 upwind 的方式选择参与计算的高度。

公式本质是:

复制代码
新 Height = 旧 Height - 净流出量

如果流出去多:

复制代码
Height 下降

如果流进来多:

复制代码
Height 上升

最后限制:

复制代码
Height = clamp(Height, 0, MaxDepth)

一句话:

复制代码
Update Height = 用边界速度算当前格子水量增减

Resample

作用:用 AdjacencyUV 修补 UV seam 附近的 Height / Velocity。

它对当前格子 C 读取:

复制代码
AdjacencyUV
IslandMask
当前格子自己的 Height / Velocity
AdjacencyUV 指向位置的 Height / Velocity

然后:

复制代码
Final = lerp(SampleAdjacency, Current, IslandMask);

也就是:

复制代码
IslandMask = 1
当前格子在 UV 岛内部
保留自己的 Height / Velocity

IslandMask = 0
当前格子在 seam 外/UV 岛外
从 AdjacencyUV 指向的位置采样 Height / Velocity
再写回当前格子

一句话:

复制代码
Resample = 从 seam 对面的有效 UV 位置采样数据,回填当前格子

Output

作用:把模拟结果写到 RenderTarget。

它读取:

复制代码
WaterGrid.Height
UVGrid.AdjacencyUV
UVGrid.ValidMask

然后输出到 OutputRT

复制代码
R = AdjacencyUV.x
G = AdjacencyUV.y
B = Height
A = 1

如果 ValidMask = 0,输出黑色:

复制代码
float4(0, 0, 0, 1)

一句话:

复制代码
Output = 把最终的修正 UV 和水高写进 RenderTarget,给材质/调试使用

整体后半段顺序是:

复制代码
Update Velocity
    根据重力/坡度/高度差更新速度

Update Height
    根据速度更新水高

Resample
    修补 UV seam 附近的 Height / Velocity

Output
    输出 AdjacencyUV + Height 到 RenderTarget
相关推荐
AA陈超1 天前
005 T03 — 角色基类与移动系统 详细功能设计文档
ue5
AA陈超2 天前
004 T02 - 俯视角摄像机系统 设计文档
网络·c++·ue5·虚幻引擎
AA陈超2 天前
006 T03 — 蓝图操作指南
c++·游戏·架构·ue5·github·虚幻引擎
FL16238631293 天前
Windows手动下载安装配置uv 0.11.29配置国内源含安装包和安装教程
windows·uv
Duo1J4 天前
【UE】Slate 编辑器工具开发01 - Slate语法 & 常用控件
ue5·编辑器·游戏引擎·ue4
朗迹 - 张伟5 天前
UE5.7 PCG从入门到精通系列课程【第五课:PCG沿一条样条线生成物体】
ue5·pcg
AA陈超5 天前
003 XiYou 西游 — P0 阶段实施计划
c++·笔记·学习·ue5
大魔王爱学习5 天前
Windows中通过uv工具安装一个默认的全局Python
开发语言·python·uv
工业胶粘剂技术6 天前
K-1924 UV光固化胶粘剂 电子精密组装与高强粘接 技术参数与选型
uv