Construindo um mundo aberto no navegador, parte 20: Simulando profundidade em um plano
Por Oleg Sidorkin, CTO e cofundador da Cinevva
Chegou agora? Consulte o guia da série. Ele explica o que é um spike e contém links para todas as partes.
A Parte 19 usou um quad plano para simular uma árvore inteira à distância. Esta parte usa um quad plano para simular profundidade de perto: mapeamento de oclusão por paralaxe, a técnica que faz uma estrada de paralelepípedos parecer ter 5 cm de rejunte rebaixado sem gastar um único vértice adicional. O objetivo era implementá-la na stack de produção (Three.js r184, WebGPU, TSL), para que os materiais detalhados do terreno possam aplicar essa ilusão de profundidade onde ela importa e pagar apenas o custo de uma textura plana em todos os outros lugares.
Três maneiras de simular profundidade, lado a lado
Abrir o Spike 39 em uma nova aba ↗ · Ver código-fonte
O spike coloca três planos de 5×5 m lado a lado, todos com a mesma estrutura de material e diferindo apenas nas UVs que alimentam os samplers. A versão plana amostra a textura diretamente e serve como referência básica. A paralaxe de amostra única desloca a UV uma vez ao longo da direção de visão, usando a altura naquele ponto. É barata e funciona bem com baixa amplitude, mas desliza em ângulos rasantes. O POM faz ray marching no espaço tangente: avança ao longo do raio de visão, encontra a primeira camada em que o raio passa abaixo do campo de alturas e refina o ponto de interseção. O espaço tangente permanece simples porque todos os planos de teste estão alinhados aos eixos; assim, a direção de visão pode ser convertida para o espaço tangente com apenas algumas inversões de sinal, em vez de uma matriz TBN completa por vértice. O conjunto de texturas é carregado diretamente pela API de arquivos do Polyhaven, pelo mesmo caminho usado pela busca de modelos da Parte 17.
Duas barreiras do WebGPU e um ray marching sem ramificações
O loop de POM clássico interrompe a busca na primeira interseção. Na r184 isso não funciona, por dois motivos distintos. If(...).and(...) compilava sem erros, mas produzia WGSL no qual o corpo do loop nunca era executado; assim, o refinamento posterior ao loop operava sobre lixo e o plano era renderizado quase branco. Além disso, Break() como nó independente ainda não havia sido incluído no build da r184, portanto, mesmo com um If funcional, não havia como expressar “pare na primeira interseção”. Ambos os problemas remontam a issues conhecidas do three.js relacionadas à otimização excessiva do fluxo de controle do TSL através dos limites de If e Loop nessa faixa de versões.
A reescrita não usa ramificações. Cada iteração amostra a textura incondicionalmente, mantendo o acesso à textura em um fluxo de controle uniforme, como exige a especificação WGSL, e depois incorpora o novo estado por meio de um sinalizador done armazenado como float. Quando done muda para 1, as chamadas de mix de cada iteração passam a equivaler a “manter o estado inalterado”, a versão sem ramificações de um break. O sinalizador done é construído com um helper step implementado como 0.5 + 0.5 × sign(x + ε), porque a coerção de booleano para float tem sido inconsistente na linha r18x, enquanto sign() é universalmente segura. O custo é que cada fragmento executa todas as 64 iterações, independentemente do ponto real de interseção, mas essa é a troca correta na escala de fragmentos: o runtime já limita pelo número máximo de passos, e uma GPU real também executaria especulativamente além de um break “de verdade”. Um fallback limpo é importante aqui: um mix(baseUV, refined, done) final garante que, com amplitude zero — no extremo distante do fade por distância —, nenhum fragmento intercepte a superfície, done permaneça 0 e o material POM seja idêntico bit a bit ao plano. Esse é justamente o objetivo do truque de LOD por distância: reduzir ao custo de uma textura plana onde o efeito já ocupa menos de um pixel.
O bug era uma falha de disciplina, não uma falha matemática
A versão sem ramificações funcionava, mas parecia distorcida, com artefatos horizontais estriados em amplitudes moderadas e um resultado sutilmente errado e sem nitidez em amplitudes baixas. A correção veio de um prompt de uma linha: vá ler a referência canônica. O tutorial da LlamAcademy que inspirou isso é apenas um nó do Unity ShaderGraph, portanto a implementação real fica em PerPixelDisplacement.hlsl, da Unity. A leitura linha por linha revelou três diferenças semânticas que eu havia introduzido sem perceber: um erro de um passo na referência inicial da altura do raio (a Unity faz um avanço inicial antes do loop; portanto, meu referencial estava defasado por um passo inteiro, colocando as interseções na camada errada em cerca de metade dos casos), uma convenção de sinal no deslocamento máximo da qual depende a etapa de refinamento e uma escolha entre contabilizar o deslocamento acumulado ou a UV acumulada que tornou minha matemática de refinamento mais difícil e confundiu o sinal.
A causa raiz não foi um erro isolado, mas a mistura de duas referências. Eu havia usado o tutorial de POM do LearnOpenGL como guia, que adota convenções de sinal semelhantes, porém diferentes, e uma fórmula de refinamento distinta. Acabei em um estado híbrido no qual dois terços da matemática correspondiam a uma fonte e um terço à outra. A reescrita é um port quase literal do HLSL da Unity para TSL: mesmos nomes de variáveis, mesmo avanço inicial e mesmo refinamento, com o sinalizador done sem ramificações acrescentado por cima. Vale guardar a lição: ao portar um shader comprovadamente funcional de outra stack, primeiro faça um port linha por linha, mantendo os mesmos nomes, e só depois refatore para o estilo local. Não tente refazer a derivação com base em uma segunda referência no meio do port.
Um plano de referência que não pode mentir
A comparação lado a lado não incluía o elemento mais óbvio: um plano com geometria real. Sem ele, dizer que “o POM parece muito bom” não pode ser refutado. Muito bom em comparação com o quê? Por isso, o spike recebeu um quarto plano, com o mesmo mapa de alturas aplicado a posições de vértices reais. O WebGPU não oferece tesselação por hardware — ela simplesmente não faz parte da especificação, tendo sido removida por compatibilidade com o Metal —, portanto o substituto é um plano densamente subdividido (256×256 segmentos, 131.072 triângulos), com deslocamento de vértices no estágio de vértices. O mesmo uniform de amplitude controla tanto o POM quanto o plano geométrico, de modo que os dois desaparecem juntos e a comparação permanece justa em qualquer distância.
Com a referência real na tela, as afirmações qualitativas tornaram-se mensuráveis. Em uma órbita de 16° olhando para baixo, o POM e o plano tesselado concordam quanto ao sombreamento interno. Em ângulos rasantes, divergem exatamente onde precisam: o POM fica limitado à borda retangular perfeitamente reta da geometria, enquanto a malha real exibe no horizonte o perfil irregular de picos e vales reais captando a luz. Assim, o “deslizamento” do POM nas bordas agora é comprovadamente intrínseco ao algoritmo, e não um artefato da textura ou da iluminação. Os dois perfis de custo também ficam claros: o POM é limitado por fragmentos — o custo escala com o número de pixels cobertos —, enquanto o plano tesselado é limitado por vértices — o custo escala com a densidade da malha, independentemente da cobertura. Para um chunk de terreno, que já paga o custo de vértices de um plano controlado por mapa de alturas, o POM é a solução correta para detalhes menores que a malha.
O plano de referência também revelou um bug sutil de UX. O usuário percebeu que a superfície parecia afundar à medida que a amplitude aumentava. A causa era a convenção da Unity, que trata o plano geométrico como o topo do campo de alturas; assim, os picos permanecem alinhados ao plano e todo o resto sofre paralaxe para baixo, arrastando a superfície média para abaixo da referência plana em (1 − mean_h) × amplitude. A correção recentraliza a convenção para que h = 0.5 corresponda ao plano: os picos se elevam em direção à câmera e os vales recuam. O algoritmo funciona exatamente como prescrito pela Unity; o spike apenas pós-processa a saída com metade de um deslocamento para corresponder ao que “amplitude” deveria significar para uma pessoa movendo um controle deslizante.
O plano de referência resolveu ainda outra questão. Um controle deslizante de “Passos” parecia não fazer nada, o que dava a impressão de um bug de integração, mas não era. O refinamento secante de três iterações após a busca linear é tão eficaz — o artigo de Tatarchuk sobre POM, de 2006, observa que uma busca de 4 passos seguida de 3 passos secantes é visualmente indistinguível de uma busca de 64 passos — que, em um mapa de alturas suave, qualquer quantidade de passos entre 4 e 64 converge para a mesma UV subpixel. A correção foi um toggle, não uma nova integração: desative o método secante e o controle de passos passa a ser o único responsável pela precisão da interseção; assim, reduzir para 4 produz degraus visíveis nos paralelepípedos, enquanto aumentar para 64 suaviza tudo novamente. O toggle é um uniform 0/1 que usa mix para transformar cada atualização de estado do método secante em uma operação neutra quando está desativado. Assim, alterná-lo nunca recompila o material nem provoca engasgos.
Tecnologia mencionada neste capítulo
Mapeamento de oclusão por paralaxe em TSL. O POM faz ray marching da direção de visão através de um campo de alturas no espaço tangente, encontra a primeira camada em que o raio desce abaixo da superfície e refina a interseção, produzindo a profundidade de rejunte rebaixado em um quad plano sem geometria adicional. Um mix(baseUV, refined, done) final torna o material idêntico bit a bit ao plano quando nenhum fragmento intercepta a superfície, permitindo que a atenuação de amplitude do LOD por distância reduza o custo ao de uma textura plana de longe. Consulte materiais de terreno.
Loops sem ramificações para o fluxo de controle do WebGPU. No Three.js r184, o If(...).and(...) do TSL pode compilar para WGSL com um corpo de loop que nunca é executado, e o Break() independente não está disponível. O padrão portátil usa uma amostra de textura incondicional por iteração — mantendo o acesso à textura em fluxo de controle uniforme, conforme a especificação WGSL — e um sinalizador done armazenado como float, que usa mix para transformar cada atualização de estado em uma operação neutra depois de ativado. Um helper step construído com sign(x + ε) evita a coerção pouco confiável de booleano para float. O custo é um número máximo constante de iterações, independentemente do ponto de saída antecipada, a troca correta na escala de fragmentos.
Port literal de shaders. O port de um shader comprovadamente funcional de outro motor deve primeiro ser feito linha por linha, usando os nomes de variáveis originais, e só depois refatorado para o estilo local. Misturar duas referências — o PerPixelDisplacement.hlsl da Unity e o tutorial do LearnOpenGL — produziu um híbrido com uma referência inicial do raio defasada por um passo, um sinal de deslocamento invertido e uma fórmula de refinamento cujo clamp mascarava pesos fora do intervalo como descontinuidades espaciais. Uma única referência canônica, não uma nova derivação.
Referência real com vértices deslocados. Sem tesselação por hardware no WebGPU, um plano densamente subdividido (256² segmentos), deslocado no estágio de vértices, funciona como geometria real para validar uma simulação no estágio de fragmentos. Controlar ambos com o mesmo uniform de amplitude mantém a comparação justa em qualquer distância. O POM é limitado por fragmentos — escala com os pixels cobertos —, enquanto o plano geométrico é limitado por vértices — escala com a densidade da malha. Por isso, eles divergem exatamente nas bordas da silhueta, provando que o deslizamento do POM nas bordas é intrínseco, não um artefato.
Parte 20 de 29. Anterior: Parte 19 — O impostor que precisa sobreviver a uma floresta Próxima: Parte 21 — Um renderizador mais rápido que não era mais rápido Guia da série: /pt-BR/blog/2026-02-25-open-world-browser-series-guide