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.x 和 Velocity.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