风场

参考战神的风场设计思路在 URP 管线下实现了一套风场系统,并依据现在的 Compute 管线特点进行了设计调整,同时增加了对流体涡旋的模拟,下面就这套系统进行具体的介绍。

1. 核心架构与管线设计

原理

战神的开发者将全局风,分级为 3 部分 [Wind Tiers] 全局静态风 +动态风 -物体移动速度

1
SampleWind(object) := StaticWind + DynamicWind[object.postion] + -object.velocity

即,风的影响的采样公式 = 全局静态风一个vector3 + 动态风场中物体位置的风采样 - 物体的移动速度vector3;

通过风力 Volume 控制动态风场作用范围,体积外仅仅启用全局静态风场,内部启用动态风场+静态+物体移速。

静态风:静态风是一个全局的风,均匀地应用于场景中的所有物体。它可以随着时间的推移而改变,也可以随着玩家在世界各地的移动而改变。有时会用scrolling noise texture 来做静态风。

动态风:动态风是他们的重点,作用范围是在玩家周围形成一个3D立体的空间,并随着玩家的移动而移动的。

逆风:逆风是其实是一个机制,用来模拟在风中移动的物体,是否受到风的影响。 如果一个物体的运动速度和方向与静态风或动态风大致相同,就会抵消风的作用,并给出物体不受风影响的表现。

Dynamic WindDetails

对于动态风场,战神开发者用32x16x32 的三维纹理来存,每立方米 一个纹理单位。 为了在GPU上快速方便的模拟风的计算,选择了标准的三维纹理volume,而没有使用层次化的volume。

战神的动态风场在玩家周围也足够大,能包含斧头扔出去的距离。所以他们的动态风场xz是比y大一倍的。

使用每帧5次的迭代,没有什么特别考虑,只是刚好找到了一个比较balance的值。

风的产生设计了不同类型的“发动机”,用来给风场注入速度。

战神里面的Advection 对流提供了,正向和反向的2种,他们强烈建议别图便宜只搞一种。

他们尝试过用压强来模拟风场,但是他们的美术不喜欢,而且压强有个弊端,就是不能是负的。但是压强他们也做了,把压强做为一个额外的使用参数。

渲染管线接入

系统通过 ScriptableRendererFeature (WindSimulationFeature.cs) 将风场计算精确注入到 BeforeRendering 阶段,确保风场数据在几何渲染前就绪。

C# 调度中心

WindManager.cs 是系统的大脑,负责维护核心的 Ping-Pong 双缓冲(基于 float4RenderTexture),并在每帧极其严谨地调度和分发各个物理计算阶段Shift -> Motor -> Diffusion -> Vorticity -> Advection的 Compute Shader 内核。

风源对象池管理

WindManager 中采用了 Unity 原生的 UnityEngine.Pool.ObjectPool 进行风源对象的池化管理,彻底杜绝了运行时的内存碎片和垃圾回收卡顿。

//TODO 贴代码

Buffer 存储

战神的设计中每个属性都有单独的三维纹理,x的速度,y的速度,z的速度,本项目简化了 3D Texture 的使用,全程使用单张 <font style="color:rgb(25, 27, 31);">Texture3D<float4></font> 作为主干,仅在必须保证原子操作的 Scatter 阶段使用 <font style="color:rgb(25, 27, 31);">RWStructuredBuffer</font> 进行中转与自我清零。

关于三维纹理的切片方向也有讲究,他们选择的是xz轴的切片。(据说,这样做在计算的更高效,因为很多时候风的流向都是水平运动),数据的局部性缓冲

XZ 的切片,基于风主要是水平移动的考量,设计数据局部缓冲

2. 动态风源系统 (Wind Motors)

