GPU Lightmapper: Una inmersión técnica

FLORENT GUINIER / UNITY TECHNOLOGIESContributor
May 20, 2019|15 minutos
GPU Lightmapper: Una inmersión técnica
Para tu comodidad, tradujimos esta página mediante traducción automática. No podemos garantizar la precisión ni la confiabilidad del contenido traducido. Si tienes alguna duda sobre la precisión del contenido traducido, consulta la versión oficial en inglés de la página web.

El Equipo de Iluminación está apostando fuerte por la velocidad de iteración. Hemos diseñado Progressive Lightmapper con ese objetivo en mente. Nuestro objetivo es proporcionarle información rápida sobre cualquier cambio que realice en la iluminación de su proyecto. En 2018.3 introdujimos una vista previa de la versión para GPU de Progressive Lightmapper. Ahora nos dirigimos hacia la paridad de prestaciones y calidad visual con su hermano de CPU. Nuestro objetivo es que la versión para GPU sea un orden de magnitud más rápida que la versión para CPU. Esto aporta un lightmapping interactivo a los flujos de trabajo artísticos, con grandes mejoras para la productividad del equipo.

Con esto en mente, hemos optado por utilizar RadeonRays: una biblioteca de trazado de rayos de código abierto de AMD. Unity y AMD han colaborado en el GPU Lightmapper para implementar varias funciones y optimizaciones clave. A saber: muestreo de potencia, compactación de rayos y recorrido BVH personalizado.

El objetivo de diseño del Lightmapper para GPU era ofrecer las mismas funciones que el Lightmapper para CPU y, al mismo tiempo, lograr un mayor rendimiento:

  • Mapa de luz interactivo e imparcial
  • Paridad de funciones entre CPU y GPU
  • Solución informática
  • Trazado de la trayectoria del frente de onda para obtener el máximo rendimiento

Sabemos que el tiempo de iteración es la clave para que los artistas mejoren la calidad visual y den rienda suelta a su creatividad. El objetivo es el lightmapping interactivo. Además de unos tiempos de cocción impresionantes, queremos que la experiencia del usuario ofrezca una respuesta inmediata.

Necesitábamos resolver un montón de problemas interesantes para conseguirlo. En este artículo analizaremos algunas de las decisiones que hemos tomado.

Retroalimentación progresiva

Para que Lightmapper ofreciera actualizaciones progresivas al usuario, tuvimos que tomar algunas decisiones de diseño.

Sin datos precalculados o almacenados en caché

No almacenamos en caché la irradiancia ni la visibilidad cuando hacemos iluminación directa (la iluminación directa podría almacenarse en caché y reutilizarse para la iluminación indirecta). En general, no almacenamos ningún dato en caché y preferimos pasos de cálculo que sean lo suficientemente pequeños como para no crear atascos y ofrecer una visualización progresiva e interactiva mientras se hornea.

Imagen

Las escenas pueden ser muy grandes y contener muchos mapas de luz. Para garantizar que el trabajo se invierte donde ofrece más beneficios al usuario, es importante centrar la cocción en la zona visible en ese momento. Para ello, primero detectamos cuáles de los lightmaps contienen más texels visibles sin converger en una pantalla, luego renderizamos esos lightmaps y priorizamos los texels visibles (los texels fuera de pantalla se bakearán una vez que todos los visibles hayan convergido).

Un texel se define como visible si se encuentra en el área actual de la cámara y si no está ocluido por ninguna geometría estática de la escena.

Esta selección se realiza en la GPU (para aprovechar la rapidez del trazado de rayos). Este es el flujo de un trabajo de selección.

Imagen

Los trabajos de selección tienen dos salidas:

  • Un búfer de culling map, que almacena si cada texel del lightmap es visible. Los trabajos de renderizado utilizan este búfer de mapa de selección.
  • Un entero que representa el número de texels visibles para el lightmap actual. Este entero será leído asíncronamente por la CPU para ajustar la programación del mapa de luz en el futuro.

En el siguiente vídeo podemos ver el efecto de la eliminación selectiva. La cocción se detiene a mitad de camino para fines de demostración. Así, cuando la vista de escena se mueve, podemos ver texels aún no bakeados (es decir, negros) que no son visibles desde la posición y dirección iniciales de la cámara.

