Реклама
Меморина
Меморина
Меморина

Zero на Three.js: инженерный разбор интерактивного сайта

Интерактивный сайт zero.university превращает скролл в историю: пользователь рисует ноль, разбивает стекло и летит через тоннель. За четыре месяца команда BUNQ LABS сжала более гигабайта исходников до 10 МБ и добилась 60 FPS даже на бюджетном Android. Разбираем ключевые инженерные решения.

Обложка: Zero на Three.js: инженерный разбор интерактивного сайта

Представьте, что вы заходите на сайт, а он не пускает. Нет кнопки «войти» — только поле, где нужно нарисовать ноль. Как только круг замыкается, из линии расходится инеевый узор, и сайт начинает рассказывать историю. Это не декоративная заглушка: так работает zero.university — кейс, где первый жест пользователя сразу задаёт тон всему повествованию.

Zero — иммерсивный лендинг индийского стартапа Zero University, который предлагает альтернативу классическому университетскому пути. Сайт превращает скролл в шестиактную историю: традиционное образование, разбитое стекло, горящие деньги, уничтоженные сертификаты, тоннель из логотипа ZERO и, наконец, интерактивная карта города с офисами реальных компаний. Цель — не просто показать красивую картинку, а провести пользователя через аргумент: диплом перестаёт быть гарантией, а навыки открывают дорогу в индустрию.

Проект разрабатывала студия BUNQ LABS четыре месяца. Исходные ассеты занимали больше гигабайта: несжатые Blender-сцены, 8K-текстуры и запечённые анимации. Финальная сборка уместилась в 10 МБ и держит 60 FPS даже на бюджетном Android. Команда опубликовала технический разбор на Codrops, а мы выделили решения, которые можно перенести в свои WebGL-проекты.

Ключевые выводы
  • Первый жест как ворота: распознавание нуля — это простая проверка угла, округлости и замкнутости, а не нейросеть.
  • Виртуальный скролл заменяет нативный: одно число управляет загрузкой, анимацией, шейдерами и текстом.
  • Ассеты важнее рендера: DRACO, KTX2/ETC1S, атласы и собственный превьювер сжали гигабайты до мегабайтов.
  • Текстурные загрузки — главный источник фризов; декодинг в воркере и очередь по requestIdleCallback решают проблему.
  • Адаптивное качество по frame time позволяет не гадать о мощности устройства.
  • Мобильная точность шейдеров: mediump на Adreno и Mali — это реальный 16-битный float, и его недостаточно для сложных эффектов.

Разбираем, как это устроено изнутри, и что из этого можно взять в свою работу.

Zero на Three.js: инженерный разбор интерактивного сайта_5
Схема повествования: шесть этапов и пять «ворот», через которые проходит пользователь. Источник: Codrops / BUNQ LABS

Сюжет из шести этапов и пяти ворот

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

От обещания к свободе

Шесть этапов идут от иллюзии контролируемого пути к интерактивной карте, где пользователь сам выбирает, куда смотреть. Финальный экран — город с башней Zero University в центре. Можно приближать, панорамировать и открывать карточки ролей, сценариев и инструментов. Это не просто финал: после того как сайт вёл пользователя за руку, он отдаёт управление обратно.

Архитектура: один скролл управляет всем

Одно из главных архитектурных решений — отказ от нативного скролла браузера. Нет ScrollTrigger, нет огромного прокручиваемого DOM. Вместо этого события колёсика и тача обновляют виртуальное значение скролла, которое плавно догоняет цель. Всё остальное — загрузка ассетов, анимации, тайминги шейдеров, текст и оверлеи — читают это единственное число.

			{
  scrollVh: 300,            // сколько виртуального скролла принадлежит сегменту
  enter(ctx)        { /* асинхронно строим объекты Three.js */ },
  scrub(ctx, p)     { /* p — локальный прогресс 0..1 внутри сегмента */ },
  update(ctx, t, dt){ /* каждый кадр, даже без скролла — idle-анимации */ },
  teardown(ctx)     { /* утилизируем и передаём управление следующему */ }
}
		

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

Конвейер 3D-ассетов: как уместить 1 ГБ в 10 МБ

Большую часть четырёх месяцев ушла не на код, а на подготовку ассетов. Исходники пришли из Blender: несжатая геометрия, 8K-текстуры, запечённые анимации — всё вместе больше гигабайта. Первый месяц команда тратила на то, чтобы выбрать формат для каждого типа ресурсов.