基于结构体和 ComputeBuffer 通信的高效风源注入模块,支持五种核心流体受力模型:

  • Directional (定向风):产生持续的平面推进力。
  • Omni (点源风):模拟爆炸冲击波或向外扩散的球形排斥力。
  • Vortex (涡流风):基于叉乘计算,产生龙卷风般的强烈旋转气流。
  • Moving (移动尾流):精准模拟物体划过空气时带动的动态尾迹流体。
  • **Cylinder(圆柱风/风洞)**在一段受限的圆柱(或圆台)区间内,沿着轴向产生推力。
  • **Pressure:(气压风) **~~~~向一个区域注入/撤回压力。已废弃。

Constants Shader 常量

通过 uniform 常量 来传递马达信息到 Shader 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
struct MotorDirectional {
float3 position;
float radiusSq;
float3 force;
};

struct MotorOmni {
float3 position;
float radiusSq;
float force;
};

struct MotorVortex {
float3 position;
float3 axis;
float radiusSq;
float force;
};

struct MotorMoving {
float3 prePosition;
float moveLen;
float3 moveDir;
float radiusSq;
float force;
};

struct MotorCylinder {
float3 position;
float3 axis;
float height;
float radiusBottonSq;
float radiusTopSq;
float force;
};

struct MotorPressure {
float3 position;
float radiusSq;
float force;
};
void ApplyMotorDirectional(float3 cellPosWS, MotorDirectional motor, inout float3 velocityWS)
{
float distanceSq = DistanceSq(cellPosWS, motor.position);
if(distanceSq < motor.radiusSq) velocityWS += motor.force;
}

void ApplyMotorOmni(float3 cellPosWS, MotorOmni motor, inout float3 velocityWS)
{
float3 dir = cellPosWS - motor.position;
float distanceSq = LengthSq(dir);
if(distanceSq < motor.radiusSq)
{
velocityWS += dir * motor.force * min(rsqrt(distanceSq), 5.0);
}
}

void ApplyMotorVortex(float3 cellPosWS, MotorVortex motor, inout float3 velocityWS)
{
float3 dir = cellPosWS - motor.position;
float distanceSq = LengthSq(dir);
if (distanceSq < motor.radiusSq)
{
velocityWS += motor.force * cross(motor.axis, dir * rsqrt(distanceSq));
}
}

void ApplyMotorMoving(float3 cellPosWS, MotorMoving motor, inout float3 velocityWS)
{
float3 dirPre = cellPosWS - motor.prePosition;
float moveLen = clamp(dot(dirPre, motor.moveDir), 0.0, motor.moveLen);

float3 curPos = moveLen * motor.moveDir + motor.prePosition;
float3 dirCur = cellPosWS - curPos;
float distanceSq = LengthSq(dirCur);
if(distanceSq < motor.radiusSq)
{
float3 blowDir = normalize(rsqrt(distanceSq) * dirCur + motor.moveDir);
velocityWS += blowDir * motor.force;
}
}

// 圆柱风 (风洞):在一段受限的圆柱(或圆台)区间内,沿着轴向产生推力
void ApplyMotorCylinder(float3 cellPosWS, MotorCylinder motor, inout float3 velocityWS)
{
float3 pToCell = cellPosWS - motor.position;
// 投影到中轴线上,计算高度
float h = dot(pToCell, motor.axis);

// 检查是否在高度范围内
if (h >= 0.0 && h <= motor.height)
{
// 投影点位置
float3 projPoint = motor.position + h * motor.axis;
float distSq = DistanceSq(cellPosWS, projPoint);

// 线性插值计算当前高度下的截面半径平方
float t = h / motor.height;
float currentRadiusSq = lerp(motor.radiusBottonSq, motor.radiusTopSq, t);

if (distSq < currentRadiusSq)
{
// 圆柱风通常是沿着轴向吹的通道风
velocityWS += motor.axis * motor.force;
}
}
}

// 气压风 (爆炸/内爆):类似 Omni,但力的大小随距离中心变远而线性衰减
void ApplyMotorPressure(float3 cellPosWS, MotorPressure motor, inout float3 velocityWS)
{
float3 dir = cellPosWS - motor.position;
float distSq = LengthSq(dir);

if (distSq < motor.radiusSq)
{
float dist = sqrt(distSq);
float radius = sqrt(motor.radiusSq);
// 距离衰减:中心力最大,边缘力为 0
float falloff = 1.0 - (dist / radius);

velocityWS += normalize(dir) * motor.force * falloff;
}
}

