← 图形原理 · 交互课 课题八 · 程序化生成 · 第 3 课

WFC(瓦片模型):约束怎么自己传开

全课题骨架:程序化生成 = 不摆位置、只定规则 —— 同一颗种子 + 同一个规则,必须长出同一片内容。 前两课把随机数变成了一张场、又把那张场拧成了山。可还有一整类内容不是"一个数在各处取多少",而是 一块一块拼起来的东西:地板、墙、路、地块、房间。
这一课换一种规则写法:不说"相邻处要相似",只说"相邻处必须是一对见过的搭配" —— README 把它叫 simple tiled model,并原话写明"这个模型的传播阶段就是邻接约束传播"。
于是全部内容只有三行:① 每格先留一叠"还可能是什么";② 某格删掉一个值时,把这个消息传给四个邻居; ③ 挑"可能性最少"的那一格动手。
四段路:一格先留一叠(14.1)→ 约束怎么自己传开(14.2)→ 先削哪一格(14.3)→ 拼不出来是常态(14.4)。 每一段只增加一样东西,其余控件一个字不动。 保真度:真机可证(可测) —— 算法真的在本页跑,四张表、十条判据、画面上的每一格 都出自同一份 propagate() / observe()。本页没有 GPU 成分(瓦片模型是 CPU 算法), 所以不涉及着色与可见面判据。

分段:
起点: ← 14.1 的旋钮
传播: |钉住一格: ← 14.2 的旋钮
观察顺序: ← 14.3 的旋钮
失败后: |网格: ← 14.4 的旋钮
(四张表与十条判据在后台跑)

(脚本启动中…)

定点观察:先点「一格一张(贪心)」→ 看格与格之间那些亮着的小横杠 → 172 个接口里有 85.7 个接不上(49.8%,手算一半);再切到「一叠可能」→ 那些接口全变成"待定",一点判定都还没有。

本帧的读数

(脚本启动中…)

(点「连跑 100 局」就在这里出数)

14.1 别一格一格挑:一格先留一叠

本段只增加一样东西:格子上存的是什么 —— 从"一张瓦片"改成"一叠可能" (旋钮是「起点」两档,传播、观察顺序、网格都还没出现)。
上一段的缺口(本课起点):要拼一张图,最直觉的做法是一格一格挑一张摆上去。 那就把它摆出来看:在 12×8 的网格上随机摆满(一共 172 个接口),接不上的有 85.7 个 = 49.8% —— 差不多每两个接口就有一个是错的。这不是"手气不好",是没有任何东西能告诉你摆错了: 你摆到最后一格才发现,而当你只有"一张一张挑"这个动作时,回头也没有用。
能拿笔核:4 张瓦片在某一侧一共 16 种搭配,其中"接口对上"的恰好 8 种 (一侧开着还是关着,两边各两选一 ⇒ 2 × 2 × 2 = 8)⇒ 随机摆,正好一半接不上。实测 49.80% / 49.77% / 49.95%。
WFC 换了个出发点:不挑,先留一叠。每格先写上"四种都可能",然后只做一件事 —— 把接不上的那些删掉。代价写在读数里:整张图 496 ≈ 6.28 × 1057 种摆法, 穷举不可能,所以唯一的出路是"删"。
★ 顺带说清本页为什么只用 4 张瓦片:真实的 WFC 图集常有几十张,但多出来的瓦片只是把邻接表加长, 算法一个字都不变;4 张的好处是下面每一条邻接规则都能拿笔核一遍。 另外本页的边界是非周期的,而且这个图集里没有一张瓦片会被边缘排除 (任何瓦片在任何方向都至少有一个合法邻居)—— 参考实现里边缘是会封边的(`Model.cs` 的 `Ban`), 本页没有这一段,如实标注。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 4 张瓦片 空 · 横 · 竖 · 弯 (接口方向 L U R D) 12 × 8 = 96 格 旋钮:起点 一格一张 / 一叠可能 ★ 本段只加"存什么" // 4 张瓦片:用“哪几边开着”表示(L=1 U=2 R=4 D=8) const OPEN = [0, L | R, U | D, U | R]; // 邻接表:方向 d 上,瓦片 a 的邻居能是哪些 function allowed(a, b, d) { return ((OPEN[a] & BIT[d]) !== 0) === ((OPEN[b] & OBIT[d]) !== 0); } // 起点:不挑,先留一叠 —— 每格四种都可能 dom.fill(0b1111); 起点=一格一张: 172 个接口里 85.7 个接不上 = 49.80% 起点=一叠可能: 96 格 × 4 种 = 4^96 ≈ 6.3e57 ★ 手算:每方向 8/16 ★ 穷举不可能 ⇒ 只能删
定点观察:点「一格一张(贪心)」→ 盯住格与格之间那些亮着的小横杠(那是"这个接口接不上")→ 一半的接口都亮着;再点「一叠可能」→ 接口全部变成"待定"的小短桩,一个判定都没有。
★ 读数行给出当前档的"非法接口 N 处 / 共 172 处",两档可以直接对着看。
表① 这个图集的全部邻接规则(12×8 网格;"左邻"读作"钉住这一张时,它左边那格还剩几种")。 合法对:每个方向 16 个组合里恰好 8 个 ⇒ 四方向合计 32。手算:接口"对上"要求两侧同开或同关, 每侧两选一 ⇒ 2 × 2 × 2 = 8。
★ 读法很关键:左邻和下邻永远还剩 2 种,只有"右邻必须是横""上邻必须是竖"这两条会掉到 1 种 —— 因为这个图集里唯一的弯是上右弯。这一列下面要反复用(它就是 14.2 那两把"尺子"的来源)。
★ 贪心档的实测:随机摆满 12×8,172 个接口里平均 85.7 个接不上(49.80%;16×12 = 49.77%、24×16 = 49.95%, 各 200 次随机摆求平均)。总摆法数 496 的精确值是 58 位十进制数,写在下面第一行。
钉住的瓦片开在哪几边左邻上邻右邻下邻

