Recientemente compití en la escena de demostración de Revision 2019 en la categoría de introducción de PC 4k, y mi introducción ganó el primer lugar. Hice codificación y gráficos, y dixan compuso música. La regla básica de la competencia es crear un archivo ejecutable o un sitio web que tenga solo 4096 bytes de tamaño. Esto significa que todo tiene que ser generado usando matemáticas y algoritmos; de ninguna otra manera puedo comprimir imágenes, video y audio en una cantidad tan pequeña de memoria. En este artículo, hablaré sobre la canalización de renderización de mi introducción de Newton. A continuación puede ver el resultado final, o
haga clic aquí para ver cómo se veía en vivo en Revision, o
vaya a pouet para comentar y descargar la introducción que participó en la competencia. Puede
leer sobre el trabajo y las correcciones de los competidores
aquí .
La técnica de los campos de distancia de marcha de Ray es muy popular en la disciplina de introducción 4k porque le permite especificar formas complejas en solo unas pocas líneas de código. Sin embargo, la desventaja de este enfoque es la velocidad de ejecución. Para renderizar la escena, debe encontrar el punto de intersección de los rayos con la escena, primero determinar lo que ve, por ejemplo, un rayo de la cámara y luego los rayos posteriores del objeto a las fuentes de luz para calcular la iluminación. Cuando se trabaja con la marcha de rayos, estas intersecciones no se pueden encontrar en un solo paso, debe dar muchos pasos pequeños a lo largo de la viga y evaluar todos los objetos en cada punto. Por otro lado, cuando se usa el trazado de rayos, puede encontrar la intersección exacta marcando cada objeto solo una vez, pero el conjunto de formas que se puede usar es muy limitado: debe tener una fórmula para cada tipo para calcular la intersección con el rayo.
En esta introducción, quería simular una iluminación muy precisa. Como era necesario reflejar millones de rayos en la escena, el trazado de rayos parecía una opción lógica para lograr este efecto. Me limité a una sola figura: una esfera, porque la intersección de un rayo y una esfera se calcula de manera bastante simple. Incluso las paredes en la introducción son en realidad esferas muy grandes. Además, simplificó la simulación de la física; fue suficiente para tener en cuenta solo los conflictos entre las esferas.
Para ilustrar la cantidad de código que cabe en 4096 bytes, a continuación presenté el código fuente completo de la introducción terminada. Todas las partes, excepto el HTML al final, están codificadas como una imagen PNG para comprimirlas a un tamaño más pequeño. Sin esta compresión, el código habría tomado casi 8900 bytes. La parte llamada Synth es una versión
simplificada de
SoundBox . Para empaquetar el código en este formato minimizado, utilicé el
Compilador de cierre de Google y el
Shader Minifier . Al final, casi todo se comprime en PNG usando
JsExe . La tubería de compilación completa se puede ver en el código fuente de mi anterior 4k intro
Core Critical , porque coincide completamente con el presentado aquí.
La música y el sintetizador están completamente implementados en Javascript. La parte en WebGL se divide en dos partes (resaltada en verde en el código); ella configura la tubería de renderizado. La física y los elementos trazadores de rayos son sombreadores GLSL. El resto del código se codifica en una imagen PNG y se agrega HTML al final de la imagen resultante sin cambios. El navegador ignora los datos de la imagen y solo ejecuta el código HTML, que a su vez decodifica PNG nuevamente en JavaScript y lo ejecuta.Tubería de renderizado
La siguiente imagen muestra la canalización de renderizado. Se compone de dos partes. La primera parte de la tubería es un simulador de física. La escena de introducción contiene 50 esferas que chocan entre sí dentro de la habitación. La habitación en sí se compone de seis esferas, algunas de las cuales son más pequeñas que otras para crear paredes más curvas. Dos fuentes verticales de iluminación en las esquinas también son esferas, es decir, un total de 58 esferas en la escena. La segunda parte de la tubería es el rastreador, que representa la escena. El siguiente diagrama muestra la representación de un cuadro en el tiempo t. La simulación física toma el cuadro anterior (t-1) y simula el estado actual. El rastreador de rayos toma las posiciones actuales y las posiciones del fotograma anterior (para el canal de velocidad) y renderiza la escena. Luego, el procesamiento posterior combina los 5 cuadros anteriores y el cuadro actual para reducir la distorsión y el ruido, y luego crea un resultado final.