3. 计算流体力学 (CFD) 模拟管线

现在到了整个风场管线的核心部分,我们在 Compute Shader (WindSimulation.compute & WindSimulation.hlsl) 内部实现了一个高度优化的网格流体模拟器,考虑风的行为包含:扩散,平流,自旋,以下是核心物理步骤:

空间平移 (Shift)

当目标(如相机)移动时,动态偏移体积网格,使高精度风场始终包裹核心区域。

1
2
3
4
5
6
7
8
9
10
11
[numthreads(THREAD_GROUP_SIZE, THREAD_GROUP_SIZE, THREAD_GROUP_SIZE)]
void CSWindShiftPosition(uint3 id : SV_DispatchThreadID)
{
if(any(id > (uint3)VolumeSizeMinusOne)) return;
int3 src = (int3)id + ShiftOffset;

// 越界区域判定为新进入的“无风区”清零
bool outB = any(src < 0) || any(src > VolumeSizeMinusOne);
WindBufferOutput[id] = outB ? float4(0,0,0,0) : WindBufferInput[src];
}

粘性扩散 (Diffusion)

摒弃昂贵的全局显存读取,引入了 LDS (Local Data Share / 组内共享内存) 优化。通过为 8x8x8 的线程组建立 10x10x10 的光晕缓存 (Halo Cache),让极其耗费带宽的拉普拉斯算子计算全部在 GPU 高速 SRAM 内完成,性能有了极大的飞跃。

Diffusion 近似理解为模糊 Blur

LDS/Halo Cache/ 拉普拉斯算子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
groupshared float3 m_Cache[1000]; // 10*10*10 Halo Cache

void LoadToCache(uint idx, uint3 Gid)
{
if (idx >= 1000) return;
int3 c = int3(idx % 10, (idx / 10) % 10, idx / 100);
int3 globalCoord = clamp(int3(Gid * 8) + c - 1, 0, VolumeSizeMinusOne);
m_Cache[idx] = WindBufferInput[globalCoord].xyz;
}

[numthreads(THREAD_GROUP_SIZE, THREAD_GROUP_SIZE, THREAD_GROUP_SIZE)]
void CSWindDiffusion(uint3 id : SV_DispatchThreadID, uint3 groupThreadID : SV_GroupThreadID, uint3 groupID : SV_GroupID)
{
uint flatIdx = groupThreadID.x + groupThreadID.y * 8 + groupThreadID.z * 64;
LoadToCache(flatIdx, groupID);
LoadToCache(flatIdx + 512, groupID); // 512个线程协同加载1000个数据
GroupMemoryBarrierWithGroupSync();

if(any(id > (uint3)VolumeSizeMinusOne)) return;

int3 c = (int3)groupThreadID + 1;
#define GET_CACHE(x,y,z) m_Cache[(z)*100 + (y)*10 + (x)]

float3 windInput = GET_CACHE(c.x, c.y, c.z);
float3 xr = GET_CACHE(c.x + 1, c.y, c.z);
float3 xl = GET_CACHE(c.x - 1, c.y, c.z);
float3 yr = GET_CACHE(c.x, c.y + 1, c.z);
float3 yl = GET_CACHE(c.x, c.y - 1, c.z);
float3 zr = GET_CACHE(c.x, c.y, c.z + 1);
float3 zl = GET_CACHE(c.x, c.y, c.z - 1);

float3 laplacian = xr + xl + yr + yl + zr + zl - 6.0 * windInput;
WindBufferOutput[id] = float4(windInput + DiffusionForce * laplacian, 0.0);
}

涡度限制 (Vorticity Confinement)

在这个3D 体积风场项目中,旋度限制(Vorticity Confinement) 是赋予流体“灵魂”的核心技术。没有它,风场就像是平淡无奇的推力;有了它,风场才会展现出龙卷风般的卷曲、湍流和撕裂感。

