Wolfenstein 3D: trazado de rayos con WebGL1

imagen

Después de la aparición de las tarjetas gráficas Nvidia RTX el verano pasado, el trazado de rayos ha recuperado su antigua popularidad. En los últimos meses, mi feed de Twitter se ha llenado con un sinfín de comparaciones gráficas con RTX habilitado y deshabilitado.

Después de admirar tantas imágenes hermosas, quería intentar combinar el clásico renderizador de avance con un rastreador de rayos por mi cuenta.

Al sufrir un síndrome de rechazo de los desarrollos de otras personas , como resultado, creé mi propio motor de renderizado híbrido basado en WebGL1. Puede jugar con la representación de nivel de demostración de Wolfenstein 3D con las esferas (que utilicé debido al trazado de rayos) aquí .

Prototipo


Comencé este proyecto creando un prototipo, tratando de recrear la iluminación global con el trazado de rayos de Metro Exodus .


El primer prototipo que muestra una iluminación global difusa (Diffuse GI)

El prototipo se basa en un renderizador hacia adelante, que representa toda la geometría de la escena. El sombreador utilizado para rasterizar la geometría no solo calcula la iluminación directa, sino que también emite rayos aleatorios desde la superficie de la geometría renderizada para acumular usando el reflector indirecto de la luz que surge de superficies no brillantes (GI difuso).

En la imagen de arriba puede ver cómo todas las esferas se iluminan correctamente solo con luz indirecta (los rayos de luz se reflejan desde la pared detrás de la cámara). La fuente de luz en sí está cubierta por una pared marrón en el lado izquierdo de la imagen.

Wolfenstein 3D


El prototipo usa una escena muy simple. Tiene solo una fuente de luz y solo se representan unas pocas esferas y cubos. Gracias a esto, el código de trazado de rayos en el sombreador es muy simple. El ciclo de verificación de la fuerza bruta en la intersección en el que se prueba la viga para la intersección con todos los cubos y esferas en la escena aún es lo suficientemente rápido como para que el programa lo ejecute en tiempo real.

Después de crear este prototipo, quería hacer algo más complejo agregando más geometría y muchas fuentes de luz a la escena.

El problema con un entorno más complejo es que todavía necesito poder rastrear rayos en la escena en tiempo real. Por lo general, se usaría una estructura de jerarquía de volumen delimitador (BVH) para acelerar el proceso de trazado de rayos, pero mi decisión de crear este proyecto en WebGL1 no lo permitió: es imposible cargar datos de 16 bits en una textura en WebGL1 y las operaciones binarias no se pueden usar en un sombreador. Esto complica el cálculo preliminar y la aplicación de BVH en sombreadores WebGL1.

Es por eso que decidí usar el nivel de demostración 3D de Wolfenstein para esto. En 2013, creé un sombreador fragmentado de WebGL en Shadertoy que no solo representa niveles similares a Wolfenstein, sino que también crea de manera procesal todas las texturas necesarias. Por mi experiencia trabajando en este sombreador, sabía que el diseño de nivel basado en la cuadrícula de Wolfenstein también se puede usar como una estructura de aceleración rápida y fácil, y que el trazado de rayos en esta estructura será muy rápido.

A continuación se muestra una captura de pantalla de la demostración, y en modo de pantalla completa puedes jugarla aquí: https://reindernijhoff.net/wolfrt .


Breve descripción


La demostración utiliza un motor de renderizado híbrido. Para representar todos los polígonos en el marco, utiliza la rasterización tradicional y luego combina el resultado con sombras, GI difuso y reflejos creados por el trazado de rayos.


Sombras


Gi difusa


Reflexiones

Representación proactiva


Las tarjetas Wolfenstein se pueden codificar completamente en una cuadrícula bidimensional de 64 × 64. El mapa utilizado en la demostración se basa en el primer nivel del episodio 1 de Wolfenstein 3D.

Al inicio, se crea toda la geometría necesaria para pasar el renderizado proactivo. Se genera una malla de muros a partir de los datos del mapa. También crea planos de piso y techo, mallas separadas para luces, puertas y esferas al azar.

Todas las texturas utilizadas para paredes y puertas se empaquetan en un atlas de textura única, por lo que todas las paredes se pueden dibujar en una llamada de sorteo.

Sombras e iluminación


La iluminación directa se calcula en el sombreador utilizado para el pase de renderizado directo. Cada fragmento puede ser iluminado (máximo) por cuatro fuentes diferentes. Para saber qué fuentes pueden influir en el fragmento en el sombreador, cuando se inicia la demostración, la textura de búsqueda se calcula previamente. Esta textura de búsqueda tiene un tamaño de 64 por 128 y codifica las posiciones de las 4 fuentes de luz más cercanas para cada posición en la cuadrícula del mapa.

