タイルマップと併用可能なプロシージャル生成パターン(後編)

ETHAN BRUINS / UNITY TECHNOLOGIES Contributor
Jun 7, 2018|14 Min
Hero image

前編では、パーリンノイズやランダムウォークなどの各種メソッドを使用してプロシージャルな手法で地形の高さを決める方法をいくつかご紹介しました。本記事では、プロシージャル生成による洞窟の作成方法をいくつかご紹介します。利用可能なバリエーションについても、ある程度ご理解いただけると思います。

本記事で取り扱っている例は、すべてこちらのプロジェクト内にあります。アセットをダウンロードしてプロシージャル生成アルゴリズムを試してみてください。

後編である本記事でも、前編と同じ規則が当てはまります。以下にこの規則を再掲します。

  • タイルとそれ以外の要素の識別はバイナリを使用して行われます。1 が ON、0 が OFF となります。
  • すべてのマップは整数の 2 次元配列内に保存されます。この配列は(レンダー時以外は)各関数の最後でユーザーに戻されます。
  • 配列を扱う関数である GetUpperBound() を使用して各マップの高さと幅を取得することで、各関数内に含まれる変数の数を減らし、コードをよりクリーンにします。
  • 私はよく Mathf.FloorToInt() を使用します。なぜなら、タイルマップの座標系が左下から始まっており、Mathf.FloorToInt() を使用することで数字を四捨五入して整数にすることができるからです。
  • 本記事内に掲載しているコードは全て C# で記述されています。
パーリンノイズ

前編の記事ではパーリンノイズを使って地形の高さを決める方法をいくつかご紹介しました。幸運なことに、洞窟もパーリンノイズで作成することができます。これは新しいパーリンノイズ値を 1 つ取得することによって行われます。この値が、現在の位置に特定の係数を乗じたパラメーターを受け取ります。この係数は 0 と 1 の間の値です。この係数の値が大きいほど、生成されるパーリンノイズが粗くなります。その後、この値を四捨五入して 0 か 1 の整数にし、それをマップ配列内に保存します。以下で実装をご覧ください。

public static int[,] PerlinNoiseCave(int[,] map, float modifier, bool edgesAreWalls)
{
int newPoint;
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{

if (edgesAreWalls && (x == 0 || y == 0 || x == map.GetUpperBound(0) - 1 || y == map.GetUpperBound(1) - 1))
{
map[x, y] = 1; //エッジは壁のままにする
}
else
{
//パーリンノイズを使って新しいポイントを 1 つ生成し、それを四捨五入して 0 か 1 の値にする
newPoint = Mathf.RoundToInt(Mathf.PerlinNoise(x * modifier, y * modifier));
map[x, y] = newPoint;
}
}
}
return map;
}

シードではなく係数を使用する理由は、0 と 0.5 の間の値で乗算する方が、生成されたパーリンノイズの見た目の質が向上するからです。値が低いほど結果の見た目が粗くなります。以下の GIF 画像をご覧ください。係数の値が 0.01 のものから 0.25 のものまで、段階的に値を増加させた結果画像を比較していただけます。

この GIF 画像を見ると、パーリンノイズの生成は、実際には同じパターンの表示範囲が 1 コマごとに拡大されているに過ぎないことが分かります。

ランダムウォーク

前編の記事では、プラットフォームが高くなるか低くなるかは「コイン投げ」によって決まることをご説明しました。本記事では、この同じ概念に「左右」の選択肢が追加されます。このバリエーションのランダムウォークアルゴリズムによって、洞窟の作成が可能になります。これは、ランダムに方向を決め、位置を移動してタイルを削除することで行われます。消去する必要のある床の数に達するまでこのプロセスを繰り返します。現段階では 4 方向(上・下・左・右)しか使用していません。

