Последние месяцы я делаю 3D‑мир, который работает прямо в браузере — на Three.js: сцена, персонажи, сеть. Самое неприятное в производительности браузерного 3D — не ошибки. Ошибку видно: она в консоли, у неё есть стек и виновник. А есть второй класс проблем: всё работает, ничего не падает, просто тормозит. Консоль чистая, кадры падают, и непонятно, с чего начинать.Ниже — три таких случая из моей практики. У всех трёх общая черта: проблема не в самой отрисовке картинки, а рядом с ней. И у каждого есть конкретные цифры до и после. Речь про Three.js, но большинство выводов переносится на любой WebGL‑стек. Читать далее
Время на прочтение7 мин
Охват и читатели5.7K
Последние месяцы я делаю 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.
Что помогло:
Геометрическое объединение. Объекты с общим материалом, которые не двигаются друг относительно друга, сливаются в одну геометрию. Результат в плотной зоне: 3064 → 837 draw call (−73%).
Замещающая геометрия вместо полной. Пока настоящие модели не загрузились (см. случай 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% — это момент, когда таймеру «пора», а не когда что‑то готово.
Что сделано:
Маска снимается только тогда, когда рендерер действительно выдал кадр — не по времени, а по факту.
Прогресс считается честнее: знаменатель — набор объектов, которые реально нужны (а не все файлы в папке), а вес делится так: 0.7 — на скачанные байты, 0.3 — на подтверждённое появление на экране.
Побочный эффект приятный: вместо «картинка замерла, потом всё сразу» получается «модели появляются одна за другой». И это не косметика, а прямое следствие того, что прогресс теперь отражает реальность, а не таймер.
Ещё одно место, где кадры стоят: разбор моделиПока модели разбираются, кадры тоже проваливаются — и это не связано ни с сетью, ни с количеством объектов в сцене.
Причина: разбор GLB (разбор структуры, сборка геометрии, подготовка текстур) происходил в основном потоке. Всё это время основной поток занят — а значит, не рисует.
Что сделано: разбор перенесён в Worker. Два момента, которые оказались важны:
Передача буферов через transferable — данные передаются, а не копируются. Копирование больших буферов само по себе съедает кадры, поэтому «просто вынести в воркер» без этого даёт мало.
Текстуры предварительно уменьшаются в воркере через OffscreenCanvas — до того, как попадут в основной поток.
Результат: разбор моделей ушёл из основного потока. Здесь важна формулировка: это оптимизация, которая меняет не скорость, а сам факт блокировки. И мелочь, которая тоже про кадры: неактивная вкладка. Когда страница уходит в фон, часть логики продолжает работать на полную. Явное дросселирование в фоне убирает и этот эффект.
Что я вынес из этих разборовСначала измерение, потом правка. Без числа любая оптимизация — это угадывание. В проекте это доведено до скриптов приёмки: изменение считается принятым, только если скрипт вернул успешный код и JSON‑отчёт.
Не доверяйте visible. В достаточно живой сцене видимость кто‑то переключает каждый кадр. Считайте то, что действительно дошло до отрисовки — через onBeforeRender.
Разделяйте «медленно» и «заблокировано». Часть проблем — это объём работы, его можно распараллелить, убрать или отложить. Другая часть — блокирующий вызов драйвера, который разбить нельзя; его можно только не вызывать или спрятать за прогрев.
Меняйте не «как быстро», а «сколько раз». Крупнейший выигрыш в этом тексте — не ускорение чего‑либо, а устранение повода это делать.
Цифры, если коротко
Что | До | После 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 | Что нужно знать про 3D-принтеры, если вы провели 30 лет в коме | 0 | 9.18 | 24-04-2026 |
| 2 | ИИ‑мастер для настольной RPG: один запрос на ход, кубик до ответа и баг, который удваивал здоровье | 0 | 9 | 18-10-2026 |
| 3 | Как один сервер на Go держит 2500 бойцов в браузерной драке | 0 | 9.85 | 17-10-2026 |
| 4 | ИИ‑агент внутри КОМПАС-3D: пишет код, строит деталь и проверяет результат | 0 | 7.54 | 20-08-2026 |
| 5 | Baseline: май 2026 | 0 | 5 | 29-06-2026 |
| 6 | Усиление прочности 3D печати деталей — за счёт внутренней их архитектуры | 0 | 9.95 | 09-06-2026 |
| 7 | От идеи создания своего React‑проекта до столкновения с реальностью | 0 | 14.59 | 06-08-2026 |
| 8 | Откуда берётся сложность | 0 | 7.74 | 21-09-2026 |
| 9 | Карточный бой, который сервер может переиграть: детерминированный движок браузерной MMO на TypeScript | 0 | 8.64 | 19-10-2026 |