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

Esboçando o impossível: um portfólio 3D construído sem um único modelo 3D | Codrops

Não consigo modelar em 3D. Isso explica muito bem todo o projeto.

Durante meses, naveguei no Awwwards e no The FWA e encontrei sites como Igloo Inc, 3D combinado com rolagem infinita, e pensei: preciso de algo assim. Aí vi o portfólio do Bruno Simon com o carrinho. Eu sabia que queria um portfólio 3D. Eu também sabia que não tinha nenhuma habilidade com o Blender e, honestamente, não tinha interesse em fingir com os modelos de outra pessoa.

Então pensei: por que não apenas codificar retângulos simples, planos, cubos, geometria plana e envolvê-los em texturas desenhadas à mão? Eu não conseguia esculpir um mundo no Blender, então esbocei um em retângulos planos. Essa solução alternativa acidentalmente se tornou toda a identidade visual do projeto.

Você pode encontrar o código-fonte no GitHub .

[codrops_course_ad id=”115510″]

Por que isso existe

Navegue por qualquer grupo do Facebook onde desenvolvedores compartilhem portfólios. Eu fiz. Cerca de 90% deles são a mesma coisa: fundo escuro, cores neon, texto à esquerda, imagem à direita. Muitos deles parecem ter sido gerados por IA… provavelmente foram. A IA tem uma tendência estética muito específica: página preta, brilho neon, pronto.

Não tenho problemas técnicos com esses sites. A experiência do usuário deles é provavelmente melhor que a minha, honestamente – é difícil se perder em um layout padrão. Mas eu queria algo diferente. Eu queria que os visitantes realmente percorressem um espaço, e não apenas rolassem uma página sobre mim. Se alguém vir meu portfólio e quiser trabalhar comigo, vai descobrir como entrar na sala de Contato. Não estou preocupado com isso.

Então, decidi construir um portfólio que você possa percorrer, não rolar.

Quatro meses, do esboço ao céu

O projeto começou em dezembro de 2025. Inicialmente pensei que seria um site ilustrado em 2D – texturas desenhadas à mão em seções HTML planas. Mas em algum momento nas primeiras semanas, percebi que HTML simples não seria suficiente. Essa coisa precisava de profundidade 3D real. Então mudei tudo para Three.js e React Three Fiber e, de repente, estava construindo salas.

Quatro meses depois, estava no ar. Quatro meses de luta com sistemas de câmeras e mecânica de rolagem que eu não tinha ideia de como construir. Também gerando uma tonelada de texturas com IA, porque de jeito nenhum eu desenharia todas elas à mão.

The Tech Stack

  • React 19 + React Three Fiber 9 + Three.js 0.182 para o ambiente 3D
  • GSAP 3.14 para todas as animações: voos de câmera, mecânica de portas, transições de revelação
  • Vite 7 para compilações e servidor de desenvolvimento
  • Shaders GLSL personalizados estendendo MeshBasicMaterial para o efeito de revelação de pintura
  • Texturas WebP geradas via IA (geração de imagens do Google), compactadas e aparadas para caber na geometria 3D plana
  • PostHog para análise, Lenis herança na filosofia de rolagem

A estética: por que tudo parece desenhado à mão

Este nunca foi o plano B. Desde o primeiro dia, eu queria que parecesse um esboço – como se você abrisse o caderno de alguém e os desenhos saltassem para 3D. Os fundos com textura de papel, as portas com linhas de tinta, os rabiscos flutuando no corredor. Tudo intencional.

O que evoluiu depois foi a cor. Eu tinha todas essas texturas estilo esboço e um dia pensei: e se elas se pintassem quando você passa o mouse? E se passar o mouse sobre algo literalmente pintá-lo de cor?

Essa se tornou a principal interação de todo o portfólio. Cada elemento clicável começa como um esboço em preto e branco e é preenchido com cor ao passar o mouse – uma revelação de pincelada conduzida por um sombreador personalizado. É basicamente uma dica visual – se algo ficar colorido quando você passar o mouse, significa que você pode clicar nele e algo acontecerá.

O sombreador PaintRevealMaterial

O efeito funciona estendendo o MeshBasicMaterial do Three.js até onBeforeCompile, injetando lógica de sombreador de fragmento personalizado que combina entre uma textura de esboço e uma textura pintada usando ruído processual:

// Brush-stroke blend: progressively swap sketch -> painted
if (uProgress > 0.001) {
    vec4 paintedColor = texture2D(uMapPainted, vMapUv);
    float rn = paintNoise(vMapUv * 15.0) * 0.15;
    // Reveal from bottom-left to top-right for organic feel
    float maskValue = (1.0 - vMapUv.y) + rn;
    float threshold = uProgress * 1.5;
    if (maskValue < threshold) {
        diffuseColor = vec4(paintedColor.rgb, 1.0);
    }
}

A função de ruído proporciona bordas orgânicas e bagunçadas – então, em vez de um pano limpo, você obtém algo que realmente parece tinta sangrando no papel. uProgress é animado de 0 a 1 pelo GSAP ao passar o mouse.

Optei por estender MeshBasicMaterial em vez de escrever um shader do zero porque precisava do pipeline de textura Three.js padrão (mapeamento UV, espaços de cores, transparência) para continuar funcionando. A lógica personalizada apenas decide quais pixels mostram a versão pintada – todo o resto permanece em estoque.

Manter todas as texturas visualmente consistentes foi, honestamente, uma das partes mais difíceis. Cada textura foi gerada por IA, e fazer com que a IA gere centenas de ativos no mesmo estilo desenhado à mão é… doloroso. Às vezes eu apenas gerava 20 versões e escolhia aquela que não parecia completamente diferente das demais.

O Corredor Infinito

A ideia é simples: você entra em um prédio através de portas duplas esboçadas, e atrás delas há um corredor que se estende infinitamente em ambas as direções. Nas paredes, em lados alternados, há quatro portas – cada uma conduzindo a uma sala com seu próprio mundo interior.

O sistema de fragmentação

O corredor é construído a partir de segmentos repetidos, cada um com 80 unidades de comprimento, gerenciados por InfiniteCorridorManager. Apenas três segmentos são montados: aquele em que a câmera está, mais um à frente e um atrás. Conforme você rola, os segmentos aparecem e desaparecem dinamicamente.

Além disso, cada segmento é envolvido em um SegmentVisibilityWrapper que usa useFrame para verificar se o segmento está realmente em exibição. Se você rolou 5 unidades além de um segmento, ele se esconde completamente – zero chamadas de desenho para geometria que a câmera nem está olhando:

useFrame(() => {
    const isBehindCamera = camera.position.z < endZ - 5;
    const isFarAhead = camera.position.z > startZ + 30;
    const isVisible = !(isBehindCamera || isFarAhead);

    if (groupRef.current.visible !== isVisible) {
        groupRef.current.visible = isVisible;
    }
});

A entrada representou um desafio complicado. Quando o usuário chega pela primeira vez, ele vê as portas de entrada – mas atrás dessas portas está o corredor infinito. Eu precisava que o segmento -1 (aquele atrás da entrada) existisse para a pré-compilação do shader, mas não poderia deixá-lo visível ou suas portas passariam pelas portas de entrada. A solução: um array hideDoorsForSegments que suprime portas de segmentos específicos durante a fase de entrada e as revela assim que o usuário entra.

O sistema de câmera: 500 linhas de dor

useInfiniteCamera.js tem 500 linhas e, honestamente, quase todas essas linhas existem por causa de um bug que tive que corrigir.

A câmera faz várias coisas ao mesmo tempo:

  • Movimento de rolagem via GSAP Observer (unifica a roda do mouse, toque e trackpad)
  • Paralaxe do mouse no desktop – a câmera balança suavemente conforme você move o mouse
  • Paralaxe do giroscópio no celular – incline o telefone, o corredor muda
  • Auto-glance – conforme você se aproxima de uma porta, a câmera sutilmente vira em direção a ela
  • Navegação pelo teclado – teclas de seta, barra de espaço, Page Up/Down para acessibilidade
  • Modo de substituição da câmera – quando o GSAP assume as animações de entrada/saída da porta

Vale a pena falar sobre o sistema de visão automática. Para cada porta, o gancho calcula a proximidade usando um modelo de distância inicial/pico/final com resistência facilitada:

for (const door of DOOR_POSITIONS) {
    const doorGlobalZ = zOffset + door.z;
    const dist = z - doorGlobalZ;

    let strength = 0;
    if (dist > PEAK_DIST && dist < START_DIST) {
        strength = (START_DIST - dist) / (START_DIST - PEAK_DIST);
    } else if (dist <= PEAK_DIST && dist > END_DIST) {
        strength = (dist - END_DIST) / (PEAK_DIST - END_DIST);
    }

    if (strength > 0) {
        const easedStrength = strength * (2 - strength); // easeOutQuad
        const dir = door.side === 'left' ? -1 : 1;
        // ...
    }
}