核心痛点:为什么要引入旋度?

在图形学中,尤其是使用** MacCormack 或半拉格朗日法(Gather 采样)进行流体平流(Advection)时,需要不断地对 3D 纹理进行三线性插值采样(SampleTrilinear)**。

  • 物理代价: 插值本质上是一种“模糊(Blur)”操作。
  • 视觉后果: 这种被称为数值耗散(Numerical Dissipation)的现象,会吃掉流体中的高频能量。风场原本应该卷曲的漩涡,会随着时间推移被迅速磨平,最后看起来像是在搅动粘稠的糖浆,而不是凌厉的狂风。

旋度限制的目的,就是把这些被“插值模糊”吃掉的旋转能量,人为地、暴力地重新注入回流体中。

也就是说**,为了弥补流体网格带来的动能流失,引入了涡度补偿机制**。通过计算旋度场 (Curl Field) 提取漩涡能量,并加入了“速度阈值熔断”、“安全归一化”、“平滑过渡遮罩”和“相对动量截断”这四重安全锁,在消除底噪的同时,完美还原了狂暴卷曲的湍流细节。


物理数学原理

在流体力学中,这套算法可以被拆解为极其经典的三个步骤:

1. 找漩涡(计算旋度 Curl)
旋度 描述了流体在空间中任意一点的“旋转趋势”。

它是一个向量,指向旋转的轴心,长度代表旋转的剧烈程度。

2. 找漩涡中心(计算梯度 Gradient)
如果我们知道了每个地方的旋转剧烈程度(旋度的长度 ),我们就可以计算它的梯度方向

在物理意义上,梯度方向 永远指向局部漩涡的最中心。

3. 推一把(注入补偿力 Confinement Force)
既然知道了漩涡在哪里,也知道了漩涡怎么转,我们就人为施加一个力,强行推动流体绕着漩涡转:

这里的 就是设置面板上暴露的 VorticityScale


项目中的工程实现

在项目的 Compute Shader 架构中,这套理论被拆分为了两个 Pass,并加入了四重安全防护:

Pass 1: CSWindCalculateVorticity (信息收集)
  • 有限差分法:通过读取上下左右前后 6 个邻居的风速,利用**离散数学近似计算出旋度**(Curl)。
  • 复用中转纹理:将计算结果存储在 WindBufferIntermediate 中。绝妙的是,利用了 float4 的特性,xyz 存储旋度向量,w 存储旋度强度(向量长度),为下一个 Pass 节省了大量 ALU 开销。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[numthreads(THREAD_GROUP_SIZE, THREAD_GROUP_SIZE, THREAD_GROUP_SIZE)]
void CSWindCalculateVorticity (uint3 id : SV_DispatchThreadID)
{
if(any(id > (uint3)VolumeSizeMinusOne)) return;
int3 i = (int3)id;

float3 v_r = WindBufferInput[clampBorder(i + int3(1,0,0))].xyz;
float3 v_l = WindBufferInput[clampBorder(i - int3(1,0,0))].xyz;
float3 v_t = WindBufferInput[clampBorder(i + int3(0,1,0))].xyz;
float3 v_b = WindBufferInput[clampBorder(i - int3(0,1,0))].xyz;
float3 v_f = WindBufferInput[clampBorder(i + int3(0,0,1))].xyz;
float3 v_k = WindBufferInput[clampBorder(i - int3(0,0,1))].xyz;

float3 curl;
curl.x = (v_t.z - v_b.z) - (v_f.y - v_k.y);
curl.y = (v_f.x - v_k.x) - (v_r.z - v_l.z);
curl.z = (v_r.y - v_l.y) - (v_t.x - v_b.x);
curl *= 0.5;

// Intermediate XYZ 存旋度,W 存旋度强度
WindBufferIntermediate[id] = float4(curl, length(curl));
}
Pass 2: CSWindApplyVorticity (施加修饰力与四重限制)

