(85) 99645-7140 nelclassico@gmail.com Praça Coronel Melquiades, 124
Articles case study Tecnologia Three.js WebGL

ZERO: A engenharia por trás de uma narrativa interativa desafiadora | Codrops

Você chega no site e ele está bloqueado. Não há botão Enter, apenas um prompt para desenhar um zero. Assim que o círculo se fecha, o gelo se espalha a partir do golpe e gradualmente revela a experiência. Não tínhamos visto esse tipo de interação antes, e esse era exatamente o apelo. Dá-lhe alguns segundos para captar a atenção do visitante com algo inesperado, ao mesmo tempo que recompensa imediatamente a interação.

A verificação de gestos é surpreendentemente simples. Ele mede apenas três coisas: ângulo total assinado, circularidade e fechamento. Se o traço passar nessas verificações, seu centróide se tornará a semente para o sombreador de gelo:

// accept the stroke as a "zero" only if it truly closes a round loop
const wound    = totalSignedAngle(points, center);   // ~2π for a full turn
const radiusCV = std(radii) / mean(radii);           // low = round, not a scribble
const closed   = dist(points[0], points.at(-1)) < meanRadius;

if (wound > 5.76 && radiusCV < 0.35 && closed) unlock();

A partir daí, a experiência se desenrola como uma única rolagem contínua, levando você da tela de carregamento para um mapa interativo da cidade. Construído ao longo de quatro meses com Three.js, GSAP, Howler e Vite, ele transforma mais de um gigabyte de ativos de origem em um site com menos de 10 MB que roda a 60fps mesmo em um telefone Android econômico. Neste artigo, veremos o pipeline 3D, as ferramentas, os sombreadores e as otimizações de desempenho que tornaram isso possível.

Como o projeto começou

Apresentamos uma prova de conceito em vez de uma apresentação. O conceito da equipe de Atul Khola foi forte desde o início: uma experiência de rolagem imersiva que desafia o caminho tradicional de graduação. Construímos o sorteio com interação zero em 48 horas e entregamos um protótipo funcional. A IA nos ajudou a colocar a primeira versão em execução rapidamente, depois passamos o resto do tempo refinando-a até que o efeito de gelo parecesse correto. A prova de conceito nos rendeu o projeto.

A IA também mudou a forma como abordamos o desenvolvimento. Em vez de gastar tempo construindo a primeira versão de uma ideia, poderíamos gerá-la rapidamente e focar em testes, refinamento e iteração. Tentamos várias versões do efeito de queimar dinheiro, diferentes tempos de quebra de vidro e várias ideias de shaders antes de decidirmos o que funcionou melhor. Escrever o código inicial tornou-se a parte fácil. O verdadeiro trabalho foi avaliar os resultados, melhorar os detalhes e garantir que cada interação parecesse correta. O restante deste artigo se concentra nesse processo.

A narrativa: seis etapas, cinco portões

A experiência se desenrola em seis estágios de rolagem, conectados por cinco portões interativos que pausam ou redirecionam o fluxo. Isso inclui desenhar um zero, segurar para quebrar o vidro e segurar para lançar através de um túnel.

A história segue uma narrativa clara. Ele começa com a promessa do caminho de graduação tradicional: estudar muito, tirar boas notas e conseguir um emprego em uma empresa de ponta. No primeiro portão, essa promessa literalmente se despedaça. A próxima etapa revela estatísticas reais de desemprego espalhadas pelos vidros quebrados. No estágio três, o diploma se torna apenas mais um pedaço de papel enquanto o dinheiro queima, os certificados são destruídos e a cena transita para um túnel com o formato do logotipo ZERO. A fase quatro emerge acima das nuvens, revelando uma cidade construída a partir de sedes de empresas reais. Por fim, forneça o controle de cinco mãos ao usuário por meio de um mapa interativo que pode ser explorado livremente.

A arquitetura: um número comanda tudo

Uma das maiores decisões arquitetônicas foi evitar depender totalmente da rolagem nativa do navegador. Não há ScrollTrigger nem DOM de rolagem superdimensionado. Em vez disso, a entrada de roda e toque atualiza um valor de rolagem virtual que se aproxima de seu alvo. Todo o resto da experiência, incluindo carregamento de ativos, animações, tempo de sombreamento, texto e sobreposições, é orientado por esse valor único.

Cada estágio e interação é definido como um segmento independente com seu próprio ciclo de vida opcional:

{
  scrollVh: 300,            // how much virtual scroll this segment owns
  enter(ctx)        { /* build this segment's Three.js objects (async) */ },
  scrub(ctx, p)     { /* p is LOCAL progress 0..1 within this segment  */ },
  update(ctx, t, dt){ /* runs every frame, scroll or not (idle motion) */ },
  teardown(ctx)     { /* dispose and hand off to the next segment      */ },
}

Na prática, o projeto é dividido em nove segmentos, sendo que o carregador também serve como primeiro portão. Manter cada segmento independente tornou a base de código muito mais fácil de manter, especialmente mais tarde no desenvolvimento. Também tornou a depuração mais fácil. Pular para qualquer estágio reproduz o ciclo de vida de cada segmento anterior, em vez de se teletransportar diretamente para lá, garantindo que a experiência seja sempre inicializada como se o usuário tivesse rolado naturalmente por ela.

Preparando o 3D para a web

A maior parte do tempo de desenvolvimento de quatro meses foi dedicada à preparação dos ativos. Os arquivos de origem chegaram como cenas de produção do Blender com geometria não compactada, texturas de 8K, animações preparadas e mais de um gigabyte de dados. Grande parte do primeiro mês foi gasto decidindo o melhor formato para cada ativo.

Toda a geometria é enviada com compactação DRACO usando decodificadores auto-hospedados. Os transcodificadores Draco e KTX2/Basis são agrupados em public/vendor/ em vez de carregados a partir de um CDN. Aprendemos essa lição da maneira mais difícil quando uma desaceleração em gstatic e unpkg fez com que todos os ativos compactados falhassem na decodificação, mesmo que os próprios ativos estivessem hospedados localmente. Se o seu decodificador reside no servidor de outra pessoa, o mesmo acontece com o seu pipeline.

As texturas tiveram o maior impacto no desempenho. Embora um PNG possa ser pequeno no disco, ele é totalmente descompactado na memória da GPU. Uma textura de 2.048² ainda ocupa cerca de 16 MB de VRAM, independentemente do tamanho do arquivo. KTX2 com compactação ETC1S permanece compactado na GPU, usa muito menos memória e carrega significativamente mais rápido.

O visualizador de compactação

Um desafio do KTX2 é que não há uma maneira fácil de visualizá-lo localmente. Como o ETC1S é um formato com perdas, encontrar as configurações de compactação corretas pode facilmente se tornar um processo de tentativa e erro. Uma única configuração global desperdiça memória ou degrada visivelmente as texturas que precisam de maior qualidade.

Para resolver isso, construímos um conversor e um visualizador em nosso painel interno. Ele exibe imagens e vídeos compactados e descompactados lado a lado, facilitando a comparação dos resultados. Isso nos permitiu ajustar as configurações do ETC1S para cada ativo individualmente, aplicando compactação mais pesada onde não era perceptível, preservando a qualidade dos ativos heróicos e desativando mipmaps onde eles não eram necessários.

É uma ferramenta simples, mas teve um grande impacto na construção final e é uma etapa que raramente vemos discutida em fluxos de trabalho WebGL.

Também consolidamos texturas relacionadas em atlas compartilhados. Por exemplo, todas as texturas manuais são agrupadas em um único atlas 4×4, com cada malha usando deslocamentos UV e escala para referenciar sua própria seção.

Aplicamos a mesma abordagem em todo o projeto. Sprites de texto foram consolidados de quatro atlas separados em um, seguidos por certificados, pedaços de papel, nuvens, moedas e cacos de vidro. Mais de cinquenta imagens individuais foram reduzidas a cerca de uma dúzia de atlas, enquanto a maioria dos fundos gradientes foram substituídos por algumas linhas de GLSL.

As primeiras compilações tinham cerca de 35 a 40 MB. A versão final vem com menos de 10 MB, com o mapa mundial interativo isolado em seu próprio grupo de carregamento para nunca bloquear a experiência de abertura.

A gagueira que você não imagina chegando: enviando texturas

Reduzir o tamanho do download é apenas parte da equação. As texturas compactadas ainda precisam ser carregadas na GPU no thread principal, e uma textura grande pode facilmente bloquear a renderização por 50 ms, o suficiente para eliminar vários quadros. Se esse upload acontecer na primeira vez que uma textura for necessária durante a rolagem, o problema será imediatamente perceptível. Um site pode ter um bom benchmark e ainda assim parecer lento.

Para evitar isso, abordamos o upload de texturas de três maneiras.

  1. Decodifique o thread principal. Usar createImageBitmap() move a decodificação da imagem para fora do thread principal, deixando apenas o upload da GPU acontecer durante a renderização.
  2. Carregar durante o tempo ocioso. Texturas decodificadas são enfileiradas e carregadas somente enquanto o navegador tiver tempo livre disponível:
// upload queued textures only while there's idle time to spare
function drainUploads(deadline) {
  while (uploadQueue.length && deadline.timeRemaining() > 5) {
    renderer.initTexture(uploadQueue.shift()); // forces the GPU upload now
  }
  if (uploadQueue.length) requestIdleCallback(drainUploads, { timeout: 2000 });
}
  1. Divida atlas grandes em pedaços menores. As texturas maiores são divididas em 256² blocos e carregadas uma por quadro, evitando que um único upload estoure o orçamento do quadro.

Sempre que sabemos que um novo estágio está prestes a ser renderizado, como após o carregador ou durante transições de portão, liberamos a fila de upload de forma síncrona. Isso garante que cada textura necessária já esteja na GPU antes de aparecer na tela, evitando uploads iniciais durante a rolagem.

O gerente de qualidade adaptativo

Como não é possível prever o dispositivo do usuário, o renderizador monitora continuamente seu próprio desempenho. Ele rastreia os tempos dos quadros usando um buffer contínuo e ajusta a qualidade dinamicamente. Se a renderização ficar muito lenta, ela descerá para um nível de qualidade inferior. Se o desempenho melhorar e permanecer estável, ele será ampliado novamente. Um resfriamento impede que ele alterne constantemente entre os níveis de qualidade:

if (avgMs > 22 && tier > LOW  && cooldownElapsed) downgrade();   // ~<45fps
else if (avgMs < 12 && tier < HIGH && cooldownElapsed) upgrade(); // ~>83fps

Os níveis de qualidade afetam apenas o acabamento visual, não a experiência em si. Eles ajustam coisas como proporção de pixels, amostras de desfoque, geometria de moedas e resolução de texto, enquanto mantêm a história idêntica em dispositivos principais e telefones econômicos. Também usamos algumas otimizações direcionadas. Durante a interação de estilhaçamento de vidro, por exemplo, o renderizador reduz temporariamente a proporção de pixels e desativa os efeitos de desfoque e gelo, ocultando o custo de desempenho dentro do próprio gesto.

Grande parte do último mês foi gasto criando perfis e otimizando um dispositivo Android econômico. Rastreamos os picos de quadros um por um até que a experiência funcionasse perfeitamente. O pior infrator acabou sendo um único quadro de 157ms.

Os sombreadores

A cadeia de pós-processamento

Cada quadro é construído a partir de uma série de passagens de pós-processamento que são aplicadas em sequência:

  1. Renderização: A cena 3D principal.
  2. Plano de fundo: Planos de fundo GLSL processuais em vez de recursos de imagem.
  3. Refração de vidro: Renderiza o vidro inteiro para que refrate o fundo por trás dele.
  4. Geada e trilha: Desenha o gesto do usuário, a propagação da geada e o efeito de derretimento.
  5. Profundidade de campo: A qualidade do desfoque é dimensionada com o nível de qualidade ativo e é desativada em configurações baixas.
  6. Primeiro plano: Aplica granulação de filme e mapeamento de tom final.
  7. Texto diferido: Compõe o texto após o mapeamento de tom para mantê-lo nítido. Esta passagem é ignorada sempre que nenhum sprite de texto estiver visível.
  8. Shatter: Desenha o vidro quebrado como a passagem final.

As passagens de vidro, texto e estilhaços são inicializadas lentamente e aquecidas durante o tempo ocioso, mantendo-os fora do caminho crítico do carregador.

// boot: the always-on spine, added in order
composer.addPass(renderPass);     // 1. the 3D scene
composer.addPass(bgPass);         // 2. procedural background
composer.addPass(frostingPass);   // 3. draw-zero frost + melt
composer.addPass(lensBlurPass);   // 4. depth-of-field, tier-gated
composer.addPass(fgPass);         // 5. grain + the one tone mapping

// later, off the loader's critical path
composer.addPass(textPass);            // type, composited after tone mapping
composer.insertPass(glassPass, 2);     // slots in right after the background
composer.addPass(shatterPass);         // the break, last over everything

// warmed during an earlier stage's idle time,
// so their first real frame is a cache hit, not a shader-compile stall
glassPass.prewarm(renderer, camera);
shatterPass.prewarm(renderer, camera);

// per frame: don't pay for the text composite when nothing's on screen