DRACO и KTX2

Вся геометрия отправляется со сжатием DRACO, декодеры для которого хостятся локально в public/vendor/. Урок команда усвоила на собственном опыте: замедление на gstatic и unpkg привело к тому, что все сжатые ассеты перестали декодироваться, хотя сами файлы лежали на ихних серверах. Если декодер зависит от чужого CDN, весь пайплайн зависит от него.

			public/vendor/
  draco-decoder/
  ktx2-transcoder/
		

Самый большой выигрыш дали текстуры. PNG может быть маленьким на диске, но в видеопамять он загружается распакованным. Текстура 2048² занимает около 16 МБ VRAM независимо от размера файла. Формат KTX2 со сжатием ETC1S остаётся сжатым на GPU, занимает меньше памяти и загружается быстрее.

Собственный превьювер компрессии

С KTX2 есть проблема: ETC1S — lossy, и локально его не посмотреть обычным просмотрщиком. Чтобы не гадать с настройками, команда сделала внутренний дашборд: сжатая и несжатая версия каждой картинки и видео рядом. Так можно было подобрать степень сжатия индивидуально — усилить там, где артефакты не заметны, сохранить качество для ключевых объектов и отключить мипмапы, где они не нужны. Инструмент простой, но редко встречается в WebGL-пайплайнах, хотя экономит огромное количество времени и памяти.

Zero на Three.js: инженерный разбор интерактивного сайта_22
Превьювер компрессии: сжатая и несжатая версии текстур и видео рядом. Источник: Codrops / BUNQ LABS

Атласы вместо дюжин картинок

Похожие текстуры собрали в общие атласы. Например, все текстуры рук уместили в один атлас 4×4, а каждая mesh сдвигала UV-координаты, чтобы взять свой кусок. Спрайты текста, сертификаты, бумажные обрывки, облака, монеты и осколки стекла тоже ушли в атласы. Больше 50 отдельных изображений превратились примерно в дюжину атласов, а большинство градиентных фонов заменили несколькими строками GLSL. Ранние сборки весили 35–40 МБ, финальная — меньше 10 МБ. При этом интерактивная карта мира вынесена в отдельную группу загрузки, чтобы не блокировать открытие сайта.

Zero на Three.js: инженерный разбор интерактивного сайта_25
Атлас рук: все текстуры рук упакованы в одну текстуру 4×4. Источник: Codrops / BUNQ LABS

Текстурные загрузки: главный источник фризов

Уменьшить скачивание — только половина дела. Сжатые текстуры всё равно нужно загрузить в GPU на главном потоке, и крупная текстура легко блокирует рендер на 50 мс — достаточно, чтобы потерять несколько кадров. Если эта загрузка случается впервые во время скролла, подёргивание заметно сразу. Сайт может хорошо показывать себя в бенчмарках и при этом чувствоваться вязким.

			// выгружаем очередь текстур только тогда, когда браузер не занят
function drainUploads(deadline) {
  while (uploadQueue.length && deadline.timeRemaining() > 5) {
    renderer.initTexture(uploadQueue.shift()); // форсируем загрузку в GPU
  }
  if (uploadQueue.length) {
    requestIdleCallback(drainUploads, { timeout: 2000 });
  }
}
		
  • Декодировать вне главного потока — через createImageBitmap(). Тогда во время рендера остаётся только загрузка в GPU.
  • Загружать в GPU в простое — декодированные текстуры ставят в очередь и выгружаются по requestIdleCallback, пока есть запас времени.
  • Разбивать большие атласы на плитки 256² и загружать по одной на кадр, чтобы не выбиваться из бюджета кадра.

Когда известно, что следующий этап вот-вот появится — после загрузчика или во время перехода между воротами — очередь сбрасывается синхронно. Все нужные текстуры уже в видеопамяти, прежде чем они появятся на экране.

Адаптивное качество по реальному frame time

Невозможно заранее знать, на каком устройстве откроется сайт. Рендерер поэтому постоянно измеряет время кадра по скользящему окну и динамически меняет уровень качества. Если рендеринг замедляется — понижается tier. Если производительность стабильно высокая — tier снова растёт. Задержка между переключениями не даёт постоянно метаться у границы.

			if (avgMs > 22 && tier > LOW  && cooldownElapsed) downgrade();   // ~<45 fps
else if (avgMs < 12 && tier < HIGH && cooldownElapsed) upgrade(); // ~>83 fps
		

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

