В детстве я впервые увидел имак G3 в художественной книге. Его полупрозрачная оболочка и характерный интерфейс заставили меня обратить внимание на визуальный дизайн цифровых продуктов задолго до того, как я узнал, как называется эта область. Apple привет (еще раз) Кампания осталась со мной и стала одной из отправных точек для этого сайта.
Сегодня я работаю в области систем проектирования и проектирования. Фронтенд-разработка началась как способ реализации моих замыслов, затем постепенно стала частью самой работы. Я время от времени обновляю свой личный сайт, чтобы собрать все, о чем я думал и что делал. Это показатель моей работы, а также самостоятельный проект дизайна и разработки.
Эта версия создавалась примерно за три-пять месяцев, в свободное время. В течение предыдущих двух лет я использовал ИИ, чтобы больше узнать о шейдерах и Three.js. На этот раз я хотел объединить эти знания в один связный опыт, а не представлять коллекцию изолированных эффектов. Визуальное направление выросло вокруг здравствуйте, стекла, цветного света и ретрофутуризма. Центральный технический вопрос заключался в том, как разделить задачи DOM и WebGL, сохраняя при этом их единство посредством прокрутки и взаимодействия.
Технический обзор
- Next.js + Реагировать
- Ленис
- Движение
- Three.js/React Three Fiber/Drei
- Пользовательские шейдеры/постобработка
- Сплайн для 3D моделей
- Фигма для рисования наклеек
1. Сохранение DOM и WebGL в одном фрейме
Один источник прокрутки для DOM и WebGL.
Сайт прокручивается по вертикали обычным способом. Я хотел, чтобы DOM обрабатывал текст и типографику, а фиксированный холст содержал модель стекла и эффекты изображения. Прокрутка естественным образом перемещает DOM, поэтому первой задачей было заставить все объекты на холсте занимать одно и то же положение.
В первой версии Ленис обновляла DOM, пока R3F читал window.scrollY внутри useFrame и обновлен WebGL. На низких скоростях все выглядело нормально, но быстрая прокрутка выявила постоянную задержку в один кадр в WebGL. Причина заключалась в том, что Ленис и R3F владели по requestAnimationFrame петля. Если R3F запускался первым, он считывал предыдущее значение прокрутки. Ленис только после этого продвинется вперед и переместит DOM для текущего кадра. Пользовательский контейнер прокрутки сделал несоответствие более очевидным, потому что window.scrollY не обязательно было той ценностью, которую поддерживала Ленис. Никакая настройка интерполяции не могла решить проблему, вызванную порядком выполнения и ненадежным источником данных.
Посмотрев на Ленис руководство raf подход и JOYCO Синхронизация прокрутки WebGLя переместил прокрутку и рендеринг в один цикл кадров. Собственный цикл Ленис отключен. R3F звонки lenis.raf через addEffectзатем ScrollBus записывает значение прокрутки Ленис для этого кадра. Каждый позже useFrame потребитель читает один и тот же снимок. DOM и WebGL теперь согласовывают как данные, так и момент, когда они становятся актуальными.
// scroll_root.tsx
function ScrollShell({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
function LenisScrollEnvBridge() {
const lenis = useLenis()
useEffect(() => {
bindLenisScrollBus(lenis ?? null)
return () => bindLenisScrollBus(null)
}, [lenis])
useEffect(() => {
if (!lenis) return
return addEffect((time: number) => {
lenis.raf(time)
})
}, [lenis])
return null
}
Один раз lenis.raf продвигает свиток, Ленис испускает его scroll событие и обновляет ScrollBus в том же кадре. Компоненты WebGL, которые запускаются позже, могут напрямую читать свежий снимок.
// lenis_scroll_bus.ts
import type Lenis from "lenis"
// The production snapshot also includes limit, progress, velocity,
// direction, and viewportHeight.
type ScrollSnapshot = { scrollTop: number }
let snapshot: ScrollSnapshot = { scrollTop: 0 }
const listeners = new Set void>()
let unbind: (() => void) | null = null
export const bindLenisScrollBus = (lenis: Lenis | null) => {
unbind?.()
unbind = null
if (!lenis) return
const onScroll = ({ scroll }: { scroll: number }) => {
snapshot = { scrollTop: scroll }
for (const listener of listeners) listener()
}
lenis.on("scroll", onScroll)
unbind = () => lenis.off("scroll", onScroll)
snapshot = { scrollTop: lenis.scroll }
}
export const getLenisScrollSnapshot = () => snapshot
export const subscribeLenisScroll = (listener: () => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
WebGL уже работает покадрово, поэтому он может просто прочитать последнее значение. Компоненты React, которым требуется состояние прокрутки для вывода DOM, подписываются на useSyncExternalStore. Остальная часть дерева компонентов остается нетронутой.
// WebGL reads during useFrame without triggering React renders.
const scrollY = getLenisScrollSnapshot().scrollTop
// React subscribes only where the value affects DOM output.
const SERVER_SCROLL_SNAPSHOT = { scrollTop: 0 }
const scroll = useSyncExternalStore(
subscribeLenisScroll,
getLenisScrollSnapshot,
() => SERVER_SCROLL_SNAPSHOT,
)
В параллельной демонстрации левый сохраняет два независимых цикла, а правый использует общий цикл кадров и ScrollBus. Однокадровый сдвиг справа пропадает даже при быстрой прокрутке.
Система координат с одним указателем для каждого эффекта
Когда взаимодействие остается внутри холста, R3F нормализован state.pointer обычно достаточно. На этом сайте тот же указатель также управляет считыванием координат DOM, параллаксом камеры, светом по краю стекла и эффектом жидкости. Более ранние версии позволяли каждой функции самостоятельно прослушивать ввод указателя. Они работали, но каждый новый эффект должен был повторять одно и то же преобразование координат, инверсию оси Y и обработку выходного состояния. С каждым добавлением путь ввода становился все более фрагментированным.
Я повторно использовал идею ScrollBus и создал глобальный PointerBus. Он один раз преобразует координаты браузера в UV от 0 до 1 и сохраняет inside флаг, указывающий, находится ли указатель в окне. Когда указатель уходит, страница теряет фокус или вкладка становится скрытой, UV возвращается в центр. Эффекты могут вернуться в исходное состояние вместо того, чтобы прыгать со устаревшей координаты, когда указатель возвращается.
Одна запись обновляет как изменяемый Vector2 для WebGL и неизменяемый снимок для React. Все новое может использовать PointerBus без добавления другого прослушивателя или изобретения собственных правил координат.
type PointerSnapshot = {
x: number
y: number
inside: boolean
}
// One write keeps DOM and WebGL on the same x / y / inside state.
const updatePointer = (next: PointerSnapshot) => {
snapshotRef.current = next // React snapshot
uv.set(next.x, next.y) // WebGL
insideRef.current = next.inside // WebGL
scheduleNotify() // React, at most once per frame
}
Объединив ввод прокрутки и указателя, я сначала заставил систему работать с сеткой проекта. Он содержит основной контент сайта и имеет наибольшее совпадение между макетом DOM и эффектами WebGL, что делает его полезным тестом для всего гибридного подхода.
2. DOM для макета, WebGL для неожиданностей
В отличие от героя, список проектов должен легко читаться и просматриваться, прежде чем он сможет делать что-либо еще. Я не хотел жертвовать работой ради эффекта, но мне также не хотелось еще одной знакомой сетки изображений. Таким образом, DOM и CSS Grid владеют структурой, отзывчивым поведением и доступностью. Прозрачные заполнители изображений измеряются и отражаются на холсте, где WebGL вмешивается только для состояний, которые было бы неудобно создавать с помощью обычного DOM. В этом опыте также не предлагается посетителям включить флаг браузера для экспериментальной версии. API HTML-in-Canvas.
Зеркальное отображение сетки DOM в WebGL
Каждое изображение проекта содержит прозрачный заполнитель DOM с ref. CSS Grid определяет его положение и размеры. браузера Element.getBoundingClientRect() дает мне этот прямоугольник, но производственный сайт не просит каждую карту читать макет в каждом кадре. Вместо этого один сэмплер поддерживает общий кэш прямоугольников.
Во время прокрутки сэмплер сначала корректирует кэшированные прямоугольники с помощью дельты прокрутки. Карточки рядом с окном просмотра измеряются в каждом кадре, поэтому они остаются выровненными после изменения макета. Удаленные карточки обновляются каждые 12 кадров, располагаясь в шахматном порядке по списку, что позволяет избежать объединения всех операций чтения DOM в один и тот же кадр.
// Simplified DomTargetRectSampler.
useFrame(() => {
const rects = targetRectMapRef.current
const scrollTop = getScrollTop()
const deltaY = scrollTop - lastScrollTop
lastScrollTop = scrollTop
// Scroll moves cached viewport rects without another layout read.
for (const rect of Object.values(rects)) {
rect.top -= deltaY
rect.bottom -= deltaY
}
layers.forEach((layer, index) => {
const previous = rects[layer.key]
const nearViewport = !previous || isNearViewport(previous)
const staggeredRefresh = frame % 12 === index % 12
if (!nearViewport && !staggeredRefresh) return
const element = layer.targetRef.current
if (element) updateCachedRect(layer.key, element.getBoundingClientRect())
})
frame += 1
}, -3)
Сэмплер запускается перед компонентами изображения, поэтому соседнее изображение считывает только что измеренный прямоугольник DOM в том же кадре. Кэш находится в карте ссылок и никогда не вызывает рендеринг React. Сетка скрывается и перестает обновляться, когда ее текстура не готова, ее прямоугольник недействителен или изображение находится далеко за пределами области просмотра. Прогресс ее раскрытия также сбрасывается за кадром, поэтому он готов, когда карта возвращается.
Я использую одну полноэкранную сетку для каждого изображения, чтобы упростить расчет координат. Вместо перемещения 3D-геометрии в соответствии с DOM я записываю положение и размер прямоугольника в uRect. Шейдер преобразует UV экрана в локальные координаты карты, сохраняя выравнивание изображения холста по его заполнителю. Полноэкранные сетки добавляют перерисовку, поэтому визуализируются только изображения рядом с областью просмотра.
// dom_sync.frag.glsl
uniform vec4 uRect; // xy origin, zw size
uniform sampler2D map;
vec4 sampleDomImage(vec2 screenUv) {
vec2 localUv = (screenUv - uRect.xy) / uRect.zw;
vec2 edge = min(localUv, 1.0 - localUv);
float inside = step(0.0, edge.x) * step(0.0, edge.y);
vec4 color = texture2D(map, clamp(localUv, 0.0, 1.0));
color.a *= inside;
return color;
}
Письмо uRect требуется только одна коррекция системы координат. Координаты экрана начинаются слева вверху, а UV шейдера — слева внизу, поэтому Y необходимо перевернуть. После этого CSS по-прежнему может изменять столбцы, пробелы и пропорции карточек. WebGL следует только за получившимися прямоугольниками.
Неожиданное начинается при наведении
Я хотел, чтобы каждая карточка проекта содержала больше визуальной информации, поэтому на каждой карточке было два изображения. Простое плавное затухание все еще казалось слишком знакомым. Вместо этого шейдер делит экран на фиксированную сетку. Обнажение распространяется от центра карты, в то время как внутри каждой ячейки растет квадрат, постепенно раскрывая второе изображение. Позже этот матричный язык нашел применение в загрузке, переходах страниц и мобильном меню.
Шейдер выполняет работу в три этапа. Первый, uRect преобразует полноэкранный UV в UV локальной карты, чтобы оба изображения соответствовали одному и тому же заполнителю DOM. Далее пространство экрана делится на ячейки фиксированного размера. Наконец, соотношение сторон карты компенсируется, а при наведении курсора мыши расширяется круглая область от центра. Внутри этой области квадрат каждой ячейки растет. Полученная маска смешивает две текстуры.
// Inputs shared by both images.
uniform sampler2D map;
uniform sampler2D mapHover;
uniform vec4 uRect;
uniform float uHoverRevealProgress;
uniform float uDotPixelSize;
uniform vec2 uViewportPx;
vec4 revealHoverImage(vec2 screenUv) {
// 1. Map the full-screen UV into the DOM card.
vec2 localUv = (screenUv - uRect.xy) / uRect.zw;
float rectWidthPx = max(uRect.z * uViewportPx.x, 1.0);
float rectHeightPx = max(uRect.w * uViewportPx.y, 1.0);
// 2. Divide screen space into fixed-size cells.
vec2 viewportPx = max(uViewportPx, vec2(1.0));
vec2 cellSizeUv = vec2(max(2.0, uDotPixelSize)) / viewportPx;
vec2 cellUv = fract(screenUv / cellSizeUv);
float squareDist = max(abs(cellUv.x - 0.5), abs(cellUv.y - 0.5));
// 3. Expand from the card center and grow a square in each cell.
float rectAspect = rectWidthPx / rectHeightPx;
vec2 centered = localUv * 2.0 - 1.0;
centered.x *= rectAspect;
float distToCenter = length(centered);
float maxRadius = length(vec2(rectAspect, 1.0));
float progress = clamp(uHoverRevealProgress, 0.0, 1.0);
float radius = progress * (maxRadius + 0.12);
float grow = 1.0 - smoothstep(radius - 0.12, radius + 0.12, distToCenter);
grow *= step(0.0001, progress);
float squareExtent = mix(0.0, 0.5, grow);
float squareAa = max(fwidth(squareDist), 0.0001);
float squareMask = 1.0 - smoothstep(
squareExtent - squareAa,
squareExtent + squareAa,
squareDist
);
// Mix the aligned textures with the generated mask.
vec4 baseColor = texture2D(map, clamp(localUv, 0.0, 1.0));
vec4 hoverColor = texture2D(mapHover,...