Четыре реализации одной сцены: WebGL, который выживает на слабом телефоне
Уровень рендера выбирается по измеренной способности устройства, а не по строке User-Agent. Порядок проверок — это контракт, деградация идёт только вниз, а постер уходит в SSR всегда.
Тяжёлая сцена на главной странице — обычно один шейдер, один канвас и один флаг isMobile. На флагмане это выглядит хорошо. На телефоне за десять тысяч рублей это слайд-шоу, и первая же попытка починить его приводит к разбору строки User-Agent.
Разбор User-Agent не работает, и не потому что «так не принято». Он отвечает на другой вопрос. iPad сообщает о себе как десктопный Safari. Планшеты на Windows выглядят как ноутбуки. Тачскрин на моноблоке — тоже. Флагман 2026 года обгоняет ноутбук 2015-го, и оба сообщают ровно то, что положено их вендору. UA Client Hints ничего не меняют: они аккуратнее сообщают тот же самый факт, а нужен другой — сколько кадров эта машина нарисует.
Четыре реализации, а не четыре настройки качества
В lib/tier.ts сцена существует в четырёх видах, и это разные реализации, а не ползунок:
T0 static — канваса нет вообще. Постер + CSS. Reduced motion, или нет WebGL2.
T1 light — 2D-канвас и CSS-движение. Save-Data, 2g, мало памяти.
T2 standard — WebGL2 без постпроцессинга. Дефолт для мобильных и средних машин.
T3 full — WebGL2 + постпроцессинг. Только десктоп и 8+ логических ядер.
Разница между «настройками качества» и «реализациями» не терминологическая. Ползунок предполагает, что дешёвый вариант — это дорогой с выключенными опциями, а значит дорогой код всё равно загрузится. Здесь T1 не умеет ничего из того, что умеет T2, зато он и не тянет за собой three.js.
Порядок проверок — это контракт
Вся функция определения — шесть условий, и важен именно их порядок:
if (prefersReducedMotion()) return TIER.STATIC;
if (conn?.saveData === true) return TIER.LIGHT;
if (!hasWebGL2()) return TIER.LIGHT;
if ((cores > 0 && cores <= 4) || (memory > 0 && memory <= 4)) return TIER.LIGHT;
if (isDesktop() && cores >= 8) return TIER.FULL;
return TIER.STANDARD;
Правило такое: настройка доступности старше сетевой подсказки, сетевая подсказка старше проверки возможностей, проверка возможностей старше эвристики производительности.
prefers-reduced-motion стоит первым, потому что это не сигнал о производительности. Это медицинское требование — вестибулярные расстройства, светочувствительность. Мощность машины здесь ни при чём, и если поменять первые два условия местами, быстрый десктоп с включённым reduced-motion спокойно доедет до T3 с полноэкранным блумом. Такой баг никто не заметит, кроме того человека, которому от него станет физически плохо.
saveData и effectiveType идут следом по похожей причине: посетитель уже сказал, что трафик стоит денег. Спорить с этим измерением ядер бессмысленно — телефон может быть мощным и при этом в роуминге.
Проба WebGL2, которую стоит скопировать
const gl = canvas.getContext("webgl2", { failIfMajorPerformanceCaveat: true });
if (!gl) return false;
gl.getExtension("WEBGL_lose_context")?.loseContext();
return true;
Здесь три отдельных решения. Первое — failIfMajorPerformanceCaveat: без него браузер выдаст контекст на программном растеризаторе, проба вернёт «да», и вы получите T2 на софтверном рендере. Это худший из возможных исходов: формально всё работает, фактически кадр рисуется процессором.
Второе — контекст немедленно освобождается через WEBGL_lose_context. Число живых WebGL-контекстов у браузера ограничено, и проба, которая держит свой до сборки мусора, отбирает слот у того рендерера, ради которого её и запускали. Освобождение должно быть детерминированным, а не «когда-нибудь».
Третье — вся проба в try/catch, потому что detectTier() не имеет права бросать. Устройство, которое ничего необычного о себе не сообщает, и устройство, которое падает на getContext, должны оба куда-то приземлиться.
Отсутствующее значение — это не «мало»
Самая частая ошибка в эвристиках железа — считать undefined нулём и сравнивать. navigator.deviceMemory есть только в Chromium, квантован по степеням двойки и сверху ограничен. hardwareConcurrency шире, но тоже не везде.
Поэтому в условии стоит не просто memory <= 4, а memory > 0 && memory <= 4. Без первой половины каждый посетитель Safari, где свойства нет, читался бы как машина с четырьмя гигабайтами и уезжал в T1 — то есть весь iOS и весь macOS получили бы урезанную сцену из-за отсутствующего поля. «Нет данных» значит «не наказывать», и это надо писать явно, потому что ?? 0 в следующей строке выглядит совершенно безобидно.
Порог T3 — десктоп и восемь логических ядер. Постпроцессинг здесь не «чуть дороже»: это полноэкранный многопроходный эффект, каждый проход читает и пишет буфер размером с экран. На мобильной GPU с общей памятью это другой порядок стоимости, а не следующий шаг качества.
Почему постер уходит в SSR всегда
SSR_TIER равен TIER.STATIC, и это не осторожность, а монотонность. Уровень — клиентский факт; на сервере его можно только угадать.
Угадать вверх — значит отдать в HTML канвас, который потом придётся размонтировать: лейаут уже сдвинулся, чанк уже скачан, посетитель увидел перестройку страницы. Угадать вниз — значит отдать постер, то есть готовый корректный кадр, поверх которого канвас монтируется, когда и если он уместен. Второе движение обратимо и незаметно, первое нет. Отсюда правило: вверх не спекулируем никогда.
Побочный выигрыш: герой не бывает пустым, пока грузится JS. T0 — это не аварийный режим, это первый кадр каждого визита.
Ничего не выполняется во время рендера
В прежней реализации уровень ставился внутри useEffect по таймеру на 500 мс. Получалось два дефекта сразу: видимый «поп» через полсекунды после загрузки и гонка, в которой канвас монтировался раньше, чем становился известен isMobile.
Правила React Compiler (react-hooks/purity, react-hooks/set-state-in-effect, react-hooks/immutability) в прежнем конфиге были выключены. Сейчас они включены — ровно из-за этого класса ошибок. Линтер здесь дешевле code review: он ловит не стиль, а конкретный дефект, который уже один раз доехал до прода.
Способность — не то же, что производительность
Проба говорит, что устройство может создать WebGL2-контекст. Она ничего не говорит о том, удержит ли оно кадры: троттлинг после трёх минут, вторая вкладка с видео, драйвер, тихо ушедший в софтверный фолбэк. Поэтому измеренный FPS может понизить уровень уже после старта.
Понижение устроено намеренно однонаправленно:
export function demote(tier: Tier): Tier {
if (tier <= TIER.LIGHT) return TIER.LIGHT;
return (tier - 1) as Tier;
}
Два решения в четырёх строках. Первое — пол на LIGHT, а не на STATIC. STATIC — это предпочтение человека, а не состояние производительности. Спустившись в него по метрике, вы молча отбираете движение, которого никто не просил лишаться, и вдобавок теряете различие между «человек попросил тишины» и «телефон не справился»: дальше по коду эти два состояния уже неразличимы.
Второе — обратной функции нет вовсе. Повышать уровень по улучшившейся метрике заманчиво и приводит к колебаниям: сцена дважды в минуту переключается между реализациями, каждый раз пересоздавая контекст. Слегка заниженный уровень до конца сессии лучше правильного, который мерцает.
Чего в этой статье нет
Чисел. Ни «на 40% быстрее», ни результатов на конкретных моделях телефонов: устройственной лаборатории у студии нет, а синтетический бенчмарк на своём же ноутбуке измеряет ноутбук. Пороги FPS и длина окна усреднения принадлежат компоненту, который владеет циклом, и подбираются так, чтобы одна пауза сборщика мусора не считалась провалом.
Проверяемая часть здесь другая и она важнее: порядок условий, освобождение контекста, обработка отсутствующего значения и однонаправленная деградация — всё это читается в lib/tier.ts целиком, файл размером в сто тридцать строк.