← 图形原理 · 交互课 课题七 · 特效 · 第 2 课

流场:让粒子自己成流

全课骨架:特效 = 一大堆各自独立的小东西,每一个只做三件事 —— 按自己的速度挪一步、 按自己的寿命决定还剩多少不透明、最后按「从远到近」的顺序盖到画面上。 第 1 课把这三件事讲完了,可它的粒子是"各飞各的":每颗粒子在出生时就领到一份自己的速度, 然后一直照着那份速度走。真实的烟不是这样 —— 它是被空气推着走的。 这一课就换掉"速度从哪来"这一件事:不再问"这颗粒子要往哪飞",而是问 "它现在所在的那个位置,空气往哪吹"。保真度:画面走 CPU 软光栅(示意 · 等价重现); 读数真机可证 —— 散度、Stokes 环量、ψ 的漂移、源汇、量阶都是本页真算出来的数: 散度在旋度场上是 逐字抵消的 0、ψ 沿流线守恒到积分器的阶、Σ curl·dA 与 ∮ v·dl 逐位相等。读者能拿笔复核。

分段:
速度来源:
100 % | 场:
2 px | 1.0
耦合方式: 1.0 |

(脚本启动中…)

定点观察:先点「8.2 场不能有源汇」,再在「取旋度 / 直接用噪声」两档之间切 → 盯住画面上粒子的分布,以及下方读数里的「全场 max|div v|」 → 直接用噪声时粒子会被吸成几坨、旋度场下不会 —— 而这两档的速度大小是同一个量级(已归一)。

本帧的读数

(脚本启动中…)

8.1 速度从哪来:自带,还是每处空间给