O resultado é um pequeno giro de cabeça que faz parecer que a câmera percebe as portas por conta própria. É sutil, mas faz com que o corredor pareça menos estático.

A parte mais difícil de todo o projeto, sem contestação, foi o sistema de câmeras de entrada e saída das salas. Quando você clica em uma porta, a câmera precisa:

  1. Alinhar-se na frente da porta (posição + rotação)
  2. Aguarde o carregamento lento da sala
  3. Animar a abertura da porta (maçaneta para baixo -> balanço da porta)
  4. Voe a câmera através da porta para a sala
  5. Na saída: inverta toda a sequência sem bater nas paredes ou cortar texturas

Cada uma dessas etapas continha bugs. A câmera iria disparar em vez de se mover suavemente. Ele cortaria a geometria da parede. Acabaria enfrentando a direção errada depois de sair. A matemática de rotação teve que levar em conta o ângulo da porta (as paredes em dente de serra são angulares, não retas), o estado de paralaxe da câmera e a transferência de controle do GSAP.

O componente DoorSection.jsx – aquele que gerencia tudo isso – cresceu para 1.287 linhas. Não estou orgulhoso do tamanho, mas cada linha lida com um caso realmente extremo.

Um truque do qual me orgulho: depois de liberar o controle da câmera de volta para o sistema de rolagem, o gancho calcula a rotação física atual da câmera e deriva um deslocamento de olhar equivalente, em vez de ajustar para o olhar “ideal” para aquela posição. Isso evita um estalo brusco ao sair de uma sala perto de uma porta:

const currentRotationY = camera.rotation.y;
const parallaxContribution = parallax.current.x * 0.3;
const derivedGlance = (currentRotationY - parallaxContribution) / 3;

const diff = Math.abs(derivedGlance - initialGlance);
if (diff > 0.02) {
    glanceOffset.current = derivedGlance; // Start from where we ARE
    targetGlance.current = initialGlance;  // Smooth toward ideal
}

As salas: abstração acima da previsibilidade

O plano original era de quartos normais. Uma sala com mesa. Uma sala com prateleiras. Você sabe – quartos. Mas isso parecia chato e previsível. Se alguém anda pelo corredor de um prédio e abre uma porta, espera um quarto. Eles não esperam de repente voar em um avião de papel através das nuvens.

Então cada cômodo se tornou seu próprio mundinho:

  • A Galeria : Cartões de projetos pendurados em um varal infinito, como roupa secando ao vento. Role para o lado para navegar. Ciclo infinito.
  • O Estúdio : Monitores flutuando no espaço, rolando verticalmente pelo meu conteúdo – vídeos, postagens de blog, mídias sociais. Infinito em ambas as direções.
  • Sobre : Você pilota um avião de papel por um céu infinito cheio de nuvens e marcos da história. Sua biografia como trajetória de vôo.
  • O Contato : Uma praia à beira-mar. Links de mídia social são barris flutuantes nos quais você clica para se conectar.

Quase todos os cômodos possuem o infinito embutido. Na Galeria, os cartões circulam. No Studio, os monitores ficam em loop. Em About, o céu nunca acaba, assim como o próprio corredor. Apenas o Contact quebra o padrão – e acho que isso é realmente melhor. O contato é o destino. Deve parecer que você chegou a algum lugar.

A sala Sobre teve um desafio técnico particularmente desagradável. Quando você voa através das nuvens e depois volta para o corredor, as nuvens vazam para a visualização do corredor. O problema: a parede de recorte invisível atrás da câmera também cortava coisas dentro do corredor, porque a câmera entra na sala em um ângulo.

A solução foi duas coisas: curvar o limite de recorte para que corresponda ao caminho de entrada em ângulo e estender a distância de aproximação especificamente para a sala Sobre – você viaja mais para dentro do Sobre do que para qualquer outra sala, dando mais espaço à parede de recorte.

A crise de desempenho (ou: dois sóis, uma lua e zero sombras visíveis)

Quando compartilhei pela primeira vez um link de visualização em grupos de desenvolvedores do Facebook, o feedback foi claro: seu site é lindo, mas funciona como uma apresentação de slides .

Passei dias tentando descobrir o que estava prejudicando o desempenho. Eu estava olhando para tamanhos de textura, chamadas de desenho, complexidade de shader – tudo. Então eu encontrei.

Duas luzes direcionais e uma luz ambiente projetavam sombras em tempo real por toda a cena. Essas eram luzes Three.js padrão que adicionei no início do desenvolvimento para uma iluminação “adequada”. O problema? Minha cena é feita inteiramente de planos de textura plana – os mapas de sombras computavam passagens de profundidade complexas para geometria que não mostrava visualmente nenhuma diferença de sombra .

