Skip to content
cfd-lab:~/zh/posts/2026-07-03-chorin-projec…● online

本文译自作者的韩语原文。 阅读原文(한국어)

NOTE #091DAY FRI CFD기법DATE 2026.07.03UPDATED 2026.10.19READ 5 min read#Incompressible#Projection-Method#Fractional-Step#Pressure-Poisson#Navier-Stokes

把压力从方程里抹掉的人 — Chorin 投影法与分步法

流入和流出只要差一点点,Neumann的Poisson就没有解。关掉投影,那点散度会一步一步留下来。

1967 年,Alexandre Chorin 把压力从方程里暂时抹掉了。求解不可压 Navier–Stokes 方程时,最棘手的一项正是压力。压力没有时间导数。密度是常数,也无法通过状态方程反推压力。Chorin 的答案很大胆:先忽略压力把速度往前推,再把结果强行拉回无散度(divergence-free)状态。本文将讲清这套分步法(fractional step,通常称为投影法 projection method)如何运作——从一行定理 Helmholtz–Hodge 分解出发,直到写出一个真正能跑的二维求解器。读到最后,你会明白为什么不可压代码的运行时间大多耗在压力 Poisson 方程上。

把左面板的源强度从 0 推到 14,散度的颜色会亮起来,右边的投影只留下旋涡。

max|∇·u*| = 0.000 → 0.0000

Red = positive divergence (source), blue = negative (sink), dark = zero. Raise the source and the left panel lights up; the right panel stays dark — projection strips the compressible part and keeps only the swirl.

源为 0 时两边几乎一样。推到 14,只有左边染上红蓝,右边保持暗色。投影把可压分量整块剥掉了。

压力没有时间导数

把不可压 Navier–Stokes 方程写出来,问题一目了然。

∂u∂t+(u⋅∇)u=−1ρ∇p+ν∇2u,∇⋅u=0\frac{\partial \mathbf{u}}{\partial t} + (\mathbf{u}\cdot\nabla)\mathbf{u} = -\frac{1}{\rho}\nabla p + \nu\nabla^2\mathbf{u}, \qquad \nabla\cdot\mathbf{u} = 0

其中 u\mathbf{u} 是速度,pp 是压力,ν\nu 是运动黏度(动量的扩散率)。动量方程告诉你 u\mathbf{u} 如何随时间演化。但第二个式子 ∇⋅u=0\nabla\cdot\mathbf{u}=0 并不是演化方程,而是每一时刻都必须满足的约束条件。

在可压缩流动中,连续性方程演化密度,密度再通过状态方程决定压力。到了不可压极限,这条链断了。压力不随时间"演化"。它只是一个拉格朗日乘子(用来施加约束的未知量),唯一的职责就是让速度在每一时刻保持无散度。所以试图对压力做时间推进,本身就是徒劳。

任何速度场都能一分为二 — Helmholtz–Hodge#

出路藏在一条古老的矢量分析定理里。在合理的区域上,任何矢量场 w\mathbf{w} 都能唯一地分解为无散度部分加上梯度部分。

w=u+∇ϕ,∇⋅u=0\mathbf{w} = \mathbf{u} + \nabla\phi, \qquad \nabla\cdot\mathbf{u} = 0

u\mathbf{u} 是旋涡(无散)分量,∇ϕ\nabla\phi 是可压(梯度)分量。把 u\mathbf{u} 单独提取出来的运算,就是投影算子 P\mathbb{P}。做法很简单。对上式取散度,由于 ∇⋅u=0\nabla\cdot\mathbf{u}=0,得到

∇2ϕ=∇⋅w\nabla^2\phi = \nabla\cdot\mathbf{w}

用这个 Poisson 方程解出 ϕ\phi,再从原场中减去 ∇ϕ\nabla\phi,就只剩下无散度部分。

u=Pw=w−∇ϕ\mathbf{u} = \mathbb{P}\mathbf{w} = \mathbf{w} - \nabla\phi

先预测,再投影 — 分步法

Chorin 的算法把这个分解直接搬进时间积分。一步拆成两半。

  1. 预测子(predictor) — 丢掉压力把速度往前推,得到中间速度 u∗\mathbf{u}^*。
