Ejercicio 10: Ray Tracing
El ejercicio
El objetivo de este ejercicio es revisar este ejemplo del algoritmo de Ray Tracing implementado en WebGL, mejorando la legibilidad del código y agregando algo de funcionalidad.
¿Como funciona el ejemplo?
Antes de mejorar la legibilidad del ejemplo, tenemos que entender su funcionamiento. Lo primero que encontramos en el código son una serie de shaders para procesar quads con textura y líneas. Posiblemente estos son los que se utilizan para la interacción con el programa, por ejemplo, el contorno que se genera al seleccionar un objeto.
A continuación de esto, se encuentra lo que posiblemente es el corazón del programa, el shader que realiza el algoritmo de Ray Tracing. Primero, inicializa una serie de parámetros del algoritmo: el numero de rebotes, un parámetro Epsilon, un valor para infinito y el tamaño y valor de la luz.
// constants for the shaders
var bounces = '5';
var epsilon = '0.0001';
var infinity = '10000.0';
var lightSize = 0.1;
var lightVal = 0.5;
Después, encontramos el vertex shader. Como era de esperarse, este no presenta la estructura que vemos tradicionalmente. Este no recibe atributos de la posición y normal del vértice como se espera, en cambio recibe un atributo vector 'vertex', una serie de vectores que representa la cámara y una serie de rayos y por ultimo le pasa un parámetro al fragment shader llamado 'initialRay'.
// vertex shader, interpolate ray per-pixel
var tracerVertexSource =
' attribute vec3 vertex;' +
' uniform vec3 eye, ray00, ray01, ray10, ray11;' +
' varying vec3 initialRay;' +
' void main() {' +
' vec2 percent = vertex.xy * 0.5 + 0.5;' +
' initialRay = mix(mix(ray00, ray01, percent.y), mix(ray10, ray11, percent.y), percent.x);' +
' gl_Position = vec4(vertex, 1.0);' +
' }';
Seguido de esto, se encuentra una serie de strings y métodos que parecen componentes para crear dinámicamente el fragment shader, incluyendo funciones para calcular las intersecciones de rayos con diferentes geometrías, el calculo de normales de la geometría que intersecta y el calculo de color.
Revisando el resto del código, veo que estos shaders son creados dinámicamente según los objetos en la escena y utilizados para crear una imagen que guardan en una textura 2D, después, usando los primeros shaders que vimos, renderizar esa imagen en un quad que cubre todo el contexto, dando la apariencia que se esta dibujando la escena. El vertex shader del programa no esta recibiendo vértices de la geometría, sino la posición de cada píxel de la imagen, y genera el rayo inicial que pasa al fragment shader. Este entonces, calcula el color que debe tener ese píxel según el algoritmo de Ray Tracing, el lugar donde el rayo primero impacto y los factores que influyen en los 5 rebotes del rayo inicial.
Si esto es correcto, lo mas importante en el funcionamiento del programa es la creación dinamica del fragment shader. Si queremos agregar nueva funcionalidad a este, se realizaria creando funciones que el fragment shader pueda utilizar para analizar la nueva geometria.
Agregando Nueva Geometria
Como concluimos anteriormente, el truco para que el programa acepte nueva geometría es agregando el código necesario a la creación del fragment shader. En este momento, el programa solo acepta cubos y esferas, los cuales tienen su propia clase. Podemos utilizar estas como guía para crear una nueva clase que maneje una nueva geometría, por ejemplo un Toro.
Un toro es una superficie de revolución generada por un circunferencia descentrada del eje de rotación, generando un sólido con forma de rosquilla.
Usando como base las clases de las otras geometrías, creamos la clase Torus. En este caso se necesitan tres parámetros: el centro del toro, la distancia entre este punto y el centro de la circunferencia de rotación, y el radio de esta circunferencia. Por simplicidad, asumimos que todos los toros están orientados sobre el plano XZ, generando la figura con una revolución sobre el eje Y. También se requiere un string identificador de este atributo, el cual sea distinto al de las otras figuras y distinto entre toros.
function Torus(center, offset, radius, id) {
this.center = center;
this.offset = offset;
this.radius = radius;
this.centerStr = 'torusCenter' + id;
this.offsetStr = 'torusOffset' + id;
this.radiusStr = 'torusRadius' + id;
this.intersectStr = 'tTorus' + id;
this.temporaryTranslation = Vector.create([0, 0, 0]);
}
Necesitamos que el fragment shader reciba como parámetros los atributos de la figura, por lo tanto creamos una función para agregar esto:
Torus.prototype.getGlobalCode = function() {
return '' +
' uniform vec3 ' + this.centerStr + ';' +
' uniform float ' + this.offsetStr + ';' +
' uniform float ' + this.radiusStr + ';';
};
Tal vez lo mas importante, es darle la capacidad al shader de calcular la intersección entre el rayo y el toro, y el calculo de la normal de la superficie del toro en caso de un impacto del rayo. Ya que su superficie comparte muchas características al de la esfera, podríamos basarnos en el código de la intersección del rayo con una esfera.
var intersectSphereSource =
' float intersectSphere(vec3 origin, vec3 ray, vec3 sphereCenter, float sphereRadius) {' +
' vec3 toSphere = origin - sphereCenter;' +
' float a = dot(ray, ray);' +
' float b = 2.0 * dot(toSphere, ray);' +
' float c = dot(toSphere, toSphere) - sphereRadius*sphereRadius;' +
' float discriminant = b*b - 4.0*a*c;' +
' if(discriminant > 0.0) {' +
' float t = (-b - sqrt(discriminant)) / (2.0 * a);' +
' if(t > 0.0) return t;' +
' }' +
' return ' + infinity + ';' +
' }';
var normalForSphereSource =
' vec3 normalForSphere(vec3 hit, vec3 sphereCenter, float sphereRadius) {' +
' return (hit - sphereCenter) / sphereRadius;' +
' }';
En el caso del calculo de la normal, se calcula de igual manera que el de una esfera cuyo centro sea el centro de la circunferencia de la tajada del torus perpendicular al punto del impacto. Por lo tanto, dentro de la función, tendríamos que calcular este punto y después utilizar el mismo proceso que la esfera.
Por suerte, este punto no es complicado de calcular. Ya que sabemos que el toro esta siempre orientado en el plano XZ, podemos hacer una proyección del punto de impacto en este plano, calcular la dirección de esta proyección al centro y por ultimo usar esta dirección para calcular el centro de la circunferencia según el offset del toro.
vec3 hitFloor = vec3(hit.x, torusCenter.y, hit.z);
vec3 centerToHit = normalize(hitFloor - torusCenter);
vec3 sphereCenter = torusCenter + (centerToHit * torusOffset);
Este procedimiento lo utilizamos dentro de la función:
vec3 normalForTorus(vec3 hit, vec3 torusCenter, float torusOffffsseett, float torusRadius) {
vec3 hitFloor = vec3(hit.x, torusCenter.y, hit.z);
vec3 centerToHit = normalize(hitFloor - torusCenter);
vec3 sphereCenter = torusCenter + (centerToHit * torusOffset);
return (hit - sphereCenter) / torusRadius;
}
Para el calculo de la intersección es un poco mas complicado. Como se aprecia en este documento, existen varias formulaciones para realizar este procedimiento. Una de las mas sencillas es considerar la esfera concéntrica al toro y de radio offset + (radio/2), como una esfera que contiene el toro completamente. Si el rayo no intersecta la esfera, entonces no intersecta el toro. Igualmente, se presentan condiciones para eliminar los casos donde el rayo pasa por la parte superior o inferior de la esfera, donde no esta el toro. Sin embargo, como se menciona, este modelo no considera los casos donde el rayo pasa por el centro hueco del toro.
También se considera la opción de ver el roro como la unión de todas las esferas con el radio especificado cuyos centros se ubican en la circunferencia con el centro del toro y radio del offset. Esto quiere decir que el calculo de la intersección es simplificado a la suma del calculo de la intersección con todas las esferas. Sin embargo, esto resulta sumamente costoso en tiempo de procesamiento.
Después de escoger uno de los modelos de detección de intersección, generamos el código para agregar al shader. Por ultimo, al igual que las clases del cubo y la esfera, se crean funciones para que estos códigos puedan ser agregados.
Torus.prototype.getIntersectCode = function() {
return '' +
' float ' + this.intersectStr + ' = intersectTorus(origin, ray, ' + this.centerStr + ', ' + this.offsetStr + ', ' + this.radiusStr + ');';
};
Torus.prototype.getNormalCalculationCode = function() {
return '' +
' else if(t == ' + this.intersectStr + ') normal = normalForTorus(hit, ' + this.centerStr + ', ' + this.offsetStr + ', ' + this.radiusStr + ');';
};
Y de esta forma, el programa seria capas de detectar colisiones con el toro, encontrar la normal según la colisión y rebotar el rayo de la superficie de el.
Tiempo dedicado
Para este ejercicio fue planeado un tiempo de tres horas, lo cual fue suficiente para realizar el objetivo.
Referencias
- Intersección de Linea con Toro: http://www.wseas.org/multimedia/journals/computers/2013/025705-201.pdf