Последние месяцы я делаю 3D‑мир, который работает прямо в браузере — на Three.js: сцена, персонажи, сеть.

Самое неприятное в производительности браузерного 3D — не ошибки. Ошибку видно: она в консоли, у неё есть стек и виновник. А есть второй класс проблем: всё работает, ничего не падает, просто тормозит. Консоль чистая, кадры падают, и непонятно, с чего начинать.

Речь про Three.js, но большинство выводов переносится на любой WebGL‑стек.

Почему речь про слой, а не про «ещё один 3D‑мир»

Когда говорят о 3D в браузере, обычно обсуждают картинку: сцену, модели, свет, материалы. Но стоит начать делать это всерьёз, и довольно быстро выясняется, что картинка — не самая дорогая часть.

Дорогое находится вокруг неё, и оно не зависит от того, какой именно мир вы делаете:

  • производительность: сколько draw call на кадр, сколько раз пересобираются шейдерные программы, сколько раз в кадр вмешивается драйвер;

  • загрузка и разбор активов: как быстро модель попадает на экран и что в это время делает основной поток;

  • чужие модели: у них разные имена костей, разные соглашения об анимации, разный масштаб;

  • синхронизация нескольких людей: что происходит, когда соединение рвётся и восстанавливается;

  • подключение внешних агентов: как дать программе, а не человеку, присутствовать в сцене.

Это и есть базовый слой: набор задач, которые придётся решать независимо от того, будет ли сверху выставочный зал, тренажёр или личный мир. Именно поэтому его имеет смысл решать один раз и отдельно — а не заново в каждом проекте.

Ниже — три таких случая из моей практики. У всех трёх общая черта: проблема не в самой отрисовке картинки, а рядом с ней. И у каждого есть конкретные цифры до и после.

Случай 1. 3064 draw call в плотной зоне

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

Первое, что стоит сделать, — перестать гадать и получить число. У Three.js есть renderer.info.render.calls — количество draw call за последний кадр. В плотной зоне оно показывало 3064.

Дальше — самое полезное открытие в этом разборе. Кажется, что на вопрос «какие объекты реально рисуются?» отвечает object.visible. В моём проекте это не работает: три разных модуля переключают видимость каждый кадр, поэтому visible === true не значит «этот объект сейчас чего‑то стоит».

Рабочий критерий — считать объекты в момент реальной отрисовки, то есть в onBeforeRender. Упрощённо это выглядит так:

const counter = new Map(); scene.traverse((obj) => { obj.onBeforeRender = () => { const k = obj.geometry.uuid; counter.set(k, (counter.get(k) || 0) + 1); }; }); // через N кадров смотрим топ по counter

Так стала видна картина: сцена собрана из множества отдельных мешей. Материал у сотен из них один и тот же, но каждый меш — отдельный объект, а значит отдельный draw call.

Что помогло:

  1. Геометрическое объединение. Объекты с общим материалом, которые не двигаются друг относительно друга, сливаются в одну геометрию. Результат в плотной зоне: 3064 → 837 draw call (−73%).

  2. Замещающая геометрия вместо полной. Пока настоящие модели не загрузились (см. случай 3), на их месте рисуется упрощённая замена — одним draw call на всю сцену, поверх более чем 1000 объектов. Результат: 2150 → 956 draw call (−56%), и при этом на горизонте видно «что‑то», а не пустоту.

Важная оговорка: объединение меняет модель данных. Если слитый объект после этого нужно двигать отдельно — объединение не подходит. Об этом лучше помнить до, а не после.

Случай 2. Смена освещения замораживала картинку на 9 секунд

Симптом: любое действие, менявшее количество источников света, приводило к полному ступору примерно на 9 секунд. Не «проседание FPS» — именно остановка.

Первая гипотеза была про GPU. Она оказалась неверной. Проблема жила на стороне CPU, и это была не «медленный код», а пересборка шейдерных программ.

Как это устроено в Three.js: программа собирается не под материал сам по себе, а под связку «материал + состояние сцены». Ключ кэша включает параметры освещения. Меняется число источников света — весь кэш программ становится недействительным, и материалы собираются заново.

Дальше начинается то, что уже не про JavaScript. На Windows в D3D11 через ANGLE синхронная рефлексия шейдера занимает 160–340 мс на одну программу и не может быть прервана. Это не работа, которую можно разбить на части, — это блокирующий вызов. Умножаем на десятки материалов и получаем те самые секунды.

Решение 1: пул источников света.

Вместо «добавить лампу / убрать лампу» — фиксированный набор из 12 лампочек на всё время работы. Кому нужно освещение, тот арендует лампу: меняются позиция, интенсивность, цвет. Количество лампочек остаётся постоянным → ключ кэша стабилен → пересборки не происходит.

Результат: фриз 8989 мс → 0. Не «стало быстрее» — пик исчез как класс.

Решение 2: бюджет на прогрев.

