В этой статье вы напишете на C# собственный тип чисел с фиксированной точкой для Unity: сложение, умножение без переполнения, деление, квадратный корень, синус и косинус, двумерный вектор и детерминированный генератор случайных чисел. А заодно разберёмся, почему обычный float может дать на двух компьютерах разные результаты, чем это грозит lockstep-мультиплееру и реплеям и как поймать рассинхрон контрольной суммой раньше, чем его заметят игроки.
Зачем вообще нужен детерминизм
Есть класс задач, где симуляция должна давать бит в бит одинаковый результат на всех машинах при одинаковых входных данных:
- Lockstep-мультиплеер. По сети передаются не позиции юнитов, а только команды игроков. Каждый клиент сам прогоняет одну и ту же симуляцию. Так устроены многие RTS и файтинги с откатами: трафик крошечный, а юнитов могут быть тысячи.
- Реплеи. Файл реплея — это начальное зерно случайности плюс список вводов по тикам. Если симуляция детерминирована, реплей на час игры весит килобайты.
- Проверка результата на сервере. Клиент присылает вводы забега, сервер прогоняет их и сверяет итоговый счёт с заявленным.
- Тесты. Записанный ввод воспроизводит баг каждый раз, а не «иногда».
Пока клиент один или все играют на одной сборке под одну платформу, детерминизм с float часто «просто работает». Проблемы начинаются, когда встречаются Windows и Android, Mono и IL2CPP, x64 и ARM.
Почему float ненадёжен
Сам по себе стандарт IEEE 754 детерминирован: сложение, умножение, деление и квадратный корень для одних и тех же операндов дают один и тот же результат. Беда в том, что между вашим C#-кодом и процессором стоит много слоёв, и каждый может поменять то, какие именно операции выполняются.
- Порядок операций.
(a + b) + cиa + (b + c)во float не равны. Компилятор с агрессивными настройками может переставить операции, и на разных платформах он переставит их по-разному. - FMA. Инструкция fused multiply-add считает
a * b + cс одним округлением вместо двух. На одной архитектуре компилятор её применит, на другой нет — и последний бит результата разойдётся. - Промежуточная точность. Исторически x87 считал во внутренних 80-битных регистрах; некоторые JIT и компиляторы до сих пор вправе держать промежуточные значения точнее, чем
float. - Трансцендентные функции.
Mathf.Sin,Math.Atan2,Math.Powне обязаны быть корректно округлёнными. Разные libm на разных ОС возвращают значения, отличающиеся на единицу в последнем разряде. - Денормализованные числа. Одна платформа сбрасывает очень маленькие значения в ноль, другая нет.
Разница в последнем бите кажется ерундой, но симуляция — это хаотическая система. Через несколько сотен тиков юнит на одном клиенте обходит препятствие слева, а на другом справа, и дальше игры расходятся полностью.
Главное правило lockstep: рассинхрон не исправляется. Его можно только найти, по возможности восстановить состояние с сервера или хоста, а лучше — не допустить.
А как же Burst
В Burst есть режим FloatMode.Deterministic. По документации Unity 6 он включает детерминированные реализации математических функций, отключает оптимизации, которые дают платформенные различия, и сбрасывает денормализованные числа в ноль на всех платформах. Но у него есть оговорки: он работает только на 64-битных архитектурах, только внутри Burst-кода, не гарантирует одинаковые битовые представления NaN, а float-значения, посчитанные снаружи Burst и переданные внутрь, могут принести недетерминизм с собой. Если вся симуляция живёт в Burst-джобах и 32-битные платформы вам не нужны, этот путь стоит проверить на своих целевых устройствах. Если же логика написана на обычном C# и должна одинаково работать под Mono, IL2CPP и в серверном .NET, надёжнее отказаться от float в симуляции совсем.
Идея фиксированной точки
Число с фиксированной точкой — это обычное целое, у которого мы договорились считать часть младших битов дробной частью. В формате Q48.16 в long 48 бит отведено под целую часть со знаком и 16 бит под дробь. Значение 1.0 хранится как 65536, значение 0.5 — как 32768, значение −2.25 — как −147456.
Шаг такой сетки — 1/65536, то есть примерно 0.000015. Диапазон огромный: около ±1.4·10^14 (2^47). Для игровой логики, где расстояния измеряются метрами, а время — тиками, этого хватает с запасом. Тот же формат Q48.16 использует, например, Photon Quantum в своём типе FP. Встречается и разбиение Q32.32: оно точнее, но диапазон всего ±2^31, а умножение требует ещё больше внимания к переполнению. Ниже используем Q48.16.
Всё, что делает целочисленная арифметика, детерминировано на любом процессоре: сложение, вычитание, умножение, деление, сдвиги в C# определены точно, включая поведение для отрицательных чисел. Значит, если мы построим всю математику симуляции из целочисленных операций, результат на всех машинах совпадёт бит в бит.
Базовый тип Fix
Начнём со структуры, которая хранит сырое значение и умеет складываться и сравниваться.
using System;
public readonly struct Fix : IEquatable<Fix>, IComparable<Fix>
{
public const int FracBits = 16;
public const long OneRaw = 1L << FracBits;
const long FracMask = OneRaw - 1;
public readonly long Raw;
Fix(long raw) => Raw = raw;
public static readonly Fix Zero = new Fix(0);
public static readonly Fix One = new Fix(OneRaw);
public static readonly Fix Half = new Fix(OneRaw / 2);
public static Fix FromRaw(long raw) => new Fix(raw);
public static Fix FromInt(int value) => new Fix((long)value << FracBits);
// Дробь без float: Fix.Ratio(3, 10) == 0.3
public static Fix Ratio(int numerator, int denominator) =>
new Fix(((long)numerator << FracBits) / denominator);
public static Fix operator +(Fix a, Fix b) => new Fix(a.Raw + b.Raw);
public static Fix operator -(Fix a, Fix b) => new Fix(a.Raw - b.Raw);
public static Fix operator -(Fix a) => new Fix(-a.Raw);
public static bool operator ==(Fix a, Fix b) => a.Raw == b.Raw;
public static bool operator !=(Fix a, Fix b) => a.Raw != b.Raw;
public static bool operator <(Fix a, Fix b) => a.Raw < b.Raw;
public static bool operator >(Fix a, Fix b) => a.Raw > b.Raw;
public static bool operator <=(Fix a, Fix b) => a.Raw <= b.Raw;
public static bool operator >=(Fix a, Fix b) => a.Raw >= b.Raw;
public bool Equals(Fix other) => Raw == other.Raw;
public override bool Equals(object obj) => obj is Fix f && f.Raw == Raw;
public override int GetHashCode() => Raw.GetHashCode();
public int CompareTo(Fix other) => Raw.CompareTo(other.Raw);
// Только для отображения: в симуляцию результат не возвращается
public float ToFloat() => Raw / (float)OneRaw;
public override string ToString() => ToFloat().ToString("0.#####");
}
Обратите внимание, чего здесь нет: публичного конструктора из float и неявных преобразований. Это сделано специально. Самая частая ошибка при переходе на фиксированную точку — где-то в середине формулы незаметно появляется Time.deltaTime или Vector3.Distance, и всё старание идёт насмарку. Если тип нельзя случайно смешать с float, компилятор сам подсветит такие места.
Константы для симуляции задавайте целыми числами и дробями: Fix.Ratio(1, 30) для шага тика, Fix.FromInt(5) для скорости. Если значения приходят из ScriptableObject с float-полями, конвертируйте их один раз при загрузке:
// Умножение float на степень двойки точное, округление определено стандартом.
// Это безопасно, пока исходный float одинаков на всех машинах (взят из ассета, а не посчитан).
public static Fix FromConfig(float value) =>
new Fix((long)Math.Round(value * OneRaw, MidpointRounding.AwayFromZero));
Ещё надёжнее хранить в конфиге сырые long или пары «числитель/знаменатель», но для большинства проектов конвертации из ассета достаточно: биты float в сериализованном ассете одинаковы на всех платформах.
Умножение без переполнения
Наивное умножение выглядит так: перемножить сырые значения и сдвинуть результат на 16 бит вправо.
public static Fix MulNaive(Fix a, Fix b) => new Fix((a.Raw * b.Raw) >> FracBits);
Проблема в промежуточном произведении. Оно содержит 32 дробных бита, и long переполнится, как только произведение реальных значений превысит примерно 2^31. Квадрат расстояния в 50 000 единиц уже не влезет. Переполнение в C# по умолчанию не бросает исключение, а молча заворачивает значение — тоже детерминированно, но бесполезно.
Решение — разложить каждый множитель на целую и дробную часть и перемножить по частям, как в школьном умножении столбиком:
public static Fix operator *(Fix a, Fix b)
{
long ai = a.Raw >> FracBits; // целая часть (с округлением вниз)
long af = a.Raw & FracMask; // дробная часть, всегда 0..65535
long bi = b.Raw >> FracBits;
long bf = b.Raw & FracMask;
long result = ((ai * bi) << FracBits)
+ ai * bf
+ af * bi
+ ((af * bf) >> FracBits);
return new Fix(result);
}
public static Fix operator *(Fix a, int b) => new Fix(a.Raw * b);
Почему это верно для отрицательных чисел: арифметический сдвиг >> в C# округляет вниз, поэтому для −2.25 целая часть станет −3, а дробная 0.75. Сумма ai·65536 + af всегда равна исходному сырому значению, а значит, раскрытие скобок даёт точное произведение, делённое на 65536 с округлением вниз. Теперь переполнение наступает только тогда, когда сам результат не помещается в Q48.16.
Отдельная перегрузка умножения на int полезна и для скорости, и для точности: speed * 3 не требует никакого сдвига.
Деление
public static Fix operator /(Fix a, Fix b) =>
new Fix((a.Raw << FracBits) / b.Raw);
public static Fix operator /(Fix a, int b) => new Fix(a.Raw / b);
Сдвиг делимого влево переполняется, если |a| больше примерно 2^31. Для игровой логики это обычно приемлемо; если нужно делить большие числа, делите целую и дробную части отдельно или используйте 128-битное промежуточное значение. Деление на ноль бросает DivideByZeroException на всех платформах одинаково, и это хорошо: такую ошибку лучше поймать сразу, чем получить Infinity, который тихо отравит состояние.
Учтите, что целочисленное деление в C# округляет к нулю, а сдвиг — вниз. Оба поведения стабильны, просто не удивляйтесь, что -x / 2 и -(x / 2) могут отличаться на один шаг сетки.
Вспомогательные функции
public static Fix Abs(Fix x) => x.Raw < 0 ? new Fix(-x.Raw) : x;
public static Fix Min(Fix a, Fix b) => a.Raw < b.Raw ? a : b;
public static Fix Max(Fix a, Fix b) => a.Raw > b.Raw ? a : b;
public static Fix Clamp(Fix x, Fix min, Fix max) => Min(Max(x, min), max);
public static Fix Floor(Fix x) => new Fix(x.Raw & ~FracMask);
public static int FloorToInt(Fix x) => (int)(x.Raw >> FracBits);
public static Fix Lerp(Fix a, Fix b, Fix t) => a + (b - a) * t;
public static int Sign(Fix x) => x.Raw > 0 ? 1 : x.Raw < 0 ? -1 : 0;
Всё это — чистые целочисленные операции, тут думать не о чем. Интересное начинается с корня и тригонометрии.
Квадратный корень на целых числах
Если x хранится как X / 65536, то его корень равен sqrt(X) / 256. Чтобы результат снова был в формате Q48.16, нужно взять целочисленный корень из X · 65536. Целочисленный корень считается классическим побитовым алгоритмом без единой float-операции:
static ulong ISqrt(ulong n)
{
ulong result = 0;
ulong bit = 1UL << 62;
while (bit > n) bit >>= 2;
while (bit != 0)
{
if (n >= result + bit)
{
n -= result + bit;
result = (result >> 1) + bit;
}
else
{
result >>= 1;
}
bit >>= 2;
}
return result;
}
public static Fix Sqrt(Fix x)
{
if (x.Raw < 0) throw new ArgumentOutOfRangeException(nameof(x));
return new Fix((long)ISqrt((ulong)x.Raw << FracBits));
}
Сдвиг в ulong даёт запас: корень корректно считается для значений до 2^32, то есть для векторов длиной до 65 536 единиц. Если миру нужно больше, считайте длину векторов в другой единице (например, в десятках метров) или используйте вариант корня с меньшим сдвигом и потерей нескольких дробных битов. Алгоритм выполняет не больше 32 итераций и одинаков на любой платформе.
Синус и косинус без таблиц из Math.Sin
Популярный совет — сгенерировать таблицу синусов через Math.Sin при старте игры. Это ловушка: Math.Sin на разных платформах может отличаться в последнем бите, и после округления до 16 дробных битов в редких ячейках таблицы значения разойдутся. Если нужна таблица, генерируйте её один раз и вставляйте в код константным массивом. Мы пойдём другим путём и посчитаем синус многочленом прямо в фиксированной точке.
Сначала константы. Их сырые значения вычислены заранее и записаны целыми числами:
public static readonly Fix Pi = FromRaw(205887); // 3.14159...
public static readonly Fix TwoPi = FromRaw(411775);
public static readonly Fix HalfPi = FromRaw(102944);
Затем приводим угол к диапазону [−π/2; π/2], где ряд Тейлора сходится быстро, и считаем пять членов ряда по схеме Горнера:
public static Fix Sin(Fix x)
{
// Приводим к [-π; π]
long r = x.Raw % TwoPi.Raw;
if (r < 0) r += TwoPi.Raw;
if (r > Pi.Raw) r -= TwoPi.Raw;
// Отражаем в [-π/2; π/2]: sin(π - a) = sin(a)
if (r > HalfPi.Raw) r = Pi.Raw - r;
else if (r < -HalfPi.Raw) r = -Pi.Raw - r;
Fix a = FromRaw(r);
Fix a2 = a * a;
// a - a³/3! + a⁵/5! - a⁷/7! + a⁹/9!
Fix t = One - a2 / 72;
t = One - a2 / 42 * t;
t = One - a2 / 20 * t;
t = One - a2 / 6 * t;
return a * t;
}
public static Fix Cos(Fix x) => Sin(x + HalfPi);
Схема Горнера раскрывается в a·(1 − a²/6·(1 − a²/20·(1 − a²/42·(1 − a²/72)))), и это ровно ряд до девятой степени. Погрешность самого ряда на краю диапазона около 4·10^−6, это меньше шага сетки Q48.16, так что основная ошибка — округления в промежуточных умножениях. На практике итог отличается от точного синуса на несколько единиц последнего разряда — для вращения юнитов и траекторий более чем достаточно. А главное, эти несколько единиц одинаковы на всех машинах.
Похожим образом пишутся Atan2 (через приведение к первому октанту и многочлен для арктангенса на [0; 1]) и Exp. Для поворотов юнитов часто удобнее вообще не хранить угол, а хранить нормализованный вектор направления — тогда тригонометрия нужна только при вводе.
Вектор FixVector2
public readonly struct FixVector2 : IEquatable<FixVector2>
{
public readonly Fix X, Y;
public FixVector2(Fix x, Fix y) { X = x; Y = y; }
public static readonly FixVector2 Zero = new FixVector2(Fix.Zero, Fix.Zero);
public static FixVector2 operator +(FixVector2 a, FixVector2 b) => new FixVector2(a.X + b.X, a.Y + b.Y);
public static FixVector2 operator -(FixVector2 a, FixVector2 b) => new FixVector2(a.X - b.X, a.Y - b.Y);
public static FixVector2 operator *(FixVector2 v, Fix s) => new FixVector2(v.X * s, v.Y * s);
public static FixVector2 operator /(FixVector2 v, Fix s) => new FixVector2(v.X / s, v.Y / s);
public static Fix Dot(FixVector2 a, FixVector2 b) => a.X * b.X + a.Y * b.Y;
public Fix SqrMagnitude => Dot(this, this);
public Fix Magnitude => Fix.Sqrt(SqrMagnitude);
public FixVector2 Normalized
{
get
{
Fix length = Magnitude;
return length == Fix.Zero ? Zero : this / length;
}
}
public static FixVector2 MoveTowards(FixVector2 current, FixVector2 target, Fix maxDelta)
{
FixVector2 delta = target - current;
Fix sqrDist = delta.SqrMagnitude;
if (sqrDist <= maxDelta * maxDelta) return target;
return current + delta / Fix.Sqrt(sqrDist) * maxDelta;
}
public bool Equals(FixVector2 other) => X == other.X && Y == other.Y;
public override int GetHashCode() => X.GetHashCode() * 31 + Y.GetHashCode();
public UnityEngine.Vector3 ToVector3XZ() =>
new UnityEngine.Vector3(X.ToFloat(), 0f, Y.ToFloat());
}
Метод ToVector3XZ — единственный мост в мир Unity, и он идёт только в одну сторону: из симуляции в отображение. Обратного преобразования из Vector3 в симуляции быть не должно.
Детерминированная случайность
Ни UnityEngine.Random, ни System.Random для симуляции не подходят. У первого глобальное состояние, которое может сдвинуть любой код — система частиц, сторонний ассет, анимация. Алгоритм второго не зафиксирован контрактом и уже менялся между версиями .NET, а Unity, сервер на современном .NET и клиент под IL2CPP могут использовать разные реализации. Свой генератор пишется в десяток строк:
public struct FixRandom
{
ulong _state;
public FixRandom(ulong seed) => _state = seed == 0 ? 0x9E3779B97F4A7C15UL : seed;
// xorshift64*
public ulong NextULong()
{
_state ^= _state >> 12;
_state ^= _state << 25;
_state ^= _state >> 27;
return _state * 0x2545F4914F6CDD1DUL;
}
// [min; max)
public int Range(int min, int max) =>
min + (int)((NextULong() >> 32) % (uint)(max - min));
// [0; 1)
public Fix NextFix() => Fix.FromRaw((long)(NextULong() >> (64 - Fix.FracBits)));
public ulong State => _state;
}
Генератор — часть состояния симуляции. Его нужно сохранять в снапшот, включать в контрольную сумму и никогда не вызывать из кода отображения. Если визуальным эффектам нужна случайность, у них должен быть свой отдельный генератор.
Симуляция по тикам
Детерминированная математика бесполезна, если сама симуляция зависит от частоты кадров. Шаг времени должен быть фиксированным и выраженным в Fix, а продвижение — строго по тикам:
public sealed class Simulation
{
public static readonly Fix TickDelta = Fix.Ratio(1, 30);
public int Tick { get; private set; }
public readonly List<Unit> Units = new List<Unit>();
FixRandom _random = new FixRandom(12345);
public void Step(IReadOnlyList<PlayerCommand> commands)
{
// Команды применяем в фиксированном порядке: по id игрока
for (int i = 0; i < commands.Count; i++)
Apply(commands[i]);
// Юниты обходим по списку, а не по Dictionary или HashSet
for (int i = 0; i < Units.Count; i++)
Units[i].Step(TickDelta);
Tick++;
}
void Apply(PlayerCommand command) { /* ... */ }
}
public sealed class Unit
{
public int Id;
public FixVector2 Position;
public FixVector2 Target;
public Fix Speed = Fix.FromInt(4);
public void Step(Fix dt)
{
Position = FixVector2.MoveTowards(Position, Target, Speed * dt);
}
}
Обычный MonoBehaviour только копит время и вызывает Step, когда пришли команды всех игроков на нужный тик:
public sealed class LockstepRunner : MonoBehaviour
{
const float TickSeconds = 1f / 30f;
Simulation _sim = new Simulation();
InputBuffer _inputs = new InputBuffer();
float _accumulator;
void Update()
{
// float здесь допустим: он решает только КОГДА сделать тик, а не ЧТО в нём посчитать
_accumulator += Time.deltaTime;
while (_accumulator >= TickSeconds && _inputs.HasAllCommands(_sim.Tick))
{
_sim.Step(_inputs.GetCommands(_sim.Tick));
_accumulator -= TickSeconds;
}
}
}
Это важное разделение. Time.deltaTime на разных машинах разный, но он влияет только на то, в какой момент реального времени выполнится тик. Содержимое тика зависит лишь от состояния и команд. Чтобы скрыть сетевую задержку, команды игрока планируют не на текущий тик, а на несколько тиков вперёд (input delay): нажатие в тике 100 исполняется у всех в тике 103.
Для плавной картинки отображение интерполирует между позицией на предыдущем и текущем тике, используя остаток аккумулятора. Здесь float уже законен: интерполированная позиция нигде не сохраняется и в симуляцию не возвращается.
Контрольная сумма и поиск рассинхрона
Даже при аккуратном коде рассинхрон рано или поздно случится: кто-то добавит Mathf.Sqrt в новую способность, кто-то переберёт HashSet. Поэтому клиенты должны регулярно сравнивать хеш состояния. Простой и быстрый вариант — FNV-1a по сырым значениям:
public struct StateHasher
{
ulong _hash;
public static StateHasher Create() => new StateHasher { _hash = 14695981039346656037UL };
public void Add(long value)
{
for (int i = 0; i < 8; i++)
{
_hash ^= (byte)(value >> (i * 8));
_hash *= 1099511628211UL;
}
}
public void Add(Fix value) => Add(value.Raw);
public void Add(FixVector2 v) { Add(v.X); Add(v.Y); }
public ulong Value => _hash;
}
// В Simulation
public ulong ComputeHash()
{
var h = StateHasher.Create();
h.Add(Tick);
h.Add((long)_random.State);
for (int i = 0; i < Units.Count; i++)
{
h.Add(Units[i].Id);
h.Add(Units[i].Position);
h.Add(Units[i].Target);
}
return h.Value;
}
Каждые, скажем, 30 тиков клиент отправляет пару «тик + хеш». Если у кого-то хеш не совпал, игру ставят на паузу или пересылают состояние с хоста, а в лог пишут номер тика. Дальше начинается отладка: обе стороны сохраняют полный дамп состояния на этом тике в текстовом виде, и обычный diff показывает, какое поле разошлось первым. Отсюда практичный совет — заведите в симуляции метод Dump(), который пишет все поля в строку в стабильном порядке, с самого начала проекта, а не в день первого рассинхрона.
Где ещё прячется недетерминизм
Перейти на Fix — половина дела. Вот что ломает lockstep чаще всего, даже когда float из симуляции уже убран:
- Перебор Dictionary и HashSet. Порядок элементов зависит от хешей и истории вставок и удалений. Хеши строк в современном .NET рандомизированы при каждом запуске процесса. Храните сущности в списках или сортируйте ключи перед обходом.
- Нестабильная сортировка.
List<T>.Sortне сохраняет порядок равных элементов, и его реализация в разных рантаймах может отличаться. Всегда добавляйте в компаратор последний критерий — уникальныйId. - Порядок Update у MonoBehaviour. Unity не гарантирует порядок вызова
Updateмежду объектами. Логика симуляции не должна жить вUpdateотдельных компонентов — только в одном явном цикле, как вSimulation.Step. - Встроенная физика. PhysX в Unity не детерминирован между платформами. Коллизии для lockstep пишутся самостоятельно на
Fix(для RTS обычно хватает кругов, сетки и spatial hash) или берутся из детерминированного движка. - Многопоточность. Если джобы пишут результаты в общий список в порядке завершения, порядок будет разным. Пишите по индексу, а не через
Add. - Время и окружение.
DateTime.Now,Time.time, размер экрана, язык системы — ничто из этого не должно влиять на тик. Особенно коваренfloat.Parseс текущей культурой: на русской Windows он ждёт запятую вместо точки. - Ленивые кэши. Кэш, который заполняется «когда понадобится», может быть заполнен у одного игрока и пуст у другого, если его заполнение зависит от того, что игрок видит на экране.
Производительность и ограничения
Сложение и сравнение Fix стоят столько же, сколько у long. Умножение по частям — четыре целочисленных умножения и несколько сдвигов вместо одного умножения float, деление и корень заметно дороже. На практике для логики RTS с тысячами юнитов это не узкое место: дороже всего обычно поиск соседей и путей, а не арифметика. Если профайлер покажет обратное, сначала уберите лишние корни: сравнивайте квадраты расстояний вместо самих расстояний, храните нормализованные направления.
Ограничения, о которых нужно помнить:
- Точность фиксирована: 1/65536 одинаково и около нуля, и на краю мира. Очень маленькие скорости вроде 0.00001 за тик просто обнулятся, поэтому подбирайте единицы измерения (тики вместо секунд, сантиметры вместо метров).
- Переполнение молчаливое. В отладочной сборке полезно обернуть умножение в
checkedили добавитьDebug.Assertна диапазон. - Готовые библиотеки Unity (NavMesh, физика, анимация) работают во float и в симуляцию не годятся — их можно использовать только для отображения.
Как проверить, что всё работает
Самый дешёвый тест детерминизма не требует сети. Запустите две независимые копии симуляции с одним зерном, подайте одинаковый поток случайных команд и сравнивайте хеши на каждом тике:
[Test]
public void TwoSimulationsStayInSync()
{
var a = new Simulation();
var b = new Simulation();
var commandRandom = new FixRandom(777);
for (int tick = 0; tick < 10000; tick++)
{
var commands = CommandGenerator.Random(ref commandRandom, tick);
a.Step(commands);
b.Step(commands);
Assert.AreEqual(a.ComputeHash(), b.ComputeHash(), $"Рассинхрон на тике {tick}");
}
}
Этот тест ловит ошибки внутри одного процесса: перебор словарей, общие статические поля, кэши. Второй уровень — записать хеши каждого тика в файл в редакторе и сравнить с хешами из сборки под Android на IL2CPP. Если файлы совпали на десятках тысяч тиков, математика у вас действительно детерминирована. Добавьте это сравнение в CI, и рассинхрон перестанет быть багом, который находят игроки.
Итог
Фиксированная точка — это не замена float везде, а инструмент для одной задачи: сделать симуляцию повторяемой бит в бит. Тип Fix на long с форматом Q48.16, умножение по частям, целочисленный корень, синус многочленом и свой генератор случайных чисел дают всё необходимое для lockstep-мультиплеера и реплеев. Остальное — дисциплина: симуляция живёт в одном цикле по тикам, не трогает Unity API, не перебирает неупорядоченные коллекции и регулярно сверяет контрольную сумму. Отображение при этом остаётся обычным Unity с float, интерполяцией и всеми эффектами.