Всем привет! Меня зовут Григорий Дядиченко, и я разрабатываю разные проекты на заказ. Сталкивались ли вы с ситуацией, когда персонаж в вашей игре начинает немного дёргаться, если поиграть достаточно долго? Или пуля иногда пролетает сквозь тонкую стену, хотя коллайдер на месте? Если да — добро пожаловать в мир проблем 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
Комментарии (5)

StreamThread
06.04.2026 19:56Интересно, а случаи ускорения перемещения персонажа во время стрейфа в бок + вперёд, и впритык к некоторым наклонным коллизиям (например бордюры в Cyberpunk 2077) не со схожими причинами ли связаны?

DyadichenkoGA Автор
06.04.2026 19:56Не, это скорее так называемый Tunneling (туннелирование). Это связано в среднем с логикой определения самого столкновения.

max-daniels
06.04.2026 19:56GPU работает на float. Шейдеры, рендеринг, физический движок — всё 32 бита.
Дык вроде перед рендерингом все абсолютные координаты конвертируются в экранные координаты float от 0 до 1. У физического движка могут быть проблемы, не знаю. Но помнится в игре The Crew от Ubisoft координаты как-то сделали через двойной float.
Epsilon-сравнение
В C++ для эпсилона есть std::numeric_limits<float>::epsilon(), неужто такого нет в c#?

beeruser
06.04.2026 19:56Дык вроде перед рендерингом все абсолютные координаты конвертируются в экранные координаты float от 0 до 1
И что с того? Они перестанут быть флотами?
В NDC координаты преобразуются после всех трансформаций и перспективного деления.
В C++ для эпсилона есть std::numeric_limits<float>::epsilon()
Бестолковая вещь в данном контексте. Пу сути она возвращает минимально возможный float.
На работе с координатами ваш epsilon аналогичен нулю.
Jijiki
так и есть, мир рисуется относительно игрока, суть в том, что игрок 0 0 0, всё остальное микроскопическое смещение этого хватает дойти до максимума порога, это делают в открытых мирах там где мир около бесконечный или очень здоровенный
порог легко пробить уже в 0 0 0 но он будет микроскопический незаметный, ~0.00005 например повороты/углы, плюс есть моменты с косинусом и синусом плюс вычисление корня или квадратичное расстояние ) (тоесть такие вычисления, которые зависимы друг от друга например двойной кватернион который может зависеть от кватерниона, а кватернион может зависеть от вектора или не зависеть ну кароче это действительно разок проделать надо, радует что на расте легко тест-кейсы писать для модулей по этой части, а вектора и кватернионы влияют на матрицы ну а матрицы перемножаются, хотя тут и с другой стороны зайти можно, двигаем мышкой получаем смещение для поворота и поидее если мы не в 0 то пошли потери и они типо копиться могут)
я сегодня как раз столкнулся с порогом и забыл, что нельзя угол и размерности сами с собой сравнивать, с каждым новым повторением всё становится яснее и яснее, я сейчас уже на расте тесчу математику - я прыгаю то на С++ то на Раст
кстати в майнкрафте и драгонфол всё это реализовано тоже