Matrices al rescate

Programando un motor 3D desde cero - Brakeza3D

Matrices al rescate

Aquí estámos nuevamente. He retrasado este último artículo por falta de tiempo, ya que he estado trabajando en parsers de mapas WAD del doom y mapas BSP del Quake original, toda una aventura de la que he conseguido por fin salir con vida. Si alguno está interesado en que escriba sobre alguno de estos dos formatos, creo que no habría problema :P.

Volvamos al trabajo. En el post de Movimiento y rotación en el espacio 3D hablamos de como conseguir mover y rotar nuestros vértices por el espacio. Con ese enfoque, conseguimos implementar unas funciones que realizaban el trabajo:

Esas funciones eran algo asi:

vertice rotarEjeX(vertice V, double degrees) {
    double rads = degreesToRadians(degrees);

    vertice A;
    A.x = 1 * V.x;
    A.y =  cos(rads) * V.y + sin(rads) * V.z;
    A.z =  -sin(rads) * V.y + cos(rads) * V.z;

    return A;
}

vertice rotarEjeY(vertice V, double degrees) {
    double rads = degreesToRadians(degrees);

    vertice A;
    A.x = ( cos(rads) * V.x ) - ( sin(rads) * V.z );
    A.y = 1 * V.y;
    A.z = ( sin(rads) * V.x ) + ( cos(rads) * V.z );

    return A;
}

vertice rotarEjeZ(vertice V, double degrees) {
    double rads = degreesToRadians(degrees);

    vertice A;
    A.x = ( cos(rads) * V.x ) + ( sin(rads) * V.y );
    A.y = ( -sin(rads) * V.x ) + ( cos(rads) * V.y );
    A.z = 1 * V.z ;

    return A;
}

En mi rigor de autodidacta, está el no utilizar aquellos conceptos que no domino, para no perder el control. En su momento decidí prescindir del uso de matrices y ver en que punto el proyecto me pedía utilizarlas. Me atrevo a aconsejaros que no cometáis el mismo error. Profundizar en el uso de matrices es algo suficientemente asequible para los beneficios que nos aportará en un futuro.

El código de Brakeza3D ha sido reescrito para operar exclusivamente con matrices 3×3, y en breve lo hará con matrices 4×4. Intentaré haceros partícipes del motivo.

En el post Creando nuestra primera cámara, os presenté los espacios de coordenadas. Una serie de fases, por las que viajarán nuestros vértices camino a su destino final, es decir, la pantalla. Todas estas fases, se corresponden al fin y al cabo con una serie de cálculos, que van modificando cada vértice en su componente (x, y, z) a base de translaciones y rotaciones.

El rendimiento es uno de los mayores beneficiados del cambio cuando empecéis a usar matrices. Pero me gustaría destacar la claridad que ganaréis en vuestro código a la hora de hacer rotaciones y translaciones, si os acostumbráis a utilizarlas.

Observemos por un segundo la función vista en el post anterior, donde convertíamos un vértice pasado como argumento al Object Space. Podemos observar como rotamos individualmente cada eje para finalmente sumarle la posición del objeto padre, y así con cada vértice del modelo que estemos renderizando, en definitiva estamos calculando la rotación del objeto constantemente (lo hacemos en CADA vértice), lo cual no es estrictamente necesario. Podríamos conservar la rotación de un objeto en una matriz y multiplicar los vértices por esa matriz.

El código para multiplcar un vértice por una matriz 3×3, una vez implementado la sobrecarga del operador “*”:

 Vertex3D B = M3(-90, 0, 0) * V;

Conversión entre espacios de coordenadas

Otro problema a solventar sin matrices es la conversión entre espacios de coordenadas, es decir, convertir un vértice del Espacio de Cámara al Espacio de Objeto, o del Espacio de Vista al de Cámara, etc. Os adelanto que será muy habitual y necesario. Yo mismo hasta ahora resolvía estas cuestiones con funciones “nada” elegantes que simplemente invertían los cálculos de una fase a otra, lo cual es correcto y teóricamente válido, pero que al final no aportan gran cosa ya que las matrices lo resuelven de forma simple y eficaz.

El paso definitivo que me animó con las matrices fue la sobrecarga de los operadores +, y *, entre ellas mismas, y contra los vectores. Lo cual me permitió operar con matrices y vectores con una sintaxis muy reducida y desde mi punto de vista mas elegante, como ya hemos visto un poco mas arriba.

El concepto

Seguro que todos sabéis lo que es una matriz y que mi explicación está llena de inexactitudes, pero podemos resumir que una matriz es un objeto que almacena información en filas y columnas. Por tanto, podemos hablar de matrices de N filas x M columnas. Vamos a imaginarnos una matriz de 3×3 en código:

Aquí os dejo el link de la clase M3 en C++ que uso actualmente. Es una Matriz de 3×3 que me permite gestionar las rotaciones. Mas adelante hablaremos de las matrices 4×4, que nos permitirán rotar y mover en un solo paso. Pero no nos adelantemos.

void M3::setup (float m0, float m1, float m2, float m3, float m4, float m5, float m6, float m7, float m8) {
    m[0] = m0  ;  m[1] = m1  ;  m[2] = m2;
    m[3] = m3  ;  m[4] = m4  ;  m[5] = m5;
    m[6] = m6  ;  m[7] = m7  ;  m[8] = m8;
}

Para nosotros una matriz de 3×3 es simplemente un array de 3×3=9 floats. Simplemente hemos de imaginar estos elementos del array en filas y columnas, o si deseáis implementar arrays bidimensionales, también valdría. Continuamos.

