En 1984, hace ya la friolera de 35 años, un grupo de investigadores de la división de gráficos por ordenador de Lucasfilm (lo que un par de años después pasaría a llamarse Píxar) publicó un paper donde presentó la solución definitiva a muchos de los problemas del momento de los gráficos por ordenador orientados al cine y al entretenimiento. Me refiero al Distributed Ray Tracing. Esta técnica permitía añadir a los gráficos por ordenador hechos mediante Ray Tracing elementos fotorrealistas tales como motion blur, profundidad de campo, sombras con penumbras, antialiasing, y más, y todo ello sin añadir prácticamente carga computacional extra. Esa era su gran novedad.
Es cierto que el Ray Tracing es una técnica tan costosa a nivel de procesador y, sobre todo, de memoria, que, durante décadas, Renderman, el render desarrollado por Píxar y utilizado en la inmensa mayoría de escenas de películas hechas por ordenador, no la utilizaba, sino que se basaba en la técnica REYES (Renders Everything You Ever Saw). Era la única manera de poder hacer gráficos fotorrealistas en los equipos de los 80, los 90 y buena parte de los 2000. Sin embargo, algunas de las técnicas desarrolladas en el paper se pudieron adaptar a dicho algoritmo, específicamente el motion blur y la profundidad de campo. Hoy en día, en cambio, sí que se utiliza Ray Tracing de manera rutinaria, basado en mejoras de las técnicas presentadas en este paper. Así trabajan hoy en día Ray Tracers comerciales como Arnold, o el propio Renderman a partir de su versión 19.
Sin embargo, además de todo esto, al final de dicho paper aparece una imagen que muestra las capacidades de los algoritmos descritos en él, y que ya se puede considerar icónica. Dicha imagen se titula 1984 y muestra una mesa de billar con cinco bolas, las cuales exhiben los efectos de motion blur, sombras suaves, reflectividad (lo que permite ver la ventana de la sala y a una persona sosteniendo un taco)…
¿Y a qué viene toda esta chapa? Pues a que he querido hacer un pequeño homenaje a dicha publicación y a los genios detrás de ella programando un pequeño Ray Tracer propio con el que reproducir dicha imagen. Y éste es el resultado:
Al igual que en la imagen original, todas las bolas son reflectantes (lo que permite apreciar el entorno de la sala), y cuatro de ellas tienen motion blur: algunas (como la blanca y la bola 9) tienen un motion blur sencillo, y las otras dos, la 8 y la 4, uno más complejo, al estar detenidas durante parte del tiempo de apertura del obturador y moverse sólo durante parte del tiempo de exposición. Por supuesto, no cabe esperar una reproducción fidedigna (¡Soy programador, Jim, no artista!), pero lo importante para mí no es tanto la imagen en sí, sino el hecho de que he sido capaz de programar un Ray Tracer desde cero. Desde que leí el paper original quedé prendado de la elegancia del algoritmo, y tenía ganas de intentar implementarlo.
Sin embargo, lo divertido (relativamente) es que acabé escribiendo no uno, sino dos Ray Tracers: cuando empecé con este proyecto, hace un par de semanas, aún no tenía muy claro el nivel de complejidad que me iba a encontrar, así que decidí no complicarme la vida más de lo estrictamente necesario y opté por utilizar Python para escribirlo, junto con numpy para toda la parte de vectores y matrices que necesitaba. Primero construí un Ray Caster capaz de mostrar esferas y planos. Luego le añadí soporte de mapeado de texturas. A continuación antialiasing y soporte de luces. Llegado a este punto añadí soporte de motion blur y empecé a preparar ya la escena de la sala de billar en lugar de las imágenes de demostración con una Cornell Box.
El resultado es el de ahí arriba. Sin embargo, tenía un problema serio: es desesperadamente lento. Es cierto que, como dije antes, la técnica de Ray Tracing es muy costosa computacionalmente, pero es que hablamos de ¡18 horas para esa imagen a 1920×1080! ¡Y eso que ya había hecho unas cuantas optimizaciones que me permitieron reducir los tiempos a, aproximadamente, dos tercios de los originales, además de utilizar todos los núcleos de mi ordenador! Es más o menos lo que habría tardado a principios de los 90 con PovRay y un 8086.
Fue por esto que, aunque el objetivo original ya estaba cumplido, tomé la decisión de portar el código a C++ para ver cuanto podía arañar. Quería saber si el problema estaba en Python o en mi código. Aunque dicho así puede sonar a locura, lo cierto es que el código de Python era bastante sencillo (actualmente son un total de 1415 líneas de código, que, tras quitar líneas en blanco y comentarios, se quedan en 899), con lo que era factible coger cada uno de los ficheros y traducirlo de un lenguaje a otro. Sólo tuve que escribir un par de clases para trabajar con vectores y matrices y alguna cosa más, lo que no fue especialmente difícil. Es cierto que probablemente podría haber usado cualquiera de las alternativas que seguro que hay por ahí ya escritas, pero dado que sólo tengo que trabajar con matrices y vectores de tamaño fijo, y sólo necesito un puñado de operaciones muy concretas, era más trabajo aprender a usarlas que escribirlo yo. Y encima es probable que me diese más rendimiento (repito: tamaño fijo).
El resultado fue, en una sola palabra: pasmoso. De las más de 18 horas pasé a 398 segundos. Repito: menos de siete minutos para la misma imagen: 1920×1080 y supersampling de 8×8.
Sí es cierto que me encontré con un problema algo raro: en Python utilicé fork() para poder aprovechar todos mis núcleos porque, como es de sobra conocido, el GIL no permite que varios threads se ejecuten de manera concurrente. Esto complicó bastante el código, pues tuve que usar pipes para que el programa principal fuese repartiendo nuevas líneas a cada proceso hijo a medida que iban terminando y quedando libres, y también para que éstos enviasen las líneas ya renderizadas al padre para que las uniese en una sola imagen. Para la versión en C++ no quería complicarme tanto, así que decidí utilizar pthreads desde el principio, de manera que todos los hijos accediesen a la misma memoria para ir depositando sus líneas en el punto correcto sin tener que enviarlas mediante IPC, y autoasignándose la siguiente línea mediante un semáforo. Sin embargo, algo raro ocurría: tardaba más que cuando utilizaba un único proceso. Si no fuese porque top indicaba una carga de procesador del 400%, y que el procesador indicaba que había seis threads de mi programa, habría asegurado que estaba utilizando una biblioteca de threads en espacio de usuario, sin threads reales.
Después de muchas pruebas, de quemarme las pestañas viendo el código, y buceando en Stack Overflow y otros sitios, no conseguí descubrir qué ocurría, así que decidí utilizar también fork() en C++, pero añadiendo memoria compartida para simplificar la comunicación y la complejidad al máximo, y ahí sí que conseguí la ganancia de rendimiento esperada; esto es, multiplicar la velocidad por (prácticamente) el número de núcleos del ordenador.
Y esto es más o menos todo. Aquellos que quieran curiosear un poco en mi código, lo tienen disponible en mi repositorio git: