Как найти просадку FPS в браузерной 2D-игре: бюджет кадра, Chrome DevTools Performance, долгие задачи и сборка мусора

Коротко

Просадку FPS в браузерной 2D-игре находят в три шага: замер в коде, запись в Chrome DevTools и поиск самой дорогой функции. Время кадра измеряют через performance.now() и сравнивают с бюджетом не по среднему, а по 99-му перцентилю. Затем проблемный участок записывают в панели Performance с замедлением процессора и ищут красные кадры, долгие задачи от 50 мс и события сборки мусора, а по вкладке Bottom-up — функцию с наибольшим собственным временем.

Содержание

  1. Бюджет кадра: 16,67 мс, из которых ваших около 10
  2. Замер в коде: performance.now() и перцентили вместо среднего
  3. Запись в панели Performance: что смотреть
  4. Долгие задачи: всё, что дольше 50 мс
  5. Сборка мусора: пила на графике памяти
  6. Практический сценарий: игра проседает при 200 врагах
  7. Когда вам это не подойдёт

Бюджет кадра: 16,67 мс, из которых ваших около 10

При 60 кадрах в секунду на кадр приходится 1000 ÷ 60 ≈ 16,67 мс, но вся эта величина игре не принадлежит. По модели RAIL браузеру нужно около 6 мс на отрисовку каждого кадра, поэтому на собственную работу кадра (логика, физика, подготовка спрайтов) рекомендуется закладывать около 10 мс (web.dev, Measure performance with the RAIL model, проверено: ноябрь 2026).

Частота кадров зависит от экрана: MDN указывает, что requestAnimationFrame обычно вызывается с частотой обновления дисплея, чаще всего 60 Гц, но распространены и 75, 120, 144 Гц (MDN, Window: requestAnimationFrame(), проверено: ноябрь 2026).

Длительность кадра при разной частоте экрана
Частота экранаДлительность кадраОриентир на свою работу (наш расчёт: минус ~6 мс браузеру)
60 Гц16,67 мсоколо 10 мс
75 Гц13,33 мсоколо 7 мс
120 Гц8,33 мсоколо 2–3 мс, если игра ограничена 120 кадрами
144 Гц6,94 мспрактически нет — игру разумнее привязать к 60 кадрам логики

Вывод для 2D-игры: логику обычно считают с фиксированным шагом, а рисуют с частотой экрана; бюджет «около 10 мс» относится к кадру при 60 Гц.

Замер в коде: performance.now() и перцентили вместо среднего

Первым шагом замеряют время кадра в самой игре, потому что это дёшево и работает на любом устройстве. performance.now() возвращает время в миллисекундах дробным числом с точностью до микросекунд и, в отличие от Date.now(), использует монотонные часы, которые не прыгают при переводе системного времени (MDN, Performance: now(), проверено: ноябрь 2026).

Главное правило — не смотреть на среднее. Длинные кадры редки, и среднее их растворяет, а игрок замечает именно их.

// Сбор статистики времени кадра: среднее, перцентили, доля кадров сверх бюджета.
function createFrameStats(budgetMs) {
  const samples = [];
  return {
    record: function (ms) { samples.push(ms); },
    summary: function () {
      const sorted = samples.slice().sort(function (a, b) { return a - b; });
      const n = sorted.length;
      const pick = function (p) { return sorted[Math.min(n - 1, Math.floor(p * n))]; };
      let sum = 0, over = 0;
      for (const ms of sorted) { sum += ms; if (ms > budgetMs) over++; }
      return {
        frames: n,
        avg: +(sum / n).toFixed(2),
        p95: pick(0.95),
        p99: pick(0.99),
        max: sorted[n - 1],
        overBudgetPct: +((over / n) * 100).toFixed(1),
      };
    },
  };
}

// Проверка в Node.js: 10 секунд при 60 к/с, каждые 50 кадров — длинный кадр 40 мс.
const stats = createFrameStats(1000 / 60);
for (let i = 0; i < 600; i++) stats.record(i % 50 === 49 ? 40 : 8);
console.log(stats.summary());
// { frames: 600, avg: 8.64, p95: 8, p99: 40, max: 40, overBudgetPct: 2 }

Среднее 8,64 мс выглядит прекрасно, а 99-й перцентиль 40 мс и 2% кадров сверх бюджета показывают, что игра дёргается каждые 50 кадров, то есть чуть меньше чем раз в секунду.

В браузере эти числа собирают в цикле requestAnimationFrame. Разница между соседними метками времени — полная длительность кадра, а замер вокруг update() — только ваша работа. Метки performance.mark() и performance.measure() к тому же видны в записи DevTools на дорожке Timings (Chrome DevTools, Performance features reference, проверено: ноябрь 2026). Этот фрагмент работает только в браузере:

// Только для браузера: замер полного кадра и собственной работы.
const frameStats = createFrameStats(1000 / 60);
const workStats = createFrameStats(10); // ориентир RAIL
let lastT = 0;

