项目坍缩浮岛

坍缩浮岛

用波函数坍缩生成一座浮空小岛。看着每一格从"不确定"被逐个观测、塌缩成地形,整体立体成一座体素岛,然后还能直接点着改地形。

已完成波函数坍缩 WFCCanvas 2D等距体素渲染解析式点选命中零依赖单文件

在线游玩打开即跑,无需下载

一句话

一座岛从“什么都还不是”里,被一格一格地观测出来 —— 然后你可以伸手改它。

这是个什么项目

一个单文件、零依赖、跑在浏览器里的程序化生成小工程。它做三件事,严格分先后:

阶段 在做什么 你看到什么
求解 在 22×22 的网格上跑波函数坍缩,记录每一格被观测的顺序 一瞬(实测 3~10 ms)
坍缩 按记录的顺序把格子逐个点亮 一张地形图从中心向外“长”出来
立体化 把 2D 地形挤出成体素岛,再整体长出 一座悬在空中的锥底小岛
编辑 点任意一格换地形,整岛即时重挤 沙地变岩石、山坡凿出一个洞

页面上只有一个主体 —— 那座岛。没有菜单、没有教程、没有“开始”按钮,打开就在跑。

什么是“波函数坍缩”

名字取自量子力学里一个不严谨的类比,实际是一个约束满足问题的求解算法:

  1. 每格初始都处于叠加态 —— 它可以是深渊、水域、沙滩、草地、林地、岩石中的任何一种。
  2. 规则表规定谁可以挨着谁 —— 这是整个工程的“物理定律”,只有一张表: 水可以挨草,沙可以挨岩,水不能挨林也不能挨岩;最外圈被钉死成深渊,于是岛一定是“环水的岛”。
  3. 反复做两件事,直到所有格子都确定:
    • 观测:挑一个“候选最少”的格子(香农熵最低),按权重掷出它到底是哪种地形;
    • 传播:把这一格的结论推给四邻 —— “你旁边是森林,那你不能再是水了”。

第 3 步里的传播才是 WFC 和“随机刷格子”的根本区别:定下一格会连带收紧一整片邻居。所以相邻地形永远自洽 —— 不会出现“悬崖边上直接是深水”这种一看就假的拼接。

观测顺序不是随机的:永远先处理候选最少的格子。从熵最低处下手,矛盾最早暴露,回溯代价最小。

下面这张就是它跑到一半的样子(观测 142 / 253,56%)。外围已经落定 —— 蓝的是一圈水域、黄的沙滩、 绿的草地与林地;中间那片深色格子还停在叠加态里,显示的是所有候选地形的加权平均色,所以暗而偏蓝:

坍缩进行中 56% 的俯视网格:外圈格子已落定成水域(蓝)、沙滩(黄)、草地与林地(绿),中间大片深色格子仍是候选地形的加权平均色,代表尚未被观测的叠加态

能力一览

能力 说明
确定性生成 同一种子永远同一座岛。点 # 输入数字即可复现别人截图里的那座
过程中的可视化 未观测的格子保持“叠加态”,显示候选地形的加权平均色(暗而偏蓝);观测过的才落定成饱和地形色
坍缩时间线 最近被观测的 10 格会闪一圈白边,看得见“这一秒发生了什么”
暂停 / 单步 可以停下来一格格看传播怎么收紧邻居的候选集
三维检视 拖拽转、滚轮缩放、双击复位、可开自转
点选改地形 指哪高亮哪,点一下就换。5 种地形 + 一个“凿空”,改完整岛即时重挤
规模可调 14×14(快,几分钟能看完)到 30×30(密,地形层次更丰富)
零依赖 单个 HTML 文件,不联网、不走 CDN、无 node_modules

在线运行

→ 打开坍缩浮岛

打开即跑,不需要点任何东西。页面上的控件:

控件 作用
⟳ 重新生成 换一个随机种子重来(快捷键 R)
⏸ 暂停 / ▶ 继续 停在坍缩过程中的某一刻
⏭ 单步 暂停后每按一次推进一格观察
规模 网格边长,14–30。拖完松手时生效
速度 每秒观测多少格
↻ 自转 岛成型后是否缓慢自转
#12345 输入种子。同一个数字永远是同一座岛
✎ 编辑地形 进入编辑态(岛成型后才可用)。见下

编辑地形

按 ✎ 进入编辑态,底部会多出一排色板:

操作 效果
移动鼠标 指到的那一格描一圈白边 —— 这就是“即将被改的那一格”
点一下 把那一格换成当前笔刷的地形
1 – 5 切笔刷:水域 / 沙滩 / 草地 / 林地 / 岩石
0 或 E 切到“凿空” —— 把那一格抹成深渊,岛上出现一个洞
拖拽 仍然可以转视角(拖动超过 4px 就不算点击,不会误改)
滚轮 缩放
Esc 退出编辑态

