Дельта-время: плавность на любом FPS

Сегодня ты сделаешь так, чтобы твой цыплёнок бежал с одной и той же скоростью на любом устройстве — хоть на старом ноутбуке, хоть на игровом мониторе на 144 герца.
Дельта-время (delta time) — это время, прошедшее между двумя кадрами; на него умножают скорость, чтобы движение было одинаковым при любом FPS.

Зачем это нужно: цыплёнок-телепорт

Представь такую ситуацию. Ты сделал игру про цыплёнка, дал её другу, и он пишет: «Слушай, у тебя цыплёнок носится как ужаленный, я вообще не успеваю на монетки нажимать». А у тебя на компьютере всё нормально, бежит спокойно. Что случилось? Никакого бага в коде нет. Просто у друга монитор быстрее.

Звучит странно: причём тут монитор? А вот причём. В уроке про игровой цикл и requestAnimationFrame мы двигали цыплёнка примерно так: «каждый кадр прибавляй к его координате 5 пикселей». Кажется логичным. Но сколько кадров в секунду рисует браузер? На обычном экране — 60. А на игровом мониторе — 120 или даже 144. И вот считай: 5 пикселей × 60 кадров = 300 пикселей в секунду у тебя, а 5 пикселей × 144 кадра = 720 пикселей в секунду у друга. Один и тот же код, а цыплёнок у друга летит больше чем вдвое быстрее.

Это классическая ловушка, в которую попадает вообще каждый, кто пишет первую игру. И сегодня мы её закроем раз и навсегда. К концу урока цыплёнок будет бежать ровно 200 пикселей в секунду — и точка. Хоть 30 FPS, хоть 144, хоть лаганул браузер на полсекунды — скорость на экране одна и та же. Магия? Нет, всего одно умножение. Поехали разбираться.

Метафора: успеть на один автобус при разной скорости ходьбы

Давай так. Представь, что тебе надо пройти от дома до школы — ровно 1000 метров. Ты можешь идти медленно или быстро, но расстояние-то одно и то же, верно? Если идёшь медленно — делаешь больше шагов, но каждый короткий. Если бежишь — шагов меньше, но каждый длиннее. Итог одинаковый: ты прошёл свою тысячу метров.

Игра — точно так же. «Расстояние, которое цыплёнок должен пройти за секунду» — это фиксированная величина, скажем 200 пикселей. А «кадры» — это шаги. На быстром мониторе кадров-шагов много, и каждый должен быть коротким. На медленном кадров мало, и каждый должен быть длиннее. Чтобы каждый шаг получился правильной длины, нам нужно знать одну вещь: сколько времени прошло с прошлого шага. Вот это время и есть дельта-время.

Если между кадрами прошло мало времени (быстрый монитор) — сдвигаем цыплёнка на чуть-чуть. Если много (медленный монитор) — сдвигаем на побольше. В сумме за секунду набегает ровно 200 пикселей в обоих случаях. Цыплёнок «успевает на один и тот же автобус», как бы быстро или медленно он ни шагал.

Главная формула урока: сдвиг за кадр = скорость (пикселей в секунду) × дельта-время (в секундах). Запомни её — на ней держится плавное движение в любой игре мира.

Откуда брать дельту: timestamp из requestAnimationFrame

Хорошая новость: браузер сам подсказывает нам время. Помнишь, в прошлых уроках функция, которую мы передавали в requestAnimationFrame, получала какой-то аргумент, а мы его игнорировали? Вот он-то нам теперь и нужен. Браузер на каждом кадре передаёт в нашу функцию timestamp — текущее время в миллисекундах с момента запуска страницы.

Сама по себе одна метка времени бесполезна. Но если запомнить метку прошлого кадра и вычесть её из метки текущего — получим как раз тот промежуток, который искали. Это и есть дельта.

let lastTime = 0;

