17.08.2026 456 материалов

Почему статичная сцена на Three.js греет телефон: цикл рендеринга и грязный флаг

Простая игра на Three.js может раскалить iPhone даже в статичном состоянии. Проблема кроется не в тяжести фреймворка, а в архитектуре render loop, который перерисовывает сцену 60 раз в секунду, даже если на экране ничего не меняется.

Почему статичная сцена на Three.js греет телефон: цикл рендеринга и грязный флаг

Непрерывный цикл рендеринга — это не просто лишняя работа, это прямая инструкция GPU сжигать батарею, рисуя один и тот же кадр сотни раз в секунду.

Разработчик Дхирадж Акула столкнулся с парадоксом: его простенькие настольные игры (крестики-нолики, лудо), написанные на популярной библиотеке для 3D-графики в браузере Three.js, нагревали iPhone до состояния, когда его было неприятно держать в руках. При этом на экране ничего не двигалось — пользователь просто ждал своего хода.

Первой мыслью было отказаться от Three.js в пользу более «лёгкого» Canvas 2D. Однако это классическая ошибка направления. Проблема лежит не в выборе рендерера, а в фундаментальном архитектурном паттерне, который кочует из одного туториала в другой.

Статичный кадр — не бесплатный кадр

Стандартный код для анимации, который предлагается в большинстве руководств, выглядит так:

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();

Функция requestAnimationFrame (часто сокращают до rAF) просит браузер вызвать переданную функцию перед следующей перерисовкой экрана. Большинство дисплеев работают с частотой 60 Гц, поэтому такой цикл выполняется примерно 60 раз в секунду. Строка renderer.render(...) каждый раз отправляет GPU полный набор команд: очистить буфер кадра и заново отрисовать каждую геометрию в сцене.

Итог: GPU получает команду перерисовать всю сцену 60 раз в секунду. Постоянно. Вне зависимости от того, изменилось ли хоть что-то. Для процессора нет понятия «статичная сцена». Он выполняет одну и ту же объёмную работу снова и снова. Вот почему даже неподвижная доска для лудо нагревает телефон так же, как анимированная.

Замена на Canvas 2D ситуацию бы не спасла — цикл с 60 кадрами в секунду, перерисовывающий весь холст, загружает графический ускоритель одинаково сильно, будь то 2D или 3D. Переменная здесь — не технология отрисовки, а сам факт непрерывного цикла.

Решение: рендер по требованию

Фикс тривиально прост, если понять суть. Нужно прекращать рендер, когда ничего не изменилось. Для этого используется так называемый «грязный флаг» (dirty flag) — простой boolean, который переключается в true только тогда, когда что-то на экране действительно обновилось. Термин «грязный» в программировании означает «данные устарели и требуют обновления».

Вот как это работает:

let needsRender = true; // отрисовать первый кадр

function invalidate() {
  needsRender = true; // «картинка устарела, нужна перерисовка»
}

function animate() {
  requestAnimationFrame(animate);

  // обновляем анимации; они сами вызовут invalidate() при изменениях
  const moving = tweens.update() || particles.update();

  if (needsRender || moving) {
    renderer.render(scene, camera);
    needsRender = false;
  }
}
animate();

Теперь, когда доска лудо ожидает хода пользователя, отрисовывается ровно ноль кадров. Функция invalidate() вызывается из обработчиков ввода, анимаций, обработчиков изменения размера окна — то есть только когда пиксели действительно меняются. Для пошаговой игры, которая бо́льшую часть времени проводит в состоянии ожидания, нагрузка на GPU падает почти до нуля.

Если вы используете @react-three/fiber — обёртку Three.js для React — аналогичный механизм встроен и активируется одной строкой: frameloop="demand" на компоненте <Canvas>.

Два скрытых множителя нагрузки

Устранив пустые кадры, можно столкнуться с тем, что каждый полезный кадр всё равно обходится дороже, чем должен. Виной тому две настройки рендерера, которые включены по умолчанию и особенно болезненны на мобильных устройствах:

// дорогие настройки по умолчанию
new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio); // часто равно 3 на телефонах

Разберём каждую.

1. Сглаживание (antialias). Оно смягчает «лесенку» на диагональных линиях. При включении Three.js использует MSAA (мультивыборочное сглаживание). Суть: вместо одного сэмпла на пиксель GPU берёт несколько и усредняет их цвет. Результат — более гладкие края, но нагрузка на шейдерный процессор растёт кратно. На небольшом экране смартфона разница визуально минимальна, а в энергопотреблении — ощутима.

2. Соотношение пикселей (setPixelRatio). Свойство devicePixelRatio показывает, сколько физических пикселей экрана приходится на один CSS-пиксель (условную единицу измерения в вёрстке). На современных флагманах это значение часто равно 2 или 3. Это значит, что канвас шириной в 390 CSS-пикселей на самом деле рисуется в 1170 физических пикселей. А поскольку масштабирование происходит по обеим осям, увеличение коэффициента с 1.5 до 3 увеличивает нагрузку не вдвое, а примерно в четыре раза.

Оптимальная стратегия для мобильных веб-приложений и WebView — определять контекст и снижать эти параметры:

const lowPower = isWebView || isMobile;
new THREE.WebGLRenderer({ antialias: !lowPower });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, lowPower ? 1.5 : 2));

Это примерно вдвое снижает нагрузку на GPU при минимальных визуальных потерях.

Два дополнительных утечки ресурсов

В процессе оптимизации обнаружились ещё два момента, которые беспричинно нагревали устройства:

  • Отсутствие паузы при сворачивании. Браузеры генерируют событие visibilitychange, когда вкладка или приложение уходят в фон. Исходные игры его игнорировали, и цикл продолжал крутиться, даже когда пользователь переключался на другое приложение. Решение — отменять кадр анимации при скрытии и возобновлять при возврате. Логично вынести это в общий хелпер.
  • Постоянные аллокации памяти внутри цикла. В некоторых играх каждый кадр создавался новый объект new THREE.Color() или пересоздавались текстуры. Выделение памяти 60 раз в секунду заставляет сборщик мусора работать интенсивнее, что на мобильных устройствах вызывает как микро-фризы, так и дополнительный нагрев. Объекты следует создавать один раз и переиспользовать.

Итог: проблема в петле, а не в библиотеке

Когда «простая» WebGL-сцена или проект на Three.js приводит к перегреву телефона, первым делом стоит проверять не замену фреймворка, а архитектуру render loop. Паттерн с безусловным requestAnimationFrame рендерит даже пустые кадры, а статичный кадр отнюдь не бесплатен.

Три простых действия исправляют ситуацию кардинально: перейти на рендер по требованию с использованием грязного флага, ограничить соотношение пикселей и сглаживание на слабых устройствах и ставить анимацию на паузу, когда приложение не активно. Фреймворк здесь ни при чём — виноват непродуманный цикл, который заставляет GPU работать вхолостую.