Всем привет! Меня зовут Григорий Дядиченко, и я разрабатываю разные проекты на заказ. Сталкивались ли вы с ситуацией, когда персонаж в вашей игре начинает немного дёргаться, если поиграть достаточно долго? Или пуля иногда пролетает сквозь тонкую стену, хотя коллайдер на месте? Если да — добро пожаловать в мир проблем float precision.

Сегодня хочется поговорить о том, почему тип float — при всей его повсеместности — может создавать тонкие и неочевидные баги в играх. Разберём, как он устроен, где именно он начинает врать, и что с этим делать.

Если вам интересна эта тема — добро пожаловать под кат!

Generated Image
Generated Image

Как float хранит числа

Прежде чем разбираться с багами, давайте посмотрим на то, что float вообще из себя представляет. Это стандарт IEEE 754 — 32 бита, которые делятся на три части:

  • 1 бит — знак (плюс или минус)

  • 8 бит — экспонента (порядок числа)

  • 23 бита — мантисса (значащие цифры)

23 бита мантиссы — это примерно 7 значащих десятичных цифр. Важно понимать: не 7 цифр после запятой, а 7 цифр всего. То есть число 1234567.0f float хранит точно, а вот 1234567.5f — уже нет. Дробная часть просто не помещается в 23 бита.

Давайте проверим:

float a = 1000000f;
float b = a + 0.1f;
Debug.Log($"a = {a:F10}"); // 1000000.0000000000
Debug.Log($"b = {b:F10}"); // 1000000.0000000000

Математически миллион плюс одна десятая не помещается в 23 бита мантиссы — 0.1 находится за пределами точности float при таком порядке числа. По стандарту IEEE 754 результат должен быть неразличимым от миллиона. Но на практике конкретный результат сравнения может зависеть от платформы: некоторые компиляторы (Mono, .NET JIT на x86) выполняют промежуточные вычисления с повышенной точностью (80-бит x87 FPU), и тогда a == b вернёт false. На ARM (Apple Silicon, мобильные устройства) или при IL2CPP — поведение может отличаться. Это само по себе показательно: один и тот же код — разный результат на разных машинах.

Откуда берётся телепортация

Представьте типичную ситуацию. У вас 2D-платформер, персонаж бежит вправо. Каждый кадр вы прибавляете к позиции:

transform.position += Vector3.right * speed * Time.deltaTime;

При 60 FPS deltaTime примерно 0.016 секунды. Скорость допустим 5. За кадр прибавляется около 0.08 к позиции. Всё нормально — позиция растёт равномерно: 0, 0.08, 0.16, 0.24 и так далее.

Но проходит 10 минут. Позиция уже 2400. Через час — 14400. Float всё ещё работает, но точность уже не та. Вместо шагов по 0.08 мы получаем шаги по 0.0625 или 0.09375 — float не может точно представить дробную часть при таких значениях целой.

А теперь добавим нестабильный FPS. При 300 FPS deltaTime составляет примерно 0.003 секунды. Умножаем на скорость — получаем приращение 0.015. Но float при позиции 14400 уже не различает числа с разницей меньше примерно 0.001. В результате 0.015 где-то прибавляется корректно, а где-то округляется. Персонаж то движется, то стоит на месте, то прыгает на два шага вперёд.

Это и есть та самая «телепортация».

Числовой эксперимент

Чтобы не быть голословным, давайте посчитаем. Простейший тест — складываем маленькое число много раз и сравниваем результат с умножением:

float sum = 0f;
float step = 0.001f;
int iterations = 1000000;

for (int i = 0; i < iterations; i++)
    sum += step;

float expected = step * iterations; // ожидаем 1000.0

Debug.Log($"Сумма:    {sum}");
Debug.Log($"Ожидали:  {expected}");
Debug.Log($"Ошибка:   {sum - expected}");

Запустите этот код у себя — результат может удивить. На одних платформах сумма будет 1000.0039, на других — 991.14. Ошибка варьируется от долей единицы до почти девяти, в зависимости от компилятора (Mono, IL2CPP), архитектуры процессора (x86 vs ARM) и того, как рантайм оптимизирует цепочку сложений. Это важный момент: один и тот же код — разный результат на разных машинах.

В любом случае, ошибка есть, и она накопительная. Даже «оптимистичные» 4 миллиметра в игровых единицах — это разница между попаданием и пролётом пули сквозь стену. А если ваша платформа даёт ошибку в несколько единиц — это уже телепортация, которую заметит любой игрок.

При 60 FPS миллион кадров — это примерно 4.5 часа игры. Достаточно оставить игру работать на ночь, и физика начнёт вести себя непредсказуемо.

Почему double не решает проблему

Логичный вопрос — а что если использовать double? Это 64 бита, 52 бита мантиссы, около 15-16 значащих цифр. Проблема действительно отодвигается далеко, но есть несколько причин, почему это не полноценное решение.

