Skip to content

Creando un mundo abierto en el navegador, parte 21: un renderizador más rápido que no lo era

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 20 simuló profundidad superficial sobre un quad plano. Esta parte trata sobre una decisión de arquitectura de renderizado, y es el experimento en el que la respuesta de manual resultó ser incorrecta para nuestro hardware. La pregunta: para renderizar hierba con prueba alfa y densidad cinematográfica bajo una cámara en tercera persona, ¿necesitamos un búfer de visibilidad antes de escalar hasta la densidad necesaria para 200 jugadores? La recomendación habitual es un sí rotundo. Lo implementamos, lo medimos y la respuesta fue no.

La técnica que todo el mundo recomienda

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

Un búfer de visibilidad divide el renderizado en dos pasadas. La pasada 1 rasteriza la geometría y escribe únicamente los identificadores de triángulo e instancia en un destino entero compacto, además de la profundidad, sin realizar ningún sombreado. La pasada 2 es una pasada a pantalla completa que lee los identificadores de cada píxel cubierto, vuelve a obtener los vértices de ese triángulo, reconstruye los atributos interpolados y sombrea cada píxel visible exactamente una vez. La propuesta es una eliminación perfecta del sobredibujado: la prueba de profundidad se ejecuta sobre fragmentos que no han realizado ningún trabajo de sombreado, por lo que el material costoso solo se ejecuta sobre aquello que realmente se ve.

El experimento ejecuta dos rutas en un mismo lienzo y dispositivo, de modo que la única variable sea dónde se realiza el sombreado. La ruta forward usa un MeshStandardNodeMaterial normal mediante three.js. La ruta del búfer de visibilidad es un pipeline WebGPU de dos pasadas sin abstracciones que se ejecuta fuera de three.js, lee la textura de hierba de three.js directamente desde el backend, escribe (instanceId, triId) en un destino RG32Uint durante la pasada 1 y resuelve la iluminación en la pasada 2. Ambas comparten una única configuración de iluminación de referencia.

Hay dos notas de implementación que merece la pena conservar. WebGPU todavía no dispone de una variable integrada primitive_index portable en los shaders de fragmentos, así que el truco consiste en incorporar un identificador de triángulo por vértice en una geometría no indexada y leerlo con interpolación flat. Esto triplica el número de vértices, pero resulta insignificante en una tarjeta de hierba de 12 vértices. Además, compartir el lienzo con el renderizador de three.js apenas causa problemas siempre que nunca se reconfigure el contexto ni se modifiquen las dimensiones del lienzo, pues three.js controla ambas cosas. Medir los tiempos de la ruta forward fue la parte más delicada, ya que three.js no ofrece ningún punto de extensión para inyectar consultas de marcas de tiempo de la GPU dentro de su pasada de renderizado. La solución alternativa consiste en delimitar su trabajo con dos pasadas de marcas de tiempo sin operaciones, enviadas antes y después, que la GPU ejecuta en el orden de envío.

Los números van en la dirección equivocada

En un Mac de la serie M a unos 1080p, con briznas de hierba formadas por tarjetas cruzadas sobre un campo de 80 m:

Con 50.000 instancias, la ruta del búfer de visibilidad ganó por un 25 %: 4,13 ms frente a los 5,51 ms de la ruta forward. Con 100.000 quedaron empatadas. Con 200.000 instancias, la ruta forward ganó por un 44 %: 5,44 ms frente a los 7,80 ms del búfer de visibilidad. La ruta del búfer de visibilidad se vuelve relativamente peor a medida que aumenta la densidad, justo lo contrario de la creencia popular según la cual destaca precisamente cuando el sobredibujado es intenso.

Por qué la ruta forward mantiene el rendimiento

Las GPU Apple Silicon son renderizadores diferidos basados en mosaicos, y eso cambia todo el cálculo. El sombreado forward en un TBDR cuenta con una etapa de eliminación de superficies ocultas que se ejecuta antes del shader de fragmentos: el rasterizador reúne todos los fragmentos que corresponden a un mosaico, los ordena por profundidad y solo los supervivientes —después de la prueba alfa— llegan al shader de fragmentos. Por tanto, la ruta forward ya obtiene gratis, dentro del propio hardware, la mayor parte de la promesa del búfer de visibilidad de «sombrear una vez por píxel». A medida que las briznas llenan la pantalla, la eliminación de superficies ocultas descarta más fragmentos antes de ejecutar cualquier sombreado, y el coste efectivo por píxel de la ruta forward se mantiene aproximadamente constante en lugar de crecer con el sobredibujado.

La pasada 1 de la ruta del búfer de visibilidad obtiene esa misma ventaja del TBDR. El problema está por completo en la pasada 2. Esta lee la matriz de instancia de cada píxel desde un búfer que, con 200.000 instancias, ocupa 12,8 MB, mucho más que cualquier caché de GPU. Los píxeles adyacentes en pantalla suelen pertenecer a instancias de hierba diferentes —la distribución usa una cuadrícula con desplazamientos aleatorios, por lo que las briznas vecinas tienen identificadores de instancia arbitrarios—, así que cada wave que accede a ese búfer provoca fallos de caché divergentes. Ese acceso aleatorio e incoherente consume por sí solo unos 4 ms por fotograma. La ruta forward lo evita por completo porque la matriz de instancia llega junto con el vértice mediante la ruta de atributos por instancia. Por ello, cuando se ejecuta el shader de fragmentos, los datos de vértices transformados ya están en registros locales del mosaico y no es necesaria ninguna lectura aleatoria a escala de megabytes.

