Привет. Я Бен Пейн. Я креативный разработчик и дизайнер из Сан-Диего, Калифорния. Я собираюсь продемонстрировать, как создавать уникальные переходы страниц с помощью WebGPU. Я обнаружил, что правильно выполненные переходы страниц экспоненциально улучшают взаимодействие с пользователем.
Причина, по которой я использую WebGPU (это работает так же, как и с WebGL, я использую WebGPU в качестве современной альтернативы), заключается в том, чтобы по существу исключить восприятие браузером «выскакивающего» состояния DOM. С помощью одной непрерывной сцены мы можем заставить пользователя плавно переходить между страницами.
Для целей руководства я очень кратко углублюсь в логику рендеринга WebGPU или каркаса SPA, но по сути я привязываю текстуры к элементу DOM и использую базовую текстуру изображения. Все это можно просмотреть в исходном коде.
1. Начало работы
Здесь я выложу немного каркаса и немного о том, как работает SPA. Это важно проиллюстрировать, поскольку понимание клиентского маршрутизатора поможет вам понять, как работают переходы, но я буду говорить очень подробно и кратко.
Есть два слоя, наложенных друг на друга. Если вы раньше работали с WebGL/GPU, это вам знакомо. Существует слой DOM с «слотами» для изображений и слой Canvas с одной сценой, которая загружает все плоскости изображения. Плоскости изображения сохраняются по всему сайту и привязаны к слотам DOM. Плоскости изображения создаются один раз при запуске и сохраняются. Мы контролируем видимость на основе страницы и доступных слотов.
Каждая плоскость изображения несет bounds = { x, y, w, h, z } выражается в CSS-пикселях с использованием getBoundingClientRect(). В любой момент границы самолета принадлежат ровно одному из:
Отслеживание DOM: плоскость указывает на «слот» DOM и обновляется каждый кадр.
Ручное управление: plane.trackedEL является null и границы записываются напрямую. Это происходит во время перехода, когда DOM разбивается и уничтожается, а переход зависит от значений lerped.
Имея это в виду, переход по существу отключает все плоскости от отслеживания DOM -> свободно изменяет их границы/непрозрачность/масштаб -> повторно присоединяется к слотам DOM целевой страницы и продолжает отслеживание DOM.
Давайте углубимся в некоторые подробности.
а. Цикл рендеринга
Все начинается с единой точки входа src/index.js. Рендерер сделан и у нас есть один единственный requestAnimationFrame петля. Мы создаем все текстуры изображений для всех страниц при запуске, поэтому у нас нет задержек при загрузке текстур каждой страницы при маршрутизации.
б. Страницы
Каждая страница — это просто функция, которая для краткости возвращает строку HTML. Вот страница «Выбранное»:
пусто. Нет в любом месте страницы. Эта пустая коробка — слот. Он существует только для того, чтобы удерживать место в макете, чтобы я мог измерить, где должно быть изображение. Реальная картина — это плоскость WebGPU, сидящая сверху и отслеживающая этот слот.
data-page сообщает роутеру, что это за страница (main, inner, index). Каждый переход выходит за пределы этой категории, а не URL-адреса.
data-link — это согласие на использование маршрутизатора. Любой его перенос перехватывается вместо того, чтобы вызвать полную загрузку страницы.
Внутренние страницы работают точно так же. Функция, которая возвращает стек слотов, одного героя плюс несколько вспомогательных изображений. Та же идея, но другая планировка.
Другая половина страницы — это функция, измеряющая эти слоты. Когда переходу необходимо знать, куда должен лететь самолет, он считывает слоты пункта назначения прямо из DOM:
В этом весь секрет синхронизации всего: я никогда не запрограммирую координаты жестко. CSS размещает слоты, getBoundingClientRect() сообщает мне, где они приземлились, и о самолетах, проходящих по этим направлениям. Измените CSS, и переход все равно окажется в нужном месте.
в. Маршрутизатор
Маршрутизатор представляет собой один класс, называемый Controller и он выполняет две задачи: перехватывает навигацию и осуществляет переход.
Давайте сначала займемся навигацией.
Он начинается с простой таблицы маршрутов, объявляющей маршруты:
Каждый маршрут состоит из трёх вещей: page категория, view функция, которая возвращает HTML, и image проиндексируйте его на переднем плане. Уведомление /1 через /5 все делятся page: 'inner' потому что «внутренняя» страница является шаблоном. Переходы заботятся о категории, а не о точном URL-адресе.
Чтобы ловить клики, я помещаю в документ один прослушиватель и фильтрую свои ссылки:
onClick(e) {
const a = e.target.closest('a[data-link]');
if (!a) return;
e.preventDefault(); // stop the full page reload
this.navigate(a.getAttribute('href'));
}
onPopState() {
this.navigate(window.location.pathname, 'back'); // back/forward button
}
Один слушатель включен documentа не по одному на ссылку. Страницы, которые я добавляю позже, покрываются автоматически, поэтому перепривязывать нечего.
preventDefault() это строка, которая превращает реальную ссылку в SPA-навигацию. остается реальным, общедоступным URL-адресом, по которому можно щелкнуть правой кнопкой мыши; Я просто перехватываю левый клик.
popstate обрабатывает кнопки браузера «Назад» и «Вперед». я передаю 'back' отметьте так navigate знает, что нельзя нажимать еще одну запись в истории.
Это половина маршрутизации. navigate() сам запускает переход, поэтому я расскажу об этом в следующем разделе, поскольку в этом проекте навигация и переход являются одним и тем же методом.
2. Переходы страниц
Это суть урока. Все вышеперечисленное было подмостками, поэтому этот раздел имеет смысл.
Сохранить, удалить, добавить
Возвращаясь к ментальной модели, планы никогда не создаются и не уничтожаются. Таким образом, переход никогда не заключается в том, чтобы «создавать новые образы и разрушать старые». Каждый самолет на экране выполняет ровно одно из трех действий:
Держать: изображение существует на обеих страницах, поэтому я трансформирую его: перемещаю его границы из старого прямоугольника в новый прямоугольник. Это плавный полет и масштабирование.
Удалять: изображение уходит. Я снижаю непрозрачность до 0. Самолет остается живым; он просто становится невидимым и используется повторно позже.
Добавлять: изображение новое для места назначения. Я отмечаю его на целевом прямоугольнике (устанавливаю границы мгновенно, без анимации) и уменьшаю непрозрачность от 0 до 1, чтобы он материализовался на месте, а не прилетал из ниоткуда.
tweenBounds это «держать и двигаться». tweenOpacity «удалить или добавить». Каждый переход в проекте построен только из этих двух.
Переход состоит из out() + in()
Каждый переход представляет собой небольшой класс с двумя асинхронными методами:
class SomeTransition {
async out(fromEl, toEl, ctx) { /* the planes that exist on the FROM page */ }
async in(fromEl, toEl, ctx) { /* the planes that are new to the TO page */ }
}
По соглашению out обрабатывает уход самолетов (преобразование общего, затемнение остальных) и in обрабатывает прибывающие самолеты (отпечатывает их, затемняет). Контроллер срабатывает одновременно и ждет их одновременно, поэтому две половины перекрываются, а старая и новая находятся в движении одновременно. Именно это совпадение заставляет его читать как одно непрерывное движение, а не как «старые листья, затем новые».
Вот main → inner переход. Вы находитесь в разделе «Выбрано», нажимаете на изображение №2. Его герой должен взлететь и вырасти в лидирующий слот на внутренней странице, остальные четыре исчезнут, а ниже появятся вспомогательные изображения №2:
// transitions/mainToInner.js
export class MainToInnerTransition {
async out(_from, toEl, ctx) {
const { gpu, toImage } = ctx;
const innerRects = getInnerTargets(toEl); // measure the destination slots
const target = innerRects[0]; // slot 0 = the hero position
const tweens = [];
for (let i = 0; i
Прочитайте это относительно трех ролей: одна плоскость сохраняется и трансформируется, четыре удаляются, четыре добавляются. Ничего не строится, ничего не разрушается.
Единственный трюк, который заставляет это работать
Есть подвох, и это самая важная деталь во всем деле.
Обычно самолет отслеживает свой слот в каждом кадре, а цикл рендеринга копирует слот. getBoundingClientRect() в пределы самолета. Это именно то, чего я хочу, пока вы сидите на странице. Но во время перехода я сам пытаюсь анимировать те же самые границы. Если бы плоскость все еще отслеживала слот, цикл рендеринга перезаписывал бы мою анимацию 60 раз в секунду, и морф был бы невидим.
Итак, первое, что делает контроллер на выходе, — это отделяет каждую плоскость от DOM и размораживает границы:
_leavePage(state) {
// ...stop the carousel, clear tilt, etc...
for (const plane of this.gpu.planes) {
plane.trackedEl = null; // ★ hand the bounds over to the tweens
}
}
С trackedEl очищено, ничто не борется с подростком, и GSAP владеет границами каждого самолета, пока переход не завершится. Как только это будет сделано, я снова прикреплю самолеты пункта назначения к их слотам, и слежение продолжится. Шов невидим, потому что анимация уже закончилась точно на прямоугольнике нового слота. я анимировался до getInnerTargets(toEl)что это за прямоугольник.
Собираем это вместе: navigate()
Сейчас navigate() читается сверху вниз (слегка обрезано для ясности):
async navigate(path, target = null) {
if (this.mutating) return; // 1. ignore clicks mid-transition
if (path === this.current?.path) return;
const next = this.routes[path];
if (!next) return;
const fromState = this.current;
const toState = { path, ...next };
const transition = this._resolveTransition(fromState.page, next.page);
this.mutating = true; // 2. lock
if (target !== 'back') history.pushState({ path }, '', path); // 3. update the URL
animateTitleOut(this.app.children[0]); // 4. animate the old page's text out
this._leavePage(fromState); // 5. detach ALL planes from the DOM
this.app.insertAdjacentHTML('beforeend', next.view()); // 6. inject dest. both pages coexist now
const fromEl = this.app.children[0];
const toEl = this.app.lastElementChild;
// 7. prepare the destination's layout engine (carousel / index) so its target rects exist
// 8. animate the new page's text in
const ctx = { gpu: this.gpu, fromImage: fromState.image, toImage: next.image, indexFloat: this.indexFloat };
const txOut = transition.out(fromEl, toEl, ctx); // 9. fire both halves at once
const txIn = transition.in(fromEl, toEl, ctx);
await Promise.all([/* text tweens, */ txOut, txIn]); // 10. wait for everything
fromEl.remove(); // 11. drop the old page
this.current = toState;
this._snapLayout(toState); // lock the exact final opacities
this._enterPage(toState); // reattach tracking + start behaviors
this.mutating = false; // 12. unlock
}
Просто:
блокировка → история обновлений → отсоединить все самолеты → добавить новую страницу (оба ненадолго на экране) → запустить out() и in() вместе → все ждем → удалить старую страницу → повторно прикрепить отслеживание → разблокировать.
Несколько решений, на которые стоит обратить внимание:
mutating замок делает все это атомарным. Смешайте навигационные ссылки, и второй щелчок будет игнорироваться до тех пор, пока не произойдет первый переход. В противном случае два перехода будут бороться за одни и те же общие плоскости.
Обе страницы сосуществуют на время. Старая страница остается в DOM (просто неактивна), поэтому ее текст можно анимировать, а макет не рухнет, пока самолеты все еще летают. Оно удаляется только после await.
Самолеты все время отсоединяются. С шага 5 по шаг 11 ничто не отслеживает DOM, поэтому подростки бесспорно владеют всеми значениями границ. В тот момент, когда переход разрешится, _enterPage повторно присоединяется, и отслеживание в реальном времени начинается с того места, на котором остановился подросток.
5. Идем дальше
В этом суть. Фиксированный пул плоскостей, маршрутизатор, который меняет местами леса, и переходы, которые сохраняют/удаляют/добавляют…