几件顺手做掉的事:进编辑态会自动关掉自转(不然没法瞄准);重新生成时会自动退出编辑态(整张地形都换掉了);色板出现时底下那排纯文字图例自动收起(色板上已经写了地形名,不必说两遍)。

参数表

想让岛换个长相,改的都是这几个数(都在源码顶部,都有注释):

参数 位置 作用 调大了会怎样
ADJ_TEXT solver.js 相邻兼容表 —— 唯一的地形规则 能相邻的组合变多,地形更“糊”、过渡更平缓
BASE_W solver.js 每种地形的基准权重 该地形占比上升,会挤掉别的
biasFor() 的高斯峰位 solver.js 每种地形“喜欢待在离岛心多远” 环带整体外移/内移;峰变宽则环带交叠得更碎
edge 的三段正弦振幅 solver.js 岛缘的起伏程度 振幅调太大岛会被撕成碎条,0.07 / 0.042 / 0.026 是试出来的甜点
9.5 build.js 锥形底座最深多少格 岛变“厚”,像一块切下来的蛋糕
lift render.js 整座岛在画面里上抬多少 量纲是世界格,应等于岛体重心高度(实测 2.0)。拍错会让岛稳定偏离屏幕中心
fit render.js 岛在屏幕里占多大 量纲是屏幕比例,由“包围盒该占屏多少”反推(实测 1.06)。拍错会让岛缩在中间空一圈

已知的坑

这几条都是开发时真踩过的,写下来省得再查一遍:

坑 症状 原因与修法
底面公式掺进地表高度 手动刷的高格悬空 底面必须只由“离岛心多远”决定,保证所有柱子同底。旧版有个 max(0, …) 夹取,天然地形下永远不触发,直到能手动刷地形才露馅 —— 详见立体化
fit / lift 拍一个数 岛缩在中间空一圈 / 稳定偏离屏幕中心 两个数都有量纲、都能实测。投出 8 个角求包围盒即可;正交投影下包围盒边长严格正比于 fit,调它不用试错
投影原点忘了加屏幕中心 整座岛稳定贴在画布左上角 正交投影只把网格中心摆到了原点,漏了 W/2、H/2。因为每帧都一致,看起来像“故意这样设计”的,最难查
颜色函数套两层 树冠变成纯黑 shade() 只吃 #rrggbb,把它的输出再喂进去会解析失败。需要改明度就在最外层调一次
锥底不做平滑 从下面看是一排悬空的尖牙 相邻两列的锥深差了好几个体素。3×3 圆盘平滑一下即可,且只动底座、不动地表
岛缘振幅太大 岛碎成几片条带 全量传播会把“最外圈钉成深渊”的约束一层层往里推,形状折得太狠时整圈被推穿
拖拽结束也算点击 想转视角,结果顺手改了地形 拖动累计超过 4px 就不判为点击
用 hidden 属性藏色板 藏不住 这里的 display 是 flex,会盖掉浏览器默认的 [hidden]{display:none}。改用类名控制
一帧推进过多格 大网格下动画一顿一顿 单帧限流(当前 40 格),剩下留到下一帧

它是怎么被做成“一个文件”的

工程分四个模块,开发时分开写、好读好改;交付时按原样内联进一个 HTML:

模块 文件 职责
求解器 code/wfc-core/solver.js 约束表、熵、传播、观测顺序
立体化 code/island-mesh/build.js 2D 地形 → 体素柱(高度、锥底、平滑)
渲染器 code/iso-renderer/render.js 正交投影、面剔除、画家排序、装饰物
点选与编辑 code/edit-pick/pick.js 屏幕点 → 哪一根柱子;换地形后整岛重挤

合并成单文件之后,整个作品就是 public/lab/wfc-island/index.html 一个文件 —— 拷走就能跑,删掉不留痕。

技术路线

  • 求解与播放分离。 求解阶段允许失败重试(WFC 里矛盾是原理性的,不是 bug),但只有成功的尝试才进入播放列表。用户看到的永远是能跑完的过程,不会播到一半突然“生成失败”。
  • 一次算完,不是逐帧求。 22×22 全量求解实测 3–10 ms,够快。所以可以直接把 顺序 + 结果 都存下来,播放时零计算 —— 暂停、单步、拖动进度条都是免费的。
  • 不用 3D 库。 这个场景没有光照、没有贴图、没有透视,只需要正交投影 + 画家算法。省掉的是一个 600 KB 的依赖和一次网络请求,还有手机上的帧率。
  • 点选不用“拾取缓冲”。 那是引擎在几何不可解析时的兜底手段。这里每个面都是已知四边形,投影后做一次射线交叉即可,多面命中时取相机深度最大者 —— 精确、无误差,也不需要深度缓冲。
  • 编辑后整岛重挤。 底面由 3×3 平滑决定,动一格会连带影响周围,所以不做增量更新。全岛几百格,重算比写增量代码省心,也快得多。

功能模块

最后更新:2026/9/25