Шейдеры под каждый эффект

Каждый ключевой момент использует собственный шейдер. ИИ помогал сгенерировать первые версии, но все финальные шейдеры переписывались и дорабатывались вручную. Вот несколько приёмов, которые стоит запомнить.

Постпроцессинг из нескольких проходов

Кадр собирается из цепочки проходов: основная 3D-сцена, процедурный фон, преломление стекла, иней и след от жеста, глубина резкости, foreground с зернистостью и тональной картой, отложенный текст, и в конце — разбитое стекло. Стеклянный, текстовый и шейдер разбития инициализируются лениво и прогреваются в простое, чтобы не попадать на критический путь загрузчика.

			// основная цепочка, которая работает всегда
composer.addPass(renderPass);     // 1. 3D-сцена
composer.addPass(bgPass);         // 2. процедурный фон
composer.addPass(frostingPass);   // 3. иней + таяние
composer.addPass(lensBlurPass);   // 4. глубина резкости, tier-зависимый
composer.addPass(fgPass);         // 5. зернистость + tone mapping

// добавляем позже, вне критического пути загрузчика
composer.addPass(textPass);
composer.insertPass(glassPass, 2);
composer.addPass(shatterPass);
		

Иней из нарисованного нуля

Эффект инея строится на ping-pong-буфере из четырёх проходов: горизонталь, вертикаль и две диагонали. Каждый проход распространяет нарисованный штрих, захватывая самые яркие соседние пиксели и создавая восьмиугольный паттерн роста. Яркость текстуры льда модулирует шаг распространения, край получается кристаллическим. После замыкания контура центр масс штриха становится точкой отсчёта для радиального таяния.

			float m    = texture2D(uPrevTrail, vUv).r;
float step = uSpreadStep * (0.4 + iceLuma * 1.2);

for (int k = 1; k <= 2; k++) {
  m = max(m, texture2D(uPrevTrail, vUv + uSpreadAxis * step * float(k)).r * 0.92);
  m = max(m, texture2D(uPrevTrail, vUv - uSpreadAxis * step * float(k)).r * 0.92);
}

gl_FragColor.r = m; // иней движется только вперёд
		

Освещение без источников света

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

			vec4 a = texture2D(uTextureA, vUv * uTexScaleA + uTexOffsetA);
vec4 b = texture2D(uTextureB, vUv * uTexScaleB + uTexOffsetB);

a.rgb *= a.a;
b.rgb *= b.a;              // premultiply перед смешиванием

vec4 col = mix(a, b, uProgress); // uProgress 0 → 1 между ключами
		

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

Zero на Three.js: инженерный разбор интерактивного сайта_47
Запечённое освещение рук: два ключевых кадра в одном атласе смешиваются по прогрессу анимации. Источник: Codrops / BUNQ LABS

Горящие деньги

Эффект сгорания использует noise-driven distance field. Фронт горения проходит по купюре, слегка опережая точку дискарда. Перед ним появляется тонкий HDR-ободок углей, затем обугленная поверхность. FBM — самая дорогая часть шейдера, поэтому фрагменты отсекаются как можно раньше, если они ещё не достигли порога горения.

			float threshold = uBurnProgress * 1.5;             // фронт горения
float burn    = distField * 0.5 + fbm(uv) * 0.5;
float edge    = burn - threshold;

if (edge < 0.0) discard;                           // уже пепел

float ch = 1.0 - smoothstep(0.0, uCharWidth,  edge);
float em = 1.0 - smoothstep(0.0, uEmberWidth, edge);
vec3  col = mix(baseColor, uCharColor, ch) + uEmberColor * em;
		

Рвущиеся сертификаты

Каждая вершина хранит индекс полосы, к которой принадлежит фрагмент диплома. Фронт разрыва движется слева направо, и каждая полоса отделяется и падает независимо со своим вращением. Нормали для освещения строятся аналитически из той же волновой функции, которая анимирует меш, поэтому normal map не нужен.

			float past = clamp((uShredProgress - uv.x) / 0.4, 0.0, 1.0);
float e    = past * past;                            // ease-in

pos.y -= e * (0.25 + hash(aStripIndex) * 0.25);      // гравитация, уникальная для полосы
pos    = rotateStrip(pos, aStripIndex, e * 3.0);     // самостоятельное кружение
		

Тоннель ZERO

