广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何用SwiftUI实现Navier-Stokes简化的流体模拟转场与ParticleFlow粒子动画?

在SwiftUI里做转场,系统默认的AnyTransition只能提供透明度、移动和缩放这些线性组合。如果想做出两个界面像墨水一样融合、打散然后重新汇聚的效果,就必须在转场期间维护一个速度场,用粒子去承载界面的颜色与轮廓。这正是简化Navier-Stokes方程能发挥作用的场景。它不要求物理级精确,只需要在每帧给粒子一个看起来合理的方向变化。简化后的模型把流体看作由速度驱动的连续介质,核心是平流、压力和外力三项。

如何用SwiftUI实现Navier-Stokes简化的流体模拟转场与ParticleFlow粒子动画?

一、先理解简化后的Navier-Stokes方程与转场映射

完整形式的Navier-Stokes方程包含时间项、平流项、扩散项、压力项和外力项。放到SwiftUI转场里,扩散项往往可以被舍弃,因为扩散会让速度场过度平滑,视觉上反而失去粒子流动的颗粒感和冲击力。平流项负责把粒子沿当前速度推向下一个位置,压力项用来维持流体不可压缩的视觉特征,避免粒子挤成一团或者出现明显真空区。

转场映射的具体做法是这样:把当前界面渲染成纹理,或者直接按颜色分区采样成粒子。转场进度用0到1表示,驱动粒子从旧布局流向新布局。粒子不仅要保存坐标,还要携带速度、颜色和寿命。每帧更新速度场后,粒子位置跟着变化,再由Canvas绘制出来。完成转场后销毁粒子或让它们自然消亡,不会残留在屏幕上。

压力投影这一项虽然计算成本略高,但在简化模型里不能完全省略。没有压力修正,粒子流动会像沙子一样分散,不会出现液体融合时的连续边界。实际工程里使用Jacobi迭代近似求解压力泊松方程,通常迭代十几次就能得到足够自然的视觉效果。

二、用Canvas与TimelineView实现CPU版ParticleFlow

SwiftUI的Canvas是一个适合粒子绘制的容器,它提供GPU加速的绘图上下文,可以批量画出大量小圆点。TimelineView则负责以屏幕刷新率驱动动画,让每一帧都能调用粒子更新逻辑。先定义一个Particle结构保存坐标、速度、生命值和颜色,再用一个FlowField结构维护简化后的速度场或外力源。CPU版本适合粒子数量在300到800个左右的场景,调试直观,可以快速验证转场效果。

更新逻辑里,为了让粒子像流体一样连续运动,不要使用完全随机的位置跳变,而是用正弦和余弦函数构造一个基础涡流场。每个粒子的速度根据当前位置产生变化,再乘以时间步长更新坐标。这样粒子轨迹会出现类似湍流的弯曲,而不是杂乱无章的飞溅。生命值逐渐减少,当粒子快要消失时,把它重置到起点并赋予新的速度,就能形成循环流动。

下面是一个简化的粒子步进函数,只保留平流和外部力,略去了扩散项。粒子的重生位置和速度被限制在画布范围内,颜色可以据此做渐变。

struct Particle {
    var x: Double
    var y: Double
    var vx: Double
    var vy: Double
    var life: Double
}
func step(particles: inout [Particle], size: CGSize, dt: Double) {
    for i in 0..<particles.count {
        var p = particles[i]
        // 简化N-S:只做平流和外部力,扩散项忽略
        let fx = sin(p.y * 0.02) * 0.8
        let fy = cos(p.x * 0.02) * 0.8
        p.vx += fx * dt
        p.vy += fy * dt
        p.x += p.vx * dt
        p.y += p.vy * dt
        p.life -= dt * 0.08
        if p.life < 0.02 {
            p.x = Double.random(in: 0..<size.width)
            p.y = Double.random(in: 0..<size.height)
            p.vx = Double.random(in: -20...20)
            p.vy = Double.random(in: -20...20)
            p.life = 1.0
        }
        particles[i] = p
    }
}

CPU版性能优化的关键是减少每帧对象分配。不要在上面的循环里创建新的数组或结构体副本,尽量使用inout修改原数组。Canvas绘制时也可以关闭无用的混合模式,粒子半径控制在一个到三个像素之间,这样即使老设备也能稳定跑满帧。

