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
dialognativo 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.