public static int[,] RandomWalkCave(int[,] map, float seed, int requiredFloorPercent)
{
// 乱数のシード値を設定する
System.Random rand = new System.Random(seed.GetHashCode());

// x 軸上の開始位置を定義する
int floorX = rand.Next(1, map.GetUpperBound(0) - 1);
// y 軸上の開始位置を定義する
int floorY = rand.Next(1, map.GetUpperBound(1) - 1);
// 必要な floorAmount を指定する
int reqFloorAmount = ((map.GetUpperBound(1) * map.GetUpperBound(0)) * requiredFloorPercent) / 100;
// while ループ用に使用される。reqFloorAmount に達したらトンネリングを停止する。
int floorCount = 0;

// タイルでなくなる開始位置を設定する(0 = 非タイル、1 = タイル)
map[floorX, floorY] = 0;
// フロアの数を増加させる
floorCount++;

この関数は以下を行うことで開始されます。

開始位置を特定する

削除しなければならないフロアタイルの数を計算する

開始位置のタイルを削除する

フロアの数に 1 を加える

次に、while ループに移ります。これにより洞窟が生成されます。

while (floorCount < reqFloorAmount)
{
// 次の方向を指定する
int randDir = rand.Next(4);

switch (randDir)
{
// 上方向
case 0:
// エッジがタイルのままになるようにする
if ((floorY + 1) < map.GetUpperBound(1) - 1) { // y を上方向に 1 動かす floorY++; // 該当部分が現在まだタイルであるかどうかチェックする if (map[floorX, floorY] == 1) { // 非タイルに変更する map[floorX, floorY] = 0; // フロアの数を増加させる floorCount++; } } break; // 下方向 case 1: // エッジがタイルのままになるようにする if ((floorY - 1) > 1)
{
// y を下方向に 1 動かす
floorY--;
// 該当部分が現在まだタイルであるかどうかチェックする
if (map[floorX, floorY] == 1)
{
// 非タイルに変更する
map[floorX, floorY] = 0;
// フロアの数を増加させる
floorCount++;
}
}
break;
// 右方向
case 2:
// エッジがタイルのままになるようにする
if ((floorX + 1) < map.GetUpperBound(0) - 1) { // x を右に動かす floorX++; // 該当部分が現在まだタイルであるかどうかチェックする if (map[floorX, floorY] == 1) { // 非タイルに変更する map[floorX, floorY] = 0; // フロアの数を増加させる floorCount++; } } break; // 左方向 case 3: // エッジがタイルのままになるようにする if ((floorX - 1) > 1)
{
// x を左に動かす
floorX--;
// 該当部分が現在まだタイルであるかどうかチェックする
if (map[floorX, floorY] == 1)
{
// 非タイルに変更する
map[floorX, floorY] = 0;
// フロアの数を増加させる
floorCount++;
}
}
break;
}
}
// 戻り値として更新されたマップを返す
return map;
}

何が行われているのか?

まず最初に、進むべき方向を乱数によって決定しています。次に、switch case ステートメントでその新しい方向をチェックします。このステートメント内で、該当の位置が壁かどうかをチェックします。壁でなければ、そのタイルを配列から削除します。これを、必要なフロアの数に達するまで続けます。最終的には以下のようになります。

この関数のカスタムバージョンとして、斜め方向も含まれるものも作成しました。この関数用のコードは少し長いので、ご覧になりたい方は本記事の冒頭に掲載されているプロジェクトへのリンクをご利用ください。

Directional tunnel

DirectionalTunnel はマップの一つの端から開始して反対側の端に向かってトンネル(穴)を生成します。トンネルの曲線とラフネスを関数内に入力して制御可能です。またトンネル各部の長さの最小値と最大値も設定できます。実装は下記のようになります。

public static int[,] DirectionalTunnel(int[,] map, int minPathWidth, int maxPathWidth, int maxPathChange, int roughness, int curvyness)
{
// あとの部分で、この tunnelWidth に設定した値の正負を反転させた数を初期値として、元の tunnelWidth の値までループを回してトンネルを掘ります。ここでは幅の値が 1 で、-1, 0, 1 で 3 回ループが回り、幅が 3 のトンネルができます。
int tunnelWidth = 1;
// x 軸上の開始位置をトンネルの中心に設定する
int x = map.GetUpperBound(0) / 2;

// 乱数のシード値を設定する
System.Random rand = new System.Random(Time.time.GetHashCode());

// トンネルの開始部分を生成する
for (int i = -tunnelWidth; i < = tunnelWidth; i++)
{
map[x + i, 0] = 0;
}

何が行われているのか?

まず始めに幅の値を設定します。この幅の値として設定した数の正負を逆転させた数から元の数までループを回し、必要な幅のトンネルができます。ここではその値を 1 にしています。これでループが回され、結果として合計の幅が 3 になります(-1、0、1 と値が変わるため)。

次に、x 軸上の開始位置が設定されます。これは、マップの幅の中央を取得することによって行われます。この時点で最初の 2 つの値が設定されたので、マップの開始部分のトンネルが生成できます。

ここで、マップの残りの部分の生成に移りましょう。

// 配列を周回する
for (int y = 1; y < map.GetUpperBound(1); y++) { // ラフネスを変更できるかチェックする if (rand.Next(0, 100) > roughness)
{
// 幅の変更量を取得する
int widthChange = Random.Range(-maxPathWidth, maxPathWidth);
// それをトンネルの幅の値に追加する
tunnelWidth += widthChange;
// パスを小さくし過ぎていないかチェックする
if (tunnelWidth < minPathWidth) { tunnelWidth = minPathWidth; } // パスの幅が最大値を超えていないかチェックする if (tunnelWidth > maxPathWidth)
{
tunnelWidth = maxPathWidth;
}
}

// 曲線を変更できるかどうかチェックする
if (rand.Next(0, 100) > curvyness)
{
// x 軸上の位置の変更量を取得する
int xChange = Random.Range(-maxPathChange, maxPathChange);
// それを x の値に追加する
x += xChange;
// マップの左側に近接し過ぎていないかチェックする
if (x < maxPathWidth) { x = maxPathWidth; } // マップの右側に近接し過ぎていないかチェックする if (x > (map.GetUpperBound(0) - maxPathWidth))
{
x = map.GetUpperBound(0) - maxPathWidth;
}
}

// トンネルの幅の分の処理を行う
for (int i = -tunnelWidth; i < = tunnelWidth; i++)
{
map[x + i, y] = 0;
}
}
return map;
}

乱数を生成して、ラフネスの値と照らし合わせてチェックし、乱数がラフネスの値を超えていればパスの幅を変更できます。また幅を小さくし過ぎていないかもチェックします。次の短いコードで、トンネリングを行いながらマップの処理を続けます。各ステップごとに以下が行われます。

曲線の値と照らし合わせてチェックするために新しい乱数を生成する。上記のチェック同様、これが曲線の値を超えていればパスの中央点を変更できます。また、マップの端の外に出ていないかどうかもチェックします。

最後に、生成した新しいセクションに穴を空けていきます。

最終的にこの実装は以下のような結果になります。

セルオートマトン

セルオートマトンはセルの近傍を使用して現在のセルが on (1) であるか off (0) であるかを決定します。これらの近傍のベースはランダムに生成されたセルのグリッドを使用しています。ここではこの最初のグリッドを C# の Random.Next 関数を使用して生成します。

セルオートマトンの実装にはいくつか異なるものがあるので、このベースグリッドを生成するための別の関数を作成しました。以下がその関数です。

public static int[,] GenerateCellularAutomata(int width, int height, float seed, int fillPercent, bool edgesAreWalls)
{
// 乱数生成器にシード値を設定する
System.Random rand = new System.Random(seed.GetHashCode());

// マップを初期化する
int[,] map = new int[width, height];

for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{
// エッジが壁に設定されている場合は、セルが on(1)に設定されるようにする
if (edgesAreWalls && (x == 0 || x == map.GetUpperBound(0) - 1 || y == 0 || y == map.GetUpperBound(1) - 1))
{
map[x, y] = 1;
}
else
{
// グリッドをランダムに生成する
map[x, y] = (rand.Next(0, 100) < fillPercent) ? 1 : 0;
}
}
}
return map;
}

この関数内では、グリッドに壁を持たせるかどうかも設定できます。それ以外は比較的単純です。フィルレートに照らし合わせて乱数をチェックし、現在のセルが on であるか off であるか特定します。結果は以下のようになります。

ムーア近傍

ムーア近傍は、初期設定のセルオートマトン生成を平滑化するために使用されます。ムーア近傍は以下のようになっています。

近傍の規則は以下の通りです。

  • 近傍のチェックを全方向において行う
  • 近傍がアクティブなタイルである場合、周囲のタイルに 1 つ追加する。
  • 近傍がアクティブなタイルでない場合、何も行わない。
  • セルの周囲にタイルが 5 つ以上ある場合、そのセルをアクティブタイルにする。
  • セルの周囲にあるタイルがちょうど 4 つである場合は、タイルをそのままにする。
  • マップ内のすべてのタイルをチェックし終わるまで繰り返す。

ムーア近傍のチェックのための関数は以下の通りです。

static int GetMooreSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls)
{
/* ムーア近傍は次のようになっている(「T」はタイル、「N」は近傍)。
*
* N N N
* N T N
* N N N
*
*/

int tileCount = 0;

for(int neighbourX = x - 1; neighbourX < = x + 1; neighbourX++)
{
for(int neighbourY = y - 1; neighbourY <= y + 1; neighbourY++) { if (neighbourX >= 0 && neighbourX < map.GetUpperBound(0) && neighbourY >= 0 && neighbourY < map.GetUpperBound(1))
{
// 周囲のチェックが行われている、中心のタイルはカウントしない
if(neighbourX != x || neighbourY != y)
{
tileCount += map[neighbourX, neighbourY];
}
}
}
}
return tileCount;
}

