Производительность и профилирование

Игра, которая дёргается и лагает, бесит даже если она классная — этот урок про то, как поймать тормоза за хвост и разогнать цыплёнка до плавных 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. Это как рентген: ты записываешь несколько секунд игры, а браузер раскладывает, на что ушло время — по функциям, до миллисекунды.

  1. Нажми кнопку записи (кружок) на вкладке Performance.
  2. Поиграй 3–5 секунд — сделай то, на чём игра тормозит (например, пробеги по уровню с кучей врагов).
  3. Останови запись. Браузер нарисует разноцветную «диаграмму пламени» (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. Браузер каждый кадр масштабирует огромную текстуру — это лишняя работа. Готовь спрайты в том размере, в каком рисуешь.

Мини-практика: разгони свою игру

Бери свой платформер про цыплёнка (или любую игру из курса) и сделай вот что:

  1. Встрой счётчик FPS и время кадра из этого урока. Пусть они всегда висят в углу, пока ты разрабатываешь.
  2. Открой DevTools → Performance, включи CPU throttling 4x и запиши 5 секунд активной игры. Найди самую широкую полоску в диаграмме пламени — запиши, какая функция виновата.
  3. Запеки статичный фон (сетку, дальний слой параллакса или интерфейс) на отдельном canvas через bakeBackground и рисуй его одним drawImage.
  4. Пройдись по update и убери создание новых объектов внутри циклов — перенеси данные в состояние сущностей.
  5. Снова запиши профиль с тем же троттлингом и сравни: насколько подрос FPS и упало время кадра?

Челлендж со звёздочкой: добавь переключатель (например, по клавише D), который показывает/прячет отладочную панель с FPS, временем кадра и числом живых сущностей на экране. Тогда счётчики не будут мешать в финальной сборке, но всегда под рукой, когда что-то затормозило.

Итоги

Сегодня ты научился не гадать, а измерять. Теперь у тебя в арсенале:

  • самодельный счётчик FPS и время кадра через performance.now() — твой термометр;
  • профайлер Performance в DevTools — рентген, который показывает прожорливые функции;
  • кэширование статичной графики на отдельном canvas — рисуем один раз, копируем дёшево;
  • привычка не плодить мусор в горячем цикле и держать стили canvas снаружи.

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

Проверьте себя
1. Сколько примерно миллисекунд занимает один кадр, если игра держит комфортные 60 FPS?
AОколо 1 мс
BОколо 16,7 мс
CОколо 60 мс
DОколо 100 мс
2. Что лучше всего сделать ПЕРВЫМ, если игра начала тормозить?
AСразу переписать функцию коллизий — обычно дело в ней
BУменьшить размер canvas в два раза
CЗаписать профиль во вкладке Performance и найти самую жирную функцию
DОтключить все спрайты по очереди
3. В чём смысл приёма «запекания» фона на отдельный (невидимый) canvas?
AНарисовать статичную картинку один раз и потом дёшево копировать её через drawImage
BСделать фон полупрозрачным
CУскорить загрузку картинок с сервера
DАвтоматически включить 120 FPS
4. Почему создание новых объектов вроде { vx: 0, vy: 0 } внутри update каждый кадр — плохая идея?
AОбъекты занимают слишком много места на диске
BБраузер не умеет создавать объекты в цикле
CЭто вызывает периодическую сборку мусора, которая подвешивает игру и даёт рывки
DЭто ломает requestAnimationFrame
5. Зачем включать CPU throttling (например, 4x slowdown) в DevTools при тестировании игры?
AЧтобы игра грузилась быстрее
BЧтобы увидеть игру глазами игрока со слабым устройством и поймать тормоза заранее
CЧтобы отключить звук
DЧтобы поднять FPS на своём компьютере
6. Зачем при движении умножать скорость на дельта-время (dt)?
AЧтобы движение было одинаковым при любом FPS — и на быстром, и на медленном устройстве
BЧтобы цыплёнок двигался ровно в два раза быстрее
CЧтобы сэкономить память
DЭто нужно только для звука, а не для движения