java3DEngine

¿Cómo funciona un motor en 3D? ¿Qué cálculos hay detrás de la renderización por software? . Estamos acostumbrados a trabajar con motores en 3D de terceros y pocas veces nos preguntamos qué hay por debajo de ellos. El objetivo de este proyecto no es el desarollo de un motor 3D comercial o competente sino tratar de resolver el problema de renderizado en 3D desde cero, sin recurrir a motores de terceros, a fin de ilustrar el funcionamiento general de dichos motores.

Con este proyecto abordaremos los siguientes temas: la proyección perspectiva de un punto en el espacio, la renderización de figuras basada en triángulos y la renderización en base a profundidad o z-buffering. Utilizaremos Java como lenguaje de programación dada su flexibilidad y portabilidad ya que la eficiencia de cálculo y gráficos no nos importan en este proyecto.

Renderizado

El primer paso en nuestro motor es ser capaces de representar cada punto en el espacio en una coordenada en el plano que pintaremos más adelante en la pantalla. Para esto utilizaremos la proyección en perspectiva por ser la más parecida a la representación del espacio del ojo humano.

Como vemos en la imagen de la izquierda todo punto en el espacio tiene una proyección en un plano situado a una determinada distancia del espectador. Identificamos estos tres objetos: la esfera de la izquierda es el observador, el plano central, nuestro plano de proyección y cualquiera de los puntos A,B o C, el punto a ser proyectado sobre él.

Para hallar las coordenadas en este plano de proyección de nuestro punto en el espacio tenemos que usar un poco de matemáticas. En primer lugar denotamos los siguientes elementos de nuestra escena:

\large \mathbf{a}_{x,y,z} - La posición en el espacio del punto a proyectar.

\large\mathbf{c}_{x,y,z} - La posición en el espacio del observador (en nuestro caso la cámara).

\large\mathbf{\theta}_{x,y,z} - El ángulo u orientación , en grados sobre los ejes x,y,z de la cámara.

\large \mathbf{e}_{x,y,z} - La posición de la cámara con respecto al plano de proyección.

Que resultarán en un punto: large mathbf{b}_{x,y} representando las coordenadas del punto en el espacio con respecto a nuestro plano de proyección. Los cálculos matemáticos son los siguientes:

\large \begin{bmatrix}\mathbf{d}_x \\\mathbf{d}_y \\\mathbf{d}_z  \\\end{bmatrix}=\begin{bmatrix}1 & 0 & 0\\ 0 & {\cos  \mathbf{\theta}_x } & { - \sin \mathbf{\theta}_x }\\ 0 & { \sin  \mathbf{\theta}_x } & { \cos \mathbf{\theta}_x  }\\\end{bmatrix}\begin{bmatrix}{ \cos \mathbf{\theta}_y } & 0 & {  \sin \mathbf{\theta}_y }\\ 0 & 1 & 0\\{ - \sin  \mathbf{\theta}_y } & 0 & { \cos \mathbf{\theta}_y  }\\\end{bmatrix}\begin{bmatrix}{ \cos \mathbf{\theta}_z } & { - \sin  \mathbf{\theta}_z } & 0\\{ \sin \mathbf{\theta}_z } & { \cos  \mathbf{\theta}_z } & 0\\ 0 & 0 & 1\\\end{bmatrix}\left(  {\begin{bmatrix}\mathbf{a}_x\\\mathbf{a}_y\\\mathbf{a}_z\\\end{bmatrix} -  \begin{bmatrix}\mathbf{c}_x\\\mathbf{c}_y\\\mathbf{c}_z\\\end{bmatrix}}  \right)

Que podemos expresar como sigue:

\large \begin{array}{lcl}d_x &= &\cos \theta_y\cdot(\sin  \theta_z\cdot(a_y-c_y)+\cos \theta_z\cdot(a_x-c_x))-\sin  \theta_y\cdot(a_z-c_z) \\d_y &= &\sin \theta_x\cdot(\cos  \theta_y\cdot(a_z-c_z)+\sin \theta_y\cdot(\sin  \theta_z\cdot(a_y-c_y)+\cos \theta_z\cdot(a_x-c_x)))+\\ & &\cos  \theta_x\cdot(\cos \theta_z\cdot(a_y-c_y)-\sin \theta_z\cdot(a_x-c_x))  \\d_z &= &\cos \theta_x\cdot(\cos \theta_y\cdot(a_z-c_z)+\sin  \theta_y\cdot(\sin \theta_z\cdot(a_y-c_y)+\cos  \theta_z\cdot(a_x-c_x)))-\\ & &\sin \theta_x\cdot(\cos  \theta_z\cdot(a_y-c_y)-\sin \theta_z\cdot(a_x-c_x)) \\\end{array}

