萤火虫为什么会一起闪:调一个耦合系数,整片林子自己同步
夏天的河边有一种怪现象:几百只萤火虫本来各闪各的,闪一会儿就整片同步,一起亮、一起灭。没有哪只在指挥,也没有谁在数拍子。上世纪 70 年代 Kuramoto 给过一个极简答案:每只虫子只要被「全场平均水平」轻轻拽一下,够多的虫子就会自己站到同一个节奏上。
静态图讲不清两件事:耦合太弱为什么永远齐不了?「打散」之后它们为什么还能自己回来?所以我把这件事做成一个网页:夜色松林里散布 320 只萤火虫,左上角是实时同步度 R 的仪表和 30 秒曲线,下面四个滑杆加暂停、重置、打散,鼠标在画面上点一下就给附近一小片萤火虫加个相位脉冲。
几条关键信息先摆出来:
- 源码:Demo Park 公开仓库 → https://atomgit.com/deli007/demo_park/tree/main/codearts-firefly-sync,
index.html可直接下载- 生成物:单个
index.html(26,354 字节,约 26 KB),纯前端、零依赖、不联网、不上传,全部计算在本地完成- 本地运行:双击文件即可;或
python -m http.server 8000- 本地验证:1920×1200 视口、320 只时稳定 60 FPS;K=6 下打散全场相位,1.38 秒后 R 回到 0.9 以上;耦合强度扫了 9 档,表在下面
它不是萤火虫的生物学仿真。模型里每只虫子只有一个变量(相位 θ),没有距离、没有邻居、没有「看见隔壁亮了才亮」的视觉耦合,牵引力作用在「全场平均相位」上——这是平均场近似,不是脉冲耦合振子。下面会如实写它的边界。