14.2 约束不是"检查",是"传开"

本段只增加一样东西:传播(旋钮是「传播 关/开」,起点仍是叠加态)。
上一段的缺口:每格都是一叠四张,可谁也没法从这一叠里删掉任何东西 —— 除非有人先"定下来"。
传播的规则只有一句话:某格删掉一个瓦片时,问它的四个邻居一句"你还撑得住吗"; 撑不住的值也跟着删,删完再往下一圈问。于是"接口"这条规则会自己走开,不需要谁来监督。
四个按钮让你把画面中央那一格钉死,看它传多远(钉「横」/「竖」/「弯」/「空」,网格 12×8):
· 钉「横」在 (x, y) ⇒ 它右边整行都被钉成「横」,唯一确定的格数 = W − x
· 钉「竖」在 (x, y) ⇒ 它上方整列都被钉成「竖」,格数 = y + 1
· 钉「弯」在 (x, y) ⇒ 两条尺子同时使,格数 = y + W − x
· 钉「空」⇒ 一格都钉不动(只有它自己 1 格)
为什么是"右"和"上"?回到表①:只有 横 ⇒ 右邻唯一、竖 ⇒ 上邻唯一 这两条会掉到 1 种。 一个方向的形状,就决定了约束往哪个方向传。
★ 而且它传出来的形状不是圆:钉「空」形成的只是"左行 + 下列"两条臂 (21×21 网格上共削动 23 格、而且一格也没削成唯一):钉「横」一边把右行钉死, 一边把整个右下象限削到 2 种(40×40 网格上共 143 格被削动、其中 19 格削成唯一)。 约束沿行、沿列走,不沿对角线。
★ 还有一件事值得单说:传播不是"重新检查一遍"。检查是"把 96 格全扫一遍";传播是 只跟着变过的地方走(把"变过的格"压进一个栈,弹出来才去问它的四邻)—— 这正是参考实现的做法。所以"从空棋盘钉住一格"要弹出 72 次(钉「横」在 (0,3)), 而一局平均下来每次观察只弹十几次:越往后,越没什么可传的。
★ 本页的传播跑到不动点才停(一直弹到栈空),这也是 AC-3 那个算法的终止条件; 判据② 会用"逐弧检查"这条独立路线复核它真的到了不动点。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 一格被钉住 (四个"钉住"按钮) 邻接表 PROP[d][t] 旋钮:传播 关 / 开 ★ 本段只加"传不传" // 传播:只跟着“变过的地方”走 while (bd.q.length) { const i = bd.q.pop(), cur = bd.dom[i]; for (let d = 0; d < 4; d++) { const j = nb(bd, i, d); if (j < 0) continue; const nd = bd.dom[j] & support(d, cur); if (nd === bd.dom[j]) continue; if (nd === 0) return false; /* 域削空 ⇒ 矛盾 */ bd.dom[j] = nd; bd.q.push(j); } } 钉「横」(0,3): 右行 11 格全变「横」 确定 12 格 / 弹出 72 次 钉「空」(5,4): 确定 1 格(钉不动) 形状不是圆: 是"沿行 + 沿列"两条臂 ★ 只跟着变过的地方走
定点观察:依次点「钉横」「钉竖」「钉弯」「钉空」→ 盯住画面里变亮(被定下)的格子 → 横钉出右边整整一行、竖钉出上方整整一列、弯两把尺子一起使、而"空"一格都钉不动。
★ 读数行会写出这一次传播的"确定格数 / 弹出格数",四张按钮的差别一眼可见。 ★ 把传播切到「关」再点这四个按钮:画面一动不动 —— 那才是"只检查、不传播"的样子。
表② 一次观察能传多远(12×8 网格,钉住画面中央偏左那一片里的指定格,跑到不动点)。 两列可以拿笔核:"闭式"一列是"右行 + 上列 + 自己"直接数出来的格数,与实测逐格相符 (判据④ 把 12×8 × 4 张 = 384 组全部扫了一遍,与闭式不符 0 处)。
★ 「弹出」= 传播里被压进栈又弹出来的格子数(每弹出一格就查它的 4 个邻居); 「最长级联」= 同一次观察里那一段连续传播的长度。两者相等说明这一次传播从头到尾就是一条链。
钉在哪钉成什么闭式(格)实测确定(格)右行变「横」上列变「竖」弹出次数最长级联

