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

粒子:一个点怎么长成一团烟

全课骨架:特效 = 一大堆各自独立的小东西,每一个只做三件事 —— 按自己的速度挪一步、 按自己的寿命决定还剩多少不透明、最后按「从远到近」的顺序盖到画面上。 前面六个课题讲的都是"一个东西怎么被画对":一条管线、一个球、一个场景、一堆刚体、一具骨架。 这一课开始讲"一堆":每个粒子都微不足道,一团的观感全来自它们怎么叠加。 「挪一步」是第 5 课的积分、「盖上去」是第 3 课的混合 —— 语言全是旧的,新的是"数量"。 保真度:画面走 CPU 软光栅(示意 · 等价重现);读数真机可证 —— 存活/回收计数、合成结果、顺序差异都是本页真算出来的数,且都有闭式对照: 合成式 c ← c + τ·Cᵢαᵢ ; τ ← τ(1−αᵢ)、相邻交换的差 τ·αaαb(Ca−Cb)、 稳态存活 R·T —— 读者能拿笔复核。

★ 画面这一侧按工业做法重做过一轮(2026-10-08,起因是"烟太假"): 粒子形状不再是一个数学上的完美圆盘,而是 4 张现场烘出来的噪声戳记(等效 flipbook 的 4 帧) × 每颗粒子自己的旋转;尺寸随寿命膨胀而不是收缩;力模型是浮力 + 阻力而不是常数重力。 三条都配了机器判据(⑨a/⑨b/⑨c),并且每一条都拿"旧档"当对照 —— 详见判据⑨。

分段:
60 /s
画序:
叠加算子:

(脚本启动中…)

定点观察:先点「7.3 必须从远到近」,再在「画序」三档之间来回切 → 盯住画面里粒子重叠的那几块,以及下方读数里的「与正确序之差(层和)」 → 反序不是"看起来差不多":它有确定的差(相邻两层交换的差 = τ·αaαb(Ca−Cb),表③ 能拿笔算)。

本帧的读数

(脚本启动中…)

7.1 一个粒子的一生:它凭什么会淡掉