为什么值得看:不靠指挥也能齐闪,而且转得动的那个点可以量出来
Kuramoto 模型只有两行。第一行是序参量,把一个「有多整齐」的模糊感觉变成一个 0~1 的数:
r·e^(iψ) = (1/N) · Σ e^(iθ_k)
白话就是:把每只虫子的相位当成圆上的一个箭头,全部加起来取平均,平均箭头的长度就是 R(0 = 完全散开,1 = 完全同步),方向就是全场平均相位 ψ。第二行是每只虫子的运动:
dθ_i/dt = ω_i + K · r · sin(ψ − θ_i)
ω_i是这只虫子自己的固有频率,页面里取高斯分布(基准 4.0 rad/s,标准差由滑杆给,默认 0.45);K · r · sin(ψ − θ_i)是耦合项:它不把虫子往「平均相位」上硬拽,而是往「平均相位方向」拽,力度还乘了 R——越是乱的时候拽得越轻,越整齐的时候拽得越狠。这一步是整套模型自洽的关键:整齐是正反馈长出来的,不是被指定的;K就是下面那个滑杆,从 0 到 6。
教科书结论是:固有频率服从高斯分布、标准差 σ 时,临界耦合强度大约是 Kc = 2σ√(2π)/π ≈ 1.6σ。σ=0.45 时 Kc 约 0.72。低于它,别管等多久,全场永远散着;跨过去,一部分虫子会被锁进同一个节奏。这个「有临界点」的结论,恰恰是静态图给不出来的东西,所以它值得做成能拖的页面。
页面里有三个实现决定值得说清楚:
- 序参量每帧现算,用显式的上一帧值。
step(dt)里先把Σcos θ、Σsin θ累出来得到 r 和 ψ,再让每只虫子按ω + K·r·sin(ψ−θ)前进,即用上一帧的 r 做显式欧拉。这种做法实现最简单,代价是 dt 不能大:切走标签页再回来时dt会是一大跳,所以代码里把它截到 0.1 秒(if (dt > 0.1) dt = 0.1),否则一帧就能把相位跨过去好几个周期。 - 亮度是
sin(θ)取正后平方。l = sin(θ) > 0 ? sin²(θ) : 0,所以大部分时间几乎全暗,亮起来是很尖的一段——这才是萤火虫的脉冲感,而不是呼吸灯;亮点芯在l > 0.6时从黄绿换成更白的#eaffa8,同步时整片亮斑会一起「跳」一下。 - 光晕用预渲染 sprite。 64×64 的径向渐变只画一次,之后每帧只做
drawImage缩放,所以 320 只到 600 只都在 60 FPS(实测数字在下面)。
也说几处真实的边界:
- 这是平均场模型,每只虫子被全场平均牵引,没有距离概念,也不会「只看见邻居」。所以页面里把虫子画得多散都不影响结果。
- 固有频率只在按下「应用」或拖动频率离散度滑杆时重新抽样(
rescaleOmegas()),拖动耦合强度不会换一套性格,这样对比 K 的时候变量是干净的。 - 窗口尺寸变化时虫子坐标按比例重排(
resize()里做了x·w/oldW的缩放),不会重新随机撒点,所以拉伸窗口不会把你正在看的同步状态打乱。
任务描述
需求是直接粘进码道 Web 输入框的,原文如下:
做一个单文件 index.html 的「萤火虫同步实验室」网页应用,纯前端、零外网依赖、不引入任何第三方库,只用原生 Canvas 2D 和原生 DOM。
画面主体是一片夜色中的林子:随机散布 320 只萤火虫,每只萤火虫按自己的固有频率周期性闪烁,亮起时有一个柔和的光晕,暗下去只剩一点微光。它们之间用 Kuramoto 耦合模型互相影响:每只萤火虫的相位会被全场平均相位牵引,耦合强度 K 用一个滑杆调节(0 ~ 6),K 越大越容易走向齐闪。
具体要求:
1. 顶部标题写「萤火虫同步实验室」,下面一行小字说明这是 Kuramoto 耦合振子模型。
2. 左上方一个实时「同步度 R」仪表:R = 全场相位向量的模除以萤火虫数量,取值 0~1,配一个大的数字和一条横向进度条;旁边再画一条随时间滚动的 R 曲线(最近 30 秒),能看出 R 从乱到整齐的过程。
3. 控制条包含:耦合强度 K 滑杆(0~6,步长 0.1)、固有频率离散度滑杆(0~1.5)、萤火虫数量滑杆(50~600)、闪烁速度倍率滑杆(0.2~3)、暂停/继续按钮、重置按钮、一键「打散」(把所有相位随机化)。
4. 鼠标在画面上点击时,给点击位置附近的一小片萤火虫加一个相位脉冲,用来观察扰动之后同步能不能自己恢复。
5. 视觉上要好看:背景是深蓝到近黑的垂直渐变夜色,萤火虫用黄绿色(约 #c8ff5a)发光点,同步时整片林子会像呼吸一样一起明灭;不同步时是零散的随机闪光。
6. 另设一个「参数输入」区,用数字输入框和「应用」按钮修改萤火虫数量和频率离散度;输入非法(空、非数字、负数、超过上限)时必须显示明确的中文错误提示(例如「萤火虫数量必须是 50~600 之间的整数」),并且保留上一次的有效值,页面不能白屏、不能崩溃、不能静默忽略。
7. 右下角显示一个极简统计区:当前 R 值、已运行时间、帧率。
完成后打开预览,让我直接看到运行效果。
码道 Web 用一个模型会话交回了 /workspace/index.html(24.1 KB 单文件),任务耗时 8 分 18 秒,并自己起了内置预览。下面的数字全部来自我在本机浏览器里对这个文件的重跑,不是转述它的总结。
它实际长成了什么样:九档耦合强度扫一遍,看同步怎么长出来
页面跑起来的第一眼是「好看」,但真正有意思的是拖 K 滑杆时 R 的变化。我把固有频率标准差固定在 0.45,每一档都先按「打散」(把所有相位随机化)再等 13 秒,读回同步度:
| 耦合强度 K | 打散后 2 秒的 R | 打散后 13 秒的 R |
|---|---|---|
| 0 | 0.030 | 0.050 |
| 0.5 | 0.057 | 0.155 |
| 1.0 | 0.040 | 0.745 |
| 1.5 | 0.347 | 0.946 |
| 2.0 | 0.522 | 0.973 |
| 2.5 | 0.281 | 0.983 |
| 3.0 | 0.698 | 0.989 |
| 4.0 | 0.782 | 0.994 |
| 6.0 | 0.980 | 0.997 |
这张表要按「单次采样」读:每一格只取了一个瞬时值,没有多次平均,所以 2 秒那一列抖得厉害(0.5 档和 1.0 档甚至反了),但 13 秒那一列的走向很清楚——K ≤ 0.5 基本停在随机水平,K = 1.0 开始明显爬起来,K ≥ 2 已经接近全同步。翻转发生在 0.5 和 1.0 之间,和 1.6σ ≈ 0.72 这个教科书临界值落在一个量级上。注意这里只能说是量级一致,我没有做多组平均去逼近临界点,所以不能拿它去反推临界指数。

同步状态下的恢复速度也量了一次:K=6、R 稳定在 0.997 时按下「打散」,之后每 250 ms 采一次 R——1.1 秒越过 0.5,1.38 秒越过 0.9,2.2 秒回到 0.997。也就是说「打散」只打断了相位,没打散耦合关系,秩序是顺着耦合项自己长回来的。

踩坑与边界:三处真实的不一致
一、暂停只冻结推进,不冻结点击。 「暂停」按钮改的是一个 paused 标志,主循环里只有非暂停时才调 step();但画布上的 pointerdown 处理函数完全不看这个标志,照样给点击范围内每只虫子加 1.8 弧度的相位脉冲。实测:同步态按暂停,读数冻结在 0.998;此时在画面上连点 24 处,读数仍然是 0.998(因为 R 只在 step() 里更新,暂停就不会刷新);一按「继续」,R 立刻掉到 0.778,然后自己爬回 0.998。所以暂停期间你点过的地方其实已经改过了,只是要等继续才看得到。要修的话,在 pointerdown 开头加一句 if (CFG.paused) return; 就行——这一版我没改,因为截图和上面的数字都是在这个行为下取的。
二、数量有两个入口,回退值会打架。 萤火虫数量既能拖「萤火虫数量」滑杆,也能在「参数输入」里填数字再点「应用」。滑杆的回调调了 setCount(n) 并同步了输入框显示,但没有把 CFG.count 一起写回。于是会有这一幕:把滑杆拖到 600(输入框跟着显示 600),然后在输入框里敲 999 点「应用」——红字提示「萤火虫数量必须是 50~600 之间的整数,已保留上一次的有效值。」,可输入框回退成了 320,滑杆还停在 600,两个入口当场打架。错在回退那一步读的是 CFG.count(初始化时的 320),不是「上一次真正生效的值」。同样是这一版没动代码,只把现象记下来。
三、一次点击只推得动个位数萤火虫。 点击扰动的作用半径是 120 px,在 1920×1200 的视口里大约 4.5 万平方像素;320 只均匀撒下去,落在圈里的期望值是 6 只左右。实测 K=6、同步态下单点一次,R 只从 0.997 掉到 0.992,远处的虫子根本不知道有人点了。这在 K 接近临界值时体感会明显一些(牵引力弱、被拽回来得慢),但想看到「整片散掉」,还是得用「打散」这个全局按钮。

顺带一个没踩到的坑。 我原本担心把闪烁速度拉到 3 倍、数量拉到 600 只时显式欧拉会失稳(每帧相位步进逼近 π 就会开始混叠)。实测 600 只 + 3 倍速 + K=6 跑 12 秒:R = 0.997,帧率仍是 60 FPS,没看出失稳。所以这里如实记一句「没复现出问题」,而不是硬编一个 bug 出来。
失败路径是唯一一处我要求得很死的地方,也是唯一一处第一次就完全达标的地方。四种非法输入都给了明确中文提示,且不白屏、不静默忽略:
| 输入 | 提示原文 |
|---|---|
数量填 999 |
萤火虫数量必须是 50~600 之间的整数,已保留上一次的有效值。 |
数量填 3.5 |
萤火虫数量必须是 50~600 之间的整数,已保留上一次的有效值。 |
| 数量留空 | 萤火虫数量必须是 50~600 之间的整数,已保留上一次的有效值。 |
离散度填 abc |
频率离散度必须是 0~1.5 之间的数字,已保留上一次的有效值。 |
校验用的是 /^\d+$/ 加范围判断,所以 3.5、负数、空串、非数字都会走到同一条分支;合法输入(比如 480 / 0.80)应用后提示会消失、滑杆和输入框一起更新。

准备环境:进入码道 Web
浏览器打开码道 Web 版:https://devcloud.cn-north-4.huaweicloud.com/chat?source=dmzntgwsf&sourcead=dmzntgwwbwz,登录后就能在对话窗口输入需求,不需要装软件。
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。
本地复现
生成物只有一个 index.html,复现没有依赖:
- 从 Demo Park 本案例目录下载
index.html。 - 双击打开。默认 K=2.0、离散度 0.45、320 只、1 倍速,运行十几秒就能看到 R 爬到 0.97 以上。
- 想走静态服务也行:
python -m http.server 8000,然后访问http://localhost:8000。 - 拉开窗口到一个较大的视口(我用的是 1920×1200),画布会自动按视口尺寸重建背景——尺寸对不对,看画面边缘有没有留黑边就知道。
下面这些数是我本地逐条跑出来的,都能自己复验:
- 画布尺寸:视口 1920×1200 时
canvas.clientWidth/Height与canvas.width/height都是 1920×1200(devicePixelRatio=1),没有出现「CSS 尺寸变了、后备缓冲还是旧值」的错位。 - 序参量:K=6 稳态下仪表、右下角统计区、曲线末端三处读数一致,都是 0.997。
- 相变扫描:上表 9 档,K=0 时 R 停在 0.05 附近,K=1.0 起明显上台。
- 恢复时间:打散后 1.1 秒过 0.5、1.38 秒过 0.9、2.2 秒回 0.997。
- 非法输入:4 种输入全部命中中文提示,且输入框回退到上一次的有效值,页面不白屏。
- 帧率:1920×1200 下 320 只与 600 只、1 倍速与 3 倍速,统计区都稳定显示 60 FPS。
使用码道体会
- 把「物理量」写进需求,别只写画面。 需求里点名了序参量 R 的定义(模长除以数量)、耦合项的作用对象(全场平均相位)、K 的取值范围,它生成的就是货真价实的 Kuramoto 平均场,而不是「一堆点闪来闪去」的动画。如果只说「做一个萤火虫同步的动画」,大概率会得到一个看着像、算不出的东西,文章里也就没有 R 可以量。
- 要求它把中间量显示出来。 「左上角要有实时 R 仪表 + 30 秒曲线」这一条,回报最大:它把「图不对」和「数不对」分开了。K 扫描那张表能列出来,全靠 R 一直在屏幕上,我读数字就行。
- 校验和推演要分开写。 我在需求里把「非法输入必须有中文提示并保留上一次有效值」写成了独立一条,它第一次就做对了;但最初版本里「数量有两个入口」的一致性没人提到,结果就留下第二处不一致。没写进需求的一致性,默认不会被检查,这条比前两条更值钱。
- 验收要自己动手。 它给自己的结论是「已用 Playwright 全量验证 33 项全部通过」,但那 33 项验的是它自己定义的断言。暂停时点击仍改相位、滑杆与输入框回退值打架这两处,都是我自己点出来、量出来才发现的。Agent 说完成不算验收。
- 没复现的问题就别写成问题。 600 只 + 3 倍速我原本预判会数值失稳,实测没有。那就写「没复现」,不要为了凑一条坑去改数字。
亲手点三下
页面打开就能验,三下够了:
- 看数字:把 K 滑杆从 0 慢慢推到 6,盯住左上角的 R。0 附近它会在 0.05 上下晃,过 1 以后开始爬,到 3 以上基本贴住 0.99——R 曲线会画出一条很陡的上升段。
- 打散再等:把 K 拉到 6,等 R 稳在 0.99 以上,按「打散」。R 立刻掉到 0.1 以下,然后一秒多就爬回 0.9 以上;这就是耦合项在干活。顺手把 K 拨到 0.5 再按一次「打散」,这次它会一直躺在 0.2 以下——临界值两侧的差别,比任何文字都直观。
- 看失败路径:在「参数输入」里把数量填成
999、离散度填成abc,点「应用」,必须看到两条中文红字提示、输入框退回上一次的有效值、页面不白屏;再把数量填成480点「应用」,提示消失、滑杆同步到 480。
直接下载试玩
- Demo Park 仓库:https://atomgit.com/deli007/demo_park
- 本案例目录:https://atomgit.com/deli007/demo_park/tree/main/codearts-firefly-sync
总结
把「萤火虫为什么会一起闪」做成一个能拖的网页之后,最值得记住的不是那 320 个发光点,而是耦合项里那个乘上去的 R:牵引力度和当前的整齐程度成正比,于是整齐这件事自己会加速。临界耦合强度 Kc ≈ 1.6σ 是这套模型的入场券,低于它,再多的虫子也只是各闪各的。想接着玩,可以试试把频率分布从高斯换成双峰(会出现两拨各自同步的群体)、给每只虫子限定一个邻居半径(把平均场换成局部耦合),或者把耦合项里的 sin 换成纯粹的脉冲触发,看看「看见别人亮才亮」的萤火虫需要多强的视觉反馈才能齐闪。
- 点赞
- 收藏
- 关注作者
评论(0)