Este es exactamente el coste que la pasada de clasificación de materiales de Nanite pretende amortizar: agrupa los píxeles por instancia y lanza waves de cómputo ordenadas para que las lecturas de cada wave sean coherentes. Nosotros no contamos con eso. Un cálculo aproximado indica que ordenar los píxeles por instancia reduciría esos 4 ms quizá hasta 1,5 o 2 ms y desplazaría el punto de cruce hasta las 400.000 o 500.000 instancias. Pero eso supone apilar optimizaciones sobre una arquitectura que, para empezar, aquí no está ganando.

La conclusión sincera y la auditoría que la respalda

Para follaje de tarjetas cruzadas con prueba alfa en WebGPU sobre Apple Silicon, la ruta forward con el pipeline TSL de three.js ya iguala o mejora el coste del búfer de visibilidad. Toda la infraestructura de este búfer no aporta ninguna ventaja visible hasta superar ampliamente las 200.000 instancias, y solo si además se añade una pasada de ordenación o agrupamiento. La decisión práctica para el motor de producción es mantener la combinación de renderizado forward, LOD e impostores de los experimentos anteriores y no invertir en infraestructura de búfer de visibilidad hasta que las GPU discretas de NVIDIA o AMD sean nuestro objetivo de despliegue dominante —donde el coste del sobredibujado es más lineal— o adoptemos una arquitectura de meshlets, en la que el búfer de visibilidad ya constituye la salida natural.

Como el resultado es contrario a la intuición, la conclusión solo tiene valor si la comparación es justa, así que sometimos el experimento a una auditoría completa. Aparecieron y se corrigieron varios errores reales: un control deslizante de escala de las briznas que desincronizaba silenciosamente ambas rutas; la mitad de las briznas forward, que se renderizaban casi negras debido a normales antiparalelas —corregido con el truco habitual de orientar hacia arriba las normales del follaje—; y el búfer de visibilidad, que se veía aproximadamente el doble de brillante por usar un factor Lambert elegido manualmente en lugar del 1/π que conserva la energía, un término de iluminación ambiental fijo y la ausencia de mapeo de tonos. La corrección copió en WGSL la curva fílmica ACES exacta de three.js y lee en cada fotograma los colores e intensidades de las luces reales de la escena. La única diferencia conocida que permanece, la falta de especular directo en la pasada 2, sesga la comparación a favor del búfer de visibilidad. Es decir, la ruta forward realiza estrictamente más trabajo por píxel y aun así gana a alta densidad. Esto hace que la conclusión principal sea conservadora, no optimista. La única salvedad sigue vigente: todo esto es específico de la serie M, y el punto de cruce bien podría invertirse en una GPU discreta, por lo que conviene repetir las pruebas antes de adoptar esta combinación para objetivos que no sean de Apple.

Tecnología mencionada en este capítulo

Renderizado mediante búfer de visibilidad. La pasada 1 rasteriza la geometría y escribe únicamente los identificadores de triángulo e instancia, además de la profundidad, sin realizar ningún sombreado. La pasada 2 es una resolución a pantalla completa que lee los identificadores de cada píxel cubierto, vuelve a obtener el triángulo de origen, reconstruye los atributos baricéntricos con corrección de perspectiva y sombrea cada píxel visible una vez. Como WebGPU carece de un primitive_index de fragmento portable, el identificador del triángulo se incorpora como atributo por vértice con interpolación flat en una geometría no indexada.

Eliminación de superficies ocultas en TBDR frente a resolución diferida. En una GPU diferida basada en mosaicos —como Apple Silicon—, el sombreado forward ya descarta los fragmentos ocluidos antes de ejecutar el shader de fragmentos. Por ello, obtiene gratis la mayor parte de la ventaja del búfer de visibilidad de sombrear una sola vez, y su coste por píxel se mantiene aproximadamente constante a medida que aumenta el sobredibujado. En cambio, una pasada de resolución del búfer de visibilidad paga el coste del acceso aleatorio incoherente a un gran búfer por instancia —12,8 MB con 200.000 instancias—, que domina a alta densidad a menos que primero se ordenen o agrupen los píxeles por instancia, como hace la clasificación de materiales de Nanite.

Compartir un lienzo con el WebGPURenderer de three.js. Los búferes de comandos WebGPU sin abstracciones se intercalan correctamente con los envíos de three.js en la cola compartida, siempre que nunca se vuelva a llamar a context.configure() ni se escriba en canvas.width/height, pues el renderizador controla ambas cosas. Los tiempos de GPU de la ruta forward, para los que three.js no ofrece ningún punto de extensión, pueden delimitarse mediante dos pasadas de renderizado de marcas de tiempo sin operaciones enviadas alrededor de su llamada de renderizado, ya que la GPU ejecuta los búferes de comandos en el orden de envío.

Validación de un benchmark contrario a la intuición. Un resultado de rendimiento sorprendente solo es tan fiable como la equidad de la comparación. Auditar ambas rutas para garantizar contenido y sombreado de escena idénticos —el mismo mapeo de tonos ACES, Lambert con conservación de energía, luces leídas de los mismos objetos y escala idéntica de las briznas— fue lo que convirtió «el búfer de visibilidad es más lento» de un probable artefacto de medición en una conclusión defendible, con la única asimetría restante sesgada en la dirección conservadora.


Parte 21 de 29. Anterior: Parte 20 - Simular profundidad en un plano Siguiente: Parte 22 - Nubes que puedes atravesar volando y un culling que merece la pena Guía de la serie: /es/blog/2026-02-25-open-world-browser-series-guide