如果直接把纯理论公式放进游戏,稍微大一点的风就会导致画面出现“黑洞”或满屏闪烁(噪点放大)。我们增加了 4 重安全防护:

  1. 速度阈值熔断 (Speed Threshold)
    • if (speed < 0.05) return;
    • 作用: 彻底掐断无风区的浮点数底噪。防止极微弱的运算误差被系统当成漩涡放大,保证无风区绝对干净。
  2. 安全归一化 (Safe Normalize)
    • if (N_length < 1e-6) return;
    • 作用: 避免了暴力的 +1e-5 带来的方向偏差(偏差会导致无风区出现莫名其妙的对角线微风),保证梯度的绝对准确。
  3. 平滑过渡遮罩 (Fade Mask)
    • smoothstep(0.05, 0.2, speed)
    • 作用: 让涡度力在微风向大风过渡时平滑介入,消灭风场边缘可能出现的块状撕裂感。
  4. 相对动量截断 (Relative Momentum Clamp -)
    • maxAllowedForce = speed * 0.1
    • 作用: 强制规定补充的“旋度力”绝对不能超过当前风速的 10%。它把补偿力死死限制在“修饰力”的范围内,彻底杜绝了“涡度爆炸(Vorticity Explosion)”组成的正反馈死循环。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[numthreads(THREAD_GROUP_SIZE, THREAD_GROUP_SIZE, THREAD_GROUP_SIZE)]
void CSWindApplyVorticity (uint3 id : SV_DispatchThreadID)
{
...

float4 c_c = WindBufferIntermediate[i];
float c_r = WindBufferIntermediate[clampBorder(i + int3(1,0,0))].w;
float c_l = WindBufferIntermediate[clampBorder(i - int3(1,0,0))].w;
float c_t = WindBufferIntermediate[clampBorder(i + int3(0,1,0))].w;
float c_b = WindBufferIntermediate[clampBorder(i - int3(0,1,0))].w;
float c_f = WindBufferIntermediate[clampBorder(i + int3(0,0,1))].w;
float c_k = WindBufferIntermediate[clampBorder(i - int3(0,0,1))].w;

float3 N = float3(c_r - c_l, c_t - c_b, c_f - c_k);

// 2. 安全归一化
float N_length = length(N);
if (N_length < 1e-6)
{
WindBufferOutput[id] = float4(velocity, 0.0);
return;
}
N /= N_length;

float3 confinementForce = cross(N, c_c.xyz) * VorticityScale;

...

}

通过这套理论与工程的双重加持,风场系统在保证极高运行效率的同时,完美保留了计算流体力学中最迷人的混沌与翻滚细节!

对流 (Advection/平):

对流 是基于速度传递能量的过程,发生在纹理和纹理之间,可以用来传播速度属性。

通过平流 来传播速度,模拟能量的流动

处理平流可以处理diffusion扩散一样,按轴进行分离,减少等待时间。

但是会存在一个问题,在做迭代的时候, 正向和反向的会同时对数据读写,****写入数据的时候发生数据争抢。多线程的时候可能同时有不同的线程在往texel纹理中写数据。

系统目前保留了两套的流体平流方案供无缝切换:

  • Scatter (前向原子散射):利用巧妙的 1D 展平定点数数组 (RWStructuredBuffer<int>) 和 InterlockedAdd 彻底解决并发写入冲突。这套方案拥有零数值耗散的特性,能极致地保留高频风浪和物理边界。
    • 这套实现需要原子锁,注意异步同步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// =========================================================================
