Почему статичная сцена на Three.js греет телефон: цикл рендеринга и грязный флаг
Простая игра на Three.js может раскалить iPhone даже в статичном состоянии. Проблема кроется не в тяжести фреймворка, а в архитектуре render loop, который перерисовывает сцену 60 раз в секунду, даже если на экране ничего не меняется.
Непрерывный цикл рендеринга — это не просто лишняя работа, это прямая инструкция 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 работать вхолостую.