Representación de un marco en el tiempo t.La parte física es bastante simple, en Internet puede encontrar muchos tutoriales sobre la creación de simulaciones primitivas para esferas. La posición, el radio, la velocidad y la masa se almacenan en dos texturas con una resolución de 1 x 58. Utilicé la funcionalidad Webgl 2, que permite renderizar a múltiples objetivos de renderizado, por lo que los datos de dos texturas se registran simultáneamente. El rastreador de rayos utiliza la misma funcionalidad para crear tres texturas. Webgl no proporciona ningún acceso a las API de trazado de rayos NVidia RTX o DirectX Raytracing (DXR), por lo que todo se hace desde cero.
Rastreador
El trazado de rayos en sí mismo es una técnica bastante primitiva. Lanzamos un rayo en la escena, se refleja 4 veces y, si entra en la fuente de luz, se acumula el color de los reflejos; de lo contrario, nos ponemos negros. En 4096 bytes (que incluye música, sintetizador, física y renderizado) no hay espacio para crear estructuras complejas de trazado acelerado de rayos. Por lo tanto, utilizamos el método de fuerza bruta, es decir, verificamos las 57 esferas (se excluye la pared frontal) para cada rayo, sin hacer ninguna optimización para excluir parte de las esferas. Esto significa que para proporcionar 60 fotogramas por segundo en una resolución de 1080p, puede emitir solo 2-6 rayos o muestras por píxel. Esto es lo suficientemente cerca como para crear una iluminación suave.
1 muestra por píxel.6 muestras por píxel.¿Cómo lidiar con esto? Al principio investigué el algoritmo de trazado de rayos, pero ya estaba simplificado. Logré aumentar ligeramente el rendimiento al eliminar los casos cuando el rayo comienza dentro de la esfera, porque tales casos son aplicables solo en presencia de efectos de transparencia, y solo los objetos opacos estaban presentes en nuestra escena. Después de eso, combiné cada condición if en una declaración separada para evitar ramificaciones innecesarias: a pesar de los cálculos "redundantes", este enfoque es aún más rápido que un conjunto de declaraciones condicionales. También fue posible mejorar el patrón de muestreo: en lugar de emitir rayos al azar, podríamos distribuirlos por la escena en un patrón más uniforme. Desafortunadamente, esto no ayudó y condujo a artefactos ondulados en cada algoritmo que probé. Sin embargo, este enfoque creó buenos resultados para las imágenes fijas. Como resultado, volví a usar una distribución completamente aleatoria.
Los píxeles vecinos deben tener una iluminación muy similar, entonces, ¿por qué no usarlos al calcular la iluminación de un solo píxel? No queremos difuminar las texturas, solo la iluminación, por lo que debemos renderizarlas en canales separados. Tampoco queremos desenfocar objetos, por lo que debemos tener en cuenta los identificadores de los objetos para saber qué píxeles se pueden desenfocar fácilmente. Dado que tenemos objetos que reflejan la luz y necesitamos reflejos claros, no es suficiente averiguar la identificación del primer objeto con el que colisiona el haz. Utilicé un caso especial para materiales reflectantes puros para incluir también las ID del primer y segundo objeto visibles en reflejos en el canal identificador de objeto. En este caso, el desenfoque puede suavizar la iluminación de los objetos en reflejos y al mismo tiempo mantener los límites de los objetos.
Canal de textura, no necesitamos desenfocarlo.Aquí en el canal rojo contiene la identificación del primer objeto, en verde, el segundo, y en azul, el tercero. En la práctica, todos ellos están codificados en un solo valor del formato flotante, en el que la parte entera almacena los identificadores de los objetos, y el fraccionario indica rugosidad: 332211.RR.Como hay objetos con diferentes rugosidades en la escena (algunas áreas son rugosas, la luz se dispersa sobre otras, en la tercera hay un reflejo del espejo), almaceno la rugosidad para controlar el radio de desenfoque. No hay pequeños detalles en la escena, por lo que utilicé un núcleo grande de 50 x 50 con los pesos en forma de cuadrados inversos para desenfocar. No tiene en cuenta el espacio mundial (esto podría realizarse para obtener resultados más precisos), porque en las superficies ubicadas en ángulo en algunas direcciones, erosiona un área más grande. Tal desenfoque crea una imagen bastante suave, pero los artefactos son claramente visibles, especialmente en movimiento.
Canal de iluminación con desenfoque y artefactos aún notables. En esta imagen, se ven puntos borrosos en la pared posterior, que son causados por un pequeño error con los identificadores del segundo objeto reflejado (los rayos abandonan la escena). En la imagen terminada, esto no es muy notable, porque se toman reflejos claros del canal de textura. Las fuentes de iluminación también se vuelven borrosas, pero me gustó este efecto y lo dejé. Si lo desea, esto puede evitarse cambiando los identificadores de los objetos según el material.Cuando hay objetos en la escena y la cámara que dispara la escena se mueve lentamente, la iluminación en cada cuadro debe permanecer constante. Por lo tanto, podemos realizar el desenfoque no solo en las coordenadas XY de la pantalla; Podemos desdibujarnos a tiempo. Si suponemos que la iluminación no cambia demasiado en 100 ms, podemos promediarla para 6 cuadros. Pero durante esta ventana de tiempo, los objetos y la cámara aún recorrerán cierta distancia, por lo que un simple cálculo del promedio de 6 cuadros creará una imagen muy borrosa. Sin embargo, sabemos dónde estaban todos los objetos y la cámara en el mapa anterior, por lo que podemos calcular los vectores de velocidad en el espacio de la pantalla. Esto se llama reproyección temporal. Si tengo un píxel en el momento t, entonces puedo tomar la velocidad de ese píxel y calcular dónde estaba en el momento t-1, y luego calcular dónde está el píxel en el momento t-1 en el momento t-2, y así sucesivamente. 5 cuadros de nuevo. A diferencia del desenfoque en el espacio de la pantalla, utilicé el mismo peso para cada cuadro, es decir acaba de promediar el color entre todos los cuadros para un "desenfoque" temporal.