Por razones de rendimiento, la información de visibilidad sólo se actualiza cada vez que se "estabiliza" el estado de la cámara. Además, no se tiene en cuenta el supermuestreo.

Rendimiento y eficacia

Las GPU están optimizadas para tomar grandes lotes de datos y realizar la misma operación en todos ellos; están optimizadas para el rendimiento. Además, la GPU consigue esta aceleración con mayor eficiencia energética y económica que una CPU multinúcleo. Sin embargo, las GPU no son tan buenas como las CPU en términos de latencia (intencionadamente, por el diseño del hardware). Por eso utilizamos un pipeline basado en datos sin puntos de sincronización CPU-GPU para aprovechar al máximo la naturaleza de cálculo paralelo inherente a la GPU.

Sin embargo, el rendimiento bruto no es suficiente. La experiencia del usuario es lo que importa, y la medimos en impacto visual a lo largo del tiempo, también conocido como índice de convergencia. Así que también necesitamos algoritmos eficientes.

Canalización basada en datos

Las GPU están pensadas para su uso con grandes conjuntos de datos y son capaces de ofrecer un alto rendimiento a costa de la latencia. Además, suelen estar controlados por una cola de comandos que la CPU llena de antemano. El objetivo de ese flujo continuo de comandos grandes es asegurarnos de que podemos saturar la GPU con trabajo. Veamos las recetas clave que estamos utilizando para maximizar el rendimiento y, por tanto, el rendimiento bruto.

Nuestros proyectos

La forma en que enfocamos la canalización de datos de lightmapping en la GPU se basa en los siguientes principios:

1. Preparamos los datos una vez.

En este punto, la CPU y la GPU podrían estar sincronizadas para reducir la asignación de memoria.

2. Una vez iniciada la cocción, no se permiten puntos de sincronización CPU-GPU.

La CPU envía una carga de trabajo predefinida a la GPU. Esta carga de trabajo será excesivamente conservadora en algunos casos (por ejemplo, utilizando 4 rebotes pero todos los rayos indirectos terminados después del 2º rebote, entonces todavía tenemos núcleos en cola que se ejecutarán pero antes de tiempo).

3. La GPU no puede generar rayos ni núcleos.

Más bien se le puede pedir que procese trabajos vacíos (o muy pequeños). Para gestionar estos casos con eficacia, los núcleos se escriben de forma que se maximice la coherencia entre datos e instrucciones. Nos ocupamos de ello mediante la "compactación" de datos, de la que hablaremos más adelante.

4. No queremos ningún punto de sincronización CPU-GPU, ni ningún tipo de burbujas en la GPU una vez iniciada la cocción.

Por ejemplo, algunos comandos OpenCL pueden crear pequeñas burbujas en la GPU (es decir, momentos en los que la GPU no tiene nada que procesar), como clEnqueueFillBuffer o clEnqueueReadBuffer (incluso en las versiones asíncronas), por lo que los evitamos en la medida de lo posible. Además, el procesamiento de los datos debe permanecer en la GPU el mayor tiempo posible (es decir, el renderizado y la composición hasta su finalización). Cuando necesitemos devolver los datos a la CPU para procesarlos de nuevo, lo haremos de forma asíncrona y no los enviaremos de nuevo a la GPU. Por ejemplo, la costura es actualmente un postproceso de la CPU.

5. La CPU adaptará la carga de la GPU de forma asíncrona.

Cambiar el lightmap que se está renderizando cuando cambia la vista de la cámara o cuando un lightmap está totalmente convergente incurrirá en cierta latencia. Los hilos de la CPU generan y gestionan estos eventos de lectura utilizando una cola sin bloqueo para evitar la contención de mutex.

Imagen
Tamaño del trabajo compatible con GPU

