Todos os artigos

Como construí este portfólio: 42 mil pontos e nenhum servidor

Astro estático, three.js puro e um shader próprio: as decisões por trás da nuvem de pontos, do desempenho e da segurança deste site.

Eu queria um portfólio que mostrasse o tipo de problema que gosto de resolver, não só uma lista de tecnologias. Como um dos meus projetos, o SiloScan, trabalha com nuvens de pontos 3D de silos de grãos, a ideia veio quase pronta: o site inteiro seria uma nuvem de pontos que muda de forma conforme você rola a página.

São até 42 mil pontos. Eles começam como o planalto da Chapada dos Guimarães, viram um silo escaneado, uma conversa de WhatsApp, a paleta de um tema de editor, as camadas de uma arquitetura limpa e, no fim, um globo com Cuiabá em destaque.

A stack

Camada Escolha Por quê
Site Astro, saída estática HTML pronto no build, sem servidor para manter ou atacar
3D three.js puro com shaders GLSL próprios Uma cena só, sem o peso de um framework de componentes 3D
Animação GSAP e ScrollTrigger Entrada do nome e parallax amarrados à rolagem
Rolagem Lenis Rolagem suave, desligada para quem pede menos movimento
Hospedagem Cloudflare Workers com arquivos estáticos Deploy a cada push, HTTPS e cache na borda

Não usei framework de interface. Os textos dos dois idiomas ficam num arquivo TypeScript só, e o Astro gera uma página para cada um.

Seis formas, um único buffer

A parte mais interessante é que a GPU faz quase tudo. Cada ponto carrega as seis posições possíveis como atributos (p0 a p5), calculadas uma vez no carregamento. A cada quadro, o JavaScript só informa de qual forma para qual forma a nuvem está indo e em que ponto da transição ela está. O shader de vértice faz a mistura:

// cada ponto parte num instante diferente: a forma "desmancha" em onda
float tt = smoothstep(0.0, 1.0, clamp((uMix - aRnd * 0.35) / 0.65, 0.0, 1.0));
vec3 pos = mix(pick(uFrom), pick(uTo), tt);

// no meio do caminho os pontos se espalham e voltam a se juntar
vec3 dir = normalize(vec3(sin(aRnd * 91.7), cos(aRnd * 47.3), sin(aRnd * 13.1 + 1.0)));
pos += dir * sin(tt * 3.14159) * (0.5 + aRnd * 0.9);

O aRnd é um número aleatório fixo por ponto. É ele que faz os pontos saírem em tempos diferentes, e por isso a transição parece uma onda e não um bloco se mexendo.

Em cima disso entram alguns detalhes que respondem a quem está navegando:

  • O cursor afasta os pontos, como uma mão passando sobre areia, e os pontos mais perto dele crescem e acendem.
  • Quando uma forma termina de se montar, uma faixa de luz desce por ela, como a leitura de um scanner.
  • Uma poeira fina atravessa a página inteira e se estica conforme a velocidade da rolagem.
  • Na seção do tema Serena, as paletas reais do tema mudam as cores da nuvem.

O brilho vem do próprio shader de fragmento, com um núcleo nítido e um halo suave em cada ponto. Não precisei de pós-processamento, que custaria mais uma passada de renderização na tela inteira.

Desempenho: a nuvem não pode atrasar o texto

Um site com WebGL pesado fica bonito no meu computador e trava no de outra pessoa. Por isso tratei o 3D como um enfeite que chega depois:

  • O three.js fica num arquivo separado e só é baixado quando o navegador está ocioso. O nome e o texto aparecem antes.
  • Antes de baixar qualquer coisa, o site verifica se o WebGL roda na placa de vídeo. Em máquinas sem GPU o navegador desenha o 3D na CPU, e aí a página inteira trava. Nesses casos o site usa um fundo estático e nem baixa a biblioteca.
  • Se os quadros ficarem lentos mesmo com GPU, a cena reduz a resolução e, se ainda precisar, corta metade dos pontos.
  • A cena para quando a aba fica oculta, e as fontes do título e do texto são pré-carregadas.

No PageSpeed Insights do Google, o resultado ficou em 97 de desempenho no celular e 100 no computador, com 100 em acessibilidade, boas práticas e SEO nos dois.

Segurança num site estático

Site estático não tem banco nem servidor para invadir, mas ainda pode ser usado para injetar script. O site manda uma Content Security Policy estrita, sem unsafe-inline. Um trecho:

default-src 'self'; script-src 'self' https://static.cloudflareinsights.com;
style-src 'self'; object-src 'none'; frame-ancestors 'none'

Para isso funcionar, todo CSS e todo JavaScript precisam estar em arquivos externos. Configurei o Astro para nunca embutir estilos no HTML e troquei o realce de código do Shiki, que usa estilos inline, pelo Prism, que usa classes. Os blocos de código deste artigo estão coloridos com a paleta Serena Dark.

O site também envia HSTS, nosniff, Referrer-Policy, Permissions-Policy e cache imutável para os arquivos com hash.

Acessibilidade

  • Quem configura o sistema para reduzir movimento recebe a página sem rolagem suave, sem parallax e com transições diretas.
  • O nome do topo é animado letra por letra, mas o leitor de tela lê o nome inteiro de uma vez.
  • A paleta de comandos (⌘K ou Ctrl+K) usa o dialog nativo e funciona só com o teclado. No celular, ela vira o menu.
  • Sem WebGL, nenhum conteúdo some: a nuvem é decoração, o texto é o site.

Um bug que valeu o artigo

Durante os testes, a nuvem às vezes ia parar fora da tela. Os números mostravam o deslocamento horizontal indo para -14, depois -126. A causa: em algumas condições, o tempo entre dois quadros saía negativo. A suavização que eu usava, lerp(alvo, 1 - exp(-dt * 3)), com dt negativo deixa de suavizar e passa a afastar o valor do alvo a cada quadro.

A correção foi uma linha: limitar o intervalo entre zero e um teto.

const dt = Math.min(Math.max((now - last) / 1000, 0), 0.05);

É o tipo de erro que só aparece em máquina lenta, e é por isso que eu testo em máquina lenta.

O que eu levaria para um projeto de cliente

  • Comece pelo conteúdo. O efeito só funciona porque cada forma da nuvem conta um projeto real.
  • Trate 3D e animação como algo que se adapta ao aparelho, não como requisito. O site precisa funcionar com eles desligados.
  • Meça. A detecção de WebGL por software subiu a nota de desempenho no celular de 58 para mais de 90.

Se quiser conversar sobre um projeto ou sobre alguma dessas decisões, meu e-mail é joao@joaoangello.dev.