function loop(t) {
  if (lastT) frameStats.record(t - lastT);   // полный кадр
  lastT = t;
  const t0 = performance.now();
  performance.mark("update-start");
  update();                                  // логика игры
  render();                                  // подготовка и отправка спрайтов
  performance.measure("update+render", "update-start");
  workStats.record(performance.now() - t0);  // ваша работа
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// Через 10–20 секунд игры: console.table([frameStats.summary(), workStats.summary()])

Помните, что в фоновой вкладке большинство браузеров приостанавливает requestAnimationFrame (MDN), поэтому первый кадр после возврата во вкладку будет огромным; такие значения из статистики исключают.

Запись в панели Performance: что смотреть

Если замер показал длинные кадры, причину ищут записью в панели Performance Chrome DevTools. Официальное руководство Chrome советует перед записью включить замедление процессора: в настройках записи выбрать для CPU замедление в 4 раза, а для проверки на слабых мобильных устройствах — в 20 раз (руководство по панели Performance в документации Chrome DevTools, проверено: ноябрь 2026). Ноутбук разработчика заметно быстрее среднего телефона, и без замедления проблема может не воспроизвестись.

Что смотреть в записи
ГдеЧто означаетЧто искать
Дорожка FPSЧастота кадров во времениКрасная полоса над графиком: частота упала так, что это, вероятно, мешает игроку
Дорожка FramesКаждый кадр отдельным блокомЗелёный — вовремя, жёлтый с пунктиром — показан частично, красный — пропущен, белый — без изменений
Дорожка MainРабота главного потока, ось Y — стек вызововШирокие блоки: чем шире, тем дольше; красный треугольник в углу задачи
Вкладка Bottom-upСуммарное время по функциямСтолбец Self Time (собственное время) — функция, которая тратит время сама, а не через вызовы
Дорожка TimingsВаши performance.mark() и measure()Совпадает ли длинный кадр с вашим участком кода

Типы кадров и цвета на дорожке Frames, столбцы Self Time и Total Time и дорожка Timings описаны в справочнике Performance features reference (Chrome DevTools, проверено: ноябрь 2026). Узкое место обычно там, где велико собственное время: большое полное время при малом собственном означает, что функция просто вызывает что-то дорогое.

Долгие задачи: всё, что дольше 50 мс

Долгая задача — это непрерывная работа главного потока длительностью 50 мс и больше. MDN перечисляет типичные причины: долгие обработчики событий, дорогие перерасчёты разметки и перерисовки (MDN, PerformanceLongTaskTiming, проверено: ноябрь 2026). В панели Performance такие задачи отмечены красным треугольником, а часть сверх 50 мс заштрихована красным (Performance features reference).

В игре долгая задача — это 3 пропущенных кадра подряд при 60 Гц, поэтому её видно сразу. Частые виновники в 2D-играх: загрузка и декодирование картинок посреди уровня, генерация уровня в одном кадре, сохранение большого объёма данных, синхронный разбор большого JSON.

Долгие задачи можно ловить и в коде через PerformanceObserver, но MDN помечает этот интерфейс как экспериментальный и не входящий в Baseline: он работает не во всех основных браузерах. Используйте его только как дополнительный сигнал и проверяйте наличие:

// Только для браузера: логирование долгих задач там, где интерфейс поддерживается.
if (typeof PerformanceObserver !== "undefined" &&
    (PerformanceObserver.supportedEntryTypes || []).indexOf("longtask") !== -1) {
  new PerformanceObserver(function (list) {
    for (const e of list.getEntries()) {
      console.warn("долгая задача:", Math.round(e.duration), "мс");
    }
  }).observe({ type: "longtask", buffered: true });
}

Сборка мусора: пила на графике памяти

Если в записи много коротких событий Minor GC, игра создаёт слишком много временных объектов. Справочник DevTools показывает пример, где под вызовами функции видны тысячи вызовов Minor GC, и во вкладке Bottom-up сборка мусора выходит на второе место по собственному времени; флажок Memory перед записью добавляет график памяти, включая кучу JavaScript (JS Heap) (Performance features reference, проверено: ноябрь 2026).

На графике памяти это выглядит как пила: куча растёт, сборщик мусора её очищает, и снова рост. Для игры это значит, что в каждом кадре создаётся что-то новое: массивы для результатов поиска столкновений, объекты {x, y} для векторов, строки для текста интерфейса, замыкания в обработчиках.

Частые источники мусора в кадре и замена
В кадре создаётсяЗамена
const hits = [] для результатов столкновенийОдин массив на всю игру, очистка через hits.length = 0
{ x: a.x + b.x, y: a.y + b.y } на каждый векторЗапись в заранее созданный объект или в два числа
Новый снаряд или частица на каждый выстрелПул объектов: заранее созданные экземпляры, которые выключаются и переиспользуются
Строка "Очки: " + score каждый кадрОбновлять текст только когда счёт изменился
array.map(...), array.filter(...) в цикле обновленияОбычный цикл for по существующему массиву

Практический сценарий: игра проседает при 200 врагах

Задача: на уровне с 200 врагами игра на ноутбуке идёт ровно, а у части игроков дёргается. Цель — найти причину и подтвердить исправление цифрами.

  1. Замер в коде. Добавьте createFrameStats и соберите 20 секунд игры на участке с врагами. Допустим, получилось: среднее 9 мс, 99-й перцентиль 31 мс, 4% кадров сверх бюджета.
  2. Запись с замедлением. Откройте Chrome DevTools, панель Performance, в настройках записи выберите замедление CPU в 4 раза, включите флажок Memory и запишите 5–10 секунд на том же участке.
  3. Кадры. На дорожке Frames найдите красные (пропущенные) кадры и выделите один из них.
  4. Главный поток. На дорожке Main под этим кадром найдите самую широкую задачу. Если в углу красный треугольник — это долгая задача.
  5. Bottom-up. Отсортируйте по Self Time. Если первая строка — ваша функция проверки столкновений, проблема в алгоритме (каждый враг с каждым — это 200 × 199 ÷ 2 = 19 900 пар за кадр). Если в верхних строках Minor GC — ищите создание объектов в кадре по таблице выше.
  6. Исправление и повтор. После правки повторите шаги 1–2 и сравните 99-й перцентиль и долю кадров сверх бюджета, а не впечатление «стало плавнее».
Симптом → вероятная причина
Что видно в записиВероятная причинаКуда смотреть дальше
Ровная высокая нагрузка в каждом кадреДорогой алгоритм в логикеSelf Time функций игры
Редкие длинные кадры, рядом Minor GCВременные объекты в кадреГрафик JS Heap, таблица источников мусора
Одна задача на 100+ мс при входе на уровеньЗагрузка или генерация в одном кадреПеренести на экран загрузки или разбить на части
Нагрузка главного потока низкая, а кадры жёлтые или красныеУзкое место в отрисовкеЧисло вызовов отрисовки и размер текстур

Когда вам это не подойдёт

Этот порядок закрывает большинство просадок в 2D-играх на JavaScript, но есть случаи, где нужен другой инструмент:

  • Проблема только в Safari или на iPhone. Панель Performance — инструмент Chrome; поведение другого браузера записывают его собственными инструментами разработчика, и цифры напрямую не сравнить.
  • Тормозит видеокарта, а не код. Если главный поток почти свободен, а кадры всё равно пропускаются, разбирать нужно отрисовку: число вызовов и размер текстур, а не функции логики.
  • Просадка только на конкретном телефоне. Замедление процессора на компьютере — приближение; окончательную проверку делают на самом устройстве.
  • Игра на WebAssembly. Имена функций в записи могут быть непонятными без отладочной информации, и Bottom-up без неё мало поможет.

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

Источники (проверено: ноябрь 2026): web.dev, Measure performance with the RAIL model (16 мс на кадр, около 6 мс браузеру, около 10 мс на свою работу); Chrome for Developers, руководство по анализу производительности в панели Performance, документация Chrome DevTools (замедление CPU в 4 и 20 раз, дорожка FPS, красный треугольник долгой задачи); Chrome for Developers, Performance features reference (типы кадров на дорожке Frames, штриховка сверх 50 мс, Minor GC в Bottom-up, Self Time и Total Time, флажок Memory, дорожка Timings); MDN, Performance: now(); MDN, Window: requestAnimationFrame(); MDN, PerformanceLongTaskTiming. Таблица бюджета для 75–144 Гц, пороги и примеры — наш расчёт.

Читайте также:

Частые вопросы

Сколько миллисекунд есть у игры на один кадр?

При 60 кадрах в секунду кадр длится около 16 мс, но по модели RAIL браузеру нужно около 6 мс на отрисовку, поэтому своей работе стоит оставлять около 10 мс. На экране 120 Гц кадр длится около 8,33 мс.

Почему среднее время кадра не показывает проблему?

Игрок замечает редкие длинные кадры, а среднее их растворяет: 588 кадров по 8 мс и 12 кадров по 40 мс дают в среднем около 8,6 мс, хотя каждые 50 кадров игра заметно дёргается. Смотрите 99-й перцентиль и долю кадров сверх бюджета.

Что такое долгая задача?

Это непрерывная работа главного потока браузера длительностью 50 мс и больше. В панели Performance Chrome DevTools такие задачи отмечены красным треугольником, а часть сверх 50 мс заштрихована красным.

Как понять, что игру тормозит сборка мусора?

В записи Performance появляются события Minor GC (во вкладке Bottom-up они видны отдельной строкой), а при включённом флажке Memory график JS Heap растёт и резко падает. Обычно это значит, что в каждом кадре создаются новые объекты или массивы.