Cada momento importante da experiência usa um shader personalizado criado para esse efeito específico. A IA ajudou a gerar as versões iniciais de muitos deles, mas cada shader foi refinado e retrabalhado antes de se tornar parte da experiência final.

O desbloqueio de gelo

O efeito de gelo é construído usando um buffer de pingue-pongue com quatro passagens: horizontal, vertical e as duas diagonais. Cada passagem expande o traço desenhado propagando os pixels vizinhos mais brilhantes, criando um padrão de crescimento octogonal. A expansão é modulada pelo brilho de uma textura gelada, conferindo à borda uma aparência mais natural e cristalina. Uma vez concluído o curso, seu centróide se torna o ponto de partida para o efeito de fusão radial.

// one of four axis passes → octagonal spread; uSpreadAxis is the pass direction
float m    = texture2D(uPrevTrail, vUv).r;

float step = uSpreadStep * (0.4 + iceLuma * 1.2);      // stepped by frost luma

for (int k = 1; k <= 2; k++) {
  m = max(m, texture2D(uPrevTrail, vUv + uSpreadAxis * step * float(k)).r * 0.92);
  m = max(m, texture2D(uPrevTrail, vUv - uSpreadAxis * step * float(k)).r * 0.92);
}

gl_FragColor.r = m;                                    // frost only ever advance

Iluminando as mãos sem luzes

A iluminação em tempo real em uma malha skinned teria sido muito cara, e a iluminação necessária para corresponder exatamente à arte original. Em vez disso, transformamos a iluminação em texturas e misturamos entre elas. Dois slots de textura são usados, com o slot de entrada atualizado para cada quadro-chave e suavemente crossfaded para criar a transição de iluminação.

// two slots crossfaded; the incoming one holds the next keyframe's texture
vec4 a = texture2D(uTextureA, vUv * uTexScaleA + uTexOffsetA);

vec4 b = texture2D(uTextureB, vUv * uTexScaleB + uTexOffsetB);

a.rgb *= a.a;  b.rgb *= b.a;              // premultiply before the blend

vec4 col = mix(a, b, uProgress);         // uProgress ramps 0→1 across the beat

Pré-multiplicamos o alfa antes de misturar para evitar halos escuros ao redor das bordas transparentes dos ponteiros. Ambas as texturas de iluminação são armazenadas como regiões dentro de um único atlas, portanto, alternar entre elas requer apenas a atualização de dois deslocamentos de UV e um fator de mesclagem.

Queimando dinheiro

O efeito de queima de dinheiro usa um campo de distância acionado por ruído para dissolver cada nota ao longo do tempo. Pouco antes de a queimadura atingir cada área, uma fina borda de brasa HDR brilhante aparece, seguida pela superfície carbonizada.

float threshold = uBurnProgress * 1.5;             // sweeps the burn front

float burn = distField * 0.5 + fbm(uv) * 0.5;

float edge = burn - threshold;

if (edge < 0.0) discard;                           // already ash

float ch = 1.0 - smoothstep(0.0, uCharWidth,  edge);

float em = 1.0 - smoothstep(0.0, uEmberWidth, edge);

vec3  col = mix(baseColor, uCharColor, ch) + uEmberColor * em;  // HDR ember rim

FBM é a parte mais cara do shader, então descartamos os fragmentos o mais cedo possível sempre que sabemos que eles ainda não podem ter atingido o limite de gravação.

Destruindo certificados

Cada vértice armazena um índice de faixa que determina a qual fragmento ele pertence. À medida que a frente do fragmento se move da esquerda para a direita, cada tira se separa da folha e cai independentemente com sua própria rotação, fazendo com que o certificado se rasgue em fitas individuais.

float past = clamp((uShredProgress - uv.x) / 0.4, 0.0, 1.0);

float e    = past * past;                             // ease-in

pos.y -= e * (0.25 + hash(aStripIndex) * 0.25);      // gravity, per strip

pos    = rotateStrip(pos, aStripIndex, e * 3.0);     // independent tumble, ~3 rad

As normais de iluminação são geradas analiticamente a partir da mesma função de onda que orienta a animação, portanto, nenhum mapa normal precisa ser armazenado ou enviado.

O túnel

O túnel é gerado pela extrusão da seção transversal do logotipo ZERO. Um pulso de brilho viaja através do túnel usando a coordenada Z do espaço mundial da geometria, permitindo que o efeito permaneça uniforme mesmo quando seções do túnel se repetem.

float q    = fract(vTunnelZ * uPulseFreq + uPulseTime);  // vTunnelZ = world-space Z

