同一套化学反应,为什么豹子长的是斑、斑马长的是条纹?1952 年图灵给过一个反直觉的答案:两种化学物质只要扩散快慢不一样,本来均匀的状态就会自己失稳,长出斑图(Turing pattern)。扩散在直觉里是「抹平差异」的,在这里却反过来制造了差异。
静态图回答不了两个问题:为什么加了扩散反而更不均匀?为什么 F、k 挪一点点,花纹就从点变成条纹、再坍成一片?所以我把这件事做成一个网页:左边一块 553×387 的反应扩散场在跑,右边五个滑杆,六个经典预设,鼠标拖过去就能播种,点一下「质量守恒自检」还会把纯扩散下 ΣU 的变化量算给你看。
几条关键信息先摆出来:
- 源码:Demo Park 公开仓库 → atomgit.com/deli007/dem...,
index.html可直接下载- 生成物:单个
index.html,纯前端、零依赖、不联网、不上传,全部计算在本地完成- 本地运行:双击文件即可;或
python -m http.server 8000- 本地验证:页面自检里 U 总量相对变化 = 4.071e-10(阈值 1e-6);1920×1200 视口下网格是 553×387 = 214011 个格点
它不是严谨的化学仿真。U、V 只是两个抽象浓度,反应项是最简的
u·v²,参数平面固定用 Du=0.2097、Dv=0.105 这一档;预设按钮上的名字和实际长出来的花纹有几处对不上,下面会如实写。

为什么值得看:扩散本来是抹平差异的,为什么反而造出了花纹
把两种东西放在一起,直觉是「时间一长就混匀了」。图灵发现这个直觉有个前提:两种东西的扩散速度一样快。一旦快慢不同,均匀态就会被扩散本身推翻。
这套网页跑的 Gray-Scott 模型就两条式子,U 是「被消耗的原料」,V 是「自我催化的产物」:
text
U/t = Du·∇²U − U·V² + F·(1 − U)
V/t = Dv·∇²V + U·V² − (F + k)·V
U·V²:V 会吃掉 U、同时复制自己------这是短程的「激活」;Du·∇²U和Dv·∇²V:U 扩散得比 V 快(Du=0.2097,Dv=0.105,差两倍),相当于长程的「抑制」;F(喂食率)往系统里补 U,k(死亡率)把 V 带走。这两个数就是右边面板上那两个滑杆。
短程激活、长程抑制,这两件事凑齐,均匀态就待不住了:某个点的 V 稍微多一点,它会就地扩张,但扩张不出多远就被 U 的扩散压住;于是「多」和「少」被固定成一定间距的花纹,间距由扩散长度决定,而不是由随机种子决定(图 1 里几千个点的间距几乎一致,就是这件事的直接后果)。
页面实现里有三个决定值得说清楚:
- 9 点拉普拉斯核要量纲对齐。 用的是
0.5×正交邻居 + 0.25×对角邻居 − 3×中心。把权重代进 Taylor 展开,长波极限正好是∇²:正交项贡献w_orth,对角项贡献2·w_diag,0.5 + 2×0.25 = 1.0。生成这页时它一开始用的是一套0.2/0.05/−1的核,同一套展开算下来只有0.2 + 2×0.05 = 0.3,等于把扩散系数悄悄乘了 0.3,系统落进非图灵区,跑 16 万步也不出花纹。换成量纲匹配的核之后经典花纹才复现。 - 边界用零通量、全域积分。 越界邻居直接取自身格点值(Neumann),每个格点都参与积分。这样核的权重和为 0,对称求和后总质量严格不漂移------页面自检量的就是这件事。
- 换预设先分帧热身。 切参数时不是瞬间跳到终态,而是先分帧跑一段(每帧 600 步,用
requestAnimationFrame让出主线程),所以切预设有连续演化的过程,也不会卡白屏。
也说几处真实的边界:
- 网格是按视口自动缩放的:1920×1200 下是 553×387,窗口变小格子会变多。同一组参数在不同分辨率下稳态覆盖率会有零点几个百分点的差别(同一参数多次重跑来来回回稳定在 11.0%~11.2%)。
- 颜色只是 V 浓度的分段映射(深蓝 → 青 → 耀白),不是物理量。
- 边界上美术上「能看」,不代表这套离散化在别的 Du/Dv 组合下都稳定;显式欧拉 + dt=1 在扩散系数更大时会炸,所以才把滑杆的 Du 上限压到 0.5。
任务描述
需求是直接粘进码道 Web 输入框的,原文如下:
text
请用单个 index.html 文件(内联 CSS 和 JS,不依赖任何外部库、不发任何网络请求)做一个【图灵斑图(反应扩散)实验室】网页应用,深色科技风、全屏自适应、左侧画布右侧控制面板。要求:1) 用 Gray-Scott 反应扩散模型在 Canvas 上实时模拟化学物质 U、V 的浓度场,9 点拉普拉斯算子 + 显式欧拉积分,U 初始 1、V 初始 0,随机撒若干种子点;2) 控制面板提供滑杆调 F(feed 0.010~0.100)、k(kill 0.030~0.080)、Du、Dv、每帧迭代步数,并提供 6 个经典预设按钮(F=0.035 k=0.065 珊瑚迷宫、F=0.054 k=0.062 斑点、F=0.098 k=0.055 自复制点、F=0.030 k=0.062 稳定条纹、F=0.026 k=0.051 混沌、F=0.014 k=0.054 大斑块),每个预设按钮上标注它会形成哪种花纹;3) 鼠标或触摸在画布上拖动即可播种 V,画笔半径可调,空格暂停/继续,R 键重置;4) 顶部状态条实时显示:迭代步数、斑图覆盖率(%)、U 总量、V 总量、当前 F 和 k;5) 内置【自检】按钮:临时把 F 和 k 都设为 0 跑 200 步,验证纯扩散下 U 总量相对变化小于 1e-6(质量守恒),再恢复原参数,并把这次自检的实测数值用文字显示在面板上;6) 输入框校验:F 或 k 输入超出范围或非数字时,在输入框下方用中文红字提示「请输入 0.010 ~ 0.100 之间的数字(当前保留上一次的有效值)」,并保留上一次的有效值继续运行,不要卡住;7) 页面底部固定一行小字说明:本工具纯本地运行,无联网、无上传、无第三方依赖。
它实际长成了什么样:六个预设的实测
面板上给了六个经典预设,我把每个都跑到稳态、记下顶部状态条的实测覆盖率和画面:
| 预设(按钮名) | F | k | 跑到步数 | 覆盖率 | 实际画面 |
|---|---|---|---|---|---|
| 珊瑚迷宫 | 0.035 | 0.065 | 35704 | 11.16% | 规则点阵(图 1) |
| 斑点 | 0.054 | 0.062 | 49524 | 35.46% | 迷宫条纹(图 2) |
| 稳定条纹 | 0.030 | 0.062 | 43140 | 12.35% | 圆点 |
| 大斑块 | 0.014 | 0.054 | 52876 | 1.29% | 大小不一的稀疏斑点(图 3) |
| 混沌 | 0.026 | 0.051 | 43132 | 1.59% | 大片弥散、几乎没有结构 |
| 自复制点 | 0.098 | 0.055 | 42532 | 100.00% | 全场饱和成均匀色块 |
六个名字里只有「大斑块」基本对得上。 这是真实偏差,不是我写错了:这套 Du=0.2097 / Dv=0.105 的参数和公开实现常用的一档(Du=0.16 / Dv=0.08 附近)不同,参数平面的位置整体平移了,所以同一个 (F, k) 落到的花纹区跟着变。「自复制点」那一支最明显------它直接跑到 100.00% 覆盖,也就是整个场被 V 铺满、没有点也没有条纹,只能当「饱和态」的演示用。我保留了按钮上的原名(改了名字就和这张表对不上了),但把偏差点写在这里。