14.3 先削哪一格:最小熵

本段只增加一样东西:观察顺序(旋钮三档;起点仍是叠加态、传播仍是开)。
上一段的缺口:传播只能削"已经有格子定下来"的地方 —— 而一开始 96 格一模一样(都是四张), 没有任何东西能把第一刀砍下去。所以必须由我们挑一格、挑一张、钉下去。 挑哪一格,就是本段唯一的旋钮。
三档:① 均匀随机挑(在"还没定下来"的格里随便挑一个); ② 最小熵 · 平局随机断(挑"还可能的值最少"的那一格 —— 权重全等时熵就是 log|D|, 所以这就是"域最小";并列时随机挑一个); ③ 最小熵 · 平局按扫描顺序(同上,但并列时取扫描顺序里最靠前的那一格)。
★ ③ 才是参考实现的写法:它的 Observe 用严格小于(entropy < min), 所以并列时"第一个遇到的"赢 —— 我们照抄这个细节,并把它单列成第三档,因为它的后果很大。
实测(12×8、各 2000 局、同一颗种子起点):失败率 7.50% → 0.65% → 0.00%。
★ 两件事值得注意。一、它省下来的全是失败,不是"少挑几格":三档平均挑 11.7 / 14.1 / 9.6 格 —— ② 反而比 ① 挑得更多(① 常常挑到"域还很大"的格,一刀就把一大片随手定下来, 代价是把后面的路堵死了)。二、平局怎么断,和"挑哪一格"一样重要:③ 与 ② 只差并列时取哪一个, 失败率从 0.65% 掉到 0.00%(2000 局一次都没失败)。这一条参考实现从没承诺过 —— 它是"严格小于"的一个副作用,是我们量出来的。 ★ 我们不声称它"永不失败":README 自己的原话是这个问题 NP-hard、 "run into contradictions surprisingly rarely";我们只是在这张图集与这几个尺寸上没量到失败。
★ 表③ 是 ③ 档下一局棋的逐步账:8 步(正好等于网格高度),第 1 步钉在 (0,0) 得到「弯」, 一次级联弹出 96 次 —— 96 格里手工只挑了 8 格,剩下 88 格(91.7%)是约束自己传开的。 同一颗种子换 ② 档要 17 步、① 档 14 步(也在表③ 说明里)。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 当前所有格子的域 (还没定下来的那些) 挑格规则 ① 随机 ② 熵·随机 ③ 熵·扫描顺序 ★ 本段只加"先削哪一格" // 挑“还可能的值最少”的那一格;并列时取扫描顺序最前 function observe(bd) { let pick = -1, best = 99; for (let i = 0; i < bd.dom.length; i++) { const s = pc(bd.dom[i]); if (s > 1 && s < best) { best = s; pick = i; } } // 钉下去,然后交给 propagate(上一段那一份) bd.dom[pick] = 1 << t; bd.q.push(pick); 同一颗种子(12×8): ① 随机挑 14 步 ② 熵·平局随机 17 步 ③ 熵·扫描平局 8 步 ③ 档这局: 手工挑 8 格 传播自己定下 88/96 = 91.7% ★ 省下来的全是失败
定点观察:把观察顺序在三档之间来回切,按「连跑 100 局」→ 盯住读数行里的"失败 N 局" → 同一颗种子、同一条约束、同一个传播,只换了"先削哪一格",失败局数一路往下掉(2000 局口径:7.50% → 0.65% → 0.00%)。
★ 同时看"手工挑了几格"那一列:它没有跟着单调下降(11.7 → 14.1 → 9.6)—— 最小熵省掉的是失败,不是工作量。
表③ ③ 档下一局棋的逐步账(12×8、种子 20261008)。「挑前 |D|」是塌缩之前那一格还剩几种; 「新定下」是这一步之后(含它自己)被唯一确定的格数;「级联弹出」是这一次传播弹出栈的次数。
★ 两处可以拿笔核:8 步 × 12 格 = 96 格(每步正好定下一整行); 传播自己定下 96 − 8 = 88 格 = 91.7%。
★ 同一颗种子换另外两档:② 档 17 步(首步 (6,7) 得到「横」,级联 13)、① 档 14 步。
第几步钉在哪挑中哪张挑前 |D|这一步新定下级联弹出累计已确定