float band = 1.0 - smoothstep(0.0, uPulseWidth, min(q, 1.0 - q));

totalEmissiveRadiance *= 1.0 + uPulseGain * band;

Texto que entra em foco

O texto narrativo usa um desfoque hexagonal de sete toques que consiste em uma amostra central e seis amostras circundantes. O raio de desfoque é determinado pelo progresso da revelação, permitindo que o texto fique gradualmente mais nítido à medida que aparece, em vez de aparecer instantaneamente.

// 7-tap hexagonal blur; r comes from the reveal progress (uProgress)
float r  = blurFactor * uMaxBlur * 0.003;

vec4 sum = texture2D(uText, vUv) * 2.0;            // centre, weighted ×2

for (int i = 0; i < 6; i++)                        // six taps at 60°
  sum += texture2D(uText, vUv + hexDir[i] * r);

gl_FragColor = sum / 8.0;

Como é executado como parte da passagem de texto adiada, o desfoque é totalmente ignorado sempre que nenhum texto estiver visível, evitando qualquer custo desnecessário de renderização.

Um problema que demorou mais do que o esperado para depurar foi a precisão do shader. Vários shaders tiveram que ser explicitamente definidos como highp float porque muitas GPUs móveis, incluindo Adreno e Mali, tratam mediump como um verdadeiro float de 16 bits. Isso causou bugs que nunca apareceram na área de trabalho, como todas as faixas de certificados se movendo de forma idêntica ou faixas visíveis no efeito de gravação. É um bom lembrete para testar em dispositivos móveis reais desde o início, não apenas no final.

Design de interação

O material de origem consistia em imagens e vídeos em vez de especificações de interação, portanto o comportamento de cada portão teve que ser projetado do zero. A maioria deles usa o mesmo sistema configurável de pressionar e segurar. Uma configuração compartilhada define quando o prompt aparece e quanto tempo dura a espera, enquanto cada portão implementa seus próprios visuais por meio de um conjunto de ganchos:

holdTrigger: {
  showAt: 0.6, holdDuration: 1.4,
  onHoldProgress(ctx, p) { /* drive this gate's visuals with p (0..1) */ },
  onHoldComplete(ctx)    { /* fire the shatter / launch, then advance  */ },
}

Durante a retenção, todo o quadro muda gradualmente para vermelho escuro. Quando o vidro se estilhaça, a cor volta em cerca de 200 ms, fazendo com que pareça que os cacos estão quebrando a escuridão. Uma transição mais longa de 400 ms pareceu visivelmente menos impactante.

O som quebrado também é sincronizado com o primeiro quadro renderizado em vez de um temporizador. Em dispositivos mais lentos, depender de um cronômetro pode fazer com que o áudio seja reproduzido antes que a animação fique realmente visível, fazendo com que o efeito pareça fora de sincronia.

A recompensa: um mundo interativo

A sequência final de lançamento leva você para um céu aberto. À medida que a fase quatro avança, as nuvens se separam para revelar a cidade abaixo, com a torre do ZERO no centro. Um halo aparece acima da torre como o portão final, antes que a câmera se acomode no mapa interativo.

O estágio cinco entrega o controle ao usuário. Você pode deslocar, ampliar e explorar a cidade, com cada marcador abrindo um cartão contendo a função, o cenário e as ferramentas, junto com um botão Join Beta . A barra de adesão persistente também se torna a inscrição na lista de espera. Depois de guiar o usuário pela narrativa, a experiência termina deixando-o explorá-la por si mesmo.

Considerações finais

Uma das maiores lições deste projeto foi que a preparação de ativos e uploads de GPU merecem tanta atenção quanto a própria renderização. Ferramentas como o visualizador de compactação, decodificadores auto-hospedados, fila de upload e gerenciador de qualidade adaptável não são as partes mais interessantes do pipeline, mas fizeram a diferença entre enviar mais de um gigabyte de ativos de origem e fornecer uma experiência abaixo de 10 MB que funciona perfeitamente em um telefone econômico.

Quanto à IA, ela fez exatamente aquilo em que era boa. Ajudou a gerar a primeira versão de grande parte do código, inclusive do protótipo que ganhou o projeto. O verdadeiro trabalho veio depois: refinar as interações, melhorar o visual, criar perfis de desempenho e tomar centenas de pequenas decisões que só se tornam óbvias quando testadas em dispositivos reais. A IA pode gerar código rapidamente, mas construir uma experiência interativa sofisticada ainda depende de iteração cuidadosa e julgamento de engenharia.

Créditos

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.