u∗−unΔt=−(un⋅∇)un+ν∇2un\frac{\mathbf{u}^* - \mathbf{u}^n}{\Delta t} = -(\mathbf{u}^n\cdot\nabla)\mathbf{u}^n + \nu\nabla^2\mathbf{u}^n

u∗\mathbf{u}^* 不是无散度的。既然忽略了压力,这很自然。

  1. 修正子(corrector) — 把 u∗\mathbf{u}^* 拉回无散度。解压力 Poisson 方程,
∇2ϕ=1Δt∇⋅u∗\nabla^2\phi = \frac{1}{\Delta t}\nabla\cdot\mathbf{u}^*

再减去梯度,得到下一步的速度。

un+1=u∗−Δt ∇ϕ\mathbf{u}^{n+1} = \mathbf{u}^* - \Delta t\,\nabla\phi

这里 ϕ\phi 实际上扮演了压力的角色(pn+1≈ρϕp^{n+1}\approx\rho\phi)。压力不是拿来演化的对象,而是每一步为满足约束重新求解的量。这两行就是投影法的全部。

压力 Poisson 与边界条件的陷阱#

实战的痛苦从这里开始。预测子很便宜。可修正子每一步都要解一个 Poisson 方程。在大网格上,这个椭圆型求解占了总成本的 70%–90%。这正是 GMRES 和多重网格成为不可压代码心脏的原因。

边界条件也满是陷阱。ϕ\phi 在壁面上的边界条件是 Neumann(∂ϕ/∂n=0\partial\phi/\partial n = 0)。Neumann 问题的解含有一个任意常数,而且要有解就必须满足相容性条件——在整个区域上 ∫∇⋅u∗ dV=0\int \nabla\cdot\mathbf{u}^*\,dV = 0。如果进出口流量不匹配,Poisson 方程根本无解。看到 NaN 时,先怀疑边界流量。

在同位(collocated,非交错)网格上,把压力和速度放在同一点会引发棋盘格振荡。要么用交错网格,要么用 Rhie–Chow 插值来抑制。面速度若记住松弛因子,收敛解本身会变。顺带一提:SIMPLE 类方法每一步把同样的投影思想重复多次,逼向定常态;而这里的分步法每步只投影一次,以保持时间精度地追踪非定常流动。

Python:用 FFT 卷起双剪切层#

在周期边界上,压力 Poisson 用 FFT 一次解出,因为在 Fourier 空间里拉普拉斯算子变成了乘以 −∣k∣2-|\mathbf{k}|^2。用双剪切层(两股相互咬合的射流)作初始条件,它会卷成 Kelvin–Helmholtz 涡——这是投影法的经典验证问题。

import numpy as np
 
N = 128                # 网格(每边)
h = 1.0 / N            # 网格间距
nu = 5e-3              # 运动黏度 -> Re = U L / nu ~ 200
dt = 2e-3              # 处于扩散/对流稳定范围内
steps = 3000
 
x = (np.arange(N) + 0.5) * h
X, Y = np.meshgrid(x, x, indexing='ij')
 
# 双剪切层 + 小扰动
rho, delta = 1.0 / 30.0, 0.05
u = np.where(Y <= 0.5, np.tanh((Y - 0.25) / rho), np.tanh((0.75 - Y) / rho))
v = delta * np.sin(2 * np.pi * X)
 
# FFT Poisson 所需的波数
k = 2 * np.pi * np.fft.fftfreq(N, d=h)
KX, KY = np.meshgrid(k, k, indexing='ij')
K2 = KX**2 + KY**2
K2[0, 0] = 1.0         # 避免对平均模除以零
 
def divergence(a, b):
    dadx = (np.roll(a, -1, 0) - np.roll(a, 1, 0)) / (2 * h)
    dbdy = (np.roll(b, -1, 1) - np.roll(b, 1, 1)) / (2 * h)
    return dadx + dbdy
 
def projection_correct(a, b):
    # Laplacian(phi) = div  ->  a <- a - grad(phi)
    phi_hat = np.fft.fft2(divergence(a, b)) / (-K2)
    phi_hat[0, 0] = 0.0
    phi = np.real(np.fft.ifft2(phi_hat))
    dpx = (np.roll(phi, -1, 0) - np.roll(phi, 1, 0)) / (2 * h)
    dpy = (np.roll(phi, -1, 1) - np.roll(phi, 1, 1)) / (2 * h)
    return a - dpx, b - dpy
 
