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

Как 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 — не враг, это инструмент с конкретными ограничениями, которые нужно учитывать.
По сути, всё сводится к трём правилам:
Не позволяйте координатам расти бесконечно — используйте floating origin
Не сравнивайте float через
==— используйте epsilon-сравнениеИспользуйте фиксированный шаг для физики —
FixedUpdateвместоUpdate
Многие разработчики сталкиваются с последствиями float precision, но не всегда знают, что проблема именно в этом. Надеюсь, что теперь, столкнувшись с дёргающимся персонажем или пулей, пролетевшей сквозь стену, вы будете знать куда смотреть.
Если вам понравилось и было интересно — ставьте плюсы. Так я пойму, что подобные разборы математики для геймдева могут быть полезны, и стоит продолжать.
Подписывайтесь на мой блог в телеграм. Там я разбираю подобные темы в коротком формате: одна задача, один разбор, с кодом и примерами.
Источники
What Every Computer Scientist Should Know About Floating-Point Arithmetic — David Goldberg
Floating-point precision in Unity — Unity Documentation
KSP — Floating Origin implementation — Kerbal Space Program Wiki
