Skip to content

Construir un mundo abierto en el navegador, parte 20: Simular profundidad en un plano

Por Oleg Sidorkin, CTO y cofundador de Cinevva

¿Acabas de llegar? Consulta la guía de la serie. Explica qué es un experimento y enlaza todas las partes.

La parte 19 usó un quad plano para simular un árbol entero a distancia. Esta parte usa un quad plano para simular profundidad de cerca: mapeado de oclusión por paralaje, la técnica que hace que una calzada de adoquines parezca tener juntas hundidas 5 cm sin emplear ni un solo vértice adicional. El objetivo era llevarla a la pila de producción (Three.js r184, WebGPU y TSL), para que los materiales de detalle del terreno puedan ofrecer esa ilusión de profundidad donde importa y pagar el coste de una textura plana en el resto.

Tres formas de simular profundidad, una junto a otra

Abrir el experimento 39 en una pestaña nueva ↗ · Ver el código fuente

El experimento coloca tres planos de 5×5 m uno junto a otro, todos con la misma estructura de material y diferenciados únicamente por las UV que alimentan los muestreadores. El modo plano muestrea la textura directamente y sirve como referencia de base. El paralaje de una sola muestra desplaza las UV una vez a lo largo de la dirección de visión según la altura de ese punto; es barato y aceptable con amplitudes bajas, pero se desliza en ángulos rasantes. POM hace avanzar un rayo por pasos en el espacio tangente: recorre el rayo de visión, encuentra la primera capa donde el rayo pasa por debajo del campo de alturas y refina la intersección. El espacio tangente sigue siendo sencillo porque todos los planos de prueba están alineados con los ejes, de modo que la dirección de visión puede transformarse al espacio tangente con un par de inversiones de signo en lugar de una matriz TBN completa por vértice. El conjunto de texturas se obtiene en directo desde la API de archivos de Polyhaven, la misma vía que usó la búsqueda de modelos de la parte 17.

Dos obstáculos de WebGPU y un trazado de rayos sin bifurcaciones

El bucle POM de manual abandona la búsqueda al encontrar la primera intersección. En r184 eso no funciona, por dos motivos distintos. If(...).and(...) compilaba sin errores, pero producía WGSL en el que el cuerpo del bucle nunca se ejecutaba, por lo que el refinamiento posterior al bucle operaba sobre basura y el plano se renderizaba casi blanco. Además, Break() como nodo independiente aún no estaba incluido en la compilación r184, así que ni siquiera con un If funcional había forma de expresar «detenerse en la primera intersección». Ambos problemas se remontan a incidencias conocidas de three.js relacionadas con una optimización excesiva del flujo de control de TSL a través de los límites de If y Loop en esta serie de versiones.

La reescritura no usa bifurcaciones. Cada iteración muestrea la textura de forma incondicional, lo que mantiene el acceso a texturas dentro del flujo de control uniforme exigido por la especificación de WGSL, y después incorpora el nuevo estado mediante una bandera done almacenada como un valor flotante. Una vez que done cambia a 1, las llamadas a mix de cada iteración se reducen a «mantener el estado sin cambios», el equivalente sin bifurcaciones de una interrupción. La bandera done se construye con una función auxiliar step implementada como 0.5 + 0.5 × sign(x + ε), porque la conversión de booleanos a valores flotantes ha sido irregular en la serie r18x y sign() resulta fiable en todas partes. El coste es que cada fragmento ejecuta las 64 iteraciones, independientemente de dónde se produzca realmente la intersección, pero es la compensación adecuada a escala de fragmento: el tiempo de ejecución ya limita el número máximo de pasos y una GPU real también ejecutaría especulativamente más allá de un break «real». Aquí es importante contar con una alternativa limpia: un mix(baseUV, refined, done) final, de modo que, con amplitud cero —el extremo lejano de la transición por distancia—, ningún fragmento interseque, done permanezca en 0 y el material POM sea idéntico bit a bit al plano. Ese es precisamente el objetivo del truco de LOD por distancia: reducirse al coste del modo plano cuando el efecto ya es menor que un píxel.

El error fue un fallo de disciplina, no de matemáticas

