Дельта-время: плавность на любом 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 пикселей в секунду:
| FPS | deltaSeconds | сдвиг за кадр | кадров в секунду | за секунду всего |
| 60 | 0.0166 | 200 × 0.0166 ≈ 3.3 px | 60 | ≈ 200 px |
| 144 | 0.0069 | 200 × 0.0069 ≈ 1.4 px | 144 | ≈ 200 px |
| 30 | 0.0333 | 200 × 0.0333 ≈ 6.7 px | 30 | ≈ 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 мс.
Мини-практика: разгони и притормози цыплёнка
Теперь твоя очередь. Возьми за основу код из третьего примера (вектор скорости + стрелки) и доработай его:
- Сделай так, чтобы при зажатом
Shiftцыплёнок бежал в два раза быстрее (спринт). Подсказка: заведи переменнуюcurrentSpeedи в начале кадра ставь её равнойchicken.speedилиchicken.speed * 2в зависимости отkeys['Shift']. - Добавь защиту от гигантского первого кадра: ограничь дельту сверху через
Math.min, как в разделе про ошибки. - Выведи на canvas текущий FPS. Подсказка: FPS ≈
1 / deltaSeconds, округли черезMath.roundи нарисуй командойcontext.fillTextв углу. - Проверь главное: открой DevTools, во вкладке Performance притормози процессор (CPU throttling) — цыплёнок должен бегать с той же скоростью, просто кадров станет меньше.
Если цыплёнок при троттлинге бежит так же ровно, как без него, — поздравляю, ты победил «телепорт» и сделал движение по-настоящему профессиональным.
Итоги и что дальше
Сегодня ты разобрался с одной из самых важных идей в геймдеве — и сделал движение цыплёнка независимым от железа игрока:
- без дельты движение «за кадр» привязано к FPS, и на быстрых мониторах игра летит быстрее — это баг «цыплёнка-телепорта»;
- дельта-время — это промежуток между кадрами; берём его как
timestamp - lastTimeиз аргументаrequestAnimationFrame; - миллисекунды переводим в секунды делением на 1000, чтобы скорость задавать «в пикселях в секунду»;
- главная формула:
координата += скорость * deltaSeconds— и движение одинаково на 30, 60 и 144 FPS; - тот же приём работает для вектора скорости
vx/vyпо обеим осям.
Теперь у тебя есть всё, чтобы двигать героя честно и плавно. В следующих уроках раздела мы накопим это в полноценное управление, а дальше — в платформере — добавим к vy гравитацию: постоянное ускорение вниз, которое каждый кадр прибавляется к вертикальной скорости. И, как ты уже догадался, прибавляться оно будет тоже с умножением на дельта-время. Увидимся в следующем уроке.