一句话
一座岛从“什么都还不是”里,被一格一格地观测出来 —— 然后你可以伸手改它。
这是个什么项目
一个单文件、零依赖、跑在浏览器里的程序化生成小工程。它做三件事,严格分先后:
| 阶段 | 在做什么 | 你看到什么 |
|---|---|---|
| 求解 | 在 22×22 的网格上跑波函数坍缩,记录每一格被观测的顺序 | 一瞬(实测 3~10 ms) |
| 坍缩 | 按记录的顺序把格子逐个点亮 | 一张地形图从中心向外“长”出来 |
| 立体化 | 把 2D 地形挤出成体素岛,再整体长出 | 一座悬在空中的锥底小岛 |
| 编辑 | 点任意一格换地形,整岛即时重挤 | 沙地变岩石、山坡凿出一个洞 |
页面上只有一个主体 —— 那座岛。没有菜单、没有教程、没有“开始”按钮,打开就在跑。
什么是“波函数坍缩”
名字取自量子力学里一个不严谨的类比,实际是一个约束满足问题的求解算法:
- 每格初始都处于叠加态 —— 它可以是深渊、水域、沙滩、草地、林地、岩石中的任何一种。
- 规则表规定谁可以挨着谁 —— 这是整个工程的“物理定律”,只有一张表: 水可以挨草,沙可以挨岩,水不能挨林也不能挨岩;最外圈被钉死成深渊,于是岛一定是“环水的岛”。
- 反复做两件事,直到所有格子都确定:
- 观测:挑一个“候选最少”的格子(香农熵最低),按权重掷出它到底是哪种地形;
- 传播:把这一格的结论推给四邻 —— “你旁边是森林,那你不能再是水了”。
第 3 步里的传播才是 WFC 和“随机刷格子”的根本区别:定下一格会连带收紧一整片邻居。所以相邻地形永远自洽 —— 不会出现“悬崖边上直接是深水”这种一看就假的拼接。
观测顺序不是随机的:永远先处理候选最少的格子。从熵最低处下手,矛盾最早暴露,回溯代价最小。
下面这张就是它跑到一半的样子(观测 142 / 253,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