Технология WebXR открывает иммерсивные сцены прямо из браузера: ни установки приложений, ни фирменных магазинов, ни ожидания обновлений. Но контент, привычно подготовленный для плоской страницы, в гарнитуре рассыпается: тяжёлые модели рвут кадр, наивные текстуры съедают память, а интерфейс из привычных блоков просто не отрисовывается. Подготовка к иммерсивным технологиям - это перестройка конвейера, а не галочка в сборке. Разберём её по звеньям: сессия, бюджет кадра, формат моделей, дозагрузка, интерфейс и запасной путь для тех, у кого гарнитуры нет.

Иммерсивная сессия начинается с проверки поддержки и выбора опорного пространства

Вход в иммерсивный режим - не тег в разметке, а явный программный шаг. Объект navigator.xr сообщает, умеет ли устройство открывать сессию immersive-vr или immersive-ar, а метод requestSession запускает её с правами на стереорендер. Оба шага обязаны происходить по клику человека, иначе браузер честно заблокирует вход и звук. Работает всё это только в защищённом контексте, поэтому локальная разработка идёт через localhost:

const xr = navigator.xr;
if (xr && await xr.isSessionSupported('immersive-vr')) {
  const session = await xr.requestSession('immersive-vr', {
    optionalFeatures: ['local-floor', 'bounded-floor', 'hand-tracking']
  });
}

Сессия живёт в системе координат, и выбор опорного пространства определяет, как виртуальный мир связан с физическим. Тип local привязывает начало координат к месту запуска, local-floor опускает ноль на уровень пола, bounded-floor описывает границу безопасной зоны массивом точек, unbounded разрешает уходить сколь угодно далеко, viewer следует за глазами пользователя. Для комнаты с диваном берут local-floor, для прогулки вдоль длинной улицы - unbounded, для AR с привязкой объектов к поверхностям - local в связке с hit-test. Рядом стоят модули: hit-test отвечает за лучи попадания, anchors за якоря в физическом мире, depth-sensing за карту глубины камеры, hand-input за трекинг рук, dom-overlays за наложение интерфейса поверх видео камеры.

Сессия забирает главный цикл себе. Вместо requestAnimationFrame окна код просит кадр у метода сессии, получает позу наблюдателя и рисует сцену дважды, по разу на каждый глаз с собственной матрицей проекции. Привычный код рендера остаётся, но привычка обновлять все объекты каждый кадр здесь стоит дороже. Третий режим, inline, встраивает трёхмерный контент прямо в страницу без полноэкранного захвата и работает даже там, где гарнитуры нет; он же служит естественным запасным путём, ведь код сцены не меняется, меняется только тип сессии. Кнопка входа в VR появляется лишь после положительной проверки, иначе посетитель получит мёртвый элемент интерфейса.

Бюджет кадра в гарнитуре жёстче чем на плоском мониторе

Арифметика бескомпромиссна. При 72 герцах на кадр отводится 13,9 миллисекунды, при 90 - 11,1, при 120 - 8,3. И это ещё не весь кадр: сцена рендерится дважды, часть времени уходит на композицию итоговой картинки, а просрочка превращается в рывок. Голова при этом непрерывно движется, и запаздывание картинки за поворотом выше двадцати миллисекунд мозг фиксирует как дискомфорт. На плоской странице упавший кадр означает задержку, в гарнитуре - физическое недомогание.

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

Практика накопила честные ориентиры. Число вызовов отрисовки держат в паре сотен, полигонаж сцены - в единицах миллионов треугольников, текстуры - в разрешении 1024 и 2048, а не 4096, шейдеры - попроще. Динамические тени выключают первыми: одна каскадная тень на кадр стоит дороже всей геометрии комнаты, поэтому свет запекают в текстуры освещённости, а в реальном времени оставляют не больше пары простых точечных ламп. На мобильных гарнитурах выручает фиксируемая фовеация: периферия кадра рендерится в пониженном разрешении, центр остаётся резким, а экономия доходит до трети работы видеокарты.

