背景

5 年前のブログの復刻版です。迷宮生成アルゴリズム "WFC" を紹介します。

WFC とは何か

WFC のリポジトリにはドラマチックなデモがありました。画像や迷宮をランダム生成しています。これは一体何なのでしょうか? Web 検索すると、波動関数だエントロピーだと謎めいた解説が目につき、謎は深まるばかりです。

X3aNDUv.gif
Figure 1: 何が起きているのか?!

そんな中、実際に WFC を使ったゲームとしては Caves of Qud がありました。開発者のプレゼンテーション Data-Driven Engines of Qud and Sproggiwood には大いに影響を受けましたから、 WFC にも時間を割いて理解してみることにしました。

なぜ WFC に熱狂したのか

WFC のスター数は 23.4k です。 Minecraft の ECS (EnTT) ですら 10.3k ですから、ゲーム開発界隈の中でも特別に注目を浴びたリポジトリと言えます。

僕自身、 WFC の万能性に熱狂しました。マップ生成のアルゴリズムは、通常 1 種類の地形に特化しています。部屋が通路で繋がれた迷宮、入り組んだ渓谷、開けた荒野、等々の地形は、それぞれ専用のアルゴリズムにより生成されます。それぞれのアルゴリズムは、せいぜいパラメータの調整しかできませんから、とても汎用的とは呼べません。

ところが WFC は『入力と類似したマップを生成する』というアイデアですから、新しい地形を作り出すには、新しい入力データを与えるだけで済みます。まるで魔法のアルゴリズム!

注意!

先に水を差してしまいますが、 WFC は万能ではありません。結局のところ、個別の専用アルゴリズムを容易した方が無難だと思います。

WFC の『モデル』

WFC の正体は『制約充足問題』であると言われています。グリッドに NxN サイズのパターンを敷き詰めて、かつ制約に違反しない配置をランダムに見つけることで、入力データと類似した出力を生成します。

……と言っても、まったくピンと来ないと思います。より具体的に、ここでは一番面白いモデル、 Overlapping Model を紹介します。

1. パターン生成

WFC のコア・アルゴリズムは、 Solver です。 Solver は、与えられた Pattern 列を Pattern 同士の 『接続性』 (AdjacencyRule) を満たすようにランダムに配置します。

Model: Input -> ([Pattern], AdjacencyRule)
Solver: ([Pattern], AdjacencyRule) -> Map

以下では単純な例でデータの流れを紹介します。

入力

このモデルでは、 NxN サイズのタイルの塊を pattern として使います。

User input = map

Model は、入力として与えられたマップから、 [Pattern]AdjacencyRule を作ります。例として、 3x3 のマップを入力とします。

..#
.##
.#.

『パタン』のサイズを 2x2 として、入力から 2x2 のパタンを展開します。

..  .#
.#  ##

.#  ##
.#  #.

これらの回転 (rotation) や裏返し (flipping/reflection) も考えてみます(2x2 サイズでは、回転だけでも十分です)。

..  ..  #.  .#
.#  #.  ..  ..

.#  #.  ##  ##
##  ##  #.  .#    <-- (duplicated patterns)

.#  ..  #.  ##
.#  ##  #.  ..

重複を除くと、 12 個のパタンができました。 Solver はこのパタンをランダムに配置してマップを生成しますが、その際に以下の制約を設けます。

制約

Overlapping model では次の制約を課します。

出力から展開できるパタンの集まりは、入力から展開したパタンの部分集合である。

たとえば、次のような出力が得られたとします。

...
.#.
###

これを 2x2 に分けていくと、次のように 4 パタンに分かれます。これらは、どれも入力から展開できるパタンです。

(output)                  (patterns)

                            ..  ..
  ...        extract        .#  #.
  .#.    --------------->
  ###    <---------------   .#  #.
             overlap        ##  ##

これが overlapping model における制約です。 Local similarity とも呼ばれます。出力のどの部分 (NxN パタン) も入力のある部分 (NxN パタン) と一致します。

Example

僕が 実装 した WFC に、迷宮らしき入力を与えてみます。 # が壁、 . が床を表します。

Input (16x16)

###.########.###
###.#####......#
#.....###......#
#.....###......#
#...............
......###......#
#.....###......#
##.######......#
##.########.####
##.########.####
##......##....##
........##......
##............##
##......##....##
##......####.###
###.########.###

パタンサイズを 3x3 として走らせと、次のような出力が得られました。入力と似た風になっています。この出力から任意の 3x3 のパタンを取り出すと、それは上の入力マップからも (回転や鏡写しで) 見つけることができます。

Output (36x36)

