Время и FPS: delta time

Игра, которая на быстром ноутбуке летает, а на стареньком ползёт — это баг. Лечится он одним числом: дельтой времени.
Суть: delta time (dt) — это сколько секунд прошло с прошлого кадра. Умножая скорость на dt, мы делаем движение одинаковым на любом железе.

Представь, что ты двигаешь героя на 5 пикселей каждый кадр. На компьютере, где 60 кадров в секунду, герой за секунду пройдёт 300 пикселей. А на мощном игровом ПК с 240 кадрами — целых 1200! Одна и та же игра на разных машинах играется по-разному. Это классическая ловушка, в которую попадают все новички.

Решение придумали давно: измерять не «сколько кадров», а «сколько времени». Перед тем как двигать героя, мы спрашиваем у часов: сколько секунд прошло с прошлого кадра? Это число и есть delta time, или dt. Обычно оно крошечное — около 0.016 секунды (это 1/60). Дальше мы говорим: герой движется со скоростью 300 пикселей в СЕКУНДУ, а за этот кадр он сдвинется на 300 * dt пикселей.

Магия в том, что на медленной машине dt будет больше (кадры реже), и за один кадр герой шагнёт дальше — итог за секунду совпадёт. Движение становится привязанным к реальному времени, а не к скорости железа.

Как работает под капотом

Метод clock.tick(60) не только ограничивает FPS, но и ВОЗВРАЩАЕТ количество миллисекунд, прошедших с прошлого вызова. Делим на 1000 — получаем секунды. Вот связь скорости, времени и расстояния:

   расстояние за кадр = скорость(пикс/сек) x dt(сек)

   FPS=60  -> dt=0.0166 -> шаг = 300 x 0.0166 = 5.0 пикс
   FPS=30  -> dt=0.0333 -> шаг = 300 x 0.0333 = 10.0 пикс
   FPS=240 -> dt=0.0041 -> шаг = 300 x 0.0041 = 1.25 пикс

   за 1 секунду везде: 300 пикселей. ИТОГ ОДИНАКОВ.

В коде pygame это выглядит так:

clock = pygame.time.Clock()
x = 100.0
SPEED = 300  # пикселей в секунду

while running:
    dt = clock.tick(60) / 1000.0   # секунд с прошлого кадра
    # ... обработка событий ...
    x += SPEED * dt                # движение, не зависящее от FPS
    screen.fill((0, 0, 0))
    pygame.draw.rect(screen, (255, 80, 80), (x, 300, 40, 40))
    pygame.display.flip()

Докажем, что dt действительно выравнивает движение. Посчитаем, сколько пройдёт герой за 1 секунду при трёх разных FPS — числа должны совпасть. Попробуй сам:

SPEED = 300.0   # пикселей в секунду

def distance_in_one_second(fps):
    dt = 1.0 / fps          # секунд на один кадр
    x = 0.0
    for _ in range(fps):    # столько кадров за секунду
        x += SPEED * dt
    return x

for fps in (30, 60, 144, 240):
    print(f"FPS={fps}: пройдено {distance_in_one_second(fps):.1f} пикселей")

Подводный камень: «спираль смерти»

У delta time есть редкая, но коварная ловушка. Представь, что компьютер на секунду «завис» (свернул окно, переключился на другую программу). Тогда dt окажется огромным — например, целую секунду. Умножив скорость на такой dt, герой телепортируется через полэкрана и может проскочить сквозь стену, не заметив коллизии. В платформерах из-за этого персонаж проваливается под пол. Профессиональное решение — ограничивать dt сверху: dt = min(clock.tick(60) / 1000.0, 0.05). Даже если кадр длился секунду, мир сдвинется максимум как за 0.05 секунды.

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

Полезно один раз прочувствовать масштаб чисел. При 60 кадрах в секунду dt равен примерно 0.0166 секунды — это всего шестнадцать тысячных. Кажется, такой крошечной величиной можно пренебречь, но именно из сложения тысяч таких микрошагов и складывается плавное движение за секунды и минуты игры. Поэтому точность здесь важна: храни позиции во float, а не в целых числах, иначе дробные приращения вроде 1.25 пикселя будут округляться до нуля или единицы, и герой на медленных скоростях начнёт двигаться рывками или вовсе застывать. Округляй координаты только в самый последний момент — при передаче в Rect для отрисовки, — а всю внутреннюю математику веди в дробных числах. Это маленькое правило избавляет от целого класса трудноуловимых багов с «дрожащим» или «прилипающим» движением.

Частые ошибки

  • Двигать на фиксированное число пикселей за кадр — скорость поедет от FPS.
  • Забыть поделить на 1000 — dt окажется в миллисекундах, и герой улетит в космос.
  • Хранить координаты в int — мелкие шаги вроде 1.25 будут теряться. Держи x, y как float.

Best practices

  • Считай dt = clock.tick(60) / 1000.0 один раз в начале каждого кадра.
  • Задавай скорости в «пикселях в секунду» — это интуитивно и переносимо.
  • Координаты объектов держи в float, округляй только при отрисовке.

Итог: dt — это мост между кадрами и реальным временем. Умножай скорость на dt — и твоя игра будет играться одинаково и на калькуляторе, и на игровом ПК.

Проверьте себя
1. Что такое delta time (dt) в игровом цикле?
AТекущий счёт игрока
BСекунды, прошедшие с прошлого кадра
CРазмер окна
DНомер кадра
2. Почему скорость задают в пикселях в СЕКУНДУ, а не в пикселях за кадр?
AТак красивее
BЧтобы движение было одинаковым при любом FPS
CЧтобы экономить память
DТак требует pygame