本段只增加一样东西:速度的来源(其余控件与上一课一致:同一份积分、同一批戳记、同一个寿命)。
上一课的粒子在出生时就领到一份速度,之后大体照着它走 —— 整团看起来是"一堆各飞各的点"。 真实烟不是这样:它自己不会飞,是周围那口气在推它。所以这一段把"速度"换成一个 每处空间都给一份的函数 v(x, y):粒子只做一件事 —— 低头看看"我现在在的位置,风往哪吹"。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 出生点 = 中下部一小块 场强 V = 100 %(归一) 寿命 T = 3.2 s 两种场都归一到 RMS 速度 90 px/s ★ 本段只动"速度从哪来" if (mode === 'own') { // 上一课:出生时领一份 /* 什么都不做:v 就是出生时那一份 */ } else { // 本课:问脚下的场 const v = fieldAt(p.x, p.y, t); if (couple === 'vel') { p.vx = v[0]; p.vy = v[1]; } else { p.vx += v[0]*g*dt; p.vy += v[1]*g*dt; } } ★ 积分(p += v·h)与上一课一字不差 P3 (1.60, 1.10) 上: 自带 → v 恒为出生值 场给 → v 随位置变 ψ = 0.6875,|v| ≈ 106 (场坐标 X, Y;见表①) 右列与表①、与画面同源
定点观察:把「速度来源」在两档之间切 → 看画面里粒子是朝各个方向散开、还是被同一只手推成一片 → 同一份引擎、同一份积分,只换了"速度从哪来",观感就从"喷泉"变成"烟"。 ★ 注意:为了让这件事看得干净,本课把上一课的浮力与阻力关掉了 —— 自带档因此退化成直线。
表① 五个采样点上,势场 ψ 的四个倍频逐项摊开:ψ = Σk Ak·sin(akuk + φk)·cos(bkwk) (每项都能拿笔算:两次三角函数、一次乘法)。末列是页内用中心差分算出的 |v|
点 (X, Y)第 1 倍频第 2 倍频第 3 倍频第 4 倍频ψ = 和|v| (px/s)

8.2 为什么"随便一个噪声场"会把粒子堆成一坨

本段只增加一样东西:场的无散性(直接拿噪声当速度 → 取它的旋度;场强、来源沿用上一段)。
上一段说"每处空间给一个速度",可它没说清这个速度该怎么给。最省事的给法是拿两路噪声当 (vₓ, v_y) —— 可那样的场里到处是"源"和"汇":粒子会被吸进汇里堆成一坨、从源里喷出来散掉。 要治它只要一件事:别直接给速度,给一个势场 ψ,再取它的旋度。 因为 ∂²ψ/∂x∂y − ∂²ψ/∂y∂x ≡ 0,旋度场的散度是逐字抵消的 0 —— 不是"很小",是恒等。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 势场 ψ(4 个倍频) 差分步长 ε = 2 px(甜点) (场坐标里 0.00667) 只需 ψ 一个标量 速度从它"转 90°"来 ★ 本段只动"无散性" function curlAt(x, y, t, eps) { // 中心差分 const e = eps / FSCALE; const dY = (sumOct(X, Y+e) - sumOct(X, Y-e)) / (2*e); const dX = (sumOct(X+e, Y) - sumOct(X-e, Y)) / (2*e); return [dY, -dX]; // v = (∂ψ/∂y, −∂ψ/∂x) } // 对照档:直接用两路噪声当 (vₓ, v_y) // → 散度不再抵消,处处有源有汇 旋度场: max|div v| = 3.2e−15 (逐字抵消,恒 0) 直接噪声场: max|div v| = 3.64 /s (差 1.1e15 倍)
定点观察:把「取旋度 / 直接用噪声」切一下 → 先看画面上的灰色短线标记(汇 = 四条朝内的短线、 源 = 四条朝外的短线;切到旋度档它们就消失),再看读数里的「全场 max|div v|」与「材料面积比」 → 直接噪声档下粒子会被那些"汇"吸着走,一撮粒子占的面积会塌掉(180 步后中位 0.092 = 塌 91%); 旋度档下同一撮的面积稳稳不动(30 步中位 1.0005、四分位距 0.005)。 ★ 两档的速度大小是同一个量级(已归一)—— 差别不是"谁快谁慢",是"有没有源汇"。 ★ 一句如实说明:光看"粒子堆得紧不紧"其实是分不出来的(实测两档的填充率只差 1.15 倍), 这条判据必须靠读数,或者靠画面上那组源汇标记。ε 也有个甜点(见判据⑤)。
表② 散度对照:div v = ∂vₓ/∂x + ∂v_y/∂y,两侧都用同一个 ε 的中心差分。 旋度场那一列是逐字抵消的 0(剩下的是浮点加法顺序,1e−16 量级);直接噪声场那一列是 O(1)。 ★ 单看第一个倍频还能写成闭式:div = −ab·cos(ax)sin(by) + ab·cos(ax)sin(by) ≡ 0(两项逐字抵消)
点 (X, Y)旋度场 div v直接噪声场 div v倍数

8.3 让场自己动起来:涡旋、卷曲与"看不见的手"

本段只增加一样东西:时间维(静态场 → 随时间演化的场;无散性、场强沿用上一段)。
上一段治好了源汇,可那个场是死的:粒子沿固定的等值线走,走完就没了 —— 看着像"几股固定的环流", 不像烟。真实的流里,涡自己会翻滚、变形、互相推挤。做法很省:让每个倍频的相位随时间走, φk(t) = φk + ωk·t —— 场还是那个场,只是它也在动。 代价是:ψ 从此不再是运动的守恒量(它显含 t)—— 这一句留给 8.4 用。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 演化速度 ω = 1.0 每个倍频一个 ω_k 时间 t 就是粒子系统的时间 ω = 0 ⇒ 场是死的 ω > 0 ⇒ 同一个点 上的速度也会变 function sumOct(X, Y, t, ch) { const O = ch ? OCT2 : OCT; let s = 0; for (const o of O) { const ph = o.ph + OM * o.w * t; // ★ 唯一新增 s += o.A * Math.sin(o.a*u + ph) * Math.cos(o.b*w + o.ps); } ★ (u, w) 是旋转 θ_k 之后的坐标 点 P3 (1.60, 1.10): t = 0 ψ 0.6875 t = 0.5 ψ 0.3313 t = 1.0 ψ −0.1130 t = 1.5 ψ −0.5429 ω = 0 时四行完全相同
定点观察:把「演化速度 ω」从 0 推上去 → 看涡旋开始自己滚动、粒子轨迹从闭合回路变成翻滚的卷 → 静态场只能让粒子沿固定等值线走;ω > 0 之后同一个点上的速度也会变,流才开始像活的。 表③ 最后一列是 ω = 0 的对照:那一列四行完全相同,这一列每行都不一样。
表③ 同一个点 P3 (1.60, 1.10) 在四档时间上的 ψ 与 v。第 3 列是本段(ω = 1.0), 第 4 列是 ω = 0 的对照(四行必须完全相同,它就是"静态场"的定义);v 用页内的中心差分算
t (s)ωt (rad)ψ(ω = 1.0)ψ(ω = 0,对照)vₓ (px/s)v_y (px/s)

8.4 场是"速度"还是"力":两件很容易混的事

本段只增加一样东西:耦合方式(v ← 场 直接设定 / v ← v + 场·g·dt;其余控件沿用上一段)。
前三段都把"场 = 速度"当成天经地义 —— 好像风一吹,粒子的速度立刻就变成风速。可空气不是这么干的: 它推粒子,粒子有惯性,加速需要时间。这两条实现看起来只差一行: p.vx = v[0] 与 p.vx += v[0]*g*dt,但它们的几何后果完全不同 —— 这件事有一个非常干净的量能分开它们:ψ 沿轨迹的漂移。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 耦合强度 g = 1.0 步长 h = 1/60 s 起手速度 = 0 本段的量: ψ 沿轨迹漂移多少 ★ 静态场下量(ω = 0) // 中点法(RK2)—— 两种耦合都用它推位置 if (couple === 'vel') { const v1 = fieldAt(x, y, t); // ★ 直接设定 const v2 = fieldAt(x+v1[0]*h/2, y+v1[1]*h/2, t+h/2); x += v2[0]*h; y += v2[1]*h; } else { // ★ 受力累加 const v = fieldAt(x, y, t); vx += v[0]*g*h; vy += v[1]*g*h; x += vx*h; y += vy*h; 场 = 速度: |Δψ|/|ψ₀| = 1.4e−4 (贴着等值线走) 场 = 力: |Δψ|/|ψ₀| = 1.6e−1 (甩出等值线,差 1.1e3 倍)
定点观察:把「场 = 速度 / 场 = 力」切一下 → 盯住读数里的 「ψ 的相对漂移」与画面里粒子的轨迹形状 → 把场当速度用时粒子严格贴着 ψ 的等值线走 (漂移是积分器的阶,不是模型的错);当力用时它会滞后、甩出等值线。 ★ 这条只在静态场(ω = 0)下成立 —— 场自己会动时 ψ 显含 t,它就不再是守恒量(这是 8.3 留下的账)。
表④ 同一颗粒子(起手速度 0、静态场 ω = 0、ε = 2 px)在两种耦合下走 60 / 120 / 240 步: 位置、ψ、以及 ψ 的相对漂移。场 = 速度那一列是判据(漂移 1e−4 量级,且它随 差分步长 ε 按 ε² 缩, 见判据⑤ —— 不是随 h 缩);场 = 力那一列是对照物(1.6e−1,差三个数量级,因为它换了物理)
耦合步数x (px)y (px)ψ|Δψ| / |ψ₀|

这一课的位置

四段只做了一件事:把"速度从哪来"换掉 —— 自带 → 每处空间给 → 给一个无散场 → 让场自己动 → 再把"场 = 速度 / 场 = 力"分开。粒子引擎、戳记形状、排序与混合全部沿用第 1 课,一行没改。
本页那一格"如实说明":画面是 CPU 软光栅逐点采样、逐层混合写进帧缓冲的(示意 · 等价重现); 真机上流场通常在 compute 里更新、粒子是实例化点精灵,本页不声称复现那两条内部路径。
本课题还没做的三课(留在 stub,不在站首页占行):软粒子(粒子不能有硬边)、 后处理链(bloom:光为什么要溢出来)、屏幕空间(SSAO 与 SSR)。

本页用到的做法,出处在哪

运行时自检 等待脚本…