Производительность и профилирование
Игра, которая дёргается и лагает, бесит даже если она классная — этот урок про то, как поймать тормоза за хвост и разогнать цыплёнка до плавных 60 кадров.
Производительность — это не магия, а измерение. Сначала меряешь, где именно игра тратит время, потом убираешь самое жирное. Угадывать «на глаз» — худшее, что можно сделать: почти всегда чинишь не то.
Представь: ты дособрал свой платформер про цыплёнка, врубаешь его на телефоне младшего брата — а он дёргается, как видео на 2G. Прыжок ощущается с задержкой, монетки мелькают рывками, играть невозможно. На твоём ноутбуке-то всё летало! Знакомая боль: игра, которая идеально работает у автора и превращается в слайд-шоу у всех остальных. В этом уроке разберёмся, как понять, что именно тормозит, и как это починить, не переписывая полигру.
И сразу спокойствие: производительность — это не «магия гениев из больших студий», а скучный измеримый процесс. Ты не будешь сидеть и медитировать над кодом, гадая, что не так. Ты включишь термометр, посмотришь на цифру, найдёшь самое жирное место и уберёшь его. Потом снова посмотришь на цифру. Всё. К концу урока у тебя в игре будет маленькая панель с FPS, ты научишься читать «диаграмму пламени» в DevTools и применишь три приёма, которые вытягивают почти любую лагающую 2D-игру обратно в стабильные 60 кадров. Поехали ловить тормоза.
Сначала измерь: счётчик FPS своими руками
Прежде чем что-то чинить, надо это увидеть. Ты не лечишь зуб, пока не нашёл, какой болит, — с играми так же. Главный индикатор здоровья игры — FPS.
FPS (кадры в секунду) — сколько раз в секунду игра перерисовывает картинку. Комфортная цель — 60 FPS, то есть один кадр должен укладываться примерно в 16,7 миллисекунды.
Вот эти 16,7 мс — твой бюджет на один кадр. За это время надо успеть обработать ввод, обновить состояние и нарисовать всё на canvas. Не успел — кадр «проседает», и игрок чувствует рывок. Это как успеть собраться в школу за то время, пока греется завтрак: уложился — всё гладко, опоздал — день поехал.
Сделаем простой счётчик. Он считает, сколько кадров прошло за каждую секунду, и пишет число в угол экрана. Помнишь пространственную сетку и наш игровой цикл на requestAnimationFrame? Встроим замер прямо туда.
let lastTime = 0;
let frames = 0;
let fpsTimer = 0;
let fps = 0;
function loop(now) {
// дельта-время в секундах: сколько прошло с прошлого кадра
const dt = (now - lastTime) / 1000;
lastTime = now;
// копим кадры и время, раз в секунду пересчитываем FPS
frames++;
fpsTimer += dt;
if (fpsTimer >= 1) {
fps = frames;
frames = 0;
fpsTimer = 0;
}
update(dt);
draw();
// рисуем FPS поверх всего
ctx.fillStyle = '#0f0';
ctx.font = '16px monospace';
ctx.fillText('FPS: ' + fps, 10, 20);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
Результат: в левом верхнем углу появляется зелёная надпись «FPS: 60». Если игра тормозит, число падает до 45, 30, а то и ниже — и ты сразу видишь это глазами, без догадок.
Разберём по шагам. now — это метка времени, которую браузер сам передаёт в колбэк requestAnimationFrame. Вычитаем прошлую метку — получаем дельта-время кадра. Складываем кадры в frames, а прошедшее время — в fpsTimer. Как только в таймере накопилась целая секунда, число накопленных кадров и есть наш FPS. Сбрасываем счётчики и считаем заново.
Время кадра — точнее, чем FPS
FPS — грубая оценка: он усреднён за секунду. Иногда полезнее знать, сколько миллисекунд занял именно этот кадр. Бюджет — 16,7 мс. Если кадр стал занимать 25 мс — ты уже не держишь 60 FPS, и это видно мгновенно, ещё до того как просядет средний FPS.
function loop(now) {
const frameStart = performance.now();
const dt = (now - lastTime) / 1000;
lastTime = now;
update(dt);
draw();
// сколько миллисекунд ушло на этот кадр
const frameMs = performance.now() - frameStart;
ctx.fillStyle = '#0f0';
ctx.font = '14px monospace';
ctx.fillText('кадр: ' + frameMs.toFixed(1) + ' мс', 10, 40);
requestAnimationFrame(loop);
}
Результат: под счётчиком FPS появляется строка «кадр: 4.2 мс» — это сколько реально занял твой update плюс draw. Пока число заметно меньше 16, есть запас. Подкрался к 16 — пора оптимизировать.
performance.now() — точные часы браузера с долями миллисекунды, специально для замеров. Запоминаем время в начале кадра, в конце вычитаем — получаем длительность кадра. Это твой главный термометр: смотри на него, а не на ощущения. Почему не обычный Date.now()? Тот выдаёт только целые миллисекунды и иногда «прыгает», когда система подкручивает часы. performance.now() создан ровно для профилирования: он плавный и точный до долей миллисекунды.
Маленькая хитрость: одно число прыгает от кадра к кадру и его трудно читать на глаз. Удобнее показывать сглаженное значение — скользящее среднее. Каждый кадр чуть-чуть подмешивай свежий замер к старому, и цифра перестанет мельтешить:
let smoothMs = 16;
function loop(now) {
const frameStart = performance.now();
// ... update и draw ...
const frameMs = performance.now() - frameStart;
// 90% старого значения + 10% нового = плавная цифра
smoothMs = smoothMs * 0.9 + frameMs * 0.1;
ctx.fillText('кадр: ' + smoothMs.toFixed(1) + ' мс', 10, 40);
requestAnimationFrame(loop);
}
Результат: цифра времени кадра больше не дёргается на каждом кадре, а плавно ползёт — её реально читать глазами, пока ты играешь и одновременно следишь за панелью.
DevTools: рентген твоей игры
Счётчик показывает, что игра тормозит. Но почему? Тут на сцену выходит профайлер браузера. Открой DevTools (F12 или Ctrl+Shift+I), вкладка Performance. Это как рентген: ты записываешь несколько секунд игры, а браузер раскладывает, на что ушло время — по функциям, до миллисекунды.
- Нажми кнопку записи (кружок) на вкладке Performance.
- Поиграй 3–5 секунд — сделай то, на чём игра тормозит (например, пробеги по уровню с кучей врагов).
- Останови запись. Браузер нарисует разноцветную «диаграмму пламени» (flame chart).
Смотришь на широкие полоски — это и есть прожорливые функции. Если твоя draw занимает половину каждого кадра, а в ней внутри жирная полоска drawImage — значит, тормозит рисование. Если толстая полоска у функции коллизий — значит, ты слишком много раз проверяешь пересечения. Профайлер не угадывает — он показывает факт.
Как читать диаграмму пламени? Время идёт слева направо, а функции уложены сверху вниз стопкой: верхняя полоска вызвала ту, что под ней. Ширина полоски — это сколько времени функция работала. Тебе нужны самые широкие полоски в нижних рядах — это места, где реально сгорает бюджет кадра. Кликни по такой полоске — DevTools покажет, в каком файле и на какой строке это происходит. Часто прямо там и прячется твой draw с сотней лишних команд или цикл коллизий, который сравнивает каждого со всеми.
Рядом обрати внимание на цвет полосок и на нижнюю сводку «Summary». Жёлтый обычно значит «Scripting» — это твой JavaScript, фиолетовый — «Rendering», зелёный — «Painting» (само рисование пикселей). Если жёлтого слишком много — тормозит твоя логика; если фиолетового и зелёного — ты слишком много и тяжело рисуешь. Это сразу подсказывает, в какую сторону копать.
Есть и быстрый способ без записи: на вкладке Performance включи «Frames per second» оверлей (в Chrome — три точки → More tools → Rendering → Frame Rendering Stats). Браузер сам нарисует график FPS поверх страницы. Зелёный — хорошо, красные провалы — там кадр просел. Это удобно держать включённым, пока ты бегаешь по уровню: видно ровно в тот момент, когда игра спотыкается.
Главное правило профилирования: сначала меряй, потом чини. 90% времени игры обычно сжирают 1–2 функции. Оптимизировать всё подряд — пустая трата сил.
Запомни ещё одно слово, которое спасёт тебе кучу нервов: преждевременная оптимизация. Это когда ты усложняешь код «чтобы было быстрее» ещё до того, как замерил, что он вообще медленный. В девяти случаях из десяти ты делаешь код страшнее, теряешь время и не получаешь ни одного лишнего FPS, потому что узкое место было в другом месте. Сначала измерение — потом действие, всегда в таком порядке.
Где обычно прячутся тормоза
Когда найдёшь виновника, чаще всего он окажется одним из трёх типичных. Разберём, как их убрать.
1. Лишние рисования каждый кадр
Самый частый грех — рисовать то, что не меняется, заново 60 раз в секунду. Фон, статичный интерфейс, надписи. Если фон у тебя — это сетка из сотен линий или большой узор, который ты перерисовываешь каждый кадр, то ты тратишь бюджет впустую: фон-то один и тот же.
// ПЛОХО: рисуем 400 линий сетки каждый кадр
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let x = 0; x < canvas.width; x += 20) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
// ... ещё 200 горизонтальных линий ...
drawChicken();
}
Результат: на слабом телефоне FPS проседает до 40 — сотни вызовов stroke() каждый кадр перегружают браузер, хотя сетка вообще не двигается.
2. Решение — кэширование на отдельном canvas
Идея простая: нарисуй статичную картинку один раз на невидимом запасном canvas, а в игровом цикле просто копируй её одним вызовом drawImage. Это как заранее распечатать фон, а не перерисовывать его фломастером на каждом кадре флипбука.
// рисуем сетку ОДИН раз в буфер при старте
const bgCanvas = document.createElement('canvas');
bgCanvas.width = canvas.width;
bgCanvas.height = canvas.height;
const bgCtx = bgCanvas.getContext('2d');
function bakeBackground() {
for (let x = 0; x < bgCanvas.width; x += 20) {
bgCtx.beginPath();
bgCtx.moveTo(x, 0);
bgCtx.lineTo(x, bgCanvas.height);
bgCtx.stroke();
}
}
bakeBackground();
// в игровом цикле — один дешёвый вызов вместо сотен
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(bgCanvas, 0, 0); // готовый фон одним движением
drawChicken();
}
Результат: картинка та же, но FPS снова стабильные 60 — вместо сотен команд рисования браузер копирует одну готовую текстуру. Этот же приём отлично ложится на дальние слои фона из урока про тайлсеты и параллакс: дальний неподвижный слой можно запечь один раз.
Создаём второй canvas через document.createElement('canvas') — он нигде не показывается, живёт только в памяти. Рисуем в него сетку один раз функцией bakeBackground (от англ. «запечь»). Дальше в каждом кадре вместо сотен stroke() у нас один drawImage — браузер просто шлёпает готовую картинку.
3. Лишние пересчёты и сборка мусора
Второй частый тормоз — создание новых объектов каждый кадр. Если ты в update 60 раз в секунду делаешь const pos = { x: 0, y: 0 } для каждого врага, браузеру приходится постоянно убирать за тобой «мусор» — старые объекты. Эта уборка (garbage collection) подвешивает игру на доли секунды, и ты видишь периодические рывки.
// ПЛОХО: новый объект каждый кадр для каждого врага
function update(dt) {
for (const enemy of enemies) {
const velocity = { vx: enemy.speed, vy: 0 }; // мусор!
enemy.x += velocity.vx * dt;
}
}
// ХОРОШО: храним вектор скорости прямо в сущности, ничего не создаём
function update(dt) {
for (const enemy of enemies) {
enemy.x += enemy.vx * dt;
enemy.y += enemy.vy * dt;
}
}
Результат: рывки раз в пару секунд исчезают — мы перестали плодить временные объекты, и сборщику мусора больше нечего убирать в горячем цикле.
Заметь: скорость теперь хранится прямо в состоянии сущности как вектор скорости vx/vy — ровно так, как мы делали для цыплёнка. Никаких новых {} внутри цикла. Правило: в update и draw, которые крутятся 60 раз в секунду, старайся ничего не создавать заново. Та же мысль работает и со звуком, и с массивами: не создавай новый массив const visible = enemies.filter(...) каждый кадр, если можешь просто пробежать циклом. Создание объектов — само по себе не зло, но в горячем цикле, который повторяется тысячи раз в секунду, мелочи складываются в заметную паузу.
4. Слишком много проверок коллизий
Ещё один тихий пожиратель FPS — проверка коллизий всех со всеми. Если у тебя на экране 100 объектов и ты сравниваешь каждый с каждым, получается 100 × 100 = 10 000 проверок AABB за один кадр. Пока врагов пять — незаметно, но стоит уровню разрастись — и время кадра прыгает. В профайлере это видно как толстая полоска у функции коллизий.
// дёшево отсекаем то, что вообще не видно на экране,
// и только видимое проверяем на пересечение
function update(dt) {
for (const enemy of enemies) {
// за пределами экрана? пропускаем коллизию целиком
if (enemy.x + enemy.w < 0 || enemy.x > canvas.width) continue;
if (aabb(chicken, enemy)) {
hitChicken();
}
}
}
Результат: игра перестаёт проседать на больших уровнях — мы тратим время только на тех врагов, кто реально в кадре, а не считаем столкновения с теми, кто за тысячу пикселей от цыплёнка. Эта идея — «проверять только то, что рядом» — прямо растёт из пространственной сетки: разбив мир на ячейки, ты сравниваешь объект только с соседями по клетке, а не со всей картой.
Частые ошибки новичков
- Оптимизируют вслепую. «Мне кажется, тормозят коллизии» — и человек переписывает их полдня, а виновником был фон. Всегда сначала запиши профиль в DevTools и найди широкую полоску. Чини то, что реально жрёт время.
- Привязывают скорость к кадру, а не к дельта-времени. Если пишешь
chicken.x += 5без умножения наdt, то на быстром компьютере цыплёнок летает, а на медленном — ползёт. Это как успеть на один автобус при разной скорости ходьбы: умножение на дельта-время выравнивает движение при любом FPS. - Меняют свойства canvas в горячем цикле. Каждое присвоение
ctx.font,ctx.fillStyle,ctx.shadowBlurстоит времени. Если выставляешь шрифт заново перед каждой буквой — это лишние траты. Ставь стиль один раз перед группой однотипных рисований. - Тестируют только на своём мощном устройстве. На твоём ноутбуке 60 FPS может держаться, даже когда код кривой. Проверяй на телефоне или включи в DevTools троттлинг CPU (вкладка Performance, шестерёнка → CPU 4x slowdown) — увидишь игру глазами игрока со слабым устройством.
- Грузят полноразмерные картинки. Спрайт цыплёнка 32×32 пикселя не нужно хранить в файле 2000×2000. Браузер каждый кадр масштабирует огромную текстуру — это лишняя работа. Готовь спрайты в том размере, в каком рисуешь.
Мини-практика: разгони свою игру
Бери свой платформер про цыплёнка (или любую игру из курса) и сделай вот что:
- Встрой счётчик FPS и время кадра из этого урока. Пусть они всегда висят в углу, пока ты разрабатываешь.
- Открой DevTools → Performance, включи CPU throttling 4x и запиши 5 секунд активной игры. Найди самую широкую полоску в диаграмме пламени — запиши, какая функция виновата.
- Запеки статичный фон (сетку, дальний слой параллакса или интерфейс) на отдельном canvas через
bakeBackgroundи рисуй его однимdrawImage. - Пройдись по
updateи убери создание новых объектов внутри циклов — перенеси данные в состояние сущностей. - Снова запиши профиль с тем же троттлингом и сравни: насколько подрос FPS и упало время кадра?
Челлендж со звёздочкой: добавь переключатель (например, по клавише D), который показывает/прячет отладочную панель с FPS, временем кадра и числом живых сущностей на экране. Тогда счётчики не будут мешать в финальной сборке, но всегда под рукой, когда что-то затормозило.
Итоги
Сегодня ты научился не гадать, а измерять. Теперь у тебя в арсенале:
- самодельный счётчик FPS и время кадра через
performance.now()— твой термометр; - профайлер Performance в DevTools — рентген, который показывает прожорливые функции;
- кэширование статичной графики на отдельном canvas — рисуем один раз, копируем дёшево;
- привычка не плодить мусор в горячем цикле и держать стили canvas снаружи.
Помни главный принцип: сначала меряй, потом чини. Один профиль в DevTools экономит часы переписывания не того кода. Теперь твой цыплёнок будет бегать плавно даже на бабушкином телефоне. В следующем уроке займёмся тем, чтобы игру можно было показать миру — соберём проект и подготовим его к публикации.