...........##.##.##########.###.####
...........##.......#######.###.####
....................####.........###
...........##.......####.........##.
#.###.###.###.####.######.###.#####.
#.###.###.###.####.######.###.######
#.###.###.....##...######.......####
................................##..
.......##.....##...######...........
.......####.####...######.......##..
#.###.#####.####...##..########.##..
#.###.####...########..########.###.
...##.####...############..####.###.
...##.####...##..########..##.......
#.###.#########..##...###..##.......
#.###.#############...###..##.......
#..##.###....######...###..#########
...##.................##############
#......##....#######.#############..
#..###.##.##.#######.#############..
##.###.##.##.#####.....######...##..
##.##..##.......##.....######...##..
...##...........##.....######.......
#......###.###.###..............##..
#..###.###.###.###.....#######.#####
######.###.###.###.....#######.#####
######.###..##.#############.....###
............##.#############........
........##..##...##..#######.....##.
........##.......##..########.#####.
............###.###..########.#####.
........##..###.###..####.......###.
........##.......##..####.......###.
####.#####..##...##..####.......###.
####.#########...########.......###.
##....########...########...........

なお、床は 1 繋がりにはなっておらず、孤立した部分もあります。 ゲームのマップとしては、そのまま使うことはできません

Caves of Qud は WFC で円などの形 (segment) を埋めてから、孤立した部分を無くすプロセスを挟んでいるようです。

先ほどの出力を円形に切り出してみると、意味深になります。荒野に配置すれば魔物の巣として使えそうです。

             .......####
          ..........####...
         ..##.......####....
       ##.###.####.######.###.
      ###.###.####.######.###.#
     .###.....##...######.......
    ............................#
    ...##.....##...######........
   ....####.####...######.......##
  ###.#####.####...##..########.##.
  ###.####...########..########.###
  .##.####...############..####.###
 ..##.####...##..########..##.......
 .###.#########..##...###..##.......
 .###.#############...###..##.......
 ..##.###....######...###..#########
 ..##.................##############
 ......##....#######.#############..
 ..###.##.##.#######.#############..
 #.###.##.##.#####.....######...##..
 #.##..##.......##.....######...##..
 ..##...........##.....######.......
 ......###.###.###..............##..
  .###.###.###.###.....#######.####
  ####.###.###.###.....#######.####
  ####.###..##.#############.....##
   .........##.#############......
    ....##..##...##..#######.....
    ....##.......##..########.###
     .......###.###..########.##
      ..##..###.###..####......
       .##.......##..####.....
         #..##...##..####...
          ####...########..
             #...#######

制約充足問題の解き方

SolverModel が定義した AdjacencyRule を満たすように Pattern 配置のグリッドを作ります。と言っても確実な計算方法があるわけではなく、『試して上手くいったら採用』という形になります。ただし『こうすればよく上手くいく』という『発見的手法 (heuristic)』に頼ります。

ここから解説が投げ槍になります!

The solving loop

出力グリッドを『未確定』の状態で初期化してから、 Solver は走ります:

public class Solver {
    // ~~
    public bool run() {
        while (this.numRemainingCells > 0) {
            var cell = selectNextCellToDecidePattern(); 
            var pattern = selectStiilAvailablePatternForCell(cell); 
            decidePatternOfCell(cell, pattern); 
            this.numRemainingCells -= 1;
            propagateRemovals(); 
        } 
    }
}

ただし、配置可能なパタン数が 0 となるセルが出た場合は、失敗です。そのときは、今は最初からやり直すことにしておきます (後述) 。

各手順を詳しく解説します。

1. Selecting "the most constrained" cell (pattern weight heuristic)

未確定のセルから、次にパタンを確定させる0セルを選びます。このときヒューリスティックとして、最も "制約された" セルを選びます。具体的には、パタンが source に現れた回数をそのパタンの重み付けとし、重みの総和が最も小さなセルを選びます。すなわち重複したパタンの出現回数を増やします。

効率のため、 BinaryHeap に重みづけされたセルを入れて 1 つずつ取り出します:

2. Selecting a pattern

選んだセルから、配置可能なパタンをランダムに選び、確定させます。そのためには、重み付けされたセルの配列を、一種の run-length encoding とみなして、乱数を使って選びます。これによりパタンの重み付けが選択確率に反映されます。

なお配置可能なパタン数が 0 の場合は 失敗 なので注意します (contradiction) 。

3. Constraint propagation

セルのパタンを確定すると、そのセルの周囲に配置可能なパタンが減ります。この変化を追跡するためのステップです。

Enabler counts

まず、それぞれのセルについて、 4 方向に向けて、隣接可能なパタン (enabler) の数を追跡することにします。

EnablerCounts は、考えとしては Grid<Dictionary<Direction>> に相当します。

Removals of patterns

あるパタンを remove したときに、その影響を、 EnablerCounts の変化として周囲のセルに伝播させます。なお、 enabler の数が 0 になる方向が出た場合、そのパタンは remove されます。したがって、パタンの除去と EnablerCounts の更新は再帰的になり得ます。

4. Repeating until solved

手順 1. に戻り、次のセルを選びます。やがて全セルを『確定』させるか、どこかのセルに配置可能なパタンが無くなって『失敗』します。

Wrapping up

簡単 & 強力な新規の自動生成アルゴリズム 『Wave FunCtion collapse』 を解説する記事でした。入力と類似した出力を生成する万能は、プロトタイピングに適しています。しかし、出力に特定の制限を加えるのは難しいため、使い方に工夫が必要です。また、大きなマップを作る場合、パフォーマンスが問題になるかもしれません。