Podemos rotar un vértice en el espacio con solo multiplicarlo por una matriz que represente dicha rotación. Recordáis las funciones que hemos visto para rotar un vértice en cada eje?. Pues podemos construir una matriz que haga exactamente cada una de estas operaciones, son las siguientes:

const M3 M3::RX(float degrees) 
{
    float rads = Maths::degreesToRadians(degrees);
    return M3(
        1, 0        , 0         ,
        0, cos(rads), -sin(rads),
        0, sin(rads), cos(rads)
    );
}

const M3 M3::RY(float degrees)
{
    float rads = Maths::degreesToRadians(degrees);
    return M3(
        cos(rads) , 0 , sin(rads),
        0         , 1 , 0        ,
        -sin(rads), 0 , cos(rads)
    );
}

const M3 M3::RZ(float degrees) 
{
    float rads = Maths::degreesToRadians(degrees);
    return M3(
        cos(rads) , -sin(rads) , 0,
        sin(rads) , cos(rads)  , 0,
        0         , 0          , 1
    );
}

Os invito a comparar estas tres matrices, con las tres funciones que hemos visto arriba y en otros posts, no dejan de ser dos formas de representar lo mismo.

Supongamos que tenemos una rotación de 10º en el EjeX, 20º en el EjeY y 30º en el ejeZ, podríamos obtener la matriz de rotación equivalente de la siguiente manera:

M3 M3::getMatrixRotationForEulerAngles(float x, float y, float z)
{
    M3 MRX = M3::RX(x);
    M3 MRY = M3::RY(y);
    M3 MRZ = M3::RZ(z);

    M3 A = (MRX * MRY * MRZ);

    return A;
}

Una vez obtenida dicha matriz podríamos reutilizarla para multiplicarla por cada vértice del modelo, para conseguir la rotación final de todo el objeto 3D. La ganancia respecto al sistema anterior es importante, pero sobre todo, ganamos en simplicidad, ya que podríamos rotar un vértice de esta forma:

M3 m = M3::getMatrixRotationForEulerAngles(10, 20, 30)
Vertex3D A = m * C;

Observar además que las matrices se pueden multiplicar entre ellas, lo que acumula en la matriz final las rotaciones de cada uno de sus multiplicandos (respetando el orden de multiplicación):

M3 A = (MRX * MRY * MRZ);

Cómo se multiplica una matriz por otra? La explicación podéis encontrarla en wikipedia. Mi recomendación es que echéis un ojo, veréis que es una sencilla multiplicación de filas por columnas. Su implementación sería algo así:

M3 M3::operator *(const M3 v)
{
    M3 M = M3();
    M.m[0] = m[0]*v.m[0] + m[1]*v.m[3] + m[2]*v.m[6];
    M.m[3] = m[3]*v.m[0] + m[4]*v.m[3] + m[5]*v.m[6];
    M.m[6] = m[6]*v.m[0] + m[7]*v.m[3] + m[8]*v.m[6];  

    M.m[1] = m[0]*v.m[1] + m[1]*v.m[4] + m[2]*v.m[7];
    M.m[4] = m[3]*v.m[1] + m[4]*v.m[4] + m[5]*v.m[7];
    M.m[7] = m[6]*v.m[1] + m[7]*v.m[4] + m[8]*v.m[7];

    M.m[2] = m[0]*v.m[2] + m[1]*v.m[5] + m[2]*v.m[8];
    M.m[5] = m[3]*v.m[2] + m[4]*v.m[5] + m[5]*v.m[8];
    M.m[8] = m[6]*v.m[2] + m[7]*v.m[5] + m[8]*v.m[8];

    return M;
}

Y cómo multiplicamos una matriz por un vértice (o vector)?

Vertex3D M3::operator *(const Vertex3D A)
{
    Vertex3D V = Vertex3D();
    V.x = (m[0] * A.x) + (m[1] * A.y) + (m[2] * A.z);
    V.y = (m[3] * A.x) + (m[4] * A.y) + (m[5] * A.z);
    V.z = (m[6] * A.x) + (m[7] * A.y) + (m[8] * A.z);

    return V;
}

Y cómo rotamos inversamente un vértice si disponemos su matriz de rotación?. Vamos al grano. La respuesta es la matriz inversa o transpuesta:

M3 M3::getTranspose()
{
    M3 m(
        this->m[0], this->m[3], this->m[6],
        this->m[1], this->m[4], this->m[7],
        this->m[2], this->m[5], this->m[8]
    );

    return m;
}

Por tanto para rotar inversamente un vértice, hemos de multiplicarlo por su matriz de rotación invertida:

T = o->getRotation().getTranspose() * T;

Hay otro argumento de cierto peso para evitar almacenar la rotación de nuestros objetos mediante matrices, en lugar de con tres valores que representen la rotación en cada ángulo (Euler Angles) y se denomina Gimbal Lock.

Evitaremos por ahora mas explicaciones de las necesarias, pero podemos concluir que las matrices nos aportan todo lo necesario para rotar vértices, sin apenas desventajas, ya que además simplificaremos la sintaxis de nuestro código al usarlas con la implementación de la sobrecarga de los operadores “mas”, “menos” y “multiplicación”, tanto contra otra matriz como contra un vector.

En siguientes capítulos os hablaré sobre las matrices 4×4 y su efecto de translación (mover), lo que nos permitirá almacenar en una única matriz la información de rotación y translación de un vértice. Otra evolución que nos hará la vida mas fácil en fases futuras de nuestro desarrollo.

Lo siguiente será produndizar en la rasterización del triángulo, parte crítica en todo render.

Os dejo, no dejéis de ver GitHub para ir viendo los avances. Si consideráis interesante que ponga en el blog un roadmap con las ideas encima de la mesa, puede ser interesante. Nos vemos!

 

Deja una respuesta

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