取一个正三角形,标出三个顶点。随便挑一个起点,然后每一步只做一件事:随机选一个顶点,把当前点朝它挪一段,落笔。重复几万次,屏幕上自己浮出一个瑟尔平斯基三角形------图案里那些小三角形没有一个是画上去的,全是随机跳出来的。
这件事值得做成一个能动手的页面,是因为它把「随机」和「确定」摆在同一张画布上:每一步都在抛骰子,最后的图形却只有一个。静态图讲不清两件事:点是怎么一点点把图案长出来的?把收缩比 r 从 0.5 挪开,图形会变成什么样? 所以我把它做成了能点的网页:左边是控制面板(顶点数、收缩比、每帧点数、总点数上限、开始/暂停/重置/打散),右边是画布,点按所选顶点着色、带辉光,状态区实时显示累计点数、当前 r,以及盒计数法估算出的分形维数 D。
几条关键信息先摆出来:
- 源码:Demo Park 公开仓库 → atomgit.com/deli007/dem...,
index.html可直接下载- 生成物:单个
index.html(约 27 KB),纯前端、零依赖、不联网、不上传,双击就能打开- 本地运行:双击文件即可;或
python -m http.server 8000- 本地验证:1920×1200 视口,画布 CSS 1110×912、后备缓冲 1110×910(devicePixelRatio ≈ 1);3 顶点 + r=0.5、30 万点时盒计数读数 1.519 ~ 1.544(页面自己给出的理论参考是 log₂3 ≈ 1.585);同一配置连测三次是 1.544 / 1.543 / 1.543
它不做物理仿真,也不追求好看的分形画,只按规则跑:随机挑顶点、按比例靠拢、落笔。下面会如实写它的边界,包括五处我实测出来的不一致。