// [Pass 5A] 平流系统 - 原子 Scatter 方案
// =========================================================================
[numthreads(THREAD_GROUP_SIZE, THREAD_GROUP_SIZE, THREAD_GROUP_SIZE)]
void CSWindAdvectionForward (uint3 id : SV_DispatchThreadID)
{
if(any(id > (uint3)VolumeSizeMinusOne)) return;
float3 velocity = WindBufferInput[id].xyz;
float3 targetPos = (float3)id + velocity * AdvectionForce;

int3 moveCell = (int3)floor(targetPos);
float3 offsetNeb = frac(targetPos);
float3 offsetOri = 1.0 - offsetNeb;

AtomicAdd(WindAtomicBuffer, moveCell, velocity * (offsetOri.x * offsetOri.y * offsetOri.z));
AtomicAdd(WindAtomicBuffer, moveCell + int3(1,0,0), velocity * (offsetNeb.x * offsetOri.y * offsetOri.z));
AtomicAdd(WindAtomicBuffer, moveCell + int3(0,1,0), velocity * (offsetOri.x * offsetNeb.y * offsetOri.z));
AtomicAdd(WindAtomicBuffer, moveCell + int3(1,1,0), velocity * (offsetNeb.x * offsetNeb.y * offsetOri.z));
AtomicAdd(WindAtomicBuffer, moveCell + int3(0,0,1), velocity * (offsetOri.x * offsetOri.y * offsetNeb.z));
AtomicAdd(WindAtomicBuffer, moveCell + int3(1,0,1), velocity * (offsetNeb.x * offsetOri.y * offsetNeb.z));
AtomicAdd(WindAtomicBuffer, moveCell + int3(0,1,1), velocity * (offsetOri.x * offsetNeb.y * offsetNeb.z));
AtomicAdd(WindAtomicBuffer, moveCell + int3(1,1,1), velocity * (offsetNeb.x * offsetNeb.y * offsetNeb.z));
}

[numthreads(THREAD_GROUP_SIZE, THREAD_GROUP_SIZE, THREAD_GROUP_SIZE)]
void CSWindAdvectionReverse (uint3 id : SV_DispatchThreadID)
{
if(any(id > (uint3)VolumeSizeMinusOne)) return;
float3 velocity = WindBufferInput[id].xyz;
float3 sourcePos = (float3)id - velocity * AdvectionForce; // 反向回溯

int3 moveCell = (int3)floor(sourcePos);
float3 offsetNeb = frac(sourcePos);
float3 offsetOri = 1.0 - offsetNeb;

int3 p000 = moveCell; int3 p100 = moveCell + int3(1,0,0);
int3 p010 = moveCell + int3(0,1,0); int3 p110 = moveCell + int3(1,1,0);
int3 p001 = moveCell + int3(0,0,1); int3 p101 = moveCell + int3(1,0,1);
int3 p011 = moveCell + int3(0,1,1); int3 p111 = moveCell + int3(1,1,1);

// 从 Intermediate 中读取目标网格的风力
float3 t000 = WindBufferIntermediate[clampBorder(p000)].xyz * (offsetOri.x * offsetOri.y * offsetOri.z);
float3 t100 = WindBufferIntermediate[clampBorder(p100)].xyz * (offsetNeb.x * offsetOri.y * offsetOri.z);
float3 t010 = WindBufferIntermediate[clampBorder(p010)].xyz * (offsetOri.x * offsetNeb.y * offsetOri.z);
float3 t110 = WindBufferIntermediate[clampBorder(p110)].xyz * (offsetNeb.x * offsetNeb.y * offsetOri.z);
float3 t001 = WindBufferIntermediate[clampBorder(p001)].xyz * (offsetOri.x * offsetOri.y * offsetNeb.z);
float3 t101 = WindBufferIntermediate[clampBorder(p101)].xyz * (offsetNeb.x * offsetOri.y * offsetNeb.z);
float3 t011 = WindBufferIntermediate[clampBorder(p011)].xyz * (offsetOri.x * offsetNeb.y * offsetNeb.z);
float3 t111 = WindBufferIntermediate[clampBorder(p111)].xyz * (offsetNeb.x * offsetNeb.y * offsetNeb.z);

// 源头抽走能量
AtomicAdd(WindAtomicBuffer, p000, -t000); AtomicAdd(WindAtomicBuffer, p100, -t100);
AtomicAdd(WindAtomicBuffer, p010, -t010); AtomicAdd(WindAtomicBuffer, p110, -t110);
AtomicAdd(WindAtomicBuffer, p001, -t001); AtomicAdd(WindAtomicBuffer, p101, -t101);
AtomicAdd(WindAtomicBuffer, p011, -t011); AtomicAdd(WindAtomicBuffer, p111, -t111);

// 注入当前网格
float3 cellData = t000 + t100 + t010 + t110 + t001 + t101 + t011 + t111;
AtomicAdd(WindAtomicBuffer, id, cellData);
}
  • MacCormack (后向收集与修正):基于三线性插值的半拉格朗日方法,并巧妙融入了**误差锐化**和 Min-Max Limiter(局部极值拦截),在保证平滑流动的同时避免了能量过度模糊。