Движение в сцене - отдельная дисциплина комфорта. Плавное перемещение с виньеткой по краям кадра переносится легче, чем резкий рывок камеры, а телепорт между точками спасает тех, кого скольжение укачивает. Масштаб держат реальным: комната с двухметровыми дверями ощущается правильной, а заниженная копия дезориентирует и усиливает дискомфорт.

Пространственный звук не грузит кадр и разгружает зрение. Web Audio с PannerNode в режиме HRTF размещает звук в трёхмерной сцене и передаёт направление почти как в жизни: металлический стук за спиной объясняет сцену лучше текстового ярлыка, а стоит это считанные килобайты.

Формат glTF со сжатием KTX2 и Draco вместо сырого экспорта из редактора

Переносчиком трёхмерного контента в вебе давно стал glTF версии 2.0: компактный, бинарный, с физически корректными материалами и открытыми расширениями. Сырой экспорт из редактора годится только как черновик: сцена выезжает со студийными текстурами в 4096, вечным стеком материалов и сетками в десятки миллионов полигонов. Форматы редакторов вроде FBX тащат за собой избыточность настроек, которые браузерному рендеру не нужны, поэтому конвейер всегда начинается с выгрузки именно в glTF.

Расширение KHR_texture_basisu упаковывает текстуры в контейнер KTX2 на базе универсального формата Basis: видеокарта получает блоки, готовые к распаковке на лету, и память экономится в разы. Расширения KHR_draco_mesh_compression и EXT_meshopt_compression сжимают геометрию, KHR_mesh_quantization переводит координаты в 16-битные целые значения с масштабированием под габариты сцены, теряя ровно столько точности, сколько глаз не заметит. Утилита gltf-transform линейки 4.x выполняет всё это из командной строки:

npx @gltf-transform/cli optimize scene.glb scene.opt.glb --compress meshopt --texture-compress ktx2
npx @gltf-transform/cli inspect scene.opt.glb

Первая команда сваривает и чистит модель, заодно прореживая ключевые кадры анимаций, вторая выводит отчёт о весах, текстурах и расширениях. По весам ориентируются просто: черновик в десятки мегабайт после конвейера обычно худеет в пять-десять раз, а цель первой загрузки - удержаться в паре мегабайт, докачивая остальное уже внутри сцены. Дальше файл подключают библиотекой рендеринга: three.js актуальной линейки 0.18x, Babylon.js или A-Frame версии 1.8 подключают glTF-парсер из коробки и автоматически распознают сжатие, если декодеры добавлены в проект.

Дозагрузка сцены по частям и уровни детализации вместо монолитного файла

Первая сцена обязана появиться быстро, даже если за ней стоит целый квартал. Приём стар как игровые движки: грузить сначала оболочку, потом ближайшие кварталы, потом детали. Каждый район хранят отдельным glTF-файлом со своим весом, а загрузчик держит очередь по расстоянию до наблюдателя. Уровни детализации подменяют далёкую сетку упрощённой копией, и глаз не замечает подмены. Повторяющиеся объекты объединяют инстансингом: один буфер геометрии, одна матрица на каждый экземпляр.

const forest = new THREE.InstancedMesh(treeGeo, treeMat, 500);
for (let i = 0; i < 500; i++) {
  forest.setMatrixAt(i, new THREE.Matrix4().setPosition(x[i], 0, z[i]));
}
scene.add(forest);

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

Текст и интерфейс внутри стереокартинки где обычный HTML не помощник

В иммерсивной сессии страницы не существует: DOM не рендерится в стереобуфер, привычные кнопки и тексты остаются за бортом. Интерфейс собирают в самой сцене. Панели превращаются в меши с текстурой, текст - в SDF-атласы инструментами вроде troika-three-text, которые держат резкость под любым углом обзора, или в canvas-текстуры для редких надписей. Курсора тоже нет: его заменяет луч из контроллера или точка взгляда, а события попадания интерпретирует hit-test.