varying vec3 vWorldPos; varying vec3 vNormal; void main(void) { vec3 ro = vWorldPos; vec3 normal = normalize(vNormal); vec3 light = vec3(0); for (int i=0; i<LIGHTS_ENCODED_IN_MAP; i++) { light += sampleLight(i, ro, normal); } 

Para obtener sombras suaves para cada fragmento y fuente de luz, se muestrea una posición aleatoria en la fuente de luz. Usando el código de trazado de rayos en el sombreador (consulte la sección Trazado de rayos a continuación), se emite un rayo de sombra a un punto de muestreo para determinar la visibilidad de la fuente de luz.

Después de agregar reflexiones (auxiliares) (consulte la sección Reflexión a continuación), se agrega GI difuso al color calculado del fragmento realizando una búsqueda en el Objetivo de renderizado de GI difuso (ver a continuación).

Trazado de rayos


Aunque en el prototipo el código de trazado de rayos para GI difuso se combinó con un sombreador preventivo, en la demostración decidí separarlos.


Los separé haciendo un segundo renderizado de toda la geometría en un objetivo de renderizado separado (Diffuse GI Render Target) usando otro sombreador que solo emite rayos aleatorios para recolectar GI difuso (ver la sección "GI difuso" a continuación). La iluminación recopilada en este objetivo de renderizado se agrega a la iluminación directa calculada en el pasaje de renderizado directo.

Al separar el paso proactivo y el GI difuso, podemos emitir menos de un haz GI difuso por píxel de pantalla. Esto se puede hacer reduciendo la escala de búfer (moviendo el control deslizante en las opciones en la esquina superior derecha de la pantalla).

Por ejemplo, si la escala de búfer es 0,5, solo se emitirá un rayo por cada cuatro píxeles de pantalla. Esto proporciona un gran aumento en la productividad. Usando la misma interfaz de usuario en la esquina superior derecha de la pantalla, también puede cambiar el número de muestras por píxel en el objetivo de representación (SPP) y el número de reflejos del haz.

Emitir un rayo


Para poder emitir rayos en la escena, toda la geometría de nivel debe tener un formato que pueda utilizar el trazador de rayos en el sombreador. La capa Wolfenstein codificó una cuadrícula de 64 × 64, por lo que es bastante fácil codificar todos los datos en una sola textura de 64 × 64:

  • En el canal rojo del color de la textura, todos los objetos ubicados en la celda correspondiente x, y de la cuadrícula del mapa están codificados. Si el valor del canal rojo es cero, entonces no hay objetos en la celda, de lo contrario, está ocupado por una pared (valores del 1 al 64), una puerta, una fuente de luz o una esfera que debe verificarse para la intersección.
  • Si una esfera ocupa una celda de cuadrícula de nivel, entonces se utilizan canales verde, azul y alfa para codificar el radio y las coordenadas relativas x e y de la esfera dentro de la celda de cuadrícula.

Se emite un rayo en una escena atravesando una textura usando el siguiente código:

 bool worldHit(n vec3 ro,in vec3 rd,in float t_min, in float t_max, inout vec3 recPos, inout vec3 recNormal, inout vec3 recColor) { vec3 pos = floor(ro); vec3 ri = 1.0/rd; vec3 rs = sign(rd); vec3 dis = (pos-ro + 0.5 + rs*0.5) * ri; for( int i=0; i<MAXSTEPS; i++ ) { vec3 mm = step(dis.xyz, dis.zyx); dis += mm * rs * ri; pos += mm * rs; vec4 mapType = texture2D(_MapTexture, pos.xz * (1. / 64.)); if (isWall(mapType)) { ... return true; } } return false; } 

Se puede encontrar un código de trazado de rayos de malla similar en este sombreador Wolfenstein en Shadertoy.

Después de calcular el punto de intersección con la pared o la puerta (usando la prueba de intersección con un paralelogramo ), buscar en el mismo atlas de textura que se usó para pasar la representación proactiva nos da puntos de intersección de albedo. Las esferas tienen un color que se determina según el procedimiento en función de sus coordenadas x, y en la cuadrícula y la función de gradiente de color .

Las puertas son un poco más complicadas porque se mueven. Para que la representación de la escena en la CPU (utilizada para renderizar mallas en el pase de renderizado hacia adelante) sea la misma que la representación de la escena en la GPU (utilizada para el trazado de rayos), todas las puertas se mueven de forma automática y determinista, en función de la distancia desde la cámara hasta la puerta.



Gi difusa


La iluminación global dispersa (GI difusa) se calcula emitiendo rayos en el sombreador, que se utiliza para dibujar toda la geometría en el objetivo de renderizado GI difuso. La dirección de estos rayos depende de la normalidad a la superficie, determinada mediante el muestreo del hemisferio ponderado por coseno.

Teniendo la dirección del haz rd y el punto de partida ro , la iluminación reflejada se puede calcular utilizando el siguiente ciclo:

 vec3 getBounceCol(in vec3 ro, in vec3 rd, in vec3 col) { vec3 emitted = vec3(0); vec3 recPos, recNormal, recColor; for (int i=0; i<MAX_RECURSION; i++) { if (worldHit(ro, rd, 0.001, 20., recPos, recNormal, recColor)) { // if (isLightHit) { // direct light sampling code // return vec3(0); // } col *= recColor; for (int i=0; i<2; i++) { emitted += col * sampleLight(i, recPos, recNormal); } } else { return emitted; } rd = cosWeightedRandomHemisphereDirection(recNormal); ro = recPos; } return emitted; } 

Para reducir el ruido, se agrega muestreo de luz directa al bucle. Esto es similar a la técnica utilizada en mi otro sombreador Cornell Box en Shadertoy.

Reflexion


Gracias a la capacidad de trazar la escena con rayos en el sombreador, es muy fácil agregar reflejos. En mi demostración, los reflejos se agregan llamando al mismo método getBounceCol que se muestra arriba usando el haz reflejado de la cámara:

 #ifdef REFLECTION col = mix(col, getReflectionCol(ro, reflect(normalize(vWorldPos - _CamPos), normal), albedo), .15); #endif 

Las reflexiones se agregan en el paso de representación hacia adelante, por lo tanto, un rayo de reflexión siempre emitirá un haz de reflexión.


Anti-aliasing temporal


Dado que ambas sombras suaves en la representación proactiva pasan y la aproximación GI difusa usan aproximadamente una muestra por píxel, el resultado final es extremadamente ruidoso. Para reducir la cantidad de ruido, se usó el anti-aliasing temporal (TAA) basado en el TAA de Playdead: Anti-Aliasing de Reproyección Temporal en el INTERIOR .

Re-proyección


La idea detrás de TAA es bastante simple: TAA calcula un subpíxel por fotograma y luego promedia sus valores con el píxel correspondiente del fotograma anterior.

Para saber dónde estaba el píxel actual en el cuadro anterior, la posición del fragmento se vuelve a proyectar usando la matriz de proyección de vista de modelo del cuadro anterior.

Colocar muestras y limitar vecindarios


En algunos casos, una muestra guardada del pasado no es válida, por ejemplo, cuando la cámara se movió de tal manera que la geometría cerró un fragmento del cuadro actual en el cuadro anterior. Para descartar tales muestras no válidas, se utiliza una restricción de vecindad. Elegí el tipo más simple de restricción:

 vec3 history = texture2D(_History, uvOld ).rgb; for (float x = -1.; x <= 1.; x+=1.) { for (float y = -1.; y <= 1.; y+=1.) { vec3 n = texture2D(_New, vUV + vec2(x,y) / _Resolution).rgb; mx = max(n, mx); mn = min(n, mn); } } vec3 history_clamped = clamp(history, mn, mx); 

También intenté usar el método de restricción basado en el paralelogramo delimitador, pero no vi mucha diferencia con mi solución. Esto probablemente sucedió porque en la escena de la demostración hay muchos colores oscuros idénticos y casi ningún objeto en movimiento.

Vibraciones de la cámara


Para obtener anti-aliasing, la cámara en cada cuadro oscila debido al uso de desplazamiento de subpíxel (pseudo) aleatorio. Esto se implementa cambiando la matriz de proyección:

 this._projectionMatrix[2 * 4 + 0] += (this.getHaltonSequence(frame % 51, 2) - .5) / renderWidth; this._projectionMatrix[2 * 4 + 1] += (this.getHaltonSequence(frame % 41, 3) - .5) / renderHeight; 

El ruido


El ruido es la base de los algoritmos utilizados para calcular el IG difuso y las sombras suaves. El uso de un buen ruido afecta en gran medida la calidad de la imagen, mientras que el mal ruido crea artefactos o ralentiza la convergencia de la imagen.

Me temo que el ruido blanco utilizado en esta demostración no es muy bueno.

Usar buen ruido es probablemente el aspecto más importante para mejorar la calidad de imagen en esta demostración. Por ejemplo, puede usar ruido azul .

Realicé experimentos con ruido basados ​​en la proporción áurea, pero no tuvieron éxito. Hasta ahora, se utiliza el infame Hash sin Seno de Dave Hoskins:

 vec2 hash2() { vec3 p3 = fract(vec3(g_seed += 0.1) * HASHSCALE3); p3 += dot(p3, p3.yzx + 19.19); return fract((p3.xx+p3.yz)*p3.zy); } 


Reducción de ruido


Incluso con TAA habilitado, la demostración todavía muestra mucho ruido. Es especialmente difícil renderizar el techo, ya que está iluminado solo por iluminación indirecta. No simplifica la situación de que el techo es una gran superficie plana, llena de un color sólido: si tuviera textura o detalles geométricos, entonces el ruido sería menos notable.

No quería pasar mucho tiempo en esta parte de la demostración, así que traté de aplicar solo un filtro de reducción de ruido: Median3x3 de Morgan McGuire y Kyle Witson . Desafortunadamente, este filtro no funciona muy bien con gráficos de "pixel art" para texturas de pared: elimina todos los detalles en la distancia y redondea las esquinas de los píxeles de las paredes cercanas.

En otro experimento, apliqué el mismo filtro al Diffuse GI Render Target. Aunque redujo levemente el ruido, al mismo tiempo, casi sin cambiar los detalles de las texturas de la pared, decidí que esta mejora no valía los milisegundos adicionales gastados.

Demo


Puedes jugar la demo aquí .

Source: https://habr.com/ru/post/444516/


All Articles