Тоннель получается выдавливанием поперечного сечения из логотипа ZERO. Импульс яркости распространяется по мировой координате Z, поэтому эффект остаётся бесшовным, даже когда секции тоннеля повторяются.

			float q    = fract(vTunnelZ * uPulseFreq + uPulseTime);
float band = 1.0 - smoothstep(0.0, uPulseWidth, min(q, 1.0 - q));

totalEmissiveRadiance *= 1.0 + uPulseGain * band;
		
Zero на Three.js: инженерный разбор интерактивного сайта_57
Тоннель ZERO: импульс яркости распространяется по мировой координате Z, сохраняя бесшовность. Источник: Codrops / BUNQ LABS

Текст, который приходит в фокус

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

			float r  = blurFactor * uMaxBlur * 0.003;
vec4 sum = texture2D(uText, vUv) * 2.0;            // центр, вес ×2

for (int i = 0; i < 6; i++)                        // шесть отводов под 60°
  sum += texture2D(uText, vUv + hexDir[i] * r);

gl_FragColor = sum / 8.0;
		

Точность на мобильных

Одна из самых неприятных находок возникла на реальных мобильных GPU. Несколько шейдеров пришлось явно перевести на highp float, потому что на Adreno и Mali mediump — это настоящий 16-битный float. На десктопе ошибок не было, а на телефоне полосы диплома двигались одинаково, а эффект горения покрывался бэндами. Вывод: тестировать на реальных мобильных устройствах нужно с первых дней, а не перед релизом.

Дизайн взаимодействий

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

			holdTrigger: {
  showAt: 0.6,
  holdDuration: 1.4,
  onHoldProgress(ctx, p) { /* визуал ворот от прогресса 0..1 */ },
  onHoldComplete(ctx)    { /* запускаем разбитие/полёт, затем переход */ }
}
		

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

Финальная награда — интерактивный мир

Последняя последовательность запуска выносит пользователя в открытое небо. Облака расступаются, открывая город с башней Zero University в центре, над которой появляется ореол — финальные ворота. Камера приземляется в интерактивную карту, где можно панорамировать, зумить и открывать карточки с ролями, сценариями и инструментами. Плашка Join Beta превращается в форму waitlist. После того как сайт вёл пользователя через историю, он отдаёт ему контроль — и просит остаться.

FAQ

Часто задаваемые вопросы
1
Что такое zero.university?

Это иммерсивный лендинг стартапа Zero University, который через интерактивный скролл рассказывает альтернативу классическому университетскому образованию: навыки и проекты вместо диплома.

2
Почему разработчики отказались от нативного скролла?

Чтобы полностью контролировать тайминг, плавность и синхронизацию анимаций, шейдеров и загрузки ассетов. Виртуальный скролл даёт одну переменную, от которой зависит всё остальное.

3
Как сайт распознаёт нарисованный ноль?

Жест проверяется по трём параметрам: полный угол закрученности около 2π, низкая вариативность радиуса (округлость) и расстояние между началом и концом штриха меньше среднего радиуса. Нейросеть не нужна.

4
Почему KTX2 важнее PNG для WebGL?

PNG распаковывается в видеопамяти. Текстура 2048² занимает около 16 МБ VRAM независимо от размера файла. KTX2 со сжатием ETC1S остаётся сжатым на GPU, занимает меньше места и загружается быстрее.

5
Как удалось достичь 60 FPS на бюджетном Android?

Комбинация из адаптивного качества по frame time, отложенной загрузки текстур, декодинга вне главного потока, атласов и оптимизации шейдеров под мобильную точность. Финальная сборка меньше 10 МБ, хотя исходники превышали 1 ГБ.

Выводы

Самый важный урок проекта — подготовка ассетов и загрузка в GPU заслуживают не меньше внимания, чем сам рендеринг. Инструменты вроде превьювера компрессии, локальных декодеров, очереди загрузки и адаптивного менеджера качества редко выглядят героически, но именно они превратили гигабайт исходников в 10-мегабайтный сайт, который не тормозит на дешёвом телефоне.

ИИ генерировала первые версии кода и прототип, который выиграл нам проект, за 48 часов. Но полировка — сотни мелких решений, тестирование на реальных устройствах и инженерная интуиция — осталась за людьми.
Sindhur DuttaFounder and Creative Director, BUNQ LABS

Источник: технический разбор команды BUNQ LABS на Codrops.
Попробуйте открыть zero.university на десктопе и на бюджетном смартфоне — и сравните, где заметнее компромиссы качества.

Рекомендуем