为什么值得看:真正决定图形的是 1−r,不是 r
设当前点为 x,随机选中的顶点为 V,每步执行 x ← x + r(V − x),也就是朝 V 挪 r 的比例。把「离顶点的距离」单独拎出来看:
text
d(下一步到顶点) = (1 − r) · d(这一步到顶点)
所以每一块拷贝的缩放比是 1−r,不是 r。 整个图形的维数由这条缩放比决定:
text
3 块拷贝,每块缩放 (1−r) → 3 · (1−r)^D = 1
D = log 3 / log( 1 / (1−r) )
- r = 0.50(需求里的默认值,也就是「挪一半」):D = log 3 / log 2 = 1.585;
- r = 0.60:D = log 3 / log 2.5 = 1.199;
- r = 0.65:D = log 3 / log(1/0.35) = 1.046;
- r < 0.50 时 1−r > 0.5,三块拷贝开始互相重叠,公式算出来是 2.15(r=0.40)和 2.55(r=0.35)------比平面本身还大,实际维数封顶在 2。这时候图形不再是「细」的分形,而是有面积的。
页面把 3 顶点 + r=0.50 的理论值直接写在状态区(log₂3 ≈ 1.585),换成别的参数就显示「无闭式表达式」。我把能对照的两个点都量了:r=0.60 读数 1.204(理论 1.199)、r=0.65 读数 1.053(理论 1.046),两次都在 0.01 以内------盒计数在这两个参数上是可信的。
三个实现决定值得说清楚:
- 点是「一次性落笔」,落完就不再擦。 所有点先画到一张离屏画布上,用叠加混合(
lighter)逐个画 1.4 像素的小方块,主画布只负责把它整张贴上来。画面只增不减、没有拖影,这也是「看着它长出来」能成立的机制:你看到的每一笔都是当时真落下的那一笔。 - 随机数没有种子。 用的是
Math.random(),所以两次运行的点序列完全不同;但吸引子只有一个。同一配置连测三次,维数读数 1.544 / 1.543 / 1.543------这就是「过程随机、结果确定」的实测版本。 - 维数是「攒够点再算」的。 少于 2,500 点不估算;之后用 k=3~8 的网格统计含点的格子数 N(s),再对 log N 与 log s 做最小二乘。最细一级是 256×256,一格约 4.3×3.6 像素。
也说几处真实的边界:
- 盒计数只对「标准」图形准。r=0.50、3 顶点时读数 1.519 ~ 1.544,比理论 1.585 低 3%~4%;换成别的参数,偏差会拉到 0.1 以上(下面的实测表里有数)。
- 画布是竖长方形(1110×912),三个顶点摆在内切圆半径
min(宽,高)×0.42的位置上,所以吸引子只占画布中间一块,四周是空的。网格是按整张画布切的,不是按吸引子切的------粗尺度上的误差有一部分来自这里。 - 每帧点数默认 900,30 万点上限要约 10 秒才填满;拉到 20000 点,15 帧就填满(不到一秒)。
任务描述
需求是直接粘进码道 Web 输入框的,原文如下:
text
做一个单文件 index.html 的「混沌游戏分形实验室」:用 Canvas 演示混沌游戏(Chaos Game)。规则是取一个正三角形的三个顶点,从任意一个随机点开始,每一步随机挑一个顶点,把当前点朝该顶点移动固定比例 r,画下这个点并继续。默认 r=0.5 时屏幕上会自己浮现出瑟尔平斯基三角形。要求:①深色科技风界面,点带辉光、按所选顶点着色,能明显看到分形逐渐长出来;②控制面板可调:顶点数(3/4/5/6)、收缩比 r(0.35~0.65,默认 0.5)、每帧点数(速度)、总点数,支持开始/暂停/重置/打散;③状态区实时显示累计点数、当前 r、以及盒计数法估算的分形维数(3 个顶点、r=0.5 时应收敛到约 1.585);④输入非法参数(比如 r 填 0 或 1.5、点数填负数或非数字)时给出明确中文提示,并保留上一次的有效值;⑤界面里写清楚:每一步都是随机的,但最终图形是确定的吸引子。完成后打开预览,让我直接看到运行效果。
实测数据:把 r 挪开,读数跟着挪
下面每个数字都是我在本机浏览器里跑出来、从状态区读出来的。跑法是:把每帧点数改成 20000(原因见下面「踩坑」第一条),点「重置」,等它自己跑到 30 万点上限,再读维数。
表 1:3 顶点,改收缩比 r(30 万点)
| r | 页面读数 D | 理论 log 3 / log(1/(1−r)) | 图形 |
|---|---|---|---|
| 0.35 | 1.815 | 2.55(封顶 2) | 三块拷贝重叠,图形有面积 |
| 0.40 | 1.775 | 2.15(封顶 2) | 同上,重叠更少 |
| 0.50 | 1.543 | 1.585 | 标准瑟尔平斯基三角形 |
| 0.60 | 1.204 | 1.199 | 变「细」,空洞更大 |
| 0.65 | 1.053 | 1.046 | 最细,几乎只剩线状骨架 |
表 2:r=0.50,改顶点数(30 万点)
| 顶点数 | 页面读数 D | 状态区里的理论参考 |
|---|---|---|
| 3 | 1.543 | log₂3 ≈ 1.585 |
| 4 | 1.854 | 无闭式表达式 |
| 5 | 1.868 | 无闭式表达式 |
| 6 | 1.895 | 无闭式表达式 |
表 3:3 顶点 + r=0.50,看它随点数怎么变
| 总点数 | 页面读数 D |
|---|---|
| 50,000 | 1.532 |
| 150,000 | 1.536 |
| 300,000 | 1.519 |
三点连测的离散度是 0.017,比它和理论值 1.585 的差距(0.04 ~ 0.07)还小------也就是说这个差距不是抖动,是估算方法本身的系统偏差。
为了确认这一点,我用同一套算法在 Python 里独立复算了一遍(同样 30 万点、同样 8×8 到 256×256 的网格):v3 r=0.50 得 1.513,v3 r=0.65 得 0.898,v3 r=0.35 得 1.750,v6 r=0.50 得 1.884。和页面读数同量级,但确实有差:r 越偏离 0.5,两套读数差得越多(最多差 0.15),说明这套盒计数对「不是标准瑟尔平斯基」的图形只有个位数量级的可信度。