Полностью убрать первую компиляцию нельзя — она происходит при первом использовании материала. Единственное, что можно сделать, — не дать ей собраться в один кадр. Непрогретые меши не пускаются в кадр; компиляция оплачивается порциями, примерно по 170 мс на кадр, пока всё не будет готово.

Это осознанный размен: вместо одной долгой заморозки — картинка, в которой модели появляются чуть позже. Для наблюдателя второе субъективно лучше.

Итог по случаю: пик заморозки убран; при первом входе в точку появления худший кадр — 386 мс, кадров дольше 500 мс — ноль.

Случай 3. Полоса загрузки застревала на 90%

Полоса доходила до 90% и замирала. Потом картинка «прыгала» сразу в готовый мир.

Причина оказалась не в загрузке. Маска снималась по таймеру, а компиляция шейдеров происходит при первой отрисовке. Таймер истекал раньше, чем рендерер что‑либо рисовал. 90% — это момент, когда таймеру «пора», а не когда что‑то готово.

Что сделано:

  1. Маска снимается только тогда, когда рендерер действительно выдал кадр — не по времени, а по факту.

  2. Прогресс считается честнее: знаменатель — набор объектов, которые реально нужны (а не все файлы в папке), а вес делится так: 0.7 — на скачанные байты, 0.3 — на подтверждённое появление на экране.

Побочный эффект приятный: вместо «картинка замерла, потом всё сразу» получается «модели появляются одна за другой». И это не косметика, а прямое следствие того, что прогресс теперь отражает реальность, а не таймер.

Ещё одно место, где кадры стоят: разбор модели

Пока модели разбираются, кадры тоже проваливаются — и это не связано ни с сетью, ни с количеством объектов в сцене.

Причина: разбор GLB (разбор структуры, сборка геометрии, подготовка текстур) происходил в основном потоке. Всё это время основной поток занят — а значит, не рисует.

Что сделано: разбор перенесён в Worker. Два момента, которые оказались важны:

  1. Передача буферов через transferable — данные передаются, а не копируются. Копирование больших буферов само по себе съедает кадры, поэтому «просто вынести в воркер» без этого даёт мало.

  2. Текстуры предварительно уменьшаются в воркере через OffscreenCanvas — до того, как попадут в основной поток.

Результат: разбор моделей ушёл из основного потока. Здесь важна формулировка: это оптимизация, которая меняет не скорость, а сам факт блокировки. И мелочь, которая тоже про кадры: неактивная вкладка. Когда страница уходит в фон, часть логики продолжает работать на полную. Явное дросселирование в фоне убирает и этот эффект.

Что я вынес из этих разборов

  1. Сначала измерение, потом правка. Без числа любая оптимизация — это угадывание. В проекте это доведено до скриптов приёмки: изменение считается принятым, только если скрипт вернул успешный код и JSON‑отчёт.

  2. Не доверяйте visible. В достаточно живой сцене видимость кто‑то переключает каждый кадр. Считайте то, что действительно дошло до отрисовки — через onBeforeRender.

  3. Разделяйте «медленно» и «заблокировано». Часть проблем — это объём работы, его можно распараллелить, убрать или отложить. Другая часть — блокирующий вызов драйвера, который разбить нельзя; его можно только не вызывать или спрятать за прогрев.

  4. Меняйте не «как быстро», а «сколько раз». Крупнейший выигрыш в этом тексте — не ускорение чего‑либо, а устранение повода это делать.

Цифры, если коротко

Что | До | После draw call в плотной зоне | 3064 | 837 (−73%) draw call с замещающей геометрией | 2150 | 956 (−56%) фриз при смене количества ламп | 8989 мс | 0 худший кадр при входе в точку появления | — | 386 мс кадры дольше 500 мс при входе | — | 0 разбор моделей | в основном потоке | в Worker (transferable)

Вместо вывода

Браузерный 3D приучает к мысли, что производительность — это «насколько тяжёлая сцена». На практике куда чаще дело не в тяжести, а в том, сколько раз вы делаете одну и ту же вещь: draw call, пересборку шейдерных программ, обращение к драйверу. Сцена может быть тяжёлой и при этом ровной — если делать это один раз, а не каждый кадр.

И последнее: ни в одном из трёх случаев в консоли не было ни одной ошибки. Это, пожалуй, главная причина, по которой про такие вещи стоит писать — их не видно, пока не начнёшь измерять.

И ещё одно наблюдение, уже не про код. Когда разбираешься с такими задачами по одной, замечаешь, что все они одного типа. Это не «сделать красиво» — это «сделать так, чтобы вообще работало». И решать их приходится в любом браузерном 3D‑проекте, что бы вы ни строили сверху.

Именно поэтому мне и кажется правильным говорить про базовый слой, а не про «мир»: содержимое мира меняется, а этот список — нет. Пока он не решён, всё остальное просто не поедет.

Код и скрипты, которыми измерялись эти цифры: github.com/miduo100/3d‑virtual‑world

Комментарии (1)


  1. Zx2001
    16.10.2026 09:52

    Тема интересна, но через ЛЛМ-слоп стайл не продраться(