29.10.2026 20 мин чтения Просмотров:1

Fixed-point в Unity: детерминированная математика для lockstep

Почему float даёт разные результаты на разных устройствах и как написать на C# свой тип с фиксированной точкой: умножение без переполнения, корень, синус, вектор, случайность и контрольная сумма для lockstep и реплеев.

Fixed-point в Unity: детерминированная математика для lockstep

В этой статье вы напишете на 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, интерполяцией и всеми эффектами.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также

Coyote time и буфер прыжка в Unity: отзывчивый прыжок в платформере
18.10.2026

Coyote time и буфер прыжка в Unity: отзывчивый прыжок в платформере

Разбираем три приёма, после которых прыжок в платформере перестаёт «глотать» нажатия: coyote time, буфер прыжка и прыжок переменной высоты. Готовый контроллер на Rigidbody2D и новой Input System, расчёт скорости по высоте и unit-тесты логики.