La versión sin bifurcaciones funcionaba, pero se veía distorsionada: presentaba artefactos horizontales alargados con una amplitud moderada y un resultado sutilmente incorrecto y poco nítido con amplitudes bajas. La solución surgió de una instrucción de una sola línea: ve a leer la referencia canónica. El tutorial de LlamAcademy que inspiró esto no es más que un nodo de ShaderGraph de Unity, así que la implementación real se encuentra en PerPixelDisplacement.hlsl de Unity. Leerlo línea por línea reveló tres diferencias semánticas que había introducido sin darme cuenta: un desfase de uno en la referencia inicial de altura del rayo —Unity realiza un avance inicial antes del bucle, por lo que mi marco de referencia estaba desfasado un paso entero y situaba las intersecciones en la capa equivocada aproximadamente la mitad de las veces—, una convención de signo para el desplazamiento máximo de la que depende el paso de refinamiento y una elección entre contabilizar el desplazamiento acumulado o las UV acumuladas que complicaba mis cálculos de refinamiento y enredaba el signo.

La causa fundamental no fue un único error, sino mezclar dos referencias. Había tomado como guía el tutorial de POM de LearnOpenGL, que usa convenciones de signo parecidas pero diferentes y una fórmula de refinamiento distinta, y acabé en un estado híbrido donde dos tercios de las matemáticas coincidían con una fuente y el tercio restante con la otra. La reescritura es un portado casi literal del HLSL de Unity a TSL: mismos nombres de variables, mismo avance inicial y mismo refinamiento, conservando por encima la bandera done sin bifurcaciones. Merece la pena recordar la lección: al portar un shader conocido y fiable desde otra pila, pórtalo primero línea por línea y con los mismos nombres; después, refactorízalo según el estilo local. No vuelvas a deducirlo usando una segunda referencia a mitad del portado.

Un plano de referencia que no puede mentir

A la comparación en paralelo le faltaba lo más evidente: un plano con geometría real. Sin él, afirmar que «POM se ve bastante bien» no se puede refutar. ¿Bastante bien comparado con qué? Por eso el experimento añadió un cuarto plano con el mismo mapa de alturas aplicado a las posiciones reales de los vértices. WebGPU no dispone de teselación por hardware —sencillamente no forma parte de la especificación, pues se eliminó por compatibilidad con Metal—, así que el sustituto es un plano densamente subdividido (256×256 segmentos, 131.072 triángulos) con desplazamiento de vértices en la etapa de vértices. El mismo uniforme de amplitud controla tanto POM como el plano geométrico, por lo que ambos se desvanecen a la vez y la comparación sigue siendo equivalente a cualquier distancia.

Con la referencia real en pantalla, las afirmaciones cualitativas pasaron a ser medibles. En una órbita de 16° mirando hacia abajo, POM y el plano teselado coinciden en el sombreado interno. En ángulos rasantes divergen exactamente donde deben hacerlo: POM queda limitado por el borde rectangular perfectamente recto de la geometría, mientras que la malla real muestra un perfil irregular en el horizonte, con picos y valles auténticos que reciben la luz. Así, el «deslizamiento» de los bordes de POM queda demostrado como algo intrínseco al algoritmo, no como un artefacto de la textura o la iluminación. Los dos perfiles de coste también quedan claros: POM está limitado por los fragmentos —el coste crece con los píxeles cubiertos—, mientras que el plano teselado está limitado por los vértices —el coste crece con la densidad de la malla sin importar la cobertura—. Para un bloque de terreno, que ya paga el coste de vértices de un plano controlado por un mapa de alturas, POM es la solución adecuada para el detalle por debajo de la escala de la malla.

El plano de referencia también detectó un error sutil de experiencia de usuario. El usuario observó que la superficie parecía hundirse al aumentar la amplitud. La causa era la convención de Unity, que trata el plano geométrico como la parte superior del campo de alturas, de modo que los picos permanecen anclados al mismo nivel y todo lo demás se desplaza hacia abajo por paralaje, arrastrando la superficie media por debajo de la referencia plana en (1 − mean_h) × amplitude. La solución vuelve a centrar la convención para que h = 0.5 corresponda al plano: los picos ascienden hacia la cámara y los valles se hunden. El algoritmo se ejecuta exactamente como prescribe Unity; el experimento simplemente posprocesa el resultado con medio desplazamiento para ajustarlo a lo que «amplitud» debería significar para una persona que mueve un control deslizante.

