2026年7月29日

プロシージャル生成に大きく依存するゲームでは、ランダム数生成器を決定論的に動作させたい場合がよくあります。1つのシードを与えれば、すべてのシステムが毎回同じゲーム要素を全員に対して生成するようにしたいのです。RNGをあまり使わないゲームであれば、エンジンに付属するRNGをそのまま使って済ませるかもしれませんが、より複雑な生成を行うゲームでは、RNGを使用するゲームの各領域ごとに異なる乱数生成器を複数用意するのが一般的です。

この方法は、プロジェクト全体で単一の乱数生成器を使用する場合に生じる、異なるシステム間の出力結果の連動を解消するのに優れています(例: Unity組み込みのRandom.valueRandom.Range())。共有の生成器が1つしかない場合、各システムの出力はこれまでのクエリ回数によって変わってしまいます。気に入ったマップ生成結果があれば、ゲームの共有シードを保存して後で同じマップを生成できます。しかし、すべてのシステムが1つの生成器の状態をクエリ・変更していると、プロセスの中で余分なアイテム抽選を1つ追加しただけで生成器が先に進み、マップ生成フェーズに到達したときの出力が変わってしまいます。かっこいいマップともお別れです :(

各システムごとに独立したRNGを用意すれば、それらの出力は互いに独立に保たれ、マップ生成コードを数ヶ月変更していなくても、敵ステータス生成の作業があっても全く同じマップを生成し続けられることが保証されます。

この方法の唯一の落とし穴は「相関RNG」と呼ばれる現象で、すべてのRNGを同じ初期シードで初期化すると発生します。それらはすべて同じ順序で同じ数値を生成するため、プレイヤーが意図しない形でそれに気づき、悪用してしまう可能性があり、ゲームの面白さを損なう恐れがあります。

この種の問題はゲームでよく起こります!Final Fantasy XIVの各エリアの天候選択は、ゲーム内現在時刻をシードとする単一のアルゴリズムで決定されており、Ultima Thuleが天体磁気嵐の天候のとき、Rak'tika Greatwoodが常に霧に覆われるといった現象を引き起こしています。FFXIVの範囲内では相関天候状態はさほど問題ではありませんが、Slay the Spireのようなローグライクでもこの問題が存在します!Jorbsがこの問題と、それを知ったときに生じる結果を分かりやすく解説した動画を公開しています:


要するに、各システムで同じ数列を生成している場合、プレイヤーはあるシステムの生成結果を見て、他のシステムでどのような選択が行われるかを予測できてしまうのです。3つの選択肢から5回連続で選ぶシステムがある場合、プレイヤーは「1, 3, 1, 1, 2」という選択肢の並びから、生成器の乱数値がこの順序で0.0 - 0.330.66 - 1.00.0 - 0.330.0 - 0.330.33 - 0.66の範囲にあることを察知するかもしれません。常に12の選択肢から選ぶ別のシステムがある場合、最初のシステムで選択肢1が出たとき、もう一方のシステムでは常に選択肢1〜3が出現することになります。

では、単一のシードから派生した複数の独立した決定論的RNGの利点を保ちつつ、この問題をどのように解決すればよいでしょうか?いくつかの方法があります。それぞれの長所と短所を見てみましょう:

1. 各生成器を異なるオフセットで開始する...?

おそらく最もシンプルな対処法ですが、根本的な解決策というよりは応急処置です。各RNGは依然として互いに同じ数列を生成しますが、シーケンスの開始位置を後ろにずらすことで、少し分かりにくくするだけです。オフセットは重複しない程度に十分大きくする必要がありますし、一部のRNGは前方スキップをうまくサポートしていません。また、どの生成器がどのオフセットを使用しているかを管理する必要もあります。個人的には、この方法は手間がかかりすぎると思います。

2. シード生成専用のRNGを用意する?

私が最初に試したアプローチで、確かに効果的です。ゲーム用の単一シードでランダムなシード生成器を初期化し、その出力から得たシードを使ってさらにRNGを作成します。各RNGはそれぞれ異なる数列を持ちつつ、同一の初期ベースシードから派生します。この方法の問題点は、各RNGの状態が生成された順序に依存してしまうことです。初期設定後は管理しやすいですが、途中で生成器を削除すると、それ以降に作成されたすべての生成器の状態が変わってしまいます。

3. ハッシュベースのシード!

私自身のRNGシステムで採用している解決策で、現時点で最善の方法だと思います。新しいRNGを作成する際、共有シードをそのRNGインスタンス固有の識別子でハッシュします。ハッシュアルゴリズムが決定論的であれば(C#のHASHCODE構造体は絶対に使わないでくださいw)、各生成器は他の生成器や外部システムから完全に独立した独自の数列を持つことになり、新しいRNGの作成にほとんど手間がかかりません。

私はRNGシステムを、RNGコンストラクタでオプションの文字列/整数をハッシュ用に受け取る形にしているので、new RNG(gameSeed, "System Name")のようにシンプルに使えます。このアプローチのもう一つの利点は、別のRNGの出力をシードと一緒にハッシュしたり、すでにハッシュ済みの別のRNGのシードとハッシュしたりすることで、意図的にRNG同士を結合することが容易になる点です。また、入力ハッシュをRNGクラス内に保存しておけば、後でデバッグ用のIDとして参照したり、シリアライズ時にシードのバリエーションを読みやすくしたりできます。まさに一石二鳥です!


更新: この記事を投稿してから約6時間後に、MegaCrit(Slay the Spire開発元)がSlay the Spire 2でほぼ全く同じ方法で相関RNGに対処していることを知りました。詳細はこちらで読めます!問題の詳細やゲームプレイへの影響に興味がある方は、tckmn氏による記事と併せて読むことを強くおすすめします。

詳細を知りたいけど長文は読みたくないエンジニア向けTLDR

問題は、C#組み込みのSystem.Randomクラスと、ランシードと識別子を組み合わせる方法の組み合わせに起因していました。System.Randomには、線形に増加するシード値が線形に相関する出力を生成するという実装上の特性があり、そのためseed = runSeed + hash("identifier")というシード結合方法では、すべての生成器が互いに固定オフセットで同じ数値を出力してしまっていました。開発者の皆さんにはお気の毒ですが、相関RNGを防ぐために努力した後にこの事実を知るのは、かなり頭を抱えたくなりますよね。ランシードと識別子を一緒にハッシュする方法でも問題は解決したはずですが、XORShiftアルゴリズムであるxoshiro256**に切り替える方が、ゲームの文脈では高速で信頼性が高いため、より良い選択です。

私はSquirrel Eiserloh氏が素晴らしいGDCトークで紹介したSquirrel3アルゴリズムを個人的に使用しています。このアルゴリズムは非常に理解しやすく、シリアライズも容易(2つのuint)、任意の位置へのジャンプも高速で、符号なし整数の全シーケンスをランダムな順序で返し、以前の値を繰り返す前に必ずすべてを返すことが保証されています。とにかく、こういうマニアックな話はもう読むのをやめて、Slay the Spire 2をプレイして、私たち二人ともが共有しているであろう悩みを和らげましょう。

#gamedev #rng