Una de las principales características de la arquitectura de la GPU es el amplio soporte de instrucciones SIMD. SIMD son las siglas de Single Instruction Multiple Data. Un conjunto de instrucciones se ejecutará secuencialmente en lockstep sobre una determinada cantidad de datos dentro de lo que se denomina un warp/wavefront. El tamaño de esos frentes de onda/enanas es de 64, 32 o 16 valores (dependiendo de la arquitectura de la GPU). Por lo tanto, una única instrucción aplicará la misma transformación a múltiples datos: una única instrucción, múltiples datos. Sin embargo, para una mayor flexibilidad, la GPU también es capaz de admitir rutas de código divergentes en su implementación SIMD. Para ello, puede desactivar algunos hilos mientras trabaja en un subconjunto antes de volver a unirse. Esto se llama SIMT: Hilos múltiples de una sola instrucción. Sin embargo, esto tiene un coste, ya que las rutas de código divergentes dentro de un frente de onda/warp sólo se beneficiarán de una fracción de la unidad SIMD. Lea esta excelente entrada de blog para obtener más información.

Por último, una buena extensión de la idea de SIMT es la capacidad de la GPU para mantener alrededor de muchos warps/frentes de onda por núcleo SIMD. Si un frente de onda/warp está esperando un acceso lento a la memoria, el planificador puede cambiar a otro frente de onda/warp y seguir trabajando en él mientras tanto (siempre que haya suficiente trabajo pendiente). Sin embargo, para que esto funcione realmente, la cantidad de recursos necesarios por contexto tiene que ser baja, para que la ocupación (la cantidad de trabajo pendiente) pueda ser alta.

En resumen, deberíamos aspirar a:

  • Muchos hilos en vuelo
  • Evitar ramas divergentes
  • Buena ocupación

Tener una buena ocupación tiene que ver con el código del núcleo y es un tema demasiado amplio para formar parte de esta entrada del blog. Aquí tienes algunos recursos estupendos:

En general, el objetivo es utilizar los recursos locales de forma dispersa, especialmente los registros vectoriales y la memoria compartida local.

Echemos un vistazo a lo que podría ser el flujo para hornear iluminación directa en la GPU. Esta sección cubre principalmente los mapas de luz, sin embargo, las sondas de luz funcionan de una manera muy similar, excepto que no tienen datos de visibilidad u ocupación.

Imagen

Aquí hay algunos problemas:

  • La ocupación del mapa de luz en ese ejemplo es del 44% (4 texels ocupados sobre 9), por lo que sólo el 44% de los hilos de la GPU producirán realmente trabajo utilizable. Además, los datos útiles son escasos en la memoria, por lo que pagaremos por el ancho de banda incluso por los texels desocupados. En la práctica, la ocupación de los mapas de iluminación suele oscilar entre el 50% y el 70%, lo que supone una enorme ganancia potencial.
  • El conjunto de datos es demasiado pequeño. El ejemplo muestra un mapa de luz de 3x3 para simplificar, pero incluso el caso común de un mapa de luz de 512x512 será un conjunto de datos demasiado pequeño para que las GPU recientes alcancen la máxima eficiencia.
  • En una sección anterior, hablamos sobre la priorización de vistas y el trabajo de selección. Los dos puntos anteriores son aún más ciertos ya que algunos texels ocupados no serán bakeados porque no son visibles actualmente en la vista de Escena, reduciendo la ocupación y el conjunto de datos en general aún más.

¿Cómo lo resolvemos? Como parte de una colaboración con AMD, se añadió la compactación de rayos. La idea mejora enormemente el rendimiento tanto del trazado de rayos como del sombreado. En resumen, la idea es crear todas las definiciones de rayos en memoria contigua permitiendo que todos los hilos de una urdimbre/frente de onda trabajen con datos calientes.

En la práctica también es necesario que cada rayo conozca el índice del texel con el que está relacionado, almacenamos esto en la carga útil del rayo. Además, almacenamos el recuento global de rayos compactados.

Este es el flujo con compactación:

Imagen

Los dos núcleos que sombrean y trazan los rayos pueden ejecutarse ahora sólo en memoria caliente y con una divergencia mínima en las rutas del código.

¿Qué sucede después? Bueno, no hemos resuelto el hecho de que el conjunto de datos podría ser demasiado pequeño para la GPU, especialmente si la priorización de vistas está activada. La siguiente idea es decorrelacionar la generación de rayos a partir de la representación gbuffer. Con el enfoque ingenuo, sólo generamos un rayo por texel. Dado que eventualmente querremos generar más rayos de todos modos, también podríamos generar varios rayos por texels por adelantado. De este modo, podemos crear un trabajo más significativo para que la GPU lo mastique. Este es el flujo:

Imagen

Antes de la compactación generamos muchos rayos por texel y llamamos a esto expansión. También generamos metainformación que se utiliza en el paso de recogida para acumular en el texel de destino correcto.

Tanto el núcleo de expansión como el de recogida no se ejecutan con mucha frecuencia. En la práctica, ampliamos y luego sombreamos cada luz (para las directas) o procesamos todos los rebotes (para las indirectas), para finalmente reunir una sola vez.

Con estas técnicas conseguimos nuestro objetivo: generamos suficiente trabajo para saturar la GPU y gastamos ancho de banda sólo en los texels que importan.

Estas son las ventajas de disparar varios rayos por texel:

  • El conjunto de rayos activos siempre será un gran conjunto de datos, incluso en el modo de priorización de vistas.
  • La preparación, el trazado y el sombreado trabajan con datos muy coherentes, ya que el núcleo de expansión creará rayos dirigidos al mismo texel en memoria continua.
  • El núcleo de expansión gestiona la ocupación y la visibilidad, lo que hace que el núcleo de preparación sea mucho más sencillo y, por tanto, más rápido.
  • El tamaño de los búferes del conjunto de datos expandido/de trabajo está desacoplado del tamaño del mapa de luz.
  • El número de rayos que disparamos por texel puede ser manejado por cualquier algoritmo, una expansión natural va a ser el muestreo adaptativo.

La iluminación indirecta utiliza ideas muy similares, aunque más complejas:

Imagen

Con la luz indirecta tenemos que realizar múltiples rebotes, cada uno de los cuales puede descartar rayos aleatorios. Así, realizamos la compactación de forma iterativa para seguir trabajando con datos calientes.

La heurística que utilizamos actualmente favorece una cantidad igual de rayos por texel. El objetivo es obtener una salida muy progresiva. Sin embargo, una extensión natural de esto sería mejorar estas heurísticas utilizando un muestreo adaptativo, para así disparar más rayos allí donde los resultados actuales son ruidosos. Además, la heurística podría aspirar a una mayor coherencia, tanto en memoria como en la ejecución de grupos de hilos, siendo consciente del tamaño del frente de onda/warp del hardware.

Transparencia/Translucidez

Activos de ArchVizPRO bakeados con GPU Lightmapper.

La transparencia/translucidez tiene muchas aplicaciones. Una forma común de manejar la transparencia y la translucidez es lanzar un rayo, detectar la intersección, obtener el material y programar un nuevo rayo si el material encontrado es translúcido o transparente. Sin embargo, en nuestro caso, la GPU no puede generar rayos por motivos de rendimiento (consulte la sección anterior "Canalización basada en datos"). Además, no podemos pedir razonablemente a la CPU que programe suficientes rayos con antelación para estar seguros de que manejamos el peor caso posible, ya que esto supondría un importante golpe para el rendimiento.

Así que optamos por una solución híbrida. Tratamos la translucidez y la transparencia de forma diferente, lo que permite resolver los problemas anteriores:

Transparencia (cuando un material no es opaco porque tiene agujeros). En ese caso, el rayo puede atravesar o rebotar en el material en función de una distribución de probabilidades. Así, la carga de trabajo preparada de antemano por la CPU no necesita cambiar, seguimos siendo independientes de la Escena.

Translucidez (cuando un material filtra la luz que lo atraviesa). En ese caso, hacemos una aproximación y no tenemos en cuenta la refracción. En otras palabras, dejamos que el material coloree la luz, pero no cambiamos su dirección. Esto nos permite manejar la translucidez mientras recorremos el BVH, lo que significa que podemos manejar fácilmente un gran número de materiales de recorte y escalar muy bien con la complejidad de la translucidez en la Escena.

Imagen

Sin embargo, hay una peculiaridad; el recorrido BVH está desordenado:

En el caso de los rayos de oclusión, esto está realmente bien ya que sólo estamos interesados en la atenuación de la translucidez de cada triángulo intersecado a lo largo del rayo. Como la multiplicación es conmutativa, el recorrido BVH fuera de orden no es un problema.

Sin embargo, para los rayos de intersección lo que queremos es poder detenernos en un triángulo (de forma probabilística cuando el triángulo es transparente) y recoger la atenuación de translucidez para cada triángulo desde el origen del rayo hasta el punto de impacto. Como el recorrido BVH está fuera de orden, la solución que hemos elegido es ejecutar primero sólo la intersección para encontrar el punto de impacto, y marcar el rayo si se ha alcanzado alguna translucencia. Para cada rayo marcado, generamos así un rayo de oclusión adicional desde el origen del rayo de intersección hasta el golpe del rayo de intersección. Para hacer esto eficientemente usamos compactación cuando generamos los rayos de oclusión, eso significa que uno sólo pagará el costo extra si el rayo de intersección fue marcado como necesitando manejo de translucidez.

Todo ello fue posible gracias a la naturaleza de código abierto de RadeonRays, que fue bifurcado y adaptado a nuestras necesidades como parte de la colaboración con AMD.

Algoritmos eficaces

Hemos visto lo que hacemos en cuanto a rendimiento bruto, ¡genial! Sin embargo, es sólo la primera parte del rompecabezas. Unas muestras por segundo elevadas están muy bien, pero lo que realmente importa, al final, es el tiempo de horneado. En otras palabras, queremos obtener el máximo de cada rayo que lancemos. Esta última afirmación es en realidad la raíz de décadas de investigación en curso. Aquí tienes algunos recursos estupendos:

Trazado de rayos en un fin de semana

Trazado de rayos: La semana siguiente

Trazado de rayos: El resto de tu vida

Unity GPU Lightmapper es un lightmapper difuso puro. Esto simplifica mucho la interacción de la luz con los materiales y también ayuda a amortiguar las luciérnagas y el ruido. Sin embargo, aún podemos hacer mucho para mejorar la tasa de convergencia. Estas son algunas de las técnicas que utilizamos:

Ruleta rusa

En cada rebote, eliminamos probabilísticamente la trayectoria basándonos en el albedo acumulado. Se puede encontrar una gran explicación en la tesis de Eric Veach (página 67).

Entorno Muestreo de Importancia Múltiple (MIS)

Los entornos HDR que presentan una alta varianza pueden causar una cantidad considerable de ruido en la salida, lo que requiere un gran número de muestras para producir resultados agradables. Por lo tanto, aplicamos una combinación de estrategias de muestreo específicamente adaptadas para evaluar el entorno analizándolo primero, identificando las áreas importantes y realizando el muestreo en consecuencia. Este enfoque, que no es exclusivo del muestreo medioambiental, se conoce generalmente como muestreo de importancia múltiple y se propuso inicialmente en la tesis de Eric Veach (página 252). Esto se hizo en colaboración con Unity Labs Grenoble.

Muchas luces

En cada rebote, seleccionamos probabilísticamente una luz directa y limitamos el número de luces que afectan a las superficies con una estructura de cuadrícula espacial. Esto se hizo en colaboración con AMD. Actualmente estamos investigando más a fondo el problema de las muchas luces, ya que el muestreo de selección de luces es fundamental para la calidad.

Imagen

Denoising

El ruido se elimina mediante un eliminador de ruido artificial entrenado con los resultados de un trazador de trayectorias. Consulta la presentación de Unity en la GDC 2019 de Jesper Mortensen.

Para terminar

Hemos visto cómo un pipeline basado en datos, la atención al rendimiento bruto y algoritmos eficientes se combinan para ofrecer una experiencia de lightmapping interactiva con el GPU Lightmapper. Tenga en cuenta que GPU Lightmapper está en desarrollo activo y en constante mejora.

Díganos lo que piensa.

El equipo de iluminación

PS: Si te ha parecido divertido leer esto y estás interesado en aceptar un nuevo reto, actualmente estamos buscando un Desarrollador de iluminación en Copenhague, ¡ponte en contacto con nosotros!

---

¿Quieres aprender a optimizar los gráficos en Unity? Echa un vistazo a este tutorial.