function gameLoop(timestamp) {
  // timestamp — время текущего кадра в миллисекундах
  const delta = timestamp - lastTime; // сколько мс прошло с прошлого кадра
  lastTime = timestamp;               // запоминаем на следующий раз

  console.log('delta =', delta);

  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

Результат: в консоли каждый кадр печатается значение delta. На обычном мониторе 60 FPS там будет крутиться число около 16.6 (потому что 1000 мс ÷ 60 ≈ 16.6), а на мониторе 144 Гц — около 6.9. Сам цыплёнок пока не двигается — мы только измеряем время между кадрами.

Разберём по шагам, что тут происходит:

  • let lastTime = 0; — заводим переменную, в которой будем хранить время прошлого кадра. Объявляем её снаружи функции, чтобы она не обнулялась на каждом кадре.
  • function gameLoop(timestamp) — теперь мы ловим тот самый аргумент, который раньше игнорировали. Браузер кладёт туда время.
  • const delta = timestamp - lastTime; — вычитаем из «сейчас» «прошлый раз» и получаем разницу в миллисекундах.
  • lastTime = timestamp; — сразу же запоминаем текущее время, чтобы на следующем кадре было от чего отнимать.

Миллисекунды или секунды?

Маленькая, но важная деталь. Браузер даёт время в миллисекундах: в одной секунде их 1000. А скорость нам удобнее задавать «в пикселях в секунду» — так понятнее: «цыплёнок бежит 200 пикселей в секунду». Чтобы формула сошлась, дельту переводят в секунды простым делением на 1000:

const deltaSeconds = (timestamp - lastTime) / 1000;
// теперь deltaSeconds на 60 FPS ≈ 0.0166 секунды

Результат: переменная deltaSeconds теперь хранит доли секунды (около 0.0166 на 60 FPS). С таким значением скорость 200 читается прямо как «200 пикселей в секунду», и формула не требует лишних нулей в коде.

Пример 1: было плохо — цыплёнок зависит от FPS

Сначала посмотрим, как делать не надо, чтобы прочувствовать проблему. Возьмём наше состояние цыплёнка из урока про состояние игрока — объект chicken с координатами и скоростью — и подвигаем его «по старинке», прибавляя пиксели каждый кадр:

const chicken = { x: 50, y: 256, speed: 5 };

function gameLoop() {
  // плохой способ: прибавляем фиксированное число каждый кадр
  chicken.x += chicken.speed;

  context.clearRect(0, 0, 480, 360);
  context.drawImage(chickenSprite, chicken.x, chicken.y, 48, 48);

  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

Результат: цыплёнок едет вправо. На твоём мониторе 60 FPS — со скоростью 300 пикселей в секунду (5 × 60). Но открой ту же игру на мониторе 144 Гц — и цыплёнок улетит за край почти в два с половиной раза быстрее (5 × 144 = 720 пикселей в секунду). Скорость пляшет от железа игрока — это и есть баг «цыплёнка-телепорта».

Здесь speed: 5 означает «5 пикселей за кадр». Беда в том, что число кадров мы не контролируем — его задаёт монитор и мощность устройства. Поэтому привязывать движение к кадрам нельзя. Давай чинить.

Пример 2: стало хорошо — скорость в пикселях в секунду

Теперь переписываем правильно. Два изменения: во-первых, speed отныне значит «пикселей в секунду» (поэтому число станет большим — 200, а не 5). Во-вторых, прибавляем не голую скорость, а скорость, умноженную на дельта-время:

const chicken = { x: 50, y: 256, speed: 200 }; // 200 пикселей в СЕКУНДУ
let lastTime = 0;

function gameLoop(timestamp) {
  const deltaSeconds = (timestamp - lastTime) / 1000;
  lastTime = timestamp;

  // главная строчка урока: скорость × дельта
  chicken.x += chicken.speed * deltaSeconds;

  context.clearRect(0, 0, 480, 360);
  context.drawImage(chickenSprite, chicken.x, chicken.y, 48, 48);

  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

Результат: цыплёнок едет вправо ровно 200 пикселей в секунду — и на 60 FPS, и на 144 Гц, и даже если браузер разок подлагнёт. На быстром мониторе каждый кадр сдвигает его на крошечную долю пикселя, на медленном — на побольше, но за секунду в сумме всегда набегает одинаковые 200 пикселей.

Прочувствуй, почему это работает, на числах. Возьмём скорость 200 пикселей в секунду:

FPSdeltaSecondsсдвиг за кадркадров в секундуза секунду всего
600.0166200 × 0.0166 ≈ 3.3 px60≈ 200 px
1440.0069200 × 0.0069 ≈ 1.4 px144≈ 200 px
300.0333200 × 0.0333 ≈ 6.7 px30≈ 200 px

Видишь? Чем больше кадров, тем меньше каждый сдвиг — и наоборот. Произведение всегда даёт одни и те же 200 пикселей в секунду. Это и есть та самая «дорога в 1000 метров»: длина шага подстраивается под их количество. Можешь даже проверить себя калькулятором — числа в последнем столбце выходят чуть-чуть разными из-за округления дельты, но это копейки: на глаз ты разницы между 199 и 201 пикселем никогда не заметишь.

И ещё один приятный бонус. Раньше, чтобы «ускорить цыплёнка», тебе пришлось бы лезть в код и подбирать загадочное число пикселей за кадр методом тыка. Теперь скорость — это честное, понятное число: 200 пикселей в секунду. Хочешь, чтобы бежал вдвое быстрее, — пиши 400. Хочешь медленную улитку — ставь 50. Никаких «а почему на 5 он летит, а на 3 еле ползёт» — значение читается как обычная физическая скорость, и его легко крутить, балансируя игру.

Пример 3: добавляем вектор скорости и стрелки

Двигать только вправо скучно. Давай дадим цыплёнку вектор скорости — пару чисел vx и vy, чтобы он мог бегать в любую сторону, и подключим стрелки. Дельта работает абсолютно так же, просто умножаем на неё обе оси:

const chicken = { x: 200, y: 150, vx: 0, vy: 0, speed: 200 };
const keys = {};
let lastTime = 0;

document.addEventListener('keydown', (e) => { keys[e.key] = true; });
document.addEventListener('keyup',   (e) => { keys[e.key] = false; });

function gameLoop(timestamp) {
  const deltaSeconds = (timestamp - lastTime) / 1000;
  lastTime = timestamp;

  // задаём вектор скорости по нажатым стрелкам
  chicken.vx = 0;
  chicken.vy = 0;
  if (keys['ArrowLeft'])  chicken.vx = -chicken.speed;
  if (keys['ArrowRight']) chicken.vx =  chicken.speed;
  if (keys['ArrowUp'])    chicken.vy = -chicken.speed;
  if (keys['ArrowDown'])  chicken.vy =  chicken.speed;

  // двигаем по обеим осям с учётом дельты
  chicken.x += chicken.vx * deltaSeconds;
  chicken.y += chicken.vy * deltaSeconds;

  context.clearRect(0, 0, 480, 360);
  context.drawImage(chickenSprite, chicken.x, chicken.y, 48, 48);

  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

Результат: цыплёнок бегает по экрану в ту сторону, куда ты жмёшь стрелку, со скоростью 200 пикселей в секунду по каждой оси. Скорость одинакова на любом устройстве, потому что и vx, и vy умножаются на дельта-время. Отпустил стрелку — цыплёнок встал.

Что важно заметить:

  • Скорость движения (speed, в пикселях в секунду) и направление (vx, vy) — это разные вещи. speed задаёт «как быстро», а знак у vx/vy — «куда».
  • Дельта применяется в самом конце, при сдвиге координат. Логику «какие клавиши нажаты» она не трогает.
  • Тот же приём мы переиспользуем в платформере: только там vy будет менять не стрелка, а гравитация — но умножение на дельту останется точно таким же.

Частые ошибки и подводные камни

Дельта-время — штука простая, но грабли тут разложены коварно. Пробеги список заранее.

  • Гигантский первый кадр. На самом первом вызове lastTime ещё равен 0, а timestamp — это уже сколько-то тысяч миллисекунд с загрузки страницы. Дельта получается огромной, и цыплёнок прыгает на пол-экрана в первом же кадре. Лечится просто: либо в первом кадре устанавливай lastTime = timestamp и пропускай движение, либо ограничивай дельту сверху (см. ниже).
  • Забыл поделить на 1000. Если задал speed: 200 «в секунду», но забыл перевести миллисекунды в секунды, цыплёнок улетит в 1000 раз быстрее, чем ждёшь — мгновенно исчезнет за краем. Либо делишь дельту на 1000, либо держишь скорость «в пикселях на миллисекунду» (тогда числа будут крошечные и неудобные).
  • Не обновил lastTime. Если забыть строчку lastTime = timestamp;, дельта будет расти с каждым кадром, и цыплёнок начнёт ускоряться до бесконечности. Обновляй lastTime сразу после вычисления дельты.
  • Скорость осталась «за кадр». Перешёл на дельту, но забыл, что теперь speed — это пиксели в секунду, и оставил старое значение 5. Цыплёнок будет ползти 5 пикселей в секунду — почти стоять на месте. После перехода на дельту увеличивай числа скорости примерно в 60 раз.
  • Туба при сворачивании вкладки. Если игрок свернул вкладку и вернулся через минуту, между кадрами «прошла» целая минута, и дельта будет гигантской — цыплёнок телепортируется. Защита — ограничить дельту сверху, например const dt = Math.min(deltaSeconds, 0.05);. Это «потолок»: даже если реально прошло много, считаем не больше 50 мс.

Мини-практика: разгони и притормози цыплёнка

Теперь твоя очередь. Возьми за основу код из третьего примера (вектор скорости + стрелки) и доработай его:

  1. Сделай так, чтобы при зажатом Shift цыплёнок бежал в два раза быстрее (спринт). Подсказка: заведи переменную currentSpeed и в начале кадра ставь её равной chicken.speed или chicken.speed * 2 в зависимости от keys['Shift'].
  2. Добавь защиту от гигантского первого кадра: ограничь дельту сверху через Math.min, как в разделе про ошибки.
  3. Выведи на canvas текущий FPS. Подсказка: FPS ≈ 1 / deltaSeconds, округли через Math.round и нарисуй командой context.fillText в углу.
  4. Проверь главное: открой DevTools, во вкладке Performance притормози процессор (CPU throttling) — цыплёнок должен бегать с той же скоростью, просто кадров станет меньше.

Если цыплёнок при троттлинге бежит так же ровно, как без него, — поздравляю, ты победил «телепорт» и сделал движение по-настоящему профессиональным.

Итоги и что дальше

Сегодня ты разобрался с одной из самых важных идей в геймдеве — и сделал движение цыплёнка независимым от железа игрока:

  • без дельты движение «за кадр» привязано к FPS, и на быстрых мониторах игра летит быстрее — это баг «цыплёнка-телепорта»;
  • дельта-время — это промежуток между кадрами; берём его как timestamp - lastTime из аргумента requestAnimationFrame;
  • миллисекунды переводим в секунды делением на 1000, чтобы скорость задавать «в пикселях в секунду»;
  • главная формула: координата += скорость * deltaSeconds — и движение одинаково на 30, 60 и 144 FPS;
  • тот же приём работает для вектора скорости vx/vy по обеим осям.

Теперь у тебя есть всё, чтобы двигать героя честно и плавно. В следующих уроках раздела мы накопим это в полноценное управление, а дальше — в платформере — добавим к vy гравитацию: постоянное ускорение вниз, которое каждый кадр прибавляется к вертикальной скорости. И, как ты уже догадался, прибавляться оно будет тоже с умножением на дельта-время. Увидимся в следующем уроке.

Проверьте себя
1. Почему без дельта-времени цыплёнок движется быстрее на мониторе 144 Гц, чем на 60 FPS?
AБыстрый монитор делает пиксели крупнее
BДвижение прибавляется каждый кадр, а кадров на 144 Гц больше
CНа 144 Гц браузер удваивает значение speed
DrequestAnimationFrame работает только до 60 FPS
2. Откуда мы берём время для вычисления дельты?
AИз глобальной переменной FPS браузера
BИз аргумента timestamp, который requestAnimationFrame передаёт в нашу функцию
CИз new Date() в начале программы
DИз свойства canvas.time
3. Как правильно сдвинуть координату с учётом дельта-времени?
Achicken.x += chicken.speed
Bchicken.x += chicken.speed + deltaSeconds
Cchicken.x += chicken.speed * deltaSeconds
Dchicken.x += deltaSeconds / chicken.speed
4. Зачем делить дельту на 1000 перед использованием?
AЧтобы перевести миллисекунды в секунды и задавать скорость в пикселях в секунду
BЧтобы ограничить FPS до 1000
CЧтобы избежать дробных чисел
DЭто требование requestAnimationFrame
5. Что произойдёт, если забыть строчку lastTime = timestamp в цикле?
AЦыплёнок вообще не сдвинется
BДельта будет расти каждый кадр, и цыплёнок начнёт ускоряться без предела
CИгра упадёт с ошибкой
DFPS зафиксируется на 60
6. Зачем ограничивать дельту сверху, например через Math.min(deltaSeconds, 0.05)?
AЧтобы игра шла ровно 20 FPS
BЧтобы сэкономить память
CЧтобы после сворачивания вкладки или первого кадра цыплёнок не телепортировался из-за огромной дельты
DЧтобы стрелки работали быстрее