Conteúdo deste artigo
Shader é um estúdio de desenvolvimento criativo com sede na Suécia, fundado há alguns anos por mim e meu colega Simon Hedlund. Começar um negócio nos jogou de cabeça no ecossistema corporativo de siglas e na retórica do LinkedIn. Nós lidamos com a única maneira razoável: zombando de tudo isso, a ponto de nomearmos nossa empresa recém-fundada como High Tech Business Solutions, realizarmos uma sessão de fotos de negócios e degradarmos os resultados até que parecessem corretos: marca d’água, um trecho horizontal generoso e salvo como JPEG com o controle deslizante de qualidade arrastado para o chão. Brilhante, pensamos. Menos brilhante em reuniões com potenciais clientes, onde nem sempre havia espaço para esclarecer que o nome e o material da empresa eram irónicos.
A vergonha eventualmente nos levou a mudar a marca como Shader Development Studio, não tão engraçado, mas pelo menos descreve o que realmente fazemos: computação gráfica, visualizações e web. Ainda assim, gostamos demais do irônico ângulo comercial da High Tech Business Solutions para deixá-lo desaparecer completamente.
Quando chegou a hora de construir nosso próprio site, apoiar-se naquela sátira corporativa parecia a única escolha honesta. Passamos anos zombando da cultura empresarial por dentro, então fomos até o fim. Continuamos voltando à tecnologia empresarial dos anos 80: computadores bege, anúncios de escritório brilhantes, folhetos de vendas, gravatas douradas e apertos de mão corporativos rígidos.
Pilha de tecnologia e ferramentas
Next.js : não é a escolha óbvia para um site pesado em Three.js, mas ganha seu lugar aqui para manipulação de metadados e busca de dados do lado do servidor de nosso CMS. A maior parte da página é tela, mas as partes que ainda não o são se beneficiam de uma estrutura adequada por baixo.
Three.js + React Three Fiber + TSL : Three.js faz o trabalho pesado, React Three Fiber nos dá uma maneira declarativa de compor cenas em React, e TSL (Three.js Shading Language) nos permite escrever materiais baseados em nós que compilam para WebGL e WebGPU. A última parte é importante: podemos direcionar o renderizador WebGPU mais recente sem abandonar completamente a compatibilidade com WebGL.
Lenis : biblioteca de rolagem suave. Nós o modificamos ligeiramente para suportar o tipo de ajuste de página que queríamos entre as cenas Hero e Our work.
@pmndrs/uikit : nos permite construir layouts e UI semelhantes a DOM diretamente dentro da tela Three.js. Como queremos que tudo fique no pipeline da WebGPU e passe pelo pós-processamento, renderizar a UI como elementos DOM reais na parte superior não era uma opção. Bifurcado para fazê-lo funcionar com WebGPU.
Renderização seletiva
A base de todo o sistema é uma configuração simples de seção de página: um array onde cada seção de página tem um tipo e um comprimento. Os comprimentos das páginas podem ser dinâmicos, adaptando-se ao conteúdo ou ao tamanho da tela; a página Sobre, por exemplo, ajusta seu comprimento com base na quantidade de conteúdo da interface do usuário que precisa caber em diferentes janelas de visualização.
Usamos Lenis para rolagem suave, o que também nos fornece um valor confiável da posição de rolagem em cada quadro. Esse valor é o que usamos para determinar quais páginas estão visualizadas no momento. A posição inicial de cada página é apenas a soma cumulativa dos comprimentos anteriores, portanto, converter um valor bruto de rolagem em um progresso para qualquer página é um cálculo simples.
Cada quadro, verificamos a posição de rolagem atual em relação à posição de cada página nessa configuração. Se uma página não estiver visível, toda a sua passagem de renderização será ignorada, nenhuma chamada de desenho e nenhum trabalho de GPU. Isso significa tudo: não apenas a renderização da cena principal, mas também todas as subpassagens que ela possui. O diagrama acima ilustra isso: apenas a cena Sobre está ativa, então a passagem do sim de tecido, a passagem da interface do usuário e o composto Sobre final são executados. Tudo acima e abaixo simplesmente não é tocado nesse quadro. Finalmente, as passagens de página são enviadas para a passagem de composição, onde são adicionados pós-efeitos, como granulação de filme, aberração cromática, brilho e outros.
As cenas ativas são renderizadas na ordem inversa: última página primeiro, primeira página por último. Essa ordem é importante porque cada cena pode receber a saída da próxima cena como uma textura. A última cena é renderizada em seu FBO e passa essa textura para a cena anterior, que pode usá-la como quiser antes de renderizar seu próprio FBO. A cadeia continua até a primeira cena, cuja saída chega a uma passagem final de composição onde o pós-processamento é aplicado antes de chegar à tela.
As páginas também podem definir um deslocamento de renderização: uma janela que estende seu intervalo ativo um pouco antes ou depois de sua posição de rolagem. Isso é usado para garantir que o FBO de uma cena esteja pronto antes que ela realmente precise ficar visível, o que é essencial para transições em que a cena atual precisa ser amostrada na próxima.
Isto é ilustrado, por exemplo, quando no final da cena Sobre: a próxima cena é renderizada simultaneamente e visível sob os pedaços de papel.
A maneira como isso funciona na prática é através do useImperativeHandle do React. Em uma configuração R3F típica, você usaria useFrame para executar o código em cada quadro, mas useFrame é cego aos pais. O retorno de chamada é inscrito no loop de renderização e é executado de acordo com a programação do loop, não com a do pai. Isso é bom para animações independentes, mas o pai não pode invocá-las sob demanda, não pode sequenciá-las em relação à sua própria estrutura e não pode recuperar um valor de retorno.
useImperativeHandle resolve isso deixando um componente expor uma função em uma ref. O pai mantém esse ref e decide quando chamá-lo ou se deve chamá-lo. Cada cena expõe uma função de renderização desta forma: ela pega o estado atual do renderizador e, opcionalmente, a textura da próxima cena, renderiza em seu FBO e retorna a textura resultante.
Cada componente da cena expõe sua função de renderização em uma referência usando useImperativeHandle. Ele recebe o estado do renderizador e a textura da próxima cena, atualiza tudo o que for necessário, renderiza em seu próprio FBO e retorna a textura. Abaixo está um exemplo simplificado que mostra como isso é feito.
const fbo = useFBO(); useImperativeHandle(renderHandle, () => ({ state, nextSceneTexture }) => { const { gl } = state; nextSceneTextureUniform.value = nextSceneTexture gl.setRenderTarget(fbo); gl.render(scene, camera); gl.setRenderTarget(null); return fbo.texture; });
O pai mantém referências para todas as funções de renderização de cena e as chama de um único useFrame. As cenas são renderizadas em ordem inversa para que cada uma possa passar sua textura para a cena anterior. Qualquer cena cujo progresso de rolagem esteja fora do alcance é totalmente ignorada.
useFrame((state) => { let aboutTexture = null; let heroTexture = null; const aboutProgress = getPageScrollProgress({ pageType: "about", clamp: false }); if (aboutProgress >= 0 && aboutProgress <= 1) { aboutTexture = renderAboutScene({ state, nextSceneTexture: null }); } const heroProgress = getPageScrollProgress({ pageType: "hero", clamp: false }); if (heroProgress >= 0 && heroProgress <= 1) { heroTexture = renderHeroScene({ state, nextSceneTexture: aboutTexture }); } });
A diferença de useFrame é a inversão de controle. O pai orquestra a ordem de renderização, passa as texturas pela cadeia e pula qualquer cena cujo progresso esteja fora do alcance, tudo de um só lugar, sem que nenhuma das cenas precise saber uma da outra.
Transições
Existem duas maneiras fundamentalmente diferentes de fazer a transição entre cenas. Qual deles usar depende se a transição precisa parecer fisicamente fundamentada no espaço 3D ou se é puramente uma revelação 2D revestida com geometria.
Amostragem de espaço de tela. Quando a transição não precisa ser ancorada em uma superfície 3D específica, as coordenadas do espaço da tela são a opção mais simples. Em vez de coordenadas UV, o material amostra a textura da próxima cena na posição da tela do fragmento. A malha pode ter qualquer formato e ficar em qualquer lugar do espaço 3D, sempre revelará a parte correta da cena subjacente.
import { Fn, screenCoordinate, texture, uniformTexture, vec2 } from "three/tsl"; const nextSceneTexture = uniformTexture(new Texture()); const resolution = uniform(new Vector2(width, height)); const material = new MeshBasicNodeMaterial(); material.colorNode = Fn(() => { const screenUv = screenCoordinate.div(resolution); return texture(nextSceneTexture, screenUv); })();
Existem várias transições de cena usando essa técnica em nosso site, por exemplo, a transição de aperto de mão corporativo para nossa página de contato.
Avião combinado com Frustum. A transição do herói precisa parecer fisicamente fundamentada na cena 3D. A câmera não se move apenas em direção a um valor z fixo; ele se move em direção à malha real da tela do monitor. Lemos a posição mundial, o tamanho e a rotação da tela e, em seguida, posicionamos a câmera ao longo da direção frontal da tela, na distância em que o tronco da câmera cobre a tela.
function fitCameraToScreen( planePosition: Vector3, planeScale: Vector3, planeQuaternion: Quaternion, viewportAspect: number, fov: number, ) { // The monitor may be rotated, so use its world-space normal instead of camera z. const normal = new Vector3(0, 0, 1).applyQuaternion(planeQuaternion).normalize(); // Pick the dimension that fills the viewport first. const planeAspect = planeScale.x / planeScale.y; const distance = viewportAspect < planeAspect ? planeScale.y / 2 / Math.tan(fov / 2) : planeScale.x / 2 / (Math.tan(fov / 2) * viewportAspect); // Move slightly past the exact fit so no monitor border leaks through. return planePosition.clone().add(normal.multiplyScalar(distance * 0.9)); }
O FBO da próxima cena precisa existir no momento em que aparece na tela do monitor, que é onde o deslocamento de renderização compensa, a próxima cena começa a renderizar algumas unidades de rolagem mais cedo, para que sua textura esteja sempre pronta quando necessário.
Conclusão
O lado técnico de um site como este é apenas parte do trabalho. Você precisa conhecer os alvos de renderização, o estado de rolagem, os shaders e o desempenho, mas não é para isso que acontece a maior parte do tempo. Na maior parte do tempo, é necessário encontrar uma ideia que pareça específica e, em seguida, ajustar animações e transições até que tenham o peso, o timing e a personalidade certos. Uma transição pode ser tecnicamente correta por dias antes de realmente parecer boa.
Uma área que nos entusiasma é o trabalho que está acontecendo em torno de HTML em canvas . Hoje, renderizar a UI em uma tela geralmente significa reconstruir coisas que o navegador já faz bem: layout, texto, entradas, estados de interação e acessibilidade. Para este site, usamos useFrame((state) => { let aboutTexture = null; let heroTexture = null; const aboutProgress = getPageScrollProgress({ pageType: "about", clamp: false }); if (aboutProgress >= 0 && aboutProgress <= 1) { aboutTexture = renderAboutScene({ state, nextSceneTexture: null }); } const heroProgress = getPageScrollProgress({ pageType: "hero", clamp: false }); if (heroProgress >= 0 && heroProgress <= 1) { heroTexture = renderHeroScene({ state, nextSceneTexture: aboutTexture }); } }); para que a IU pudesse viver no mesmo pipeline WebGPU que todo o resto, mas ter maneiras mais nativas de trazer HTML real para experiências baseadas em tela tornaria isso muito mais fácil. Também tornaria a acessibilidade muito melhor, porque a UI poderia manter mais a semântica e o comportamento que as pessoas já esperam da web.
Em breve lançaremos um exemplo completo de código aberto utilizando todas as técnicas abordadas aqui. Siga-nos no LinkedIn , X e Instagram para saber quando ele será lançado.