踩坑与边界:五处真实的不一致
一、维数读数在默认速度下根本不刷新。 这是我这轮实测里最值得说的一个。代码里只有「这一帧画了 9000 个点以上」时才会把维数标脏、触发重算,而默认每帧只有 900 点。所以:
- 默认参数下打开页面,第一次估算是 5,400 点时做的,读数 D ≈ 1.442,标注写着「基于最新 5,400 个点的盒计数回归」;等它长到 68,400 点,这一栏还是那行 5,400 点的旧账。
- 如果第一次估算时点太少、尺度不够(估算函数直接返回空),读数会停在「--」,并且再也不会更新。我把它重置后一路跑到 30 万点,那一栏始终是「-- 点数不足 2500,继续生长...」。
要想让维数动起来,得先把每帧点数改成 9000 以上(我用的 20000)。这不是文档里的已知限制,是代码里 if (grew >= 9000) S.dimDirty = true; 这一行和「每帧点数」这个控件互相打架。
二、「调大上限后可继续生长」做不到。 触到总点数上限后,页面会弹出「调大上限后可继续生长」,状态区也写着「调大总量或重置后可继续」。实测:把上限从 300,000 改成 500,000,提示变成「总点数上限已设为 500,000(现有 300,000 点不变)」,但「开始」按钮仍然是灰的、点不动,点数纹丝不动停在 300,000,状态还是「已满暂停」。原因是「已满」这个内部标志只有「重置」会清掉,而「开始」按钮在已满时被禁用------这句提示给出的两条路,一条走不通(调大上限),另一条(重置)会清空数据。
三、「点数不足 2500」这句提示会说谎。 估算函数返回空的时候,页面一律写「点数不足 2500,继续生长...」。但 30 万点时它也这么写------真实原因是「可用的回归尺度不够」,不是点数不够。读者很容易被这句话误导成「再多跑一会儿就好了」。
四、需求里「收缩比 r(0.35~0.65)」没有落成硬限制。 滑杆实际能拖 0.05~0.95,数字框接受 (0,1) 内的任意值,「0.35 ~ 0.65」只写在提示文字里当推荐区间。所以这两个端点是能跑出来的(本文表 1 的两端就是这么量的),但「填 0.8 会被拒绝」这条保护并不存在。
五、type=number 让「非数字」这条分支走不到。 需求要求「点数填负数或非数字时给出明确提示」。实测:负数(-5)会走到提示分支,但纯文字(abc)被浏览器直接清空成空串,提示里「当前输入」那一格打出的是空白------也就是说「非数字」这个场景实际是靠「空值」兜住的。
(另外一处不算坑但值得记:每帧点数的错误提示原文是「每帧点数必须是 1 ~ 20000 的整数正整数」,「整数正整数」多写了两个字。小毛病,但读起来卡一下。)
本地验收证据
这轮我逐条跑出来的东西,都能自己复验:
- 画布与缩放:1920×1200 视口下画布 CSS 尺寸 1110×912、后备缓冲 1110×910、devicePixelRatio ≈ 1;页面总高 1457,比视口高一些,所以截全屏时最下面那栏会被裁掉一角。
- 默认速度下的冻结 :载入 2.5 秒、累计 68,400 点时,维数栏显示
D ≈ 1.442,标注「基于最新 5,400 个点的盒计数回归」。 - 重置后的冻结:每帧 60 点跑 6 秒(11,580 点)和每帧 900 点跑到上限(300,000 点),维数栏都是「-- / 点数不足 2500,继续生长...」。
- 维数对照:表 1、表 2、表 3 的全部读数(每帧 20000 点,跑到上限再读)。
- 重复性:3 顶点 + r=0.50 + 30 万点,连测三次读数 1.544 / 1.543 / 1.543。
- 独立复算:同一套算法在 Python 里跑 30 万点,v3 r=0.50 得 1.513、v6 r=0.50 得 1.884,与页面读数同量级。
- 失败路径:下面表里每一条提示原文都是我从页面上抄下来的。
| 输入 | 提示原文 |
|---|---|
r 填 1.5 |
无效的收缩比 r = 1.5:必须是 (0, 1) 内的有效数字(如 0.5)。已保留上次有效值 0.50。 |
r 填 0 |
无效的收缩比 r = 0:必须是 (0, 1) 内的有效数字(如 0.5)。已保留上次有效值 0.50。 |
r 填 abc 或留空 |
无效的收缩比 r = :必须是 (0, 1) 内的有效数字(如 0.5)。已保留上次有效值 0.50。 |
每帧点数填 0 |
每帧点数必须是 1 ~ 20000 的整数正整数。当前输入「0」无效,已恢复上次有效值 900。 |
每帧点数填 999999 |
每帧点数必须是 1 ~ 20000 的整数正整数。当前输入「999999」无效,已恢复上次有效值 900。 |
总点数上限填 -5 |
总点数上限必须是 1000 ~ 5,000,000 的正整数。当前输入「-5」无效,已恢复上次有效值 300000。 |
| 还没长点就按「打散」 | 当前还没有已生成的点,先让分形长一会儿再打散。 |


