2026 年 7 月 29 日
大量依賴程序化生成的遊戲,常常希望它們的隨機數生成器(RNG)能保持確定性——也就是說,只要給定單一種子,每個系統都會為所有玩家產生相同的遊戲元素。對 RNG 需求不高的遊戲,或許只要使用引擎內建的 RNG 就足夠了;但對於生成邏輯更複雜的遊戲,一個更常見的做法是為每個使用 RNG 的不同遊戲領域,各自建立獨立的隨機數生成器。
這種方法很適合解除不同系統之間的輸出耦合,否則如果全專案共用單一 RNG(例如 Unity 內建的 Random.value 或 Random.Range()),各系統的輸出就會彼此牽動。當你只有一個共享的生成器時,每個系統的輸出結果都會取決於它到目前為止被查詢過多少次。假設你很喜歡某張地圖生成結果,你可以儲存遊戲的共享種子,以便日後重現。但當所有系統都在查詢並改變單一生成器的狀態時,只要在流程中多加一個物品抽取,就會讓生成器狀態往前推進,導致之後的地圖生成階段輸出完全不同。可喜愛的地圖就此消失 :(
為每個系統準備各自的 RNG,代表它們的輸出可以保持獨立。只要你幾個月沒動地圖生成程式碼,即使你在敵人數值生成上做了再多修改,仍然能保證產生完全相同的地圖。
這種方法的唯一陷阱,就是所謂的「相關性 RNG」。當你用同一個初始種子去初始化所有 RNG 時,它們就會產生完全相同且依序相同的數列。玩家可能因此發現規律並加以利用,而這往往不是你原本希望的,也可能破壞他們的遊戲樂趣。
這種問題在遊戲中其實很常見!《Final Fantasy XIV》每個區域的天氣選擇,就是由單一演算法根據遊戲內當前時間作為種子來決定,導致每當 Ultima Thule 出現星磁風暴時,Rak'tika Greatwood 就會籠罩在霧氣之中。相關性天氣在《FFXIV》的影響相對輕微,但即使是像《Slay the Spire》這樣的 roguelike 也存在同樣問題!Jorbs 曾製作了一支影片,詳細解析這個問題以及一旦發現後會帶來的後果:
簡單來說,如果每個系統都產生相同的數列,玩家就能從一個系統的生成結果,推測其他系統的選擇。如果你有一個系統使用獨立的生成器,並且連續五次從三個選項中做選擇,玩家可能注意到「看到選項 1、3、1、1、2」就代表生成器的隨機值落在這些區間:0.0 - 0.33、0.66 - 1.0、0.0 - 0.33、0.0 - 0.33、0.33 - 0.66。若另一個系統總是從 12 個選項中挑選,當第一個系統出現選項 1 時,第二個系統就一定會看到選項 1–3。
那麼,在保留多個獨立且確定性 RNG(皆來自單一初始種子)的好處的前提下,我們該如何解決這個問題?這裡有幾種方法,以下列出它們的優缺點:
1. 讓每個生成器從不同偏移量開始…?
這也許是最簡單的解決方式,但它更像是一個權宜之計而非根本解法。每個 RNG 仍然會產生相同的數列,只是把序列起點往後推得更遠而已。偏移量必須夠大才能避免重疊,有些 RNG 並不支援輕易跳轉,而且你還得記錄每個生成器使用了哪個偏移值。我個人認為這個方法麻煩大於效益。
2. 使用專門的 RNG 來產生種子?
這是我第一次嘗試的解法,而且確實有效。你先用遊戲的單一初始種子去初始化一個「種子生成器」,然後用它輸出的值去初始化其他所有的 RNG。這樣每個 RNG 都能擁有獨立的數列,同時仍源自同一個初始種子。問題在於,每個 RNG 的狀態現在會與它們被建立的順序綁定在一起。雖然在初始設定之後比較好控制,但只要之後移除任何一個生成器,所有在它之後建立的生成器都會受到影響。
3. 基於雜湊的種子!
這是我目前在自己的 RNG 系統中採用的方案,我認為這是目前最好的解法。當你要建立一個新的 RNG 時,把共享種子與該 RNG 實例的唯一識別碼一起進行雜湊。只要你的雜湊演算法是確定性的(千萬不要用 C# 的 HASHCODE STRUCT LMAO),每個生成器就會擁有完全獨立且互不干擾的數列,而且新增 RNG 時幾乎不需要額外工作。
我把 RNG 系統設計成可以在建構子中接受一個可選的字串或整數來進行雜湊,因此使用方式非常簡單:new RNG(gameSeed, "System Name")。這種方法的另一個優點是,你可以輕鬆地「刻意」將不同的 RNG 耦合在一起——只要把另一個 RNG 的輸出或已雜湊過的種子,再次與新種子一起雜湊即可。如果你同時把輸入的雜湊值也存進 RNG 類別中,之後就能用來做除錯識別,或是在序列化時讓種子變體更容易閱讀。世界任你遨遊!
更新:發文後約六小時,我發現 MegaCrit(《Slay the Spire》開發商)在《Slay the Spire 2》中實際上使用了幾乎完全相同的方法來解決相關性 RNG 問題,詳情可參閱這裡!如果你對這個問題及其遊戲性後果感興趣,強烈建議一併閱讀 tckmn 的分析文章。
給想看細節但不想讀完整篇文章的工程師的 TLDR問題源自於同時使用 C# 內建的 System.Random 類別,以及將執行種子與識別碼結合的方式。System.Random 有一個實作特性:線性遞增的種子值會產生線性遞增且相關的輸出,這代表他們原本的種子組合方式 seed = runSeed + hash("identifier") 會讓所有生成器輸出彼此只有固定偏移的相同數字。我很能體會開發者的心情——在費盡心思避免相關性 RNG 之後才發現這件事,真的會抓狂。把執行種子與識別碼一起雜湊也能解決問題,但切換到 xoshiro256** XORShift 演算法是更好的選擇,因為 XOR shift 演算法在遊戲情境下速度更快且更可靠。
我個人使用 Squirrel Eiserloh 在這場精彩的 GDC 演講中提到的 Squirrel3 演算法,因為它非常容易理解、容易序列化(只需兩個 uint)、能在任意位置快速跳轉,而且保證在重複任何先前數值之前,會先完整走完整個無號整數序列。好了,別再看這些工程細節了,去玩 Slay the Spire 2 來緩解我們共同的症狀吧(如果你真的讀到這裡)。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.