El plano de referencia aclaró una cuestión más. Un control deslizante de «Pasos» parecía no hacer nada, lo que daba la impresión de ser un error de conexión, pero no lo era. El refinamiento por secante de tres iteraciones posterior a la búsqueda lineal es tan bueno —el artículo de Tatarchuk de 2006 sobre POM señala que una búsqueda de 4 pasos más 3 pasos de secante resulta visualmente indistinguible de una búsqueda de 64 pasos— que, en un mapa de alturas suave, cualquier cantidad de pasos entre 4 y 64 converge a las mismas UV con precisión inferior a un téxel. La solución fue añadir un interruptor, no rehacer las conexiones: al desactivar la secante, el control de pasos se convierte en el único ajuste de la precisión de la intersección, por lo que reducirlo a 4 produce escalones visibles en los adoquines y aumentarlo a 64 vuelve a suavizarlos. El interruptor es un uniforme 0/1 que usa mix para convertir cada actualización de estado de la secante en una operación nula cuando está desactivado, por lo que alternarlo nunca reconstruye el material ni provoca tirones.

Tecnología mencionada en este capítulo

Mapeado de oclusión por paralaje en TSL. POM hace avanzar un rayo por la dirección de visión a través de un campo de alturas en el espacio tangente, encuentra la primera capa donde el rayo pasa por debajo de la superficie y refina la intersección, produciendo la profundidad de unas juntas hundidas sobre un quad plano sin geometría adicional. Un mix(baseUV, refined, done) final hace que el material sea idéntico bit a bit al modo plano cuando ningún fragmento interseca, lo que permite que la atenuación de amplitud del LOD por distancia reduzca el coste al de una textura plana en la lejanía. Consulta materiales de terreno.

Bucles sin bifurcaciones para el flujo de control de WebGPU. En Three.js r184, If(...).and(...) de TSL puede compilarse a WGSL con un cuerpo de bucle que nunca se ejecuta, y Break() como nodo independiente no está disponible. El patrón portable consiste en realizar una muestra de textura incondicional por iteración —manteniendo el acceso a texturas dentro de un flujo de control uniforme, conforme a la especificación de WGSL— y usar una bandera done almacenada como valor flotante que, mediante mix, convierte cada actualización de estado en una operación nula una vez activada. Una función auxiliar step construida a partir de sign(x + ε) evita la conversión poco fiable de booleanos a valores flotantes. El coste mantiene constante el número máximo de iteraciones, independientemente del punto de salida anticipada, la compensación correcta a escala de fragmento.

Portado literal de shaders. El portado de un shader conocido y fiable desde otro motor debe hacerse primero línea por línea y con los nombres de variables originales; la adaptación al estilo local viene después. Mezclar dos referencias —PerPixelDisplacement.hlsl de Unity y el tutorial de LearnOpenGL— produjo un híbrido con un desfase de uno en la referencia inicial del rayo, un signo de desplazamiento invertido y una fórmula de refinamiento cuyo límite ocultaba los pesos fuera de rango en forma de discontinuidades espaciales. Una única referencia canónica, no una nueva deducción.

Referencia real con vértices desplazados. Como WebGPU no dispone de teselación por hardware, un plano densamente subdividido —256² segmentos— y desplazado en la etapa de vértices sirve como geometría real para validar una simulación ejecutada en la etapa de fragmentos. Controlar ambos mediante el mismo uniforme de amplitud mantiene una comparación rigurosa a cualquier distancia. POM está limitado por los fragmentos —escala con los píxeles cubiertos— y el plano geométrico está limitado por los vértices —escala con la densidad de la malla—, por lo que divergen precisamente en los bordes de la silueta, demostrando que el deslizamiento de los bordes de POM es intrínseco y no un artefacto.


Parte 20 de 29. Anterior: Parte 19 - El impostor que debe sobrevivir a un bosque Siguiente: Parte 21 - Un renderizador más rápido que no era más rápido Guía de la serie: /es/blog/2026-02-25-open-world-browser-series-guide