准备环境:进入码道 Web
浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source...,登录后就能在对话窗口输入需求,不需要装软件。
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。
本地复现
生成物只有一个 index.html,复现没有依赖:
- 从 Demo Park 本案例目录下载
index.html。 - 双击打开,默认 3 顶点、r=0.50、每帧 900 点,页面一打开就开始长。
- 想看维数跟着动,先把「每帧点数」改成 20000 再点「重置」;想看清点一颗颗垒上去,把它改成 60。
- 想走静态服务也行:
python -m http.server 8000,然后访问http://localhost:8000。 - 窗口大小会影响画布尺寸,但顶点是按画布内切圆摆的,构图不变。
使用码道体会
- 把规则写进需求,别只写画面。 需求里点明了「每步随机挑顶点、朝该顶点移动固定比例 r、默认 r=0.5 出现瑟尔平斯基三角形」,它生成的就是真混沌游戏,而不是「一堆彩色像素乱飞」。这也是我能在表 1 里拿理论值逐行对照的前提。
- 点名要「维数」,比点名要「好看」有用得多。 一个实时维数栏把一个定性说法(「这是分形」)变成了可检验的数字,我自己算过的偏差也才有地方落。下次我会把「网格尺度范围、估算的最小点数、刷新条件」也写进需求,这次这三件事都是它自己定的,直接导致了第一个坑。
- 需求里的数值区间,要写清是「硬约束」还是「建议」。 我写了「r(0.35
0.65,默认 0.5)」,它实现成滑杆 0.050.95 + 提示文字里的推荐区间。这条歧义不怪它,是我写得含糊。 - 验收要自己动手,尤其是「看起来对」的地方。 这个页面第一次打开就很惊艳,三角形一次就对;但「维数不刷新」「上限改不了」这两件事只有盯着状态区读数、来回改参数才会露出来。Agent 说完成不算验收。
- 随机的东西要自己多测几次。 「过程随机、结果确定」是本文的结论,验证方式就是把同一配置跑三次看读数:1.544 / 1.543 / 1.543------比任何形容词都有说服力。
亲手点三下
页面打开就能验,三下够了:
- 看它长出来:把「每帧点数」改成 60,点「重置」,盯着画布看------点会一颗颗从顶点附近冒出来,几十秒后三角形的三级结构才成形。想快点就把每帧点数改回 20000,半秒填满。
- 挪收缩比 :把 r 改成 0.60 回车,点「重置」等它跑满,看图形从实心三角形变成「更细、空洞更大」的样子;再改 0.65,空洞几乎连成一片。同时盯住维数栏------记得先把每帧点数调到 20000 以上,否则它不会刷新。
- 看失败路径 :r 框填
1.5回车,必须看到红色提示和「已保留上次有效值 0.50。」,页面不白屏、继续按 0.50 跑;再把「每帧点数」填0,看它怎么提示并回到上一次的有效值。
直接下载试玩
- Demo Park 仓库:atomgit.com/deli007/dem...
- 本案例目录:atomgit.com/deli007/dem...
总结
混沌游戏最反直觉的地方在于:每一步都在抛骰子,最后的图形却只有一个,而且它还是个正经的分形。顺着这个页面往下看,最值得记住的其实是 1−r:需求里管它叫「收缩比」,但真正决定图形粗细的是补出来的那一半------r=0.50 给 1.585,r=0.60 给 1.199,r=0.65 给 1.046,一路量下来都能对上;而 r 一旦小于 0.5,三块拷贝开始重叠,维数就不再看公式、直接顶到 2 了。想接着玩,可以把 r 从 0.5 往 0.45 挪一点点,看图形从「细」到「胖」的那段过渡;也可以把顶点数推到 6 再把 r 调到 0.65,看看六个方向的骨架被剥成什么样子。