Technologia · Unity

Ten sam seed,
inna gra,
a oczywista poprawka pogarsza sprawę.

Zapisanie generatora to łatwiejsza połowa. Kampanie psuje to, że jeden wspólny strumień przesuwa się, gdy cokolwiek innego losuje — a odgałęzienie własnego generatora, żeby z niego uciec, czerpie dokładnie z tego strumienia, który chroniłeś.

Marcin Firmuga·2026-10-05·12 min czytania·Technologia

Wszystko tutaj pochodzi z jednego projektu: symulacji zarządzania, w której kampania trwa około 1100 dni gry, każdy dzień losuje dziesiątki wyników, a całość musi wczytać się z pliku zapisu i toczyć dalej tak, jakby nic się nie stało. Na tym siedzą 1624 testy automatyczne, a spora ich część sprawdza dokładne liczby.

Piszę to dlatego, że wyniki wyszukiwania na ten problem to głównie ludzie, którym radzi się wywołać Random.InitState, czyli porada na zupełnie inny problem. Ustawienie ziarna decyduje o tym, gdzie strumień się zaczyna. Prawie każdy błąd determinizmu, jaki miałem, dotyczył tego, co dzieje się ze strumieniem później.

Uwaga o wersji. Obserwowane na Unity 6000.5, w czystym C#, bez DOTS. Trzy omawiane generatory to te, które masz pod ręką w zwykłym projekcie Unity; zachowanie każdego z nich jest podlinkowane do jego dokumentacji, a nie podane z pamięci.

Trzy generatory, trzy różne obietnice

W projekcie Unity masz w zasięgu ręki trzy oczywiste generatory i każdy obiecuje co innego. Wybranie jednego z przyzwyczajenia jest początkiem tej historii.

GeneratorCzym naprawdę jestGdzie się łamie
UnityEngine.Random Klasa statyczna, Xorshift 128, zasiewana raz przy starcie procesu z systemu. Stan da się odczytać i zapisać. Stan jest globalnie współdzielony. Cokolwiek w procesie coś losuje, przesuwa twój strumień.
System.Random Instancyjny, więc bez problemu współdzielenia. Da się zasiać. Algorytm nie jest kontraktem. Dokumentacja Microsoftu sama zaznacza, że to samo ziarno może dać inną sekwencję na innej wersji .NET.
Unity.Mathematics.Random 32-bitowa struktura xorshift, zaprojektowana do osadzania w komponentach i używania w jobach. Nic, w swoim zastosowaniu. To właściwe narzędzie do Bursta i jobów, zależność pakietowa, z ziarnem, które nigdy nie może być zerem.

Trzeci wiersz wart jest przypisu: Unity.Mathematics.Random to całkowicie dobra odpowiedź, a nawet dostarcza pomocnik CreateFromIndex, czyli drugi wzorzec z tego artykułu z oficjalnym wsparciem. Jeśli już masz ten pakiet, używaj go. Reszta poradnika dotyczy rozumowania, a ono jest takie samo w obu przypadkach.

Problemem nie jest zapisywanie. Problemem jest współdzielenie.

Obiegowa opinia mówi, że UnityEngine.Random nie da się uczynić deterministycznym. To nie całkiem prawda, a precyzja ma tu znaczenie, bo wskazuje właściwą naprawę. Dokumentacja Unity mówi wprost, że stan da się zapisać i odtworzyć, i opisuje generator jako inicjowany statycznie ziarnem o wysokiej entropii z systemu operacyjnego, po czym zostaje on „w całości pod kontrolą skryptów”.

Da się go więc utrwalić. Czego się nie da, to go posiadać. Ta sama dokumentacja mówi równie wprost dlaczego: to klasa statyczna, więc jej stan jest globalnie współdzielony. Twoja symulacja jest jednym z wielu czytelników. System cząsteczek, wtyczka, narzędzie edytora, kawałek interfejsu losujący wariant animacji bezczynności — każde z nich czerpie z tej samej sekwencji, a każde pobranie przesuwa wszystko, co następuje po nim.