Во-первых, Unity внутри работает на float. Transform.position — это Vector3, а Vector3 — это три float. Вы можете считать промежуточные результаты в double, но в момент присвоения позиции всё обрежется обратно до 32 бит.

Во-вторых, GPU работает на float. Шейдеры, рендеринг, физический движок — всё 32 бита. Переход на double удвоил бы потребление памяти и существенно ударил по производительности.

В-третьих, проблема не устраняется, а откладывается. При координатах порядка миллиарда double тоже начнёт терять точность.

Как с этим бороться

Существует несколько проверенных подходов, которые применяются в реальных проектах.

Floating origin — сдвиг начала координат

Идея простая: не позволять координатам расти бесконечно. Когда игрок уходит далеко от начала координат — сдвинуть весь мир обратно к нулю:

void Update()
{
    if (player.position.magnitude > 1000f)
    {
        Vector3 offset = player.position;
        foreach (var obj in allWorldObjects)
            obj.position -= offset;
        player.position = Vector3.zero;
    }
}

Именно так работает Kerbal Space Program. Когда ваша ракета летит к далёкой планете, игра не хранит координаты в миллиардах. Она двигает всю вселенную, а ракета остаётся вблизи нуля, где float наиболее точен.

Подход требует внимания к деталям — нужно сдвигать не только объекты, но и системы частиц, трейлы, точки навигации и всё остальное, что имеет мировые координаты.

Epsilon-сравнение

Никогда не сравнивайте float через оператор ==. Всегда используйте сравнение с допуском:

// Так делать не стоит
if (a == b) { ... }

// Epsilon-сравнение
if (Mathf.Abs(a - b) < 0.0001f) { ... }

// Или встроенный метод Unity
if (Mathf.Approximately(a, b)) { ... }

Это кажется базовым правилом, но на практике == 0f в коде коллизий встречается регулярно. И именно из-за этого объект «иногда» проваливается сквозь пол — его позиция отличается от границы коллайдера на ничтожную дробь, которая для оператора == уже не равна нулю.

Небольшая ремарка. Выбор значения epsilon — это отдельный вопрос. Mathf.Approximately использует Mathf.Epsilon, который для больших чисел может быть слишком маленьким. В некоторых случаях стоит использовать относительное сравнение:

bool ApproximatelyEqual(float a, float b, float tolerance = 0.0001f)
{
    return Mathf.Abs(a - b) <= tolerance * Mathf.Max(1f, Mathf.Max(Mathf.Abs(a), Mathf.Abs(b)));
}

Фиксированный шаг для физики

Для физических расчётов используйте FixedUpdate и Time.fixedDeltaTime вместо Update с Time.deltaTime. Фиксированный временной шаг убирает зависимость от FPS и делает накопление ошибки предсказуемым.

// Зависит от FPS — deltaTime каждый кадр разный
void Update()
{
    rb.velocity += gravity * Time.deltaTime;
}

// Фиксированный шаг — стабильный и предсказуемый
void FixedUpdate()
{
    rb.velocity += gravity * Time.fixedDeltaTime;
}

В Godot аналогичный механизм: physicsprocess(delta) вместо _process(delta).

Где ещё float создаёт проблемы

Телепортация — это наиболее наглядный симптом, но float precision влияет и на другие области.

Коллизии. Два объекта с почти одинаковой позицией могут оказаться в одной точке для float. Результат — объекты слипаются или проходят друг сквозь друга.

Анимации. Lerp от 0 до 1 при очень маленьком шаге может никогда не достичь единицы. Анимация «почти» завершается, но последний кадр не наступает.

Процедурная генерация. Noise-функции на больших координатах начинают давать артефакты — повторяющиеся паттерны, которых не должно быть.

Сетевая синхронизация. Одна и та же формула на клиенте и сервере может давать разные результаты. Порядок операций с плавающей точкой влияет на округление, и детерминизм теряется.

Заключение

Я постарался рассказать про одну из тех тем, которые редко попадают в туториалы, но регулярно вызывают проблемы в реальных проектах. Float — не враг, это инструмент с конкретными ограничениями, которые нужно учитывать.

По сути, всё сводится к трём правилам:

  1. Не позволяйте координатам расти бесконечно — используйте floating origin

  2. Не сравнивайте float через == — используйте epsilon-сравнение

  3. Используйте фиксированный шаг для физики — FixedUpdate вместо Update

Многие разработчики сталкиваются с последствиями float precision, но не всегда знают, что проблема именно в этом. Надеюсь, что теперь, столкнувшись с дёргающимся персонажем или пулей, пролетевшей сквозь стену, вы будете знать куда смотреть.

Если вам понравилось и было интересно — ставьте плюсы. Так я пойму, что подобные разборы математики для геймдева могут быть полезны, и стоит продолжать.

Подписывайтесь на мой блог в телеграм. Там я разбираю подобные темы в коротком формате: одна задача, один разбор, с кодом и примерами.

Источники