タイルのチェックが終了したら、平滑化の関数内の情報の使用へと進みます。ここでも、初期設定のセルオートマトン生成同様に、マップのエッジを壁にするかどうか設定できます。

public static int[,] SmoothMooreCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount)
{
for (int i = 0; i < smoothCount; i++)
{
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++) { int surroundingTiles = GetMooreSurroundingTiles(map, x, y, edgesAreWalls); if (edgesAreWalls && (x == 0 || x == (map.GetUpperBound(0) - 1) || y == 0 || y == (map.GetUpperBound(1) - 1))) { // edgesAreWalls が true であればエッジを壁として設定する map[x, y] = 1; } // デフォルトのムーア近傍の規則では 5 つ以上の近傍が必要 else if (surroundingTiles > 4)
{
map[x, y] = 1;
}
else if (surroundingTiles < 4)
{
map[x, y] = 0;
}
}
}
}
// 戻り値として修正されたマップを返す
return map;
}

この関数内で重要なのは、マップ全体の平滑化を一定回数行うための for ループがあることです。これが最終的なマップの見た目を向上させます。

このアルゴリズムは、(例えば穴と穴の間にブロックが 2 つしかない場合などに)穴同士を繋げて修正することができます。

ノイマン近傍

ノイマン近傍も、セルオートマトン生成の一般的な実装メソッドのひとつです。この生成では、ムーア生成で使用したものよりもシンプルな近傍を使用します。近傍は以下のようになっています。