本段只增加一样东西:寿命曲线(不透明度随 t 怎么衰减;单发模式,画面上只有一个粒子,其余控件与整页一致)。
上一课结束时你已经能让一具骨架动起来了,可那是"一个"角色。特效要的是一团 —— 而一团里最省事的起点是 一个粒子:它从哪出发、往哪飞、什么时候消失。位置这一步第 5 课讲过(p ← p + v·dt,v ← v + a·dt), 本课不再重复;真正决定"像不像烟"的是另一件事 —— 它不能"啪"地消失,得越到后面淡得越快。
★ 有一点顺手改了,而改的方向是查出来的:力的模型从"常数重力 g"换成了 浮力 A + 空气阻力 k。因为常数重力给出的是对称抛物线,粒子会"先上后下"—— 那是礼花的弹道; 烟是热浮力把它托上去、阻力让它越升越慢(Unreal 给烟的 Acceleration Force 与 Drag、 GPU Gems 3 §30 的浮力项都是这个形状)。这一换是可量的:新档 2 s 处净加速度只剩 +9.1 px/s²(趋近力平衡), 旧档却恒有 +120 px/s² 的向下重力、4 s 处已经把粒子拉成朝下。注意这两行不是"调参调好看", 是判据⑨c 卡住的:净加速度必须衰减到旧档的 1/5 以下。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 发射点 p₀ = (480, 520) 初速 v₀ = (60, −260) 浮力 A = 150 px/s² 朝上 阻力 k = 1.8 /s(空气) 寿命 T = 2.0 s,α₀ = 0.9 ★ 本段只动 α(u) 这条曲线 function lifeAlpha(u) { // 越到后面淡得越快 const k = clamp(1 - u, 0, 1); return A0 * k * k; } function particleAt(t) { // 闭式:与 step() 同一格式 const n = t/DT, q = 1/(1+K_DRAG*DT), VY = -A_BUOY/K_DRAG; const sq = q*(1-Math.pow(q, n))/(1-q); // Σ qⁱ return { x: EX0 + DT*EVX*sq, y: EY0 + DT*(n*VY + (EVY-VY)*sq), u: t/T_LIFE }; } t = 0 → α 0.900,y 520 t = 0.5 → α 0.506,y 421 t = 1.0 → α 0.225,y 355 t = 1.5 → α 0.056,y 304 t = 2.0 → α 0.000,y 258 ★ 一路升上去、越升越慢
定点观察:把「时间轴 t」从 0 慢慢拖到 2 → 看那个粒子一路升上去、越升越慢,同时越来越淡 → 它在 t = 2 那一刻的 α 恰好是 0,不是"跳没了"。这就是"淡出"与"硬切"的差别:u = 1 时曲线自己走到 0。
表① 一个粒子的五个时刻:u = t / T,α(u) = α₀(1−u)²;位置是与 step() 同一格式的闭式—— vₙ = v∞ + (v₀−v∞)·qⁿ、yₙ = y₀ + h[n·v∞ + (v₀−v∞)·q(1−qⁿ)/(1−q)],其中 q = 1/(1+k·h)、v∞ = −A/k (h = 1/60、k = 1.8、A = 150 ⇒ v∞ = −83.33 px/s)。全部可拿笔算,实测与它差 0
t (s)uαx (px)y (px)离屏?

7.2 从 1 到 N:发射速率与环形缓冲

本段只增加一样东西:发射速率 R(个/秒;时间轴与画序、算子沿用上一段,画面从单发变成连发)。
上一段把"一个粒子的一生"讲完了,可一个点永远不成"团"。要让粒子多起来,只要每秒多生几个就行 —— 但这立刻带出一个上一段没有的问题:粒子会死,死了的槽位谁来收? 如果只生不收,占用会一直涨;所以真实实现都用一条环形缓冲:写指针转圈, 写到哪个槽就把那个槽里的旧粒子顶掉。这一顶,就产生了一条必须成立、而且可以逐帧核对的账。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 发射速率 R(个/秒) 步长 dt = 1/60 s 寿命 T = 2.0 s 缓冲容量 cap = 512 稳态存活 = R · T ★ cap 必须 ≥ R·T function step(sim, dt) { // 每帧一次 sim.acc += sim.R * dt; // 攒够一个就发一个 while (sim.acc >= 1) { sim.acc -= 1; spawn(sim); } for (const p of sim.P) { if (!p.on) continue; p.age += dt; p.vy -= A_BUOY*dt; p.vy *= f; p.vx *= f; p.x += p.vx*dt; p.y += p.vy*dt; if (p.age >= p.life) recycle(sim, p); } // ★ 顶掉旧槽 = 回收:恒等式靠这一行 R = 20 → 40 个 R = 60 → 120 个 R = 120 → 240 个 R = 240 → 480 个 存活是"存量",与帧率无关 累计发射是"流水",一直涨
定点观察:把「发射速率 R」从 20 拖到 240 → 看读数里的 「存活」跟着涨、而「累计发射」涨得更快且从不回落 → 稳态存活 = 发射速率 × 寿命(40 / 120 / 240 / 480),与帧率、与跑了多久都无关; 而 累计发射 − 存活 − 已回收 恒等于 0(判据①)—— 这就是"环形缓冲没漏人"的唯一证据。
表② 四档发射速率的稳态账:闭式 存活 = R·T 与实测对照;末列是逐帧恒等式(须恒为 0)
R (个/s)闭式 R·T实测存活差累计发射已回收发射−存活−回收

7.3 一团粒子为什么必须从远到近画

本段只增加一样东西:画序(按深度从远到近 / 交换前两层 / 完全反序;发射速率、时间轴沿用上一段)。
粒子多起来之后,画面上的问题第一次从"每个粒子长什么样"变成了"谁盖谁"。 只要两个粒子有重叠,先画谁后画谁就会给出不同的颜色 —— 而 α 混合又是顺序敏感的。 本段要把它做成一件可量化的事:差多少有闭式,表③ 能拿笔算。

输入(三个粒子) 这个阶段在执行的规则(照抄件二真源码) 输出 P1 z0.90 C0.95 α0.60 P2 z0.50 C0.35 α0.50 P3 z0.10 C0.85 α0.40 (P1 最远、P3 最近) z 只用来定序与大小 ★ 本段只动"谁先画" const order = idx.slice().sort((a, b) => P[b].z - P[a].z); let c = 0, tr = 1; // c = 已累积颜色,tr = 还剩多少透射 for (const i of order) { // 远 → 近 const a = alphaOf(P[i]); c += tr * colOf(P[i]) * a; // over:被前面的按 tr 打折 tr *= (1 - a); } 正确序 P1→P2→P3 c = 0.708 交换 P1、P2 c = 0.528(差 0.180) 完全反序 c = 0.616
定点观察:在「画序」三档之间来回切 → 盯住读数里的 「与正确序之差(层和)」(那三个数是表③ 末列,能拿笔算:0.000 / 0.180 / 0.092)与 「与正确序之差」(整幅画面逐通道) → 三档给出三个不同的数,而且画面差的大小随粒子重叠程度一起变。 这不是"抖一下",是合成算式本身在依赖顺序。
表③ 三个粒子的逐层合成:c ← c + τ·Cᵢαᵢ、τ ← τ(1−αᵢ)。末两列只看"交换相邻两层"那一行有单一闭式 τ·αaαb(Ca−Cb)(a = 正确序里先画的、更远的那个);完全反序是三处逆序叠在一起,只能逐层算
序第 1 层第 2 层第 3 层结果 c正确序 − 本序闭式对照

7.4 加法还是混合:让粒子发光还是遮挡

本段只增加一样东西:叠加算子(α 混合 / 加法;画序三档沿用上一段,而这一段的重点正是拿它做对照)。
上一段说"α 混合必须从远到近",可它没说清另一半:为什么有的叠加方式根本不在乎顺序。 把合成式从 `c += τ·Cα; τ *= (1−α)` 换成 `c += Cα`(不再打折)之后,它变成了一个加法 —— 而加法可交换,所以画序自然无关。这一换,两种完全不同的观感就出来了:一种像光,一种像烟。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 同一堆粒子 同样的 C、α、画序 只有算子不同 ★ α 混合:凸组合 结果必落在参与层之间 // ① 加法(add):可交换 —— 与画序无关 c += C * a; // 不打折、谁来都往上加 c = Math.min(1, c); // 加过头就饱和(这才是"亮") // ② α 混合(over):凸组合 —— 与画序有关 c += tr * C * a; // 被前面挡住的按 tr 打折 tr *= (1 - a); // 透射率只降不升 ★ 共用的那一半:两者都先乘 a,再决定折不折 加法:Σ C·a = 1.085 → 钳到 1.000(越过 0.95) → 实数上三种画序必然相同 α:正确序 0.708 → 落在 [0.35, 0.95] 之内 → 反序 0.528,差 0.180
定点观察:点「加法(发光)」,再在「画序」三档之间来回切 → 盯住读数里的 「与正确序之差(层和)」和「三序最大差」两处 → 加法档下这两个数都停在 1e−7 的浮点底噪上(换哪一档都不动:层和恒 0.000、三序最大差 1.2e−7), 而 α 档下层和每切一档就换一个值(0.180 / 0.092)、三序最大差 4.2e−2 —— 比加法大了五个数量级 ⇒ 顺序问题不是"粒子系统的通病",它只属于 α 混合这一种算子; 而加法之所以像光,正因为它允许结果越出所有参与层的上界(三层之和 = 1.085 → 钳到 1.000, 而 α 的 0.708 永远落在 [0.35, 0.95] 之内)。
表④ 同一堆粒子 × 两种算子 × 三种画序:加法那三行都是同一堆数的和(本例三层之和 1.085,钳到 1.000 ⇒ 三行相同); α 那三行必须不同(a = 正确序里先画的、更远的那个)。钳制之前的逐位差别要上整幅画面才看得到,见判据⑤
算子画序合成结果(最亮通道)是否越出上界与正确序的差

这一课的位置

四段只做了三件事:积分(第 5 课学过)、寿命(本课新加)、排序 + 混合(本课新加)。 一整团粒子的观感,全在这三件事的参数里;它们各自都能拿笔算,也各自都有实测对照。
本页那一格"如实说明":画面是 CPU 软光栅逐点采样、逐层混合写进帧缓冲的(示意 · 等价重现); 真机上"更新粒子"通常在 compute、"画粒子"是实例化点精灵,本页不声称复现那两条内部路径。 粒子的颜色全部取自 token 色(`--beam` / `--ink` / `--ink-mute` / `--void`), 显示不做色调映射 —— 这样画面上的数与表里的数能一一对应。
本课题还没做的四课(留在 stub,不在站首页占行):流场(让粒子自己成流)、 软粒子(粒子不能有硬边)、后处理链(bloom:光为什么要溢出来)、屏幕空间(SSAO 与 SSR)。

本页画面这一层用了哪些做法,出处在哪

画面的每一条都是照着工业做法的最小复现做的,不是"调参调好看"。列出来是为了让读者能自己去核, 也是为了说清哪一条本页故意没做、留给哪一课:

运行时自检 等待脚本…