Un canal de velocidad de píxeles que informa dónde se encontraba el píxel en el último fotograma en función del movimiento del objeto y la cámara.Para evitar el desenfoque conjunto de los objetos, volveremos a utilizar el canal de identificadores de objetos. En este caso, solo consideramos el primer objeto con el que colisionó el rayo. Esto proporciona anti-aliasing dentro del objeto, es decir en reflejosPor supuesto, el píxel podría no haber sido visible en el cuadro anterior; podría estar oculto por otro objeto o estar fuera del campo de visión de la cámara. En tales casos, no podemos usar la información previa. Esta verificación se realiza por separado para cada cuadro, por lo que obtenemos de 1 a 6 muestras o cuadros por píxel y usamos los que son posibles. La siguiente figura muestra que para objetos lentos este no es un problema muy serio.
Cuando los objetos se mueven y abren nuevas partes de la escena, no tenemos 6 cuadros de información para promediar estas partes. Esta imagen muestra áreas que tienen 6 cuadros (blanco), así como aquellas que carecen de ellas (sombras que se oscurecen gradualmente). La apariencia de los contornos es causada por la aleatorización de las ubicaciones de muestreo para el píxel en cada cuadro y el hecho de que tomamos el identificador del objeto de la primera muestra.La iluminación borrosa se promedia en seis cuadros. Los artefactos son casi invisibles y el resultado es estable en el tiempo, porque en cada cuadro solo uno de cada seis cambios en los que se tiene en cuenta la iluminación.Combinando todo esto, obtenemos una imagen terminada. La iluminación se difumina a los píxeles vecinos, mientras que las texturas y los reflejos permanecen claros. Luego, todo esto se promedia entre seis cuadros para crear una imagen aún más uniforme y estable a lo largo del tiempo.
La imagen terminada.Los artefactos de amortiguación aún son notables, porque promedié varias muestras por píxel, aunque tomé el canal del identificador de objeto y la velocidad para la primera intersección. Puede intentar solucionar esto y suavizar las reflexiones descartando las muestras si no coinciden con la primera, o al menos si la primera colisión no coincide en orden. En la práctica, los rastros son casi invisibles, así que no me molesté en eliminarlos. Los límites de los objetos también están distorsionados, porque los canales de velocidad y los identificadores de objetos no se pueden suavizar. Estaba considerando la posibilidad de renderizar toda la imagen a 2160p con una reducción adicional de escala a 1080p, pero mi NVidia GTX 980ti no es capaz de procesar tales resoluciones a 60 fps, así que decidí abandonar esta idea.
En general, estoy muy satisfecho con la presentación. Me las arreglé para exprimir todo lo que tenía en mente, y a pesar de errores menores, el resultado final fue de muy alta calidad. En el futuro, puede intentar corregir errores y mejorar el suavizado. También vale la pena experimentar con características como la transparencia, el desenfoque de movimiento, diversas formas y transformaciones de objetos.