この近傍の規則は以下の通りです。

  • タイルの周囲の直接の近傍をチェックする(斜め方向は含めない)。
  • セルがアクティブであれば、カウントに 1 つ追加する。
  • セルが非アクティブであれば、何も行わない。
  • 3 つ以上の近傍がある場合は、現在のセルをアクティブにする。
  • 近傍が 1 つ以下の場合は、現在のセルを非アクティブにする。
  • 近傍の数がちょうど 2 つであれば、現在のセルを修正しない。

2 つ目の結果は 1 つ目の結果と原則的に同じですが、近傍エリアが拡大されます。

近傍のチェックには以下の関数を使用します。

static int GetVNSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls)
{
/* ノイマン近傍は次のようになっている(「T」はタイル、「N」は近傍)。
*
* N
* N T N
* N
*
*/

int tileCount = 0;

// エッジは壁のままにする
if(edgesAreWalls && (x - 1 == 0 || x + 1 == map.GetUpperBound(0) || y - 1 == 0 || y + 1 == map.GetUpperBound(1)))
{
tileCount++;
}

// マップの左側に接触しないようにする
if(x - 1 > 0)
{
tileCount += map[x - 1, y];
}

// マップの最下部に接触しないようにする
if(y - 1 > 0)
{
tileCount += map[x, y - 1];
}

// マップの右側に接触しないようにする
if(x + 1 < map.GetUpperBound(0))
{
tileCount += map[x + 1, y];
}

// マップの最上部に接触しないようにする
if(y + 1 < map.GetUpperBound(1))
{
tileCount += map[x, y + 1];
}

return tileCount;
}

近傍がいくつあるかの結果が取得できたら、配列の平滑化に移ることができます。上述のムーア近傍の場合と同様、入力された必要な回数の平滑化のイテレーションを行うための for ループがあります。

public static int[,] SmoothVNCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount)
{
for (int i = 0; i < smoothCount; i++)
{
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++) { // 周囲のタイルを取得する int surroundingTiles = GetVNSurroundingTiles(map, x, y, edgesAreWalls); if (edgesAreWalls && (x == 0 || x == map.GetUpperBound(0) - 1 || y == 0 || y == map.GetUpperBound(1))) { // エッジは壁のままにする map[x, y] = 1; } // ノイマン近傍を使う場合、周囲タイルの数は 3 つあればタイルに変更する else if (surroundingTiles > 2)
{
map[x, y] = 1;
}
else if (surroundingTiles < 2)
{
map[x, y] = 0;
}
}
}
}
// 戻り値として修正されたマップを返す
return map;
}

結果は以下のように、ムーア近傍よりも大幅に粗くなります。

ここでもムーア近傍と同様、この生成に加えて更に追加スクリプトを実行し、マップ各部の間の接続を向上させることも可能です。

まとめ

本記事が、皆様がプロジェクトにプロシージャル生成を使用するきっかけになれば嬉しく思います。まだプロジェクトをダウンロードされていない方は、こちらから是非ダウンロードしてください。プロシージャル生成マップについてより詳しく学びたい場合は Procedural Generation Wiki(英語) および Roguebasin.com(英語)をご覧になることをお勧めします。

プロシージャル生成を使用して何か面白いものを作成されたなら、ぜひ Twitter あるいは下のコメント欄で私に教えてください!

Unite Berlin での 2 次元プロシージャル生成についての講演

より深く掘り下げて学べるライブデモ・セッションに参加したいですか? 6 月 20 日、Unite Berlin 会場のホールのミニシアターで、タイルマップと併用可能なプロシージャル生成パターンについてお話します。また講演後も会場におりますので、直接お話しましょう!(※訳注:Unite Berlin は 6 月 21 日、盛況のうちに閉幕しました)