Removi todas as luzes. Em seguida, adicionei um leve tom diretamente às texturas no código – um pouquinho de sombra simulada incorporada aos valores das cores. Visualmente? Quase nenhuma mudança. Em termos de desempenho? Noite e dia.

O truque de pré-compilação do shader

Outro feedback da comunidade: “Por que espero pelo pré-carregador e espero NOVAMENTE quando clico em uma porta?”

A resposta foi compilação de shaders. Three.js compila shaders preguiçosamente – na primeira vez que um material é renderizado, a GPU compila seu programa de shader, causando uma falha visível. Cada sala tinha dezenas de materiais, e todos compilados na primeira entrada.

A solução foi RoomWarmup: um componente que monta todas as quatro salas 500 unidades abaixo da cena durante a fase de pré-carregamento, força a GPU a compilar cada shader via gl.compileAsync(), depois desmonta tudo:

const RoomWarmup = ({ onWarmupComplete, isLowTier }) => {
    // Mount all rooms 500 units below the scene
    // Wait 3 frames for everything to render
    // Then force-compile all shaders

    if (gl.compileAsync) {
        gl.compileAsync(scene, camera, scene)
            .then(finishWarmup)
            .catch(() => {
                gl.compile(scene, camera); // sync fallback
                finishWarmup();
            });
    }
};

// In the JSX:
<group position={[0, -500, 0]}>
    <GalleryRoom showRoom={true} isWarmup={true} />
    <StudioRoom showRoom={true} isWarmup={true} />
    <AboutRoom showRoom={true} isWarmup={true} />
    <ContactRoom showRoom={true} isWarmup={true} />
</group>

Em dispositivos de última geração, isso eliminou todas as falhas de entrada – todas as salas abrem instantaneamente porque a GPU já conhece todos os shaders. Em dispositivos de baixo custo, o aquecimento é totalmente ignorado (para evitar a perda de contexto do WebGL devido à pressão de memória) e as salas são carregadas sob demanda com um pequeno atraso.

Eu mesmo criei essa abordagem depois de ler as reclamações da comunidade. Nenhum tutorial me ensinou isso – veio da frustração e de muita depuração do console.log.

O experimento KTX2 que falhou

Eu li em todos os lugares que as texturas KTX2 / Basis Universal são o padrão ouro para desempenho WebGL – descompressão nativa da GPU, cargas úteis menores, tudo bem. Então eu converti tudo.

Foi um desastre. Texturas desalinhadas. As cores mudaram. O pré-carregador ficou dois segundos mais lento. E a qualidade visual das texturas desenhadas à mão – que dependem de linhas nítidas e gradientes sutis – degradou-se visivelmente.

Voltei para WebP. O site já rodava a 60 FPS em celulares e 144 FPS em desktop com texturas WebP. Às vezes, a otimização “correta” simplesmente não vale a pena se a solução atual já funcionar. Eu sei que os desenvolvedores 3D podem me julgar por isso, mas estou falando da perspectiva real da experiência do usuário – e é tranquilo.

Classificação adaptativa de dispositivos

O site detecta os recursos do dispositivo no momento do carregamento e se ajusta de acordo:

const isMobileDevice = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const isWeakCPU = navigator.hardwareConcurrency <= 4;
const isLowRAM = navigator.deviceMemory <= 4;
const isSmallScreen = window.innerWidth < 450;
const isLowEnd = isMobileDevice || isWeakCPU || isLowRAM || isSmallScreen;

Dispositivos de baixo custo obtêm menos texturas pré-carregadas, nenhum aquecimento da sala e renderização mais simples. Além disso, um PerformanceMonitor de drei assiste FPS em tempo real e rebaixa automaticamente o nível de qualidade se os frames começarem a cair.

No meu PC principal (3 monitores, 144 Hz), o site mantém constantes 144 FPS. Nos telefones, mantém cerca de 60 FPS. No meu Thinkpad de 80 euros – honestamente, é um milagre que ele funcione – ele consegue 15-30 FPS. Mas funciona.

Design de som: o detalhe que muda tudo

Percebi que os sites que mais admirava – Bruno Simon, Igloo Inc – tinham som. O som é o que faz um site 3D parecer um lugar, em vez de apenas uma página com gráficos.