14.4 拼不出来是常态

本段只增加一样东西:失败之后怎么办(旋钮是「失败后 停下/重开」与「网格尺寸」)。
上一段的缺口:最小熵把失败率压到了 0(在这张图集上),可为什么会有失败,还没有答案。
答案可以在钉下去之前就算出来:相邻两格随机取值,合法搭配只有 8/16 = 一半 ⇒ 距离 1 的两格,矛盾概率 = 1 − 1/2 = 50.0%(实测也正是 50.0%)。再往外散: 距离 2 → 31.3%、距离 4 → 21.9%、距离 10 → 16.3%、距离 24 → 13.6%(实测曲线在表④ 说明里)。 所以"拼不出来"不是实现出错,是约束本身无解 —— 而它离得越远越难撞上,恰恰因为那条"规则"传不了那么远。
网格越大越容易撞上:随机挑档的失败率 7.50% → 13.70% → 20.70% → 31.00% → 40.67% (12×8 → 16×12 → 24×16 → 32×24 → 48×32,表④ 现场跑出来)。
参考实现遇到这件事怎么办?一手源的原话是不回溯 —— "Unlike many constraint solvers, the reference implementation of WFC does not employ backtracking. When it encounters a contradiction, it restarts from the beginning, giving up if it fails too many times (by default, the limit is ten attempts)." 本页的两个按钮就是这两种处置:停下(把那个被削空的格子用白色叉标出来)与 重开(换一颗种子重来,最多 10 次 —— 面板上会写"第几次重开")。
★ 本页只做瓦片模型(规则 = 邻接),所以"拼不出来"只来自"接口接不上"这一条。 下一课换重叠模型,规则变成"输出里出现的每个 N×N 局部图案都必须来自输入"(README 的 C1 条件)—— 那时"拼不出来"会换一副面貌,而 C1 的好处是它可以逐图案机器验证,比这里的"失败率"硬得多。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 某格的域被削空 (= 矛盾,画面上打白叉) 旋钮:失败后 停下 / 重开(≤10 次) 旋钮:网格尺寸 12×8 … 48×32(表④) ★ 本段只加"失败怎么办" /* 失败之后:参考实现不回溯 —— 整张重来,最多 10 次 */ if (!propagate(bd)) { attempt++; if (attempt >= 10) return null; // 放弃 seed++; // 换一颗种子 bd = newBoard(W, H); attempt === 0 && dg('第 ' + attempt + ' 次重开'); } // 12×8 有 172 个接口;相邻两格合法搭配只有 8/16 失败率(12×8 → 48×32): ① 随机挑 7.50% → 40.67% ② 熵·平局随机 0.65% → 11.67% ③ 熵·扫描平局 0.00% → 0.00% ★ 手算(距离 1):  1 − 8/16 = 50.0% ★ 失败=约束无解
定点观察:切到「① 随机挑」,把网格从「12×8」换到「24×16」,按「连跑 100 局」→ 盯住读数行里的失败局数 → 同一个算法只因为格子多了,失败就从 7.50% 涨到 20.70%; 再用「观察一步」慢慢走一局,撞上矛盾时画面上会打一个白叉(那就是被削空的格子)。
★ 把「失败后」切到「重开」:同样会撞上,但面板会报"第 N 次重开"并换种子继续 —— 这正是参考实现的处置方式:不回溯,整张重来。
表④ 三档观察顺序 × 五个网格尺寸的失败率(现场跑出来的;每格的局数写在最右列)。 三档的差别只有"先削哪一格"这一件事,约束、传播、瓦片集完全一样。
★ 可以拿笔核的那一格:距离 1 的两格随机取值,合法搭配 8/16 ⇒ 矛盾概率 50.0%(实测 50.0%); 实测的整条曲线 —— 距离 2 → 31.3%、3 → 25.0%、4 → 21.9%、6 → 18.8%、10 → 16.3%、16 → 14.8%、24 → 13.6% (48×48 网格、A 钉在正中、B 覆盖全部四个方向,穷举 16 种瓦片组合)。
★ 「手工挑」= 成功局里人(观察步骤)挑了几格;「传播自己定下」= 1 − 它 ÷ 总格数。 ★ 尺寸越大,"传播自己定下"的比例越高(87.8% → 97.3%)—— 约束比人勤快得多,而且格子越多越明显。
网格格数① 随机挑② 熵·平局随机③ 熵·扫描平局手工挑(③档)传播自己定下(③档)每格局数

这一课带走了什么

整课的三个动作,就是 WFC 的全部:① 每格留一叠;② 某格删一个值时,把这个消息传给四个邻居; ③ 挑"可能性最少"的那一格动手。剩下的东西(权重、对称性、几十张瓦片、3D、边界封边、重叠模型) 都只是把这三行加长,没有换掉任何一行。
它还解释了几件工业上的事:为什么 WFC 一跑起来就"好看"(传播把 85%~97% 的格子自己定下来了, 人只挑十几格);为什么"挑哪一格"这种细节会被单独写进论文(它是唯一真正影响成败的旋钮); 为什么这类算法永远要给一个"失败上限"(约束无解是内在的,不是 bug); 以及为什么它至今仍被当成"能看的过程"(每一步都在删东西,读者能跟着看)。
下一课指针:这一课的规则是"相邻两格必须是一对见过的搭配"。下一课换一种写法 —— "输出里出现的每一个 N×N 局部图案都必须来自输入"(README 的 C1 条件),也就是 重叠模型。规则从"两格"变成"N×N 的一块",好处是 C1 可以逐图案机器验证 —— 我们可以真的去数"输出里有没有出现过输入里没有的图案",这个数必须是 0。

本页用到的做法,出处在哪

运行时自检 等待脚本…