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

举报
deli007 发表于 2026/10/08 22:05:01 2026/10/08
【摘要】 随机乱跳为什么能画出完美三角形:混沌游戏分形实验室

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

这件事值得做成一个能动手的页面,是因为它把「随机」和「确定」摆在同一张画布上:每一步都在抛骰子,最后的图形却只有一个。静态图讲不清两件事:点是怎么一点点把图案长出来的?把收缩比 r 从 0.5 挪开,图形会变成什么样? 所以我把它做成了能点的网页:左边是控制面板(顶点数、收缩比、每帧点数、总点数上限、开始/暂停/重置/打散),右边是画布,点按所选顶点着色、带辉光,状态区实时显示累计点数、当前 r,以及盒计数法估算出的分形维数 D。

几条关键信息先摆出来:

  • 源码:Demo Park 公开仓库 → https://atomgit.com/deli007/demo_park/tree/main/codearts-chaos-game-fractal,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:3 顶点、r=0.50、每帧 60 点,跑了约 6 秒。累计 11,580 点,三角形的三级结构已经看得出来,右下角的雏形还在往外冒点

为什么值得看:真正决定图形的是 1−r,不是 r

设当前点为 x,随机选中的顶点为 V,每步执行 x ← x + r(V − x),也就是朝 V 挪 r 的比例。把「离顶点的距离」单独拎出来看:

d(下一步到顶点) = (1 − r) · d(这一步到顶点)

所以每一块拷贝的缩放比是 1−r,不是 r。 整个图形的维数由这条缩放比决定:

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 输入框的,原文如下:

做一个单文件 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),说明这套盒计数对「不是标准瑟尔平斯基」的图形只有个位数量级的可信度。

图 2:3 顶点、r=0.50、跑到 30 万点上限。状态区显示「已达上限,暂停」,右下角弹出「已达到总点数上限(300,000 点),已自动暂停。调大上限后可继续生长,或按「重置」重新开始。」——但这时候维数那一栏还停在「-- / 点数不足 2500,继续生长…」,这就是下面第一条坑

图 3:同一张图,把每帧点数拉到 20000 之后维数终于跟上了:D ≈ 1.542,「基于最新 300,000 个点的盒计数回归」,正上方并排着理论参考 log₂3 ≈ 1.585

踩坑与边界:五处真实的不一致

一、维数读数在默认速度下根本不刷新。 这是我这轮实测里最值得说的一个。代码里只有「这一帧画了 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。
还没长点就按「打散」 当前还没有已生成的点,先让分形长一会儿再打散。

图 4:6 顶点 + r=0.50 跑到 30 万点后按「打散」,30 万个点被随机抛回画布,右下角是「已打散:300,000 个点被随机抛散。继续运行,它们将再次被吸引子捕获。」

图 5:收缩比填 1.5 回车,弹出红色提示「无效的收缩比 r = 1.5:必须是 (0, 1) 内的有效数字(如 0.5)。已保留上次有效值 0.50。」,页面不白屏,仍按 0.50 继续跑

准备环境:进入码道 Web

浏览器打开码道 Web 版:https://devcloud.cn-north-4.huaweicloud.com/chat?source=dmzntgwsf&sourcead=dmzntgwwbwz,登录后就能在对话窗口输入需求,不需要装软件。

码道有三种使用方式: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.35~0.65,默认 0.5)」,它实现成滑杆 0.05~0.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,看看六个方向的骨架被剥成什么样子。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。