豹纹和斑马纹是怎么长出来的:调两个参数,花纹自己浮现

同一套化学反应,为什么豹子长的是斑、斑马长的是条纹?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 里几千个点的间距几乎一致,就是这件事的直接后果)。

页面实现里有三个决定值得说清楚:

  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 万步也不出花纹。换成量纲匹配的核之后经典花纹才复现。
  2. 边界用零通量、全域积分。 越界邻居直接取自身格点值(Neumann),每个格点都参与积分。这样核的权重和为 0,对称求和后总质量严格不漂移------页面自检量的就是这件事。
  3. 换预设先分帧热身。 切参数时不是瞬间跳到终态,而是先分帧跑一段(每帧 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,复现没有依赖:

  1. 从 Demo Park 本案例目录下载 index.html。
  2. 双击打开,页面默认是 F=0.054、k=0.062,会自动热身 1800 步再进入实时迭代。
  3. 想走静态服务也行:python -m http.server 8000,然后访问 http://localhost:8000。
  4. 点「质量守恒自检」应看到「✓ 通过 · 质量守恒成立」和一行 U 总量相对变化 = x.xxxe-10(阈值 1e-6)。想看清花纹的稳态,把「每帧迭代步」拉到 16,跑一两分钟覆盖率就不动了。

下面这 5 项断言是我本地逐条跑出来的,都能自己复验:

  1. 自检:F=k=0 跑 200 步纯扩散,ΣU 从 119633.26 变成 119633.26,相对变化 4.071e-10(另几次重跑在 1.6e-10 ~ 5.5e-10,量级一致,阈值是 1e-6)。
  2. 覆盖率达到稳态:F=0.035、k=0.065 跑到 35000 步后,覆盖率在 11.0%~11.2% 之间不再上涨。
  3. 网格规模:1920×1200 视口下 W×H = 553×387 = 214011 个格点,状态条的 ΣU+ΣV 与逐格点求和一致。
  4. 画布缓冲区 = CSS 尺寸:修复后 canvas.width/height 是 1602×1120,与 clientWidth/clientHeight 相等,画布正中读像素不再返回透明。
  5. 非法输入: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%」这种具体数字。

亲手点三下

页面打开就能验,三下够了:

  1. 看数字:点「质量守恒自检」,确认出现「✓ 通过 · 质量守恒成立」,并顺手核一眼迭代步数、覆盖率、ΣU 在动。
  2. 换参数:点预设「斑点」,等一分钟看它从种子长成迷宫条纹(覆盖率会从个位数爬到 35% 左右);再把「每帧迭代步」拉到 16,演化明显变快。
  3. 看失败路径 :F 框里敲 0.5、k 框里敲 abc,必须看到两条中文红字提示、输入框退回上一次的有效值,页面不白屏。

直接下载试玩

总结

把「豹纹怎么来的」做成一个能拖的网页之后,最关键的不是渲染也不是配色,而是那两条式子里的 Du ≠ Dv:一旦两种东西扩散得一样快,短程激活和长程抑制就分不开,花纹立刻消失。想接着玩,可以试试把 Du/Dv 做成滑杆扫一遍(Du 调到接近 Dv 时花纹会整体退掉)、把网格换成六边形、或者给 V 加一个缓慢的全局衰减,看图案怎么在「点---条纹---斑块」之间切换。

相关推荐
卸任21 小时前
Vibe coding工具使用心得
前端·ai编程
brien91 天前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance1 天前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M1 天前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写1 天前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李1 天前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易1 天前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马1 天前
前沿视界
前端
kyriewen1 天前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易1 天前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员