随机乱跳为什么能画出完美三角形:混沌游戏分形实验室

取一个正三角形,标出三个顶点。随便挑一个起点,然后每一步只做一件事:随机选一个顶点,把当前点朝它挪一段,落笔。重复几万次,屏幕上自己浮出一个瑟尔平斯基三角形------图案里那些小三角形没有一个是画上去的,全是随机跳出来的。

这件事值得做成一个能动手的页面,是因为它把「随机」和「确定」摆在同一张画布上:每一步都在抛骰子,最后的图形却只有一个。静态图讲不清两件事:点是怎么一点点把图案长出来的?把收缩比 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 以内------盒计数在这两个参数上是可信的。

三个实现决定值得说清楚:

  1. 点是「一次性落笔」,落完就不再擦。 所有点先画到一张离屏画布上,用叠加混合(lighter)逐个画 1.4 像素的小方块,主画布只负责把它整张贴上来。画面只增不减、没有拖影,这也是「看着它长出来」能成立的机制:你看到的每一笔都是当时真落下的那一笔。
  2. 随机数没有种子。 用的是 Math.random(),所以两次运行的点序列完全不同;但吸引子只有一个。同一配置连测三次,维数读数 1.544 / 1.543 / 1.543------这就是「过程随机、结果确定」的实测版本。
  3. 维数是「攒够点再算」的。 少于 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 的整数正整数」,「整数正整数」多写了两个字。小毛病,但读起来卡一下。)

本地验收证据

这轮我逐条跑出来的东西,都能自己复验:

  1. 画布与缩放:1920×1200 视口下画布 CSS 尺寸 1110×912、后备缓冲 1110×910、devicePixelRatio ≈ 1;页面总高 1457,比视口高一些,所以截全屏时最下面那栏会被裁掉一角。
  2. 默认速度下的冻结 :载入 2.5 秒、累计 68,400 点时,维数栏显示 D ≈ 1.442,标注「基于最新 5,400 个点的盒计数回归」。
  3. 重置后的冻结:每帧 60 点跑 6 秒(11,580 点)和每帧 900 点跑到上限(300,000 点),维数栏都是「-- / 点数不足 2500,继续生长...」。
  4. 维数对照:表 1、表 2、表 3 的全部读数(每帧 20000 点,跑到上限再读)。
  5. 重复性:3 顶点 + r=0.50 + 30 万点,连测三次读数 1.544 / 1.543 / 1.543。
  6. 独立复算:同一套算法在 Python 里跑 30 万点,v3 r=0.50 得 1.513、v6 r=0.50 得 1.884,与页面读数同量级。
  7. 失败路径:下面表里每一条提示原文都是我从页面上抄下来的。
输入 提示原文
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,复现没有依赖:

  1. 从 Demo Park 本案例目录下载 index.html。
  2. 双击打开,默认 3 顶点、r=0.50、每帧 900 点,页面一打开就开始长。
  3. 想看维数跟着动,先把「每帧点数」改成 20000 再点「重置」;想看清点一颗颗垒上去,把它改成 60。
  4. 想走静态服务也行:python -m http.server 8000,然后访问 http://localhost:8000。
  5. 窗口大小会影响画布尺寸,但顶点是按画布内切圆摆的,构图不变。

使用码道体会

  • 把规则写进需求,别只写画面。 需求里点明了「每步随机挑顶点、朝该顶点移动固定比例 r、默认 r=0.5 出现瑟尔平斯基三角形」,它生成的就是真混沌游戏,而不是「一堆彩色像素乱飞」。这也是我能在表 1 里拿理论值逐行对照的前提。
  • 点名要「维数」,比点名要「好看」有用得多。 一个实时维数栏把一个定性说法(「这是分形」)变成了可检验的数字,我自己算过的偏差也才有地方落。下次我会把「网格尺度范围、估算的最小点数、刷新条件」也写进需求,这次这三件事都是它自己定的,直接导致了第一个坑。
  • 需求里的数值区间,要写清是「硬约束」还是「建议」。 我写了「r(0.350.65,默认 0.5)」,它实现成滑杆 0.050.95 + 提示文字里的推荐区间。这条歧义不怪它,是我写得含糊。
  • 验收要自己动手,尤其是「看起来对」的地方。 这个页面第一次打开就很惊艳,三角形一次就对;但「维数不刷新」「上限改不了」这两件事只有盯着状态区读数、来回改参数才会露出来。Agent 说完成不算验收。
  • 随机的东西要自己多测几次。 「过程随机、结果确定」是本文的结论,验证方式就是把同一配置跑三次看读数:1.544 / 1.543 / 1.543------比任何形容词都有说服力。

亲手点三下

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

  1. 看它长出来:把「每帧点数」改成 60,点「重置」,盯着画布看------点会一颗颗从顶点附近冒出来,几十秒后三角形的三级结构才成形。想快点就把每帧点数改回 20000,半秒填满。
  2. 挪收缩比 :把 r 改成 0.60 回车,点「重置」等它跑满,看图形从实心三角形变成「更细、空洞更大」的样子;再改 0.65,空洞几乎连成一片。同时盯住维数栏------记得先把每帧点数调到 20000 以上,否则它不会刷新。
  3. 看失败路径 :r 框填 1.5 回车,必须看到红色提示和「已保留上次有效值 0.50。」,页面不白屏、继续按 0.50 跑;再把「每帧点数」填 0,看它怎么提示并回到上一次的有效值。

直接下载试玩

总结

混沌游戏最反直觉的地方在于:每一步都在抛骰子,最后的图形却只有一个,而且它还是个正经的分形。顺着这个页面往下看,最值得记住的其实是 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,看看六个方向的骨架被剥成什么样子。

相关推荐
Gizzap_Tech2 小时前
国内官网增加英文版:URL、语言切换与 hreflang 怎么配置?
前端
剪刀石头布啊2 小时前
泛洪DFS、BFS
前端
qetfw2 小时前
Windows Server AD CS:根 CA、Web 证书模板与域内自动注册
前端·windows·windows-server
剪刀石头布啊2 小时前
为什么React组件很少前缀,而vue的不少组件都有前缀
前端
剪刀石头布啊2 小时前
gird网格布局
前端
啃火龙果的兔子2 小时前
Google Chrome(谷歌浏览器)常用快捷键
前端·chrome
剪刀石头布啊2 小时前
js真的是单线程实现异步并发么?
前端
剪刀石头布啊2 小时前
js中改变this指向的操作有哪些
前端
剪刀石头布啊2 小时前
vue、react 列表中使用 index 作为 key 的话,修改某一个元素后会发生什么
前端