踩坑:两个真 bug,和把它们抓出来的证据
第一条最难发现,因为它不影响数值、只影响画面。
画布缓冲区没跟着窗口尺寸走。 index.html 里画布铺满窗口是用 CSS 撑的(width:100%),但代码里只改了 CSS 尺寸,没有同步 canvas.width/height------它一直停在 HTML 默认的 300×150。后果是:模拟照样在全场 553×387 上跑、状态条数字全对,可是 drawImage 的目标矩形是 1602×1120,超出 300×150 的部分被丢掉,实际能看到的只有左上角 103×52 个格点,占全场 2.5%,再被 CSS 横向拉 5.34 倍、纵向拉 7.47 倍。我用 eval 把几何打出来才抓到:canvas.width=300、canvas.clientWidth=1602,在画布中间 (800,600) 读像素是 (0,0,0,0)------那里根本没有画布。修法是在 rebuildGrid() 里 syncDisplaySize() 之后补上两行 canvas.width = Math.round(dispW); canvas.height = Math.round(dispH);。同一个参数改动前后差别极大:改之前画面是几个被放大到糊掉的大团块,改之后才是图 1 那种满屏细点。
两个提示组件共用了一个类名。 舞台底部那条操作提示和每个输入框下面的校验红字都叫 .hint,样式表里先定义 .hint(绝对定位、white-space:nowrap)再定义一次 .hint(display:none)。结果是两个都坏:舞台提示被后一条规则永久隐藏,从来不会显示;校验红字反过来继承了绝对定位和 nowrap,从输入框下面跑到控件左侧、被画布裁掉------非法输入时只能看到后半句「...(当前保留上一次的有效值)」。修法是把舞台提示改成 #stageHint 选择器(元素上也不再挂 hint 类),校验提示保留 .hint。修完之后红字在面板里正常换行(实测 position:static、宽 289 px、高 32 px 两行)。