三、用压力投影让粒子流动更像液体

只有平流和外力时,粒子容易出现局部堆积,速度快的地方会形成空洞。压力投影的核心思想是先计算速度场的散度,如果散度不为零,说明该区域正在被压缩或拉伸。接着用Jacobi迭代求解压力场,再从速度中减去压力梯度,让散度趋向零。这个修正过程会让粒子群表现出类似液体的不可压缩感,边界处会更柔和。

实现时把二维空间离散成网格,u和v分别表示水平和垂直速度。计算散度时使用相邻格子的中心差分,然后反复更新压力值。迭代次数越多,压力场越接近真实解,但在移动端实时渲染时,10到20次已经完全够用。边界条件通常设置成固体墙,也就是边界速度为零,压力法向梯度为零,代码里跳过首尾索引就是这个目的。

func projectVelocity(u: inout [[Double]], v: inout [[Double]], iterations: Int) {
    let width = u.count
    let height = u[0].count
    var p = Array(repeating: Array(repeating: 0.0, count: height), count: width)
    var div = Array(repeating: Array(repeating: 0.0, count: height), count: width)
    for _ in 0..<iterations {
        for i in 1..<(width - 1) {
            for j in 1..<(height - 1) {
                div[i][j] = -0.5 * (u[i + 1][j] - u[i - 1][j] + v[i][j + 1] - v[i][j - 1])
            }
        }
        for i in 1..<(width - 1) {
            for j in 1..<(height - 1) {
                p[i][j] = (div[i][j] + p[i + 1][j] + p[i - 1][j] + p[i][j + 1] + p[i][j - 1]) * 0.25
            }
        }
        for i in 1..<(width - 1) {
            for j in 1..<(height - 1) {
                u[i][j] -= 0.5 * (p[i + 1][j] - p[i - 1][j])
                v[i][j] -= 0.5 * (p[i][j + 1] - p[i][j - 1])
            }
        }
    }
}

经过压力投影后,粒子的运动会带上一种粘稠的连续感。转场时如果给不同来源的粒子做颜色插值,它们会像油彩一样慢慢混合,而不是生硬地替换。这个步骤虽然简化了粘度项和扩散项,但已经足够支撑大多数UI转场的视觉要求。

四、交互手势与Metal GPU加速方案

粒子流动如果只能自动播放,交互感会弱很多。SwiftUI的DragGesture可以捕获手指位置,将触点附近的粒子推开,模拟手搅动液体的效果。具体做法是在拖拽过程中把触点转换为画布坐标,写入FlowField的力源列表。每帧更新粒子速度时,先叠加这些外部力,再执行平流和压力修正。这样用户滑动屏幕时,粒子流会被局部扰动,转场路径也会发生弯曲。

.gesture(
    DragGesture(minimumDistance: 0)
        .onChanged { value in
            let location = value.location
            flowField.addForce(at: location, radius: 60, strength: 12)
        }
)

当粒子数量达到2000以上,CPU逐帧循环会成为明显瓶颈。Metal compute shader能把粒子更新放到GPU并行执行,每个线程处理一个粒子,速度远高于串行循环。将Particle数组存入MTLBuffer,速度场和力源作为参数传入,着色器内完成与CPU版几乎相同的计算。需要注意Metal着色器在iOS模拟器上不可用,调试必须使用真机。

kernel void stepParticles(device Particle *particles [[buffer(0)]],
                          constant float2 &force [[buffer(1)]],
                          uint idx [[thread_position_in_grid]]) {
    if (idx >= particleCount) return;
    Particle p = particles[idx];
    float2 f = float2(sin(p.position.y * 0.02), cos(p.position.x * 0.02)) * 0.8;
    p.velocity += f * dt;
    p.position += p.velocity * dt;
    particles[idx] = p;
}

结合简化Navier-Stokes方程和ParticleFlow粒子系统,SwiftUI转场可以摆脱线性动画的机械感。CPU方案适合快速原型和少量粒子,GPU方案适合复杂界面或高频交互。把压力投影、手势外力和粒子生命周期控制好,就能做出液体打散再汇聚的转场效果。

广告位 · 文章底部(后台-模块管理替换此区域)