这套方案可以理解为 Scatter 的逆向思维版本,规避了原子锁。我们在正向对流中需要互斥的写入风速,那么我们也可以反向查找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// =========================================================================
// [Pass 5B] 平流系统 - MacCormack Gather 方案
// =========================================================================
[numthreads(THREAD_GROUP_SIZE, THREAD_GROUP_SIZE, THREAD_GROUP_SIZE)]
void CSWindAdvectionForward_MacCormack (uint3 id : SV_DispatchThreadID)
{
if(any(id > (uint3)VolumeSizeMinusOne)) return;
float3 velocity = WindBufferInput[id].xyz;
WindBufferIntermediate[id] = float4(SampleTrilinear(WindBufferInput, (float3)id - velocity * AdvectionForce), 0.0);
}

[numthreads(THREAD_GROUP_SIZE, THREAD_GROUP_SIZE, THREAD_GROUP_SIZE)]
void CSWindAdvectionCorrection (uint3 id : SV_DispatchThreadID)
{
if(any(id > (uint3)VolumeSizeMinusOne)) return;

float3 velocity = WindBufferInput[id].xyz;
float3 forwardVal = WindBufferIntermediate[id].xyz;
float3 backVal = SampleTrilinear(WindBufferIntermediate, (float3)id + forwardVal * AdvectionForce);

// 1. 误差锐化增强
float macCormackWeight = 0.65;
float3 finalVal = forwardVal - (backVal - velocity) * macCormackWeight;

// 2. 局部极值拦截
int3 offsets[6] = { int3(1,0,0), int3(-1,0,0), int3(0,1,0), int3(0,-1,0), int3(0,0,1), int3(0,0,-1) };
float3 minV = velocity, maxV = velocity;

for(int i = 0; i < 6; ++i)
{
float3 neighborV = WindBufferInput[clampBorder((int3)id + offsets[i])].xyz;
minV = min(minV, neighborV);
maxV = max(maxV, neighborV);
}

finalVal = clamp(finalVal, minV, maxV);
finalVal = clamp(finalVal, -MaxWindSpeed, MaxWindSpeed);

WindBufferOutput[id] = float4(finalVal, 0.0);

uint idx = id.x + id.y * VolumeSize.x + id.z * VolumeSize.x * VolumeSize.y;
WindDataForCPUBuffer[idx] = finalVal;
}

4. 工程化

数据流转现代化

现在的工程,全程使用单张 Texture3D<float4> 作为主干,仅在必须保证**原子操作**的 Scatter 阶段使用 RWStructuredBuffer 进行中转与自我清零,兼顾了代码的简洁性与 GPU 底层执行的正确性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// ==========================================
// 展平缓冲与定点数原子累加 (Scatter核心)
// ==========================================
#define FXDPT_SIZE (float)(1 << 12)
#define FXDPT_SIZE_R (1.0 / (float)(1 << 12))

int PackFloatToInt(float f) { return (int)(f * FXDPT_SIZE); }
float PackIntToFloat(int i) { return (float)(i * FXDPT_SIZE_R); }

int GetFlatIndex(uint3 id)
{
uint3 volSize = (uint3)VolumeSize;
return (id.x + id.y * volSize.x + id.z * volSize.x * volSize.y) * 3;
}