Dlaczego ten błąd jest tak trudny do zobaczenia

Wspólny strumień nie psuje się głośno. Psuje się jako kampania, która po wczytaniu toczy się prawie tak samo, jako test przechodzący w pojedynkę i oblewający w zestawie, i jako zgłoszenie błędu brzmiące „za drugim razem liczby były inne”, bez kroków do odtworzenia, bo zgłaszający nie widzi, co jeszcze losowało.

Sygnałem rozpoznawczym jest wrażliwość na kolejność: jeśli dodanie funkcji w jednym miejscu zmienia wyniki w zupełnie innym, masz jeden strumień, a nie kilka.

Ziarno, które po cichu przestaje znaczyć to samo

Odruch po przeczytaniu powyższego to przesiadka na System.Random, który jest instancyjny, a więc nie współdzielony. To usuwa jeden problem i wprowadza cichszy, i tej części bym nie zgadł: zasiany System.Random nie ma obietnicy, że zawsze da tę samą sekwencję.

Dokumentacja Microsoftu, w sekcji pokazującej, jak uzyskać tę samą sekwencję wartości losowych, niesie taką uwagę: przykład „może dawać różne sekwencje liczb losowych uruchomiony na różnych wersjach .NET”. Algorytm jest tam opisany jako zmodyfikowany generator subtraktywny, a taki opis to udokumentowanie implementacji, nie gwarancja na nią.

Dla szumu rozgrywki to bez znaczenia. Robi się kosztowne w chwili, gdy liczba musi coś przetrwać:

Żadne z tego się nie zapowiada. Aktualizacja środowiska, która zmienia sekwencję, daje build, który się kompiluje, uruchamia, nic nie loguje i jest subtelnie inną grą.

Czterdzieści linijek wartych posiadania

Prowadzi to do mało efektownego wniosku: jeśli wynik ma być stabilny między wersjami i maszynami, generator należy do twojego repozytorium, gdzie nic go nie zmieni bez commita. Mój to xorshift32 z jednym słowem stanu i to jest całość:

public sealed class DeterministicRandom { private const uint DefaultSeed = 0x5CA1AB1E; private uint state; public DeterministicRandom(uint seed = DefaultSeed) => state = seed == 0 ? DefaultSeed : seed; /// <summary>Cały generator. Zapisz to, odtwórz, dostajesz tę samą kampanię.</summary> public uint State { get => state; set => state = value == 0 ? DefaultSeed : value; } public uint NextUInt() { state ^= state << 13; state ^= state >> 17; state ^= state << 5; return state; } }

Trzy rzeczy ważą tu więcej niż sam algorytm. Stan to jeden uint, więc utrwalenie go jest pojedynczym polem, a nie ćwiczeniem z serializacji. Zero jest zabezpieczone w dwóch miejscach, w konstruktorze i w setterze, bo xorshift jest w zerze pochłaniający: podaj mu zero, a będzie zwracał zera w nieskończoność. To nie mój spryt — własny pakiet matematyczny Unity dokumentuje tę samą regułę dla swojej struktury, że ziarno musi być niezerowe. I algorytm jest teraz twój, więc aktualizacja Unity nie renegocjuje go za ciebie.

Na tym siedzą wygody, z których symulacja faktycznie korzysta: NextDouble, zakres całkowity, NextChance(p) oraz NextGaussian metodą Boxa–Mullera, żeby przebieg treningu lądował blisko swojej prognozy, a nie dokładnie na niej. Wszystkie są tylko odczytami tego samego słowa stanu, i o to chodzi: jedna rzecz do zapisania.

Dwa rodzaje losowości, które nie są wymienne

Posiadanie generatora nie mówi jeszcze, ile ich mieć. Tu spędziłem najwięcej czasu, a odpowiedź nie brzmi „po jednym na system”. Brzmi tak, że symulacje zawierają dwa różne rodzaje losowości, a każdy ma własny właściwy kształt.

Pierwszy rodzaj jest sekwencyjny. Postępuje z czasem, a kolejność losowań jest częścią znaczenia. Kolejka kandydatów do pracy napływających przez tygodnie jest sekwencyjna: każde losowanie zależy od tego, jak daleko zaszła kampania. Ten rodzaj potrzebuje strumienia z zapisywanym słowem stanu, żeby wczytanie wznawiało sekwencję w środku, a nie zaczynało ją od nowa.

Drugi rodzaj jest adresowany współrzędną i to jego najczęściej brakuje w projektach. Niektóre wartości wcale nie są sekwencją. Są właściwością rzeczy: składem kadry w konkurencyjnej firmie, inwestorem, który zjawia się konkretnego dnia, charakterem laboratorium numer cztery. Tego nie trzeba pamiętać, bo da się to przeliczyć. Zbuduj generator z samej współrzędnej, a ta sama współrzędna odbuduje te same wartości na zawsze:

// Kadra jednego rywala. Nic z tego nie trafia do pliku zapisu. public static List<RivalStaffMember> RosterFor(CompetitorId lab, GameDate today, uint campaignSeed, IReadOnlyCollection<int> gone = null) { var random = new DeterministicRandom(Mix(campaignSeed, (uint)lab)); ... }

Komentarz nad tą metodą w moim repozytorium nazywa kontrakt, którego ona dotrzymuje: „Deterministyczne względem laboratorium i ziarna kampanii, więc ta sama firma ma tych samych ludzi za każdym otwarciem, a dwie kampanie na tym samym ziarnie się zgadzają.”

Funkcja mieszająca ma tu znaczenie, bo współrzędna w stylu seed ^ labId jest fatalnym ziarnem: sąsiednie laboratoria dawałyby widocznie spokrewnione składy. Potrzebna jest lawina, żeby jeden zmieniony bit zmieniał wszystko:

private static uint RivalryMix(uint seed, uint lab, uint day, uint salt) { unchecked { var value = seed ^ (lab * 2654435761u) ^ (day * 40503u) ^ salt; value ^= value >> 15; value *= 2246822519u; value ^= value >> 13; value *= 3266489917u; value ^= value >> 16; return value == 0 ? 0x9E3779B9u : value; } }

Zwróć uwagę na argument salt. To on pozwala dwóm niezwiązanym pytaniom o to samo laboratorium tego samego dnia — czy dziś coś wyda, czy podkupi komuś człowieka — dostać niezależne odpowiedzi, bez tego żeby którekolwiek wiedziało o istnieniu drugiego.

Praktyczny test na to, który rodzaj masz

Zapytaj, czy wartość dałoby się przeliczyć od zera zamiast ją pamiętać. Jeśli tak, jest adresowana współrzędną i nie należy do żadnego strumienia: nic nie przechowuje, nie może się rozjechać, nie może zdesynchronizować i za darmo przeżywa zmianę formatu zapisu, bo nigdy w tym zapisie jej nie było.

W moim projekcie okazało się, że to pokrywa większość. Dokładnie dwa strumienie sekwencyjne mają w całej grze zapisywane słowo stanu. Wszystko dotyczące rywali, sojuszy, inwestorów i ich kadr jest adresowane współrzędną i nie przechowuje niczego.

Metoda, którą napisałem, udokumentowałem i nigdy nie wywołałem

Gdy podsystem potrzebuje własnego strumienia, oczywistym ruchem jest odgałęzienie go od rodzica. Wylosuj liczbę, użyj jej jako ziarna potomka, podaj potomka dalej. To ładne API i je napisałem:

/// <summary> /// Generator potomny zasiany z tego. Pozwala podsystemowi losować bez przesuwania /// strumienia rodzica, co chroni niezwiązane systemy przed desynchronizacją. /// </summary> public DeterministicRandom Fork() => new(NextUInt());

Przeczytaj implementację obok jej własnego komentarza. Obiecuje pozwolić podsystemowi losować bez przesuwania strumienia rodzica, a pierwsze, co robi, to wywołuje NextUInt(), co przesuwa strumień rodzica.

To nie literówka, to kształt samego pomysłu. Nie da się wyprowadzić ziarna potomka z generatora rodzica bez posunięcia rodzica, bo posuwanie się jest tym, w jaki sposób generator cokolwiek produkuje. Akt izolowania podsystemu sam jest losowaniem, a izolacja kosztuje dokładnie to, co miała kupić.

W przypadku, na którym naprawdę ci zależy, jest gorzej. Jeśli odgałęzienie jest warunkowe — podsystem budzi się dopiero, gdy gracz coś odblokuje, albo w dniu pierwszego uruchomienia funkcji — to czy w ogóle się odgałęziłeś zależy od przebiegu kampanii, a strumień rodzica jest już inny u dwóch graczy, którzy zrobili co innego. Czyli dokładnie ta klasa błędu, przed którą odgałęzienie miało chronić.

Znalazłem to, dając systemowi rekrutacji własny strumień, a to, co mówi tam kod, jest najjaśniejszym zdaniem na ten temat w całym repozytorium:

/// Zasiany stałą, a nie odgałęziony od firmowego, bo odgałęzianie czerpie dokładnie /// z tego strumienia, który to ma zostawić w spokoju. Jego stan jest zapisywany, więc /// kampania wczytuje tych samych ludzi. public DeterministicRandom Random { get; } = new(SeedSalt);

Fork() jest więc nadal w pliku, wkompilowany w każdy build, i wywołany dokładnie zero razy. Oba problemy, dla których powstał, miały lepsze odpowiedzi: stałe ziarno z własnym zapisywanym słowem stanu dla przypadku sekwencyjnego i zmieszana współrzędna dla całej reszty.

Jeśli mimo wszystko chcesz mieć odgałęzianie

Istnieje wersja, która działa, i warto ją znać, bo to zmiana jednej linijki w myśleniu. Nie wyprowadzaj potomka ze strumienia rodzica, wyprowadź go z jego tożsamości. Zmieszaj ziarno kampanii ze stałą nazywającą podsystem, a potomek jest niezależny od tego, co rodzic wylosował albo czego nie wylosował.

To ta sama funkcja mieszająca co wyżej, z solą dla każdego systemu — i dlatego odgałęzianie zniknęło, zamiast zostać naprawione: kiedy już to napiszesz, nie zostaje dla niego nic do roboty.

Mam nawyk znajdowania u siebie takich funkcji: napisanych, przetestowanych, nieosiągalnych dla nikogo. Według mojego własnego licznika to szesnasta. Różnica polega na tym, że ta metoda nie jest nieosiągalna przez zapomnienie. Jest nieosiągalna, bo podłączenie jej byłoby błędem, a komentarz trzy pliki dalej wyjaśnia dlaczego. To jest ten rodzaj martwego kodu, który warto zostawić — pod warunkiem że powód leży zapisany obok.

Cztery testy, które czynią to prawdziwym

Nic z powyższego nie jest nic warte jako intencja. Determinizm to własność, która psuje się po cichu, więc trzeba go asercjonować. Te cztery faktycznie wyłapały regresje i są na tyle tanie, że nie ma wymówki, żeby ich nie mieć.

Raz: to samo ziarno daje tę samą kampanię. Najkrótsze możliwe sformułowanie całej idei i pierwsza rzecz, która pęka, gdy ktoś doda losowanie w złym miejscu.

Dwa: strumień przeżywa podróż w obie strony przez plik zapisu. Posuń go, zapisz, wczytaj i sprawdź zarówno stan, jak i następną wartość z niego. Sprawdzanie samego stanu przeoczy odtwarzanie, które zapisuje pole, ale nigdy nie dochodzi do generatora:

[Test] public void TheRandomStreamResumesSoTheCampaignStaysReplayable() { var original = BuildCampaign(); original.Random.NextUInt(); original.Random.NextUInt(); var restored = SaveStore.Restore(SaveStore.Parse( JsonUtility.ToJson(SaveStore.Capture(original)))); Assert.That(restored.Random.State, Is.EqualTo(original.Random.State)); Assert.That(restored.Random.NextUInt(), Is.EqualTo(original.Random.NextUInt())); }

Trzy: cała kampania odtwarza się pod skryptowanym graczem. Ten zarabia na swój czas wykonania. Bot gra 1100 dni gry dwa razy na tym samym ziarnie, a oba przebiegi muszą zgodzić się co do gotówki, zdolności i liczby wydanych modeli. Wyłapuje to, czego testy jednostkowe nie widzą, bo trybem awarii jest tu narastające rozjeżdżanie się, a nie pojedyncza zła wartość:

[Test] public void TheWholeCampaignStaysDeterministicUnderAScriptedPlayer() { static (long Cash, double Capability, int Models) Play(uint seed) { var simulation = NewGame(seed); var bot = new ScriptedOperator(simulation); bot.Run(1100); return (simulation.State.CashUsd, simulation.State.BestCapability, bot.ModelsShipped); } Assert.That(Play(777), Is.EqualTo(Play(777))); }

Cztery: systemy adresowane współrzędną zgadzają się same ze sobą. Poproś dwa razy o to samo pole rywali, ten sam skład kadry, ten sam harmonogram inwestorów, z dwóch osobno zbudowanych kampanii na jednym ziarnie, i porównaj. To pilnuje wzorca z poprzedniej sekcji, gdzie nie ma zapisanego stanu, który wyłapałby pomyłkę.

Ten, który zwraca się sam

Jeśli masz napisać tylko jeden, napisz trzeci. Porównanie dwóch przebiegów długiej kampanii nie wymaga wiedzy o tym, który system zawinił, ani utrzymania, gdy liczby się zmienią, bo nigdy nie sprawdza wartości — tylko to, że gra zgodziła się sama ze sobą.

Czego to nie naprawia

Poradnik o determinizmie, który nie podaje swoich granic, coś ci sprzedaje. Wszystko powyżej daje powtarzalną sekwencję liczb. To nie to samo co powtarzalna gra, a różnica ma znaczenie:

W symulacji turowej, krokowanej dniami jak moja, pierwsze dwa w zasadzie nie występują i dlatego ten poradnik jest o generatorze. Trzeciego pilnowałbym: łatwo napisać go przez przypadek, a psuje się dokładnie tak samo, więc jeśli przebieg rozjeżdża się po zrobieniu wszystkiego powyżej, zajrzyj najpierw w to, co iterowałeś, a nie w to, co losowałeś.

Kształt odpowiedzi w czterech punktach

Jeśli nie zabierasz stąd nic innego:

Pytania, które ludzie zadają

Dlaczego Unity daje różne wyniki przy tym samym ziarnie?

Najczęściej dlatego, że generator jest wspólny. UnityEngine.Random to klasa statyczna, więc jej stan jest globalny: efekt cząsteczkowy, wtyczka, skrypt edytora albo dowolny niezwiązany MonoBehaviour, który coś wylosuje, przesuwa ten sam strumień, z którego czyta twoja symulacja. Ponowne ustawienie ziarna naprawia pierwszą klatkę i nic poza nią. Druga częsta przyczyna to własne losowanie dodane wcześniej w turze, które przesuwa każdą kolejną wartość. Żadnej z nich nie rozwiązuje zapisanie ziarna, bo to nie ziarno się zmieniło.

Czy da się zapisać i odtworzyć UnityEngine.Random?

Tak — i to jest ta część, którą internet zwykle podaje źle. Unity udostępnia właściwość state, którą można odczytać i zapisać, i dokumentuje generator jako Xorshift 128 zasiewany raz z systemu operacyjnego przy starcie procesu, a potem zostawiony pod kontrolą skryptów. Trwałość nie jest ograniczeniem. Ograniczeniem jest własność: stan, który zapisujesz, należy do całego procesu, więc jego przywrócenie odtworzy przebieg tylko wtedy, gdy w międzyczasie nic innego ze strumienia nie czerpało.

Czy System.Random z tym samym ziarnem daje tę samą sekwencję na każdej wersji .NET?

Nie, i Microsoft dokumentuje to wprost. Jego własny przykład pokazujący, jak uzyskać tę samą sekwencję wartości losowych, niesie uwagę, że kod może dawać różne sekwencje na różnych wersjach .NET. Dla szumu rozgrywki to w porządku. Dla pliku zapisu, wspólnego ziarna dziennego albo testu sprawdzającego dokładną liczbę oznacza to, że aktualizacja środowiska może zmienić twoje wyniki w buildzie, który kompiluje się czysto i niczego nie zgłasza.

Czy każdy system powinien mieć własny generator?

Każdy, który ma pozostać stabilny, gdy inne systemy się zmieniają, tak — ale są dwa sposoby, żeby mu go dać, i nie są wymienne. Strumień sekwencyjny ma własne zapisywane słowo stanu i pasuje do wszystkiego, co postępuje w czasie, jak kolejka kandydatów. Generator adresowany współrzędną powstaje na żądanie z ziarna zmieszanego z tożsamością, na przykład ziarna kampanii i identyfikatora firmy, i nie przechowuje niczego. Po drugi sięgaj zawsze, gdy dane da się przeliczyć zamiast pamiętać; w moim projekcie pokrywa to wszystko poza dwoma strumieniami.

Czy odgałęzienie generatora potomnego od rodzica jest bezpieczne?

Nie dla izolacji, a zwykle właśnie po to się po nie sięga. Pobranie ziarna dla potomka z rodzica przesuwa rodzica, więc izolowanie podsystemu rusza dokładnie ten strumień, który izolacja miała chronić — a jeśli odgałęzienie jest warunkowe, dwóch graczy, którzy zagrali inaczej, ma teraz inne strumienie. Zasiej potomka stałą albo współrzędną, a jeśli ma przetrwać wczytanie, daj mu własne zapisywane słowo stanu.

Czy to czyni grę deterministyczną między platformami?

Nie. To czyni powtarzalną sekwencję liczb. Arytmetyka zmiennoprzecinkowa nadal może różnić się między procesorami i celami kompilacji, domyślna fizyka Unity nie jest deterministyczną symulacją lockstep, a iterowanie nieuporządkowanej kolekcji rozjedzie nawet idealny generator. Dla symulacji turowej albo krokowanej dniami to zwykle wystarcza. Dla multiplayera w lockstep to pierwszy z kilku kroków.

Wszystko tutaj da się sprawdzić. Generator, funkcja mieszająca, nieużywany Fork() i wszystkie cztery testy są w publicznym repozytorium Scaling Laws, podlinkowanym w źródłach tego artykułu. Jeśli coś pomyliłem, kod jest tam po to, żeby to udowodnić.
Dalej w tej serii: testy balansu, które dowodzą, że grę da się wygrać · 1541 testów, które nie uruchamiają gry · ekonomia tycoona jako prawa.
MF

Marcin Firmuga

Solo developer · HCK_Labs · buduję publicznie

Piszę o tym, co naprawdę wypuściłem, z prawdziwymi liczbami i prawdziwym kodem, łącznie z tym, co nie zadziałało. Więcej: moja historia.