失败路径也照样验了。 F 框里敲 0.5(超出 0.010~0.100),k 框里敲 abc(不是数字),红字提示分别是「请输入 0.010 ~ 0.100 之间的数字(当前保留上一次的有效值)」和「请输入 0.030 ~ 0.080 之间的数字(当前保留上一次的有效值)」,两个输入框都回退到上一次的有效值(当时是 0.035 / 0.065),画面继续跑、不白屏、不报错。

准备环境:进入码道 Web
浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source...,登录后就能在对话窗口输入需求,不需要装软件。
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。
本地复现
生成物只有一个 index.html,复现没有依赖:
- 从 Demo Park 本案例目录下载
index.html。 - 双击打开,页面默认是 F=0.054、k=0.062,会自动热身 1800 步再进入实时迭代。
- 想走静态服务也行:
python -m http.server 8000,然后访问http://localhost:8000。 - 点「质量守恒自检」应看到「✓ 通过 · 质量守恒成立」和一行
U 总量相对变化 = x.xxxe-10(阈值 1e-6)。想看清花纹的稳态,把「每帧迭代步」拉到 16,跑一两分钟覆盖率就不动了。
下面这 5 项断言是我本地逐条跑出来的,都能自己复验:
- 自检:F=k=0 跑 200 步纯扩散,ΣU 从 119633.26 变成 119633.26,相对变化 4.071e-10(另几次重跑在 1.6e-10 ~ 5.5e-10,量级一致,阈值是 1e-6)。
- 覆盖率达到稳态:F=0.035、k=0.065 跑到 35000 步后,覆盖率在 11.0%~11.2% 之间不再上涨。
- 网格规模:1920×1200 视口下 W×H = 553×387 = 214011 个格点,状态条的 ΣU+ΣV 与逐格点求和一致。
- 画布缓冲区 = CSS 尺寸:修复后
canvas.width/height是 1602×1120,与clientWidth/clientHeight相等,画布正中读像素不再返回透明。 - 非法输入:F 填
0.5、k 填abc,两条红字提示都出现,且模型继续用旧值运行。
使用码道体会
这轮下来能带走的,主要是几条和模型打交道的习惯:
- 需求里把「物理量」和「量纲」写清楚。 只写「用拉普拉斯算子」,它可能给你一个系数不归一化的核;这次踩到的那套
0.2/0.05/−1核就是把有效扩散率乘了 0.3。写上「9 点核 + 显式欧拉、参数范围」之后,它自己会去对照公开实现校准 Du/Dv。 - 数值类的 bug 要它自己给「可读的数字」。 需求里要求状态条实时显示迭代步数、覆盖率、ΣU、ΣV,还要求自检把前后 ΣU 都打印出来------这两个要求让「图不对」和「数不对」能分开定位。没有状态条,我大概只会看到一张糊图,然后以为是自己拖错了滑杆。
- 视觉 bug 得自己截图看。 画布缓冲区那类问题,代码逻辑完全正确、控制台一个错都不报,只有把窗口尺寸和画布尺寸打出来、再读一次像素才现形。它给自己的结论是「已在本地用 Chromium 完成全部自动化验证」,但自动化验证的是数值,不是「画面是不是只画了 2.5%」。
- 参数区的名字要留个心眼。 「斑点」「稳定条纹」这类名字是它按公开资料的印象写的,落到自己这套扩散系数上不一定成立;验收时最好把每个预设都跑一遍、记下覆盖率,名字对不上的地方在文档里说明,别顺手改按钮名------改了反而和截图对不上。
- 失败路径单独写一段。 需求里写「给明确中文提示,并保留上一次的有效值」,它照做了;不写,默认往往是什么都不发生。
- Agent 说完成不算验收。 自检那行数字是页面里点出来的,非法输入是我自己敲的,画布尺寸是我自己量的------这三样都对上了,才敢在上面写「11.16%」这种具体数字。
亲手点三下
页面打开就能验,三下够了:
- 看数字:点「质量守恒自检」,确认出现「✓ 通过 · 质量守恒成立」,并顺手核一眼迭代步数、覆盖率、ΣU 在动。
- 换参数:点预设「斑点」,等一分钟看它从种子长成迷宫条纹(覆盖率会从个位数爬到 35% 左右);再把「每帧迭代步」拉到 16,演化明显变快。
- 看失败路径 :F 框里敲
0.5、k 框里敲abc,必须看到两条中文红字提示、输入框退回上一次的有效值,页面不白屏。
直接下载试玩
- Demo Park 仓库:atomgit.com/deli007/dem...
- 本案例目录:atomgit.com/deli007/dem...
总结
把「豹纹怎么来的」做成一个能拖的网页之后,最关键的不是渲染也不是配色,而是那两条式子里的 Du ≠ Dv:一旦两种东西扩散得一样快,短程激活和长程抑制就分不开,花纹立刻消失。想接着玩,可以试试把 Du/Dv 做成滑杆扫一遍(Du 调到接近 Dv 时花纹会整体退掉)、把网格换成六边形、或者给 V 加一个缓慢的全局衰减,看图案怎么在「点---条纹---斑块」之间切换。