def predictor(a, b):
    # 对流(中心差分) + 扩散,显式 Euler 一步
    ax = (np.roll(a, -1, 0) - np.roll(a, 1, 0)) / (2 * h)
    ay = (np.roll(a, -1, 1) - np.roll(a, 1, 1)) / (2 * h)
    bx = (np.roll(b, -1, 0) - np.roll(b, 1, 0)) / (2 * h)
    by = (np.roll(b, -1, 1) - np.roll(b, 1, 1)) / (2 * h)
    lap = lambda f: (np.roll(f, -1, 0) + np.roll(f, 1, 0)
                     + np.roll(f, -1, 1) + np.roll(f, 1, 1) - 4 * f) / h**2
    astar = a + dt * (-(a * ax + b * ay) + nu * lap(a))
    bstar = b + dt * (-(a * bx + b * by) + nu * lap(b))
    return astar, bstar
 
for n in range(steps):
    us, vs = predictor(u, v)                 # 预测:中间速度 u*
    d_before = np.abs(divergence(us, vs)).max()
    u, v = projection_correct(us, vs)        # 投影:去除散度
    if n % 500 == 0:
        d_after = np.abs(divergence(u, v)).max()
        print(f"step {n:4d}  |div u*|={d_before:.2e} -> |div u|={d_after:.2e}")

看输出:每一步 ∣∇⋅u∗∣|\nabla\cdot\mathbf{u}^*| 都在 10−210^{-2} 量级,但投影后 ∣∇⋅u∣|\nabla\cdot\mathbf{u}| 跌到 10−1310^{-13}。FFT 投影把散度压到了机器精度。

一边 128 个点,间距就是 1/128。运动粘度 5e-3,速度尺度 1,区域长度 1,则 Re 是 1 除以 5e-3,等于 200。和注释里 Re 约 200 一致。时间步 2e-3 叠 3000 次,积分时间是 6。剪切层厚度 1/30 约 0.033,间距 1/128 约 0.0078,差不多四格多一点。扰动振幅 0.05 是射流速度 1 的 5%,卷起就从这道小正弦开始。预测刚结束的散度 10−210^{-2} 和投影后的 10−1310^{-13},相差十一个数量级。FFT 把平均模态钉在 0 的那一行,关上了 Neumann 问题的常数自由度。流入和流出对不上、相容条件破了,只改那个分母并不会生出解。费用占 70% 到 90% 说的也是同一块矩阵。一步里预测子拿走的,只是剩下的 10% 到 30%。

时间步 2e-3 的修正,是从中间速度里减去这个步长乘上 ϕ\phi 的梯度。要抹掉 10−210^{-2} 的散度,ϕ\phi 的拉普拉斯应是散度除以时间步,大约为 5。压力梯度在一步内把速度拉回这么多。关掉投影,这项修正进不了速度,散度就留下来。3000 步足够让这些残渣铺满网格。

关掉投影会发生什么

没有修正子,速度场每一步都会累积一点散度。质量不再守恒,涡被抹糊,很快变成填满网格的噪声。在下面的模拟里亲自看看。

max|∇·u| = 0.000

Double shear layer rolling up. Red/blue = vorticity sign. Turn projection OFF and max|∇·u| climbs while the vortices dissolve into noise.

把 Projection 设为 ON,剪切层会卷成一对干净的涡。一旦切到 OFF,右上角的 max|∇·u| 读数飙升,涡的花纹随之崩塌。调大 dt 会让对流更激进,崩塌来得更快。这一次切换,就是"为什么需要投影"最简短的答案。

流量差一滴,Poisson就没有答案#

在不可压流动里,压力不是拿来演化的变量,而是每步施加 ∇⋅u=0\nabla\cdot\mathbf{u}=0 的拉格朗日乘子。投影先丢掉压力做出 u∗\mathbf{u}^*,再解 Poisson 求出 ϕ\phi 并减去梯度。这个次序本身很短。坏掉的地方在边界。流入和流出对不上,就没有相容条件,矩阵只剩下常数自由度,给不出解。

用每步一次投影保住时间精度,就不能在这一步里把对流再解一遍。只抬粘性的阶、把对流推到外面,这条选择的差别写在Gear外插对流、把误差放大的地方。把预测子的对流弄得看起来更准,投影刚配好的零散度会晃。

如果对您有帮助,请分享。