El punto proyectado lo podemos expresar como sigue:

\large \begin{array}{lcl}d_x &= &\cos \theta_y\cdot(\sin  \theta_z\cdot(a_y-c_y)+\cos \theta_z\cdot(a_x-c_x))-\sin  \theta_y\cdot(a_z-c_z) \\d_y &= &\sin \theta_x\cdot(\cos  \theta_y\cdot(a_z-c_z)+\sin \theta_y\cdot(\sin  \theta_z\cdot(a_y-c_y)+\cos \theta_z\cdot(a_x-c_x)))+\\ & &\cos  \theta_x\cdot(\cos \theta_z\cdot(a_y-c_y)-\sin \theta_z\cdot(a_x-c_x))  \\d_z &= &\cos \theta_x\cdot(\cos \theta_y\cdot(a_z-c_z)+\sin  \theta_y\cdot(\sin \theta_z\cdot(a_y-c_y)+\cos  \theta_z\cdot(a_x-c_x)))-\\ & &\sin \theta_x\cdot(\cos  \theta_z\cdot(a_y-c_y)-\sin \theta_z\cdot(a_x-c_x)) \\\end{array}

Con estas ecuaciones podemos codificar nuestra función para proyectar un punto en el espacio de la siguiente forma:

private int get2DX(double a,double b,double c) {
        double dx, dz;
        double dirx=actualCamera.getRotation().getX();
        double diry=actualCamera.getRotation().getY();
        double dirz=actualCamera.getRotation().getZ();
        double cx=actualCamera.getPosition().getX();
        double cy=actualCamera.getPosition().getY();
        double cz=actualCamera.getPosition().getZ();
        double fov=actualCamera.getFOV();
        //Convertir punto segun origen de referenias=camara
        dx=cos(diry)*(sin(dirz)*(b-cy)+cos(dirz)*(a-cx))-sin(diry)*(c-cz);
        dz=cos(dirx)*(cos(diry)*(c-cz)+sin(diry)*(sin(dirz)*(b-cy)+
                 cos(dirz)*(a-cx)))-sin(dirx)*(cos(dirz)*(b-cy)-
                 sin(dirz)*(a-cx));
        if((dz>0)&&((int)Math.abs(dx)<fov)){
            return (int)(-dx*fov/dz);
        } else {
            return -1;
        }

Esta función retorna el valor de la coordenada X del punto (a,b,c) proyectado sobre el plano. La función para la coordenada Y es análoga.

Una vez somos capaces de representar un punto del espacio en la pantalla veremos un método primitivo para dibujar triángulos a partir de 3 puntos en el plano.

Representación de triángulos

La imagen que vemos a la izquierda forma parte de una escena renderizada con el motor que estamos construyendo.

Podemos apreciar a simple vista que los objetos en ella están formados por triángulos de distintos tamaños. Dado que ya somos capaces de representar un punto en la pantalla representar un triángulo comenzará con obtener las coordenadas en el plano de dicho punto y aplicar el algoritmo que veremos a continuación para rellenarlo.

Suponemos los puntos P1,P2 y P3 en la pantalla con coordenadas (x,y) ordenados de menor a mayor, es decir, el punto P1: (x1,y1) está más arriba en la pantalla que el punto P2: (x2,y2) y este está por encima de P3: (x3,y3). Recordemos que en la pantalla el origen de coordenadas está en la esquina superior izquierda y el eje y está invertido.

El algoritmo que utilizaremos es el siguiente:

if ((y2-y1)>0) {
	dx1=(x2-x1)/(double)(y2-y1);
} else {
	dx1=(x2-x1);
}
if ((y3-y1)>0) {
	dx2=(x3-x1)/(double)(y3-y1);
} else {
	dx2=(x3-x1);
}
if ((y3-y2)>0) {
	dx3=(x3-x2)/(double)(y3-y2);
} else {
	dx3=(x3-x2);
}
ex=x1;
sx=x1;
ey=y1;
sy=y1;

if (dx1>dx2) {
	for (;sy<=y2 && sy<PEHeight;sy++,ey++,sx+=dx2,ex+=dx1) {
		if(sy>0){
			int left=Math.max((int)sx, 0);
			for(int i=left;i<(int)ex && i<PEWidth;i++){
				dibujarPunto(i,sy);
			}
		}
	}
	ex=x2;
	ey=y2;
	for (;sy<=y3 && sy<PEHeight;sy++,ey++,sx+=dx2,ex+=dx3) {
		if(sy>0){
			int left=Math.max((int)sx, 0);
			for(int i=left;i<(int)ex && i<PEWidth;i++){
				dibujarPunto(i,sy);
			}
		}
	}
} else {
	for (;sy<=y2 && sy<PEHeight;sy++,ey++,sx+=dx1,ex+=dx2) {
		if(sy>0){
			int left=Math.max((int)sx, 0);
			for(int i=left;i<(int)ex && i<PEWidth;i++){
				dibujarPunto(i,sy);
			}
		}
	}
	sx=x2;
	sy=y2;
	for (;sy<=y3 && sy<PEHeight;sy++,ey++,sx+=dx3,ex+=dx2) {
		if(sy>0){
			int left=Math.max((int)sx, 0);
			for(int i=left;i<(int)ex && i<PEWidth;i++){
			   dibujarPunto(i,sy);
			}
		}
	}
}

Con este algoritmo podremos dibujar pixel a pixel los triángulos obtenidos con la función anterior. Más adelante tendremos que hacer algunos cálculos con esos puntos para la renderización basada en profundidad pero antes deberemos organizar las estructuras necesarias para hacer funcionar el motor con casos reales.

El motor incluye las clases Object3D y Object3DGroup que se encargan de almacenar los distintos triángulos que puede contener un objeto en nuestra escena. Dado que el motor es ilustrativo sólo he implementado la carga de ficheros .OBJ que puede realizarse desde la clase  ModelLoader. Podemos definir asi objetos de las siguientes formas:

Object3D object=new Object3D();
        object.addTriangle(new Triangle3D(0,0,0,0,0,100,0,100,50,
                   new java3DEngine.util.Color(0,0,255)));
        object.addTriangle(new Triangle3D(50,0,0,50,0,100,-50,100,50,
                   new java3DEngine.util.Color(0,255,255)));
        object.setPosition(new Vector3D(0,0,0));
        core.addObjectToScene(object);

        Object3DGroup group1=ModelLoader.loadModel(new File("Objeto7.obj"),
                  ModelLoader.OBJECT_OBJ);
        core.addObjectToScene(group1);

Z-Buffering

Una vez hemos terminado la parte de renderización y somos capaces de renderizar varios triángulos se nos presenta un nuevo problema: renderizar en base a la profundidad de los objetos. Imaginemos la siguiente disposición de triangulos:


Cuando rendericemos los triángulos queremos que aparezcan en la pantalla en orden: primero el azul, luego el amarillo y por último el rojo, como vemos en la imagen de la derecha.  Para ello utilizaremos una ténica conocida como z-buffering con la que almacenaremos la profundidad de los objetos que vayamos renderizando para determinar qué partes de los siguiente serán mostradas o no.

En esta ténica utilizaremos una matriz bidimensional denominada z-buffer que tendrá tantas filas y columnas como el alto y ancho respectivamente de nuestro área de renderizado. Cada vez que rendericemos un pixel en la pantalla almacenaremos la distancia de ese pixel a la cámara en su posición correspondiente del z-buffer. Sólo renderizaremos un pixel si su profundidad es inferior a la profundidad de ese pixel almacenada en el z-buffer, es decir, cuándo está por delante del anterior pixel en esa posición.

Entonces, ¿Cómo calculamos la distancia a la cámara para cada pixel del triángulo que estamos renderizando? Aqui es donde tenemos que utilizar un poco de imaginación. Ya que es inviable aplicar la inversa a la transformada anterior para cada punto en la pantalla trataremos de interpolar los puntos de la siguiente forma:

Supongamos tres puntos M1,M2 y M3 pertenecientes a displaystylemathbb{R}^3 que hemos renderizado con nuestro algoritmo y a partir de los cuales hemos hallado los puntos P1,P2 y P3 en displaystylemathbb{R}^2. Conocidas las distancias de los puntos M1,M2 y M3 a la cámara (a través del módulo del vector formado por cada uno de los puntos y la cámara) queremos calcular la distancia a la cámara de un punto Q:(x,y) cualquiera en la pantalla.

Vamos a tomar tres puntos P1', P2' y P3' cuyas coordenadas (x,y) son las de los puntos P1,P2 y P3 y cuyas coordenadas (z) son las distancias de los puntos M1,M2 y M3 respectivamente a la cámara. Si generamos un plano alpha con los puntos P1',P2' y P3' podremos interpolar la distancia a la cámara de cualquier punto Q(x,y) de la pantalla hallando su coordenada (z) en el plano alpha a través de sus coordenadas (x,y).

Para hallar el plano tendremos que realizar los siguientes cálculos matemáticos:

Tomando como \vec{u} el vector formado por P1' y P2' y como \vec{v} el vector formado por P1' y P3' hallamos el plano resolviendo el siguiente sistema de ecuaciones:

\large\left\{\begin{array}{c}x-x_0=u_1\lambda + v_1\mu \\ y-y_0=u_2\lambda + v_2\mu \\ z-z_0=u_3\lambda + v_3\mu\end{array}\right.

Dado que el sistema ha de ser compatible determinado igualamos a cero el determinante de la matriz ampliada:

\large\displaystyle\begin{vmatrix}x-x_0 & u_1 & v_1 \\y-y_0  & u_2 & v_2 \\z-z_0 & u_3 & v_3\end{vmatrix}

\large A=\displaystyle\begin{vmatrix} u_2 & v_2 \\ u_3 &  v_3 \end{vmatrix} ,B=-\displaystyle\begin{vmatrix} u_1 & v_1 \\ u_3  & v_3 \end{vmatrix} ,C=\displaystyle\begin{vmatrix} u_1 & v_1 \\  u_2 & v_2 \end{vmatrix},D=-Ax_0-By_0-Cz_0

De este modo tendremos una ecuación como la que sigue:

\large Ax+By+Cz+D=0

Donde la coordenada en el eje z reperesentará nuestra distancia a la cámara y con la cual podremos obtener dicha coordenada a partir de dos coordenadas (x,y) de un punto cualquiera en la pantalla en base a un triangulo renderizado a partir de los puntos M1,M2 y M3 mencionados anteriormente. Vemos la fórmula para hallar dicha profundidad a continuación (Suponemos los vectores vec{u} y vec{v} anteriores -formados por los puntos P1'P2' y P1'P3' respectivamente- y tomamos las coordenadas de P1' como (x_0,y_0,z_0)  ):

z= - \frac{x\cdot (y1\cdot (z2 - z3) + y2\cdot (z3 - z1) + y3\cdot (z1  - z2)) - y\cdot (x1\cdot (z2 - z3) + x2\cdot (z3 - z1) + x3\cdot (z1 -  z2)) + x1\cdot (y3\cdot z2 - y2\cdot z3) + x2\cdot (y1\cdot z3 - y3\cdot  z1) + x3\cdot (y2\cdot z1 - y1\cdot z2)}{x1\cdot (y2 - y3) + x2\cdot  (y3 - y1) + x3\cdot (y1 - y2)}

Veamos la función en Java que realiza esta operación:

 private double pesoPlano(int x,int y,int x1,int y1,int x2,int y2,
     int x3,int y3,double peso1,double peso2,double peso3){
        double coef,z;
        coef=(x1*(y2 - y3) + x2*(y3 - y1) + x3*(y1 - y2));
        if(coef!=0){
            z = - (x*(y1*(peso2 - peso3) + y2*(peso3 - peso1)
                + y3*(peso1 - peso2)) - y*(x1*(peso2 - peso3)
                + x2*(peso3 - peso1) + x3*(peso1 - peso2))
                + x1*(y3*peso2 - y2*peso3)
                + x2*(y1*peso3 - y3*peso1) + x3*(y2*peso1 - y1*peso2))/coef;
        }
        else {
            z=0;
        }
        return z;
    }

La función recibe en primer lugar las coordenadas del punto en la pantalla cuya distancia a la cámara queremos calcular. Los 6 siguientes parámetros corresponden a las coordenadas (x,y) de los puntos P1,P2 y P3 mencionados anteriormente (Las proyecciones en la pantalla de los puntos M1,M2 y M3). Por último recibe tres parámetros que representan la distancia a la cámara de los puntos M1,M2 y M3.

Código fuente y documentación

El código fuente de este proyecto puede encontrarse aqui. La documentación puede encontrarse aqui. Dentro del proyecto hay un main de prueba y el modelo en 3D que vimos en una imagen más arriba.

Sin duda los motores comerciales utilizan ténicas de renderizado bastante más complejas pero, como dije al principio de esta entrada, el objetivo de este proyecto no era realizar un motor competente sino ilustrar el funcionamiento del nucleo de cualquier motor 3D.

Esta entrada fue publicada en Software. Guarda el enlace permanente.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos necesarios están marcados *

Puedes usar las siguientes etiquetas y atributos HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>