Создание постоянных переходов страниц с помощью WebGPU и Vanilla JavaScript | Кодропс

Создание постоянных переходов страниц с помощью WebGPU и Vanilla JavaScript | Кодропс



Привет. Я Бен Пейн. Я креативный разработчик и дизайнер из Сан-Диего, Калифорния. Я собираюсь продемонстрировать, как создавать уникальные переходы страниц с помощью 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. Вот страница «Выбранное»:

// pages/home.js
export function home() {
  const slots = [0, 1, 2, 3, 4]
    .map(
      (i) =>
        `i+1"datalinkclass="slotslot{i + 1}" data-link class="slot slot-i+1"datalinkclass="slotslot{i}">
`, ) .join(''); return `

${slots}

`; }

Несколько вещей, которые я хочу отметить:


  • пусто. Нет в любом месте страницы. Эта пустая коробка — слот. Он существует только для того, чтобы удерживать место в макете, чтобы я мог измерить, где должно быть изображение. Реальная картина — это плоскость WebGPU, сидящая сверху и отслеживающая этот слот.
  • data-page сообщает роутеру, что это за страница (main, inner, index). Каждый переход выходит за пределы этой категории, а не URL-адреса.
  • data-link — это согласие на использование маршрутизатора. Любой его перенос перехватывается вместо того, чтобы вызвать полную загрузку страницы.

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

Другая половина страницы — это функция, измеряющая эти слоты. Когда переходу необходимо знать, куда должен лететь самолет, он считывает слоты пункта назначения прямо из DOM:

// pages/home.js
export function getMainTargets(rootEl) {
  const slots = rootEl.querySelectorAll('.slot');
  return Array.from(slots, (s) => {
    const r = s.getBoundingClientRect();
    return { x: r.left, y: r.top, w: r.width, h: r.height };
  });
}

В этом весь секрет синхронизации всего: я никогда не запрограммирую координаты жестко. CSS размещает слоты, getBoundingClientRect() сообщает мне, где они приземлились, и о самолетах, проходящих по этим направлениям. Измените CSS, и переход все равно окажется в нужном месте.

в. Маршрутизатор

Маршрутизатор представляет собой один класс, называемый Controller и он выполняет две задачи: перехватывает навигацию и осуществляет переход.

Давайте сначала займемся навигацией.

Он начинается с простой таблицы маршрутов, объявляющей маршруты:

const ROUTES = {
  '/':      { page: 'main',  view: home,      image: null },
  '/index': { page: 'index', view: indexPage, image: null },
  '/1':     { page: 'inner', view: inner(0),  image: 0 },
  '/2':     { page: 'inner', view: inner(1),  image: 1 },
  // ...through /5
};

Каждый маршрут состоит из трёх вещей: 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
}

Это половина маршрутизации. navigate() сам запускает переход, поэтому я расскажу об этом в следующем разделе, поскольку в этом проекте навигация и переход являются одним и тем же методом.

2. Переходы страниц

Это суть урока. Все вышеперечисленное было подмостками, поэтому этот раздел имеет смысл.

Сохранить, удалить, добавить

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

  • Держать: изображение существует на обеих страницах, поэтому я трансформирую его: перемещаю его границы из старого прямоугольника в новый прямоугольник. Это плавный полет и масштабирование.
  • Удалять: изображение уходит. Я снижаю непрозрачность до 0. Самолет остается живым; он просто становится невидимым и используется повторно позже.
  • Добавлять: изображение новое для места назначения. Я отмечаю его на целевом прямоугольнике (устанавливаю границы мгновенно, без анимации) и уменьшаю непрозрачность от 0 до 1, чтобы он материализовался на месте, а не прилетал из ниоткуда.
// transitions/constants.js
export function tweenBounds(plane, target, opts = {}) {
  return gsap.to(plane.bounds, { x: target.x, y: target.y, w: target.w, h: target.h, /* ... */ });
}

export function tweenOpacity(plane, to, opts = {}) {
  return gsap.to(plane, { opacity: to, /* ... */ });
}

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. Идем дальше

В этом суть. Фиксированный пул плоскостей, маршрутизатор, который меняет местами леса, и переходы, которые сохраняют/удаляют/добавляют…



Источник

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Прокрутить вверх