(85) 99645-7140 nelclassico@gmail.com Praça Coronel Melquiades, 124
Tecnologia

Desvendando HAOQI.DESIGN: Integrando DOM e WebGL em um Palco Retro-Futurista

Desde criança, fui fascinado pelo design digital, especialmente após ver o iMac G3 em um livro de arte. Sua shell translúcida e interface DISTINCTA me fizeram notar a importância do design visual em produtos digitais, muito antes de saber o que esse campo era. A campanha hello (again) da Apple também me marcou e se tornou um dos pontos de partida para este site.

Hoje, trabalho com sistemas de design e engenharia de design. O desenvolvimento frontend começou como uma forma de realizar meus projetos de design, mas gradualmente se tornou parte integrante do meu trabalho. Atualizo meu site pessoal de tempos em tempos para coletar o que tenho pensado e criado. Ele é um índice do meu trabalho, mas também um projeto de design e desenvolvimento por direito próprio.

Visão Geral Técnica

  • Next.js + React
  • Lenis
  • Motion
  • Three.js / React Three Fiber / Drei
  • Shaders personalizados / pós-processamento
  • Spline para modelos 3D
  • Figma para desenhar adesivos

1. Manter DOM e WebGL na Mesma Tela

Uma Fonte de Rolar para DOM e WebGL

O site rola verticalmente da maneira usual. Queria que o DOM lidasse com texto e tipografia, enquanto um canvas fixo carregava o modelo de vidro e efeitos de imagem. O Rolamento natural movimenta o DOM, então a primeira tarefa foi fazer que todos os objetos no canvas seguissem a mesma posição.

Na primeira versão, o Lenis atualizava o DOM enquanto o R3F lia window.scrollY dentro de useFrame e atualizava o WebGL. Parecia bom em velocidades baixas, mas um rolo rápido revelou um atraso de um quadro consistente no WebGL. A razão era que o Lenis e o R3F tinham cada um seu loop de requestAnimationFrame. Se o R3F rodasse primeiro, lia o valor de rolagem anterior. O Lenis apenas avançaria depois e moveria o DOM para o quadro atual. Um contêiner de rolagem personalizado tornou a inconsistência mais óbvia porque window.scrollY nem sempre era o valor que o Lenis estava mantendo. Nenhum ajuste de interpolação poderia resolver um problema causado por ordem de execução e uma fonte de dados poco confiável.

Depois de olhar o manual do Lenis e a abordagem de WebGL Scroll Sync da JOYCO, movi a rolagem e o processamento para um único loop de frames. O loop do Lenis é desabilitado. O R3F chama lenis.raf por addEffect, então um ScrollBus grava o valor de rolagem do Lenis para aquele quadro. Todos os consumidores subsequentes de useFrame leem a mesma imagem.

// scroll_root.tsx function ScrollShell({ children }: { children: React.ReactNode }) { return ( <ReactLenis options={{ /* ... */, autoRaf: false }}> <LenisScrollEnvBridge /> {children} </ReactLenis> ) } 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 }

Um Sistema de Coordenadas de Ponteiro para Todos os Efeitos

Quando a interação permanece dentro de um canvas, o estado.normalize da R3F geralmente é suficiente. Neste site, o mesmo ponteiro também impulsiona as saídas de coordenadas DOM, a paralaxe da câmera, a luz do rebordo do vidro e um efeito fluido. Em versões anteriores, cada recurso ouvia a entrada do ponteiro sozinho. Funcionavam, mas cada novo efeito tinha que repetir a mesma conversão de coordenadas, inversão do eixo Y e tratamento de estado.

// 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) }

Reaproveitei a ideia do ScrollBus e construí um PointerBus global. Ele converte coordenadas do navegador em uma UV 0-1 uma vez e mantém uma etiqueta para dizer se o ponteiro ainda está na janela. Quando o ponteiro sai, a página perde o foco ou a guia fica oculta, a UV retorna ao centro. Os efeitos podem voltar ao seu estado inicial em vez de pular de uma coordenada estagnada quando o ponteiro retorna.

2. DOM para Layout, WebGL para o Inesperado

Diferente do herói, a lista de projetos precisa ser fácil de ler e navegar antes de fazer qualquer coisa mais. Não queria sacrificar o trabalho por um efeito, mas também não queria outra grade de imagens familiar. O DOM e a Grade CSS, portanto, possuem a estrutura, o comportamento responsivo e a acessibilidade. Placeholders de imagem transparentes são medidos e refletidos no canvas, onde o WebGL entra apenas para estados que seriam difíceis de criar com DOM regular.

// 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, )

Refletindo uma Grade DOM em WebGL

Cada imagem do projeto mantém um placeholder DOM transparente com um ref. A Grade CSS decide sua posição e dimensões. O Element.getBoundingClientRect() do navegador me dá esse retângulo, mas o site de produção não pede que cada cartão leia o layout a cada frame. Um amostrador mantém um cache de retângulos compartilhados.

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 }

Um mesh esconde e para de atualizar quando sua textura não está pronta, seu retângulo é inválido ou a imagem está longe da viewport. Seu progresso de revelação também é reiniciado fora da tela, então está pronto quando o cartão volta.

O Inesperado Começa no Hover

Queria que cada cartão do projeto contivesse mais informação visual, então cada cartão tem duas imagens. Um crossfade simples ainda parecia muito familiar. Em vez disso, o shader divide a tela em uma grade fixa. A revelação se espalha do centro do cartão enquanto um quadrado cresce dentro de cada célula, gradualmente descobrindo a segunda imagem.

// 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)

Desenvolvendo a Imagem ao Entrar na Tela

Esta ideia veio de um site de um fotógrafo que eu vi certa vez. A mudança de um negativo de volta para a cor completa parecia certa como uma entrada de imagem, então a trouxe para a grade de projetos. Uma vez que um cartão entra na viewport, sua imagem se desenvolve em 0,8 segundos. O progresso volta a zero quando o cartão sai completamente, pronto para tocar novamente em sua próxima visita.

// 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; }

Tornando a Velocidade de Rolar Visível com um Shader

Depois que os efeitos de hover e entrada estavam no lugar, queria um último comportamento WebGL para a grade. O rolo rápido deve se sentir mais rápido. A distância percorrida entre dois quadros, dividida pelo tempo, me dá a velocidade. A normalizei em uCurlStrength, então as imagens flexionam ligeiramente ao longo do eixo horizontal em resposta à velocidade em vez de acumular distorção com a distância de rolagem.

// 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, clamp(localUv, 0.0, 1.0)); vec4 color = mix(baseColor, hoverColor, squareMask); vec2 edge = min(localUv, 1.0 - localUv); color.a *= step(0.0, edge.x) * step(0.0, edge.y); return color; }

Para mais informações e detalhes técnicos, você pode visitar o site HAOQI.DESIGN diretamente e explorar os projetos e tecnologias utilizadas.

Deixe um comentário

Seu email não será publicado. Campos obrigatórios marcados com *

Este site utiliza o Akismet para reduzir spam. Saiba como seus dados em comentários são processados.