Габариты читаются не в пикселях, а в метрах и углах. Панель размещают на расстоянии метра-двух от глаз, не заставляя фокусироваться ближе, иначе глаза быстро устают. Кнопка растёт до размеров, которые удобно поражать лучом с расстояния вытянутой руки. Прицел ведёт себя мягко, без резких прыжков, иначе рука не поспевает за интерфейсом. Материал панели выключают из расчёта освещения, иначе текст то темнеет в тени, то бликует на свету, и читаемость уплывает вместе с освещением. Интерактив собирают из лучей и пересечений: луч из контроллера проверяется на попадание в панели, наведение подсвечивает кнопку мягким тоном, выбор сопровождается коротким щелчком. Отклик обязан приходить в тот же кадр, потому что в гарнитуре задержка в сотню миллисекунд ощущается как полсекунды. В AR модуль dom-overlays возвращает часть привычной вёрстки: элементы страницы остаются видимыми на экране телефона поверх проходного видео и продолжают реагировать на обычные клики, а это дешевле, чем строить весь интерфейс в сцене.

Плоский запасной путь с тегом model-viewer для устройств без гарнитуры

У большинства посетителей гарнитуры нет, и честная страница обязана работать без неё. Тег model-viewer линейки 4.x - самый короткий путь: он показывает модель во встроенном просмотрщике с вращением мышью, а на телефонах открывает её в системном AR-просмотре:

<model-viewer
  src="/scene.glb"
  camera-controls
  ar
  ar-modes="webxr scene-viewer quick-look">
</model-viewer>

Атрибут ar-modes перечисляет провайдеров по убыванию приоритета, и браузер сам выбирает доступный: на Android это scene-viewer, на iOS - Quick Look с форматом USDZ. Файл USDZ собирают из того же glTF, экспортёр входит в примеры three.js, поэтому отдельной ветки конвейера заводить не нужно. Той же логике следует самодельный фолбэк: проверка isSessionSupported, затем трёхмерный предпросмотр на канвасе с управлением мышью, затем статичная картинка. Браузеры на iOS не открывают иммерсивные VR-сессии, поэтому для них запасной путь не опция, а основной. Пустую рамку до загрузки закрывает атрибут poster с заранее отрендеренным кадром сцены: посетитель сразу видит картинку, а модель догружается в фоне.

Порядок подготовки сцены от редактора до релиза в гарнитуре

Конвейер собирается из названных деталей в повторяемую последовательность. Минимальный порядок подготовки выглядит так:

  1. собрать сцену в редакторе и выгрузить в glTF с текстурами умеренного размера;
  2. прогнать модель через gltf-transform со сжатием геометрии и конвертацией текстур в KTX2;
  3. свериться с отчётом inspect и статистикой рендера по числу вызовов и памяти;
  4. замерить кадр на целевом устройстве и вычистить то, что выбивается из бюджета;
  5. подключить плоский запасной путь тегом model-viewer и выпустить сборку.

Статистика рендера в three.js выводится одной строкой:

console.log(renderer.info.render.calls, renderer.info.memory.textures);

Потерянные кадры ловят прямо в цикле сессии по дельте между колбэками:

let last = 0;
function onFrame(time) {
  if (last && time - last > 14) console.warn('кадр потерян');
  last = time;
  session.requestAnimationFrame(onFrame);
}
session.requestAnimationFrame(onFrame);

Финальную проверку делают на живых людях: десять минут в гарнитуре и наблюдение за тем, где человек инстинктивно сбавляет ход или щурится, говорят о сцене больше, чем любые метрики. Число вызовов, треугольников, занятых текстур и дельт между кадрами дисциплинирует лучше ощущений: рендер не врёт, он показывает счёт. Когда сцена перестаёт влезать в бюджет, выбор всегда один - резать вес и детализацию, а не частоту обновления, потому что стабильные 72 герца ощущаются лучше рваных девяноста. Иммерсивный веб прощает простую графику, но не прощает нестабильный кадр: посетитель запомнит не скромные текстуры, а дурноту от рывков.