void AtomicAdd(RWStructuredBuffer<int> atomicBuffer, uint3 id, float3 velocity)
{
if (any(id >= (uint3)VolumeSize)) return;

int baseIdx = GetFlatIndex(id);
InterlockedAdd(atomicBuffer[baseIdx], PackFloatToInt(velocity.x));
InterlockedAdd(atomicBuffer[baseIdx + 1], PackFloatToInt(velocity.y));
InterlockedAdd(atomicBuffer[baseIdx + 2], PackFloatToInt(velocity.z));
}

// =========================================================================
// [Pass 6] 合并与清理 (针对 Scatter 原子缓冲)
// =========================================================================
[numthreads(THREAD_GROUP_SIZE, THREAD_GROUP_SIZE, THREAD_GROUP_SIZE)]
void CSWindMerge (uint3 id : SV_DispatchThreadID)
{
if(any(id > (uint3)VolumeSizeMinusOne)) return;

int baseIdx = GetFlatIndex(id);
float x = PackIntToFloat(WindAtomicBuffer[baseIdx]);
float y = PackIntToFloat(WindAtomicBuffer[baseIdx + 1]);
float z = PackIntToFloat(WindAtomicBuffer[baseIdx + 2]);

WindBufferOutput[id] = float4(clamp(float3(x, y, z), -MaxWindSpeed, MaxWindSpeed), 0.0);
}

[numthreads(THREAD_GROUP_SIZE, THREAD_GROUP_SIZE, THREAD_GROUP_SIZE)]
void CSWindMergeAndClear (uint3 id : SV_DispatchThreadID)
{
if(any(id > (uint3)VolumeSizeMinusOne)) return;

int baseIdx = GetFlatIndex(id);
float x = PackIntToFloat(WindAtomicBuffer[baseIdx]);
float y = PackIntToFloat(WindAtomicBuffer[baseIdx + 1]);
float z = PackIntToFloat(WindAtomicBuffer[baseIdx + 2]);

float3 finalVelocity = clamp(float3(x, y, z), -MaxWindSpeed, MaxWindSpeed);
WindBufferOutput[id] = float4(finalVelocity, 0.0);

uint idx = id.x + id.y * VolumeSize.x + id.z * VolumeSize.x * VolumeSize.y;
WindDataForCPUBuffer[idx] = finalVelocity;

// 原地清零
WindAtomicBuffer[baseIdx] = 0;
WindAtomicBuffer[baseIdx + 1] = 0;
WindAtomicBuffer[baseIdx + 2] = 0;
}

浮点数与定点数(Fixed-Point Number)的互相转换

Debug

好的debug工具可以事半功倍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
RWTexture2D<float4> WindDebugTexture2D;
uniform int DebugGridCols;

[numthreads(8, 8, 1)]
void CSWindDebugFlatten(uint3 id : SV_DispatchThreadID)
{
uint sliceX = id.x / VolumeSize.x;
uint sliceY = id.y / VolumeSize.z;
uint sliceIdx = sliceY * DebugGridCols + sliceX;

if (sliceIdx < (uint)VolumeSize.y)
{
int3 coord3D = int3(id.x % VolumeSize.x, sliceIdx, id.y % VolumeSize.z);
float3 vel = WindBufferInput[coord3D].xyz;

float3 color = abs(vel) / MaxWindSpeed;

// 绘制切片边框分隔线
if (id.x % VolumeSize.x == 0 || id.y % VolumeSize.z == 0)
color = float3(0.8, 0.8, 0.8);

WindDebugTexture2D[id.xy] = float4(color, 1.0);
}
else
{
// 越界留白区域填充黑色
WindDebugTexture2D[id.xy] = float4(0, 0, 0, 1.0);
}
}

结语

一个物理法则极其严谨、内存调度极其干净、并且拥有极高定制化上限的 3D 流体中间件。它可以被无缝地用于驱动大规模 GPU Instancing 草地、或是体积云,体积雾系统中。

  • 风的模拟使游戏更加生动
  • 高性能和低消耗也是可以实现的
  • 正向平流和反向一定要同时使用
  • 好的debug工具可以事半功倍