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

The Sleepers: Criando uma experiência WebGL atmosférica com técnicas leves | Codrops

The Sleepers é uma curta experiência WebGL que criei como submissão para o desafio Three.js de Bruno Simon.

Preparar-se para um desafio como este significa trabalhar no seu próprio tempo, de graça e dentro de um prazo apertado. Isso naturalmente leva você a uma coisa: eficiência.

Este estudo de caso explora uma coleção de técnicas WebGL simples e leves que são rápidas de implementar e desenvolvidas para desempenho. O objetivo não era criar a experiência tecnicamente mais complexa, mas sim alcançar o impacto visual mais forte com as soluções mais simples possíveis.

Vamos detalhar algumas das técnicas por trás disso.

A introdução: transição giratória

Para criar essa transição, primeiro precisaremos de uma textura preta e branca, como esta one:

Em seguida, usamos essa textura como um uniforme em um shader de pós-processamento para controlar como a cena transita progressivamente de uma versão em tons de cinza escuro para suas cores originais.

O canal vermelho da textura atua como um limite por pixel: cada fragmento faz a transição quando o progresso uniforme (uProgress) excede seu valor de limite correspondente na textura.

Aqui está uma versão simplificada do shader que usei:

uniform float uProgress;
uniform sampler2D uTransitionTexture;

vec3 greyscale(vec3 color, float str) {
    float g = dot(color, vec3(0.1));
    return mix(color, vec3(g), str);
}

void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
    vec3 greyScaledColor = greyscale(inputColor.rgb, 1.);
    greyScaledColor = mix(greyScaledColor, vec3(0.1, 0.1, .9), pow(uProgress, 5.));

    vec4 textureColor = texture2D(uTransitionTexture, vUv);
    float mixer = step(textureColor.r, uProgress);

    outputColor = vec4(mix(greyScaledColor, inputColor.rgb, mixer), 1.);
}

As uProgress aumenta de 0 para 1, diferentes partes da tela são reveladas em momentos diferentes com base nos valores de textura. As áreas escuras aparecem primeiro, enquanto as áreas mais claras aparecem depois, criando a impressão de que a imagem está sendo revelada pelo padrão giratório. Esta técnica simples pode produzir transições ricas e de aparência orgânica, mantendo a lógica do shader simples! 👌

Nevoeiro

Este nevoeiro obviamente não é volumétrico, é apenas uma espécie de truque de cores aplicado aos materiais do cena.

Passo 1: Névoa vertical linear

Primeiro, precisamos configurar uma modificação de shader (usando onBeforeCompile), e vamos aplicá-la a todos os materiais da cena mais tarde.

Neste onBeforeCompile, simplesmente usaremos a posição mundial de cada fragmento para determinar sua cor. Acima de uma determinada posição no eixo Y, deixamos a cor intacta. Abaixo dele damos ao fragmento a cor da neblina.

const fogOnBeforeCompile = (shader) => {
    // 1. Retrieve the worldPosition and pass it as a varying
    shader.vertexShader = shader.vertexShader.replace(
        'void main() {',
        `
        varying vec3 vWorldPosition;
        void main() {
            vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
    `)

    // 2. Define the uniforms and the varying in the fragment shader
    shader.fragmentShader = shader.fragmentShader.replace(
        'void main() {',
        `
        uniform float fogPositionY;
        uniform float fogSmoothness;
        varying vec3 vWorldPosition;
        void main() {
    `)

    3. Use them to create a basic fog
    shader.fragmentShader = shader.fragmentShader.replace(
        'vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;',
        `
        vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;

        // vertical gradient
        float verticalMixer = smoothstep(vWorldPosition.y - fogSmoothness, vWorldPosition.y + fogSmoothness, fogPositionY);

        float mixer = clamp(verticalMixer, 0., 1.);

        vec3 fogColor = vec3(1.);
        
        outgoingLight = mix(outgoingLight, fogColor, mixer);
    `);
    
    shader.uniforms.fogPositionY = { value: fogSettings.height };
    shader.uniforms.fogSmoothness = { value: fogSettings.smoothness };
}

Então podemos aplicar esta substituição de shader aos nossos materiais (observe que ele se destina a funcionar com instâncias de MeshStandardMaterial, pode ser necessário ajustá-lo se estiver trabalhando com materiais diferentes).

gltf.scene.traverse(child => {
    if (child.isMesh) {
        child.material.onBeforeCompile = (shader) => {
            fogOnBeforeCompile(shader);
        };
    }
});

Com essa lógica o resultado já deve ficar assim:

Neste exemplo, também envolvi toda a cena em uma esfera e sombreei usando a mesma técnica: cor da névoa sob uma determinada posição do mundo, transparente acima dela. Isso cria a névoa do horizonte.

Etapa 2: Animação de neblina baseada em ruído

Para fazer a neblina parecer viva, obviamente adicionaremos algum ruído. Mas o cálculo do ruído pode ser caro, especialmente quando aplicado a vários materiais. Então, vamos usar uma textura de ruído contínua, como esta:

Há muitas maneiras de incorporar ruído e muitas maneiras de personalizar a aparência do nevoeiro. Eu pessoalmente usei a distorção de domínio para obter uma superfície de neblina interessante e usei a distância do worldPosition até a câmera para ter controle sobre a profundidade visual.

Para simplificar, aqui está uma versão básica do shader final:

const fogOnBeforeCompile = (shader) => {
    // 1. Retrieve the worldPosition and pass it as a varying
    shader.vertexShader = shader.vertexShader.replace(
        'void main() {',
        `
        varying vec3 vWorldPosition;
        varying vec2 vUv;

        void main() {
            vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
            vUv = uv;
    `)

    // 2. Define the uniforms and the varying in the fragment shader
    shader.fragmentShader = shader.fragmentShader.replace(
        'void main() {',
        `
        uniform float fogPositionY;
        uniform float fogSmoothness;
        uniform sampler2D noiseTexture;
        uniform float uTime;
        varying vec3 vWorldPosition;
        varying vec2 vUv;

        void main() {
    `)

    3. Use them to create a basic fog
    shader.fragmentShader = shader.fragmentShader.replace(
        'vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;',
        `
        vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;

        vec4 noiseColor = texture2D(noiseTexture, vec2(vWorldPosition.x * noiseFreq + uTime, vWorldPosition.z * noiseFreq + uTime));
        float noise = noiseColor.r;

        // vertical gradient
        float verticalMixer = smoothstep(
            vWorldPosition.y - fogSmoothness, 
            vWorldPosition.y + fogSmoothness, 
            fogPositionY + noise);

        float mixer = clamp(verticalMixer, 0., 1.);

        vec3 fogColor = vec3(1.);
        
        outgoingLight = mix(outgoingLight, fogColor, mixer);
    `);
    
    shader.uniforms.fogPositionY = { value: fogSettings.height };
    shader.uniforms.fogSmoothness = { value: fogSettings.smoothness };
    shader.uniforms.uTime = 0;
    shader.uniforms.noiseTexture = fogSettings.noiseTexture;
}

E aqui está uma demonstração que você pode conferir e brincar:

Contornos de malha

Train outline

Esta técnica tem muitos nomes: contorno de concha, contorno de backface, contorno de casco invertido, etc. e na verdade é uma técnica do Blender:

Passo 1: Crie um material dedicado ao esboço.

Como queremos um contorno preto, criaremos um material preto. Não muito tempo atrás, era possível usar um nó RGB que seria então interpretado como MeshBasicMaterial por Three.js. Mas o nó RGB foi removido do Blender desde a versão 5.0.0 e até hoje, não há nenhuma maneira (que eu saiba) de fazer com que um material do Blender seja traduzido em um MeshBasicMaterial.
Então vamos manter o nó Principled BSDF. Three.js criará então um MeshStandardMaterial, mas tudo bem, pois será fácil reatribuí-lo manualmente a um MeshBasicMaterial em JavaScript.

Certifique-se de colocar seu material de contorno no último slot. Isso tornará nossas vidas mais fáceis na próxima etapa.

Também precisaremos ativar a seleção da face posterior (esta opção significa que as faces posteriores do material não serão renderizadas).

2. Modificador Solidify

Agora adicione um modificador Solidify com as seguintes configurações:
– Uma espessura negativa
– Normais invertidas
– E forneça a posição do slot do seu material de contorno em “Material Offset”. No meu caso seria “2”, mas você também pode simplesmente preencher um número alto e o Blender selecionará apenas o último slot de material da malha. Isso tornará sua vida muito mais fácil se você copiar esse modificador em muitos objetos que não possuem necessariamente a mesma quantidade de slots.

É isso! Ao exportar como glTF, não se esqueça de selecionar “ aplicar modificadores ” e “ exportar materiais ”.

Cidade de rolagem infinita

Visualização ampliada mostrando o reposicionamento dos pedaços

Nenhuma surpresa aqui, usei um único pedaço repetível gerenciado por um sistema de grade dinâmico que mantém um layout 3×3 ao redor da câmera. Os blocos são clonados uma vez e reposicionados perfeitamente conforme a câmera se move, criando a ilusão de uma cidade infinita e mantendo baixo o consumo de memória.

Visualização isolada de um único pedaço

Iluminação

Eu acho é hora de um pouco de autopromoção. Em quase 100% dos meus projetos, gosto de usar Three.js LightKit para lidar com a iluminação.

É um complemento simples que permite navegar e testar centenas de HDRs diferentes (toda a biblioteca polyhaven) em um piscar de olhos. Ele também permite que você experimente diferentes toneMapping e configurações de exposição.

Quando estiver satisfeito com sua predefinição, basta clicar em “exportar” para salvá-la como um arquivo JSON e carregá-la como sua configuração de iluminação padrão.

Além disso, funciona com projetos WebGL e WebGPU, bem como Vanilla JS e React.

Se quiser conferir ao vivo, você pode experimentar a demonstração aqui: Three.js LightKit

Isso é tudo pessoal!

Espero que você tenha gostado deste detalhamento e encontrado algumas ideias que você pode aplicar em seus próprios projetos! 🫡

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.