Cada sala tem sua própria paisagem sonora ambiente:

  • Corredor : Loop de música de fundo, ambiente semelhante a passos
  • Galeria : zumbido da cidade
  • Estúdio : zumbido do monitor, zumbido eletrônico
  • Sobre : Vento passando como você voa
  • Contato : Ondas do oceano

As interações da porta têm três sons distintos: um rangido quando você passa o mouse (a porta se inclina levemente), um som de abertura total quando você clica e um som de fechamento quando ela se fecha atrás de você.

Encontrar os sons certos foi realmente frustrante. Tudo tinha que ser gratuito para uso comercial, combinar com o tom estético e parecer coeso em todos os ambientes. Ainda não estou 100% satisfeito com cada som, mas ter algum som, mesmo imperfeito, é muito melhor do que o silêncio total.

O Sistema de Realização: Gamificação como UX

Inspirado no portfólio de Bruno Simon (onde você pode derrubar pinos de boliche e ganhar conquistas), adicionei um sistema de conquistas que funciona como um tutorial.

Quando você entra em uma nova sala, uma dica de ferramenta aparece: “Role para navegar pela minha história” em Sobre, “Arraste para girar e navegar” no Studio. Porém, estas não são apenas instruções – são conquistas esperando para serem desbloqueadas. Conclua a ação e a dica de ferramenta se transformará em um emblema completo com um sinal sonoro.

const ACHIEVEMENTS = {
    corridor_enter:  { label: 'Click a door to enter', title: 'Explorer' },
    corridor_explore: { label: 'Scroll to explore the corridor', title: 'Wanderer' },
    about_fly:       { label: 'Scroll to fly through my story', title: 'Sky Walker' },
    studio_interact: { label: 'Drag to rotate and browse', title: 'Director' },
    gallery_inspect: { label: 'Click project to inspect', title: 'Art Critic' },
    contact_choose:  { label: 'Find a contact method', title: 'Sociable' }
};

Isso resolve um problema real: em uma interface fora do padrão, as pessoas não sabem o que fazer. As conquistas ensinam padrões de interação enquanto recompensam a exploração. O progresso persiste em localStorage, e cada desbloqueio dispara um evento de análise PostHog para que eu possa rastrear quais salas obtêm mais engajamento.

Reconhecimento

O portfólio ganhou alguns prêmios ao longo do caminho:

  • Site GSAP do Dia + adicionado ao GSAP Showcase oficial
  • FWA do dia
  • Kudos especiais CSSDA + 3 prêmios de escolha pública
  • Orpetron SOTD
  • Vencedor CSS SOTD
  • Awwwards Menção Honrosa

O que eu faria diferente

Honestamente? Quase nada em termos de abordagem. A filosofia “mecânica primeiro” – construir o pergaminho, a câmera, a lógica do corredor antes de tocar em uma única textura – me salvou da armadilha de polir visuais em cima de fundações quebradas.

O que eu pularia: aquelas duas luzes direcionais e o sombreador lunar que me custaram dias de depuração sem nenhum benefício visual. E o experimento KTX2. Às vezes, saber o que NÃO fazer é a verdadeira otimização.

O que este projeto me ensinou

Que ainda sou iniciante.

Este é talvez o meu primeiro projeto que obteve reconhecimento real – os prêmios, a resposta da comunidade, a chance de escrever este artigo. Meu projeto anterior, um site para o rapper polonês Young Multi, chamou a atenção quando o construí. Mas quando olho para isso agora, vejo tudo que eu faria de forma diferente.

É assim que sei que estou crescendo.

Este portfólio também não é perfeito. A UX não é tão limpa quanto um layout padrão – eu sei disso. Mas as pessoas parecem gostar de explorá-lo, o que significa que algo deu certo.

O que vem a seguir

Meu maior objetivo agora: construir um site para o coletivo de hip-hop polonês 2115 e submetê-lo aos Webby Awards e Lovie Awards . É um padrão alto. Mas quando você recebe prêmios como o FWA of the Day nesta idade e é convidado para escrever para o Codrops – quero dizer, não existe realmente impossível.

Um conselho

Primeiro: escreva o roteiro. Antes de tocar em uma única linha de código, imagine o filme. O que o usuário vê? Para onde vai a câmera? Qual é a história?

Em seguida, construa a mecânica com formas simples. Retângulos. Cubos. Sem texturas, sem shaders – apenas movimento e fluxo brutos. Faça com que pareça certo quando parece nada.

Só assim você veste o mundo.

A tecnologia nunca é o bloqueador – é sempre a sua imaginação. Se você consegue pensar em uma solução, você pode codificá-la. Leva apenas tempo e teimosia.

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.