Dec 22, 2010

Cargar un modelo MD2 animado en PyQt

Hoy vamos a aprender a cargar un modelo 3D a partir de un archivo MD2, conectarlo a una interfaz pre diseñada en QtDesigner, renderizarlo y animarlo con OpenGL, y todo esto usando el legendario lenguaje Python, Oh, Yeah!

Leyendo un archivo MD2

El formato de archivo MD2 es el usa juego Quake II para cargar modelos 3D animados, así como muchos otros juegos basados en el motor de Quake II. A diferencia de otros formatos para almacenar modelos 3D, las animación se guarda en frames donde cada frame guarda las coordenadas de cada uno de los vértices que componen el modelo en un momento dado, es lo que se conoce como animación por vértice, otros formatos guardan la información necesaria para realizar animación por huesos, esta técnica es un poco mas compleja de explicar.
Para ahorrar espacio en disco y que los archivos no sean tan grandes solo se almacenan algunos frames claves y luego el motor se encarga de interpolar los frames para crear una animación mas suave.
Ahora bien, ¿Porque elegir este formato habiendo tantos otros? Porque este es uno de los pocos formatos, en realidad creo que es el único :/ , que Blender soporta importar y exportar animaciones, además de estar completamente documentado.

Veamos cual es el formato de un archivo MD2.
Primero comenzamos leyendo la cabecera.
Cada uno de los campos de la cabecera es igual a un int (4 bytes). Veamos a continuación el significado de cada campo:



CampoDescripción
identNumero que indica que se trata de un archivo MD2, debe ser igual a IPD2 ó 844121161
versionIndica la versión del formato de archivo, debe ser igual a 8
skinwidthAncho de la imagen que se usara como textura
skinheightAlto de la imagen que se usara como textura
framesizeTamaño en bytes de cada frame
num_skinsNumero de texturas que aplicaremos a nuestro modelo
num_xyzNumero de vértices
num_stNumero de coordenadas para las texturas
num_trisNumero de triángulos(caras)
num_glcmdsNumero de comandos OpenGl
num_framesNumero de cuadros de la animación
ofs_skinsoffset desde el comienzo del archivo hasta donde esta el nombre de las texturas a utilizar
ofs_stoffset desde el comienzo del archivo hasta donde están las coordenadas de las texturas
ofs_trisoffset desde el comienzo del archivo hasta donde esta la información para armar los triángulos
ofs_framesoffset desde el comienzo del archivo hasta donde están los frames
ofs_glcmdsoffset desde el comienzo del archivo hasta donde están los comandos OpenGL
ofs_endoffset que indica el final del archivo

Ahora vamos a leer los nombres de los archivos de texturas, cada nombre es una cadena de 64 bytes de largo, si el largo de la cadena a almacenar es menor que 64 los bytes restantes se ponen a 0.
f.seek(ofs_skins, os.SEEK_SET)

formato = ' '.join(num_skins * ['64s'])
skins_raw = struct.unpack(formato, f.read(64 * num_skins))

skins = []

for skin in skins_raw:
    skins += [skin.replace('\x00', '')]
Luego leemos las coordenadas de las texturas, cada coordenada esta esta compuesta de 2 short (2 bytes).
f.seek(ofs_st, os.SEEK_SET)
text_cords = []

for st in range(num_st):
    text_cords += [struct.unpack('h h', f.read(4))]
Leemos los triángulos, cada triangulo esta compuesto 6 short, los 3 primeros corresponden a los indices de los vértices que conforman el triangulo, y los 3 últimos corresponden a los indices de las coordenadas a aplicar a la cara.
f.seek(ofs_tris, os.SEEK_SET)
triangles = []

for triangle in range(num_tris):
    vertices = struct.unpack('h h h', f.read(6))
    textures = struct.unpack('h h h', f.read(6))
    triangles += [[vertices, textures]]
Leemos los frames. Cada frame esta compuesto de: un factor escala, un factor de traslación, un nombre para el frame y los vértices.
La escala son 3 float (4 bytes).
La traslación son 3 float.
El nombre del frame es una cadena de 16 bytes, si el largo del nombre es menor que 16 los restantes bytes se ponen a 0.
Cada vértice esta compuesto de 4 bytes, los 3 primeros corresponden a las coordenadas del mismo, y el 4° corresponde al índice de la normal a utilizar.
f.seek(ofs_frames, os.SEEK_SET)
frames = []

for frame in range(num_frames):
    scale = struct.unpack('f f f', f.read(12))
    translate = struct.unpack('f f f', f.read(12))
    name = struct.unpack('16s', f.read(16))[0].replace('\x00', '')

    vertices = []

    for vertex in range(num_xyz):
        vertices += [struct.unpack('B B B B', f.read(4))]

    frames += [[scale, translate, name, vertices]]
Finalmente leemos los comandos OpenGLOpenGL renderizar el modelo.
Comenzamos leyendo un int, el valor absoluto de este int indica el numero de vértice a trazar y el signo indica el tipo de triángulo a trazar, si es negativo se trazara un abanico de triángulos, y si es positivo se trazara una lista de triángulos.
Luego se lee la información de los vértice a trazar, cada vértice esta compuesto de 2 float y un int, los dos primeros números corresponden a las coordenadas de la textura y el tercero al índice donde se encuentran las coordenadas del vértice.
GL_TRIANGLE_FAN = -1
GL_TRIANGLE_STRIP = 1

f.seek(ofs_glcmds, os.SEEK_SET)
glcmds = []

while True:
    vertices_data = struct.unpack('i', f.read(4))[0]

    if vertices_data == 0:
        break

    n_vertices = abs(vertices_data)

    if vertices_data < 0:
        type_vertices = GL_TRIANGLE_FAN
    else:
        type_vertices = GL_TRIANGLE_STRIP

    vertices = []

    for vertex in range(n_vertices):
        vertices += [struct.unpack('f f i', f.read(12))]

    glcmds += [[n_vertices, type_vertices, vertices]]
Bueno, ya con esto terminamos de explicar el formato de archivo MD2.
Si tienen alguna duda, pueden consultar las especificaciones completas del formato en estos enlaces:

http://tfc.duke.free.fr/coding/md2-specs-en.html
http://tfc.duke.free.fr/old/models/md2.htm
http://amontandon.blogspot.com/2010/02/el-formato-de-archivo-md2-de-quake-ii.html

Inicializar OpenGL y conectarlo con QtDesigner

Primero debemos cargar los módulos de PyOpenGL y PyQt.
Los modulos OpenGL.GL y OpenGL.GLU son los que permiten utilizar las funciones para renderizar en OpenGL.
QtOpenGL es el modulo que nos permite conectar OpenGL con Qt.
from OpenGL.GL import *
from OpenGL.GLU import *
from PyQt4 import QtGui, QtOpenGL, QtCore
Para poder renderizar una escena OpenGL debemos crear un widget que herede la clase QGLWidget y sobrescribir los métodos initializeGL, resizeGL y paintGL.
initializeGL: Aquí escribimos el código necesario para initializar OpenGL.
resizeGL: Esta función es llamada cada vez que se redimenciona la ventana contenedora.
paintGL: Aquí escribimos el código necesario para renderizar la escena.
Para crear la animación, creamos un temporizador que llame al método paintGL cada determinado tiempo.
class OGLScene(QtOpenGL.QGLWidget):
    def __init__(self, parent):
        # Inicializamos la clase madre.
        QtOpenGL.QGLWidget.__init__(self, parent)

        # Creamos un temporizador para que renderize la escena cada 1 mili segundo.
        self.timer = QtCore.QTimer(parent)
        self.timer.setInterval(1)
        parent.connect(self.timer, QtCore.SIGNAL('timeout()'), self.paintGL)
        self.timer.start()

    def initializeGL(self):
        # Habilitamos el renderizado de texturas.
        glEnable(GL_TEXTURE_2D)

        # Limpiamos la escena con color negro antes de renderizar un nuevo cuadro(frame).
        glClearColor(0.0, 0.0, 0.0, 0.0)

        # Habilitamos y configuramos los calculos de profundidad para los objetos 3D.
        glClearDepth(1.0)
        glDepthFunc(GL_LESS)
        glEnable(GL_DEPTH_TEST)

        # Habilitamos el suavizado de los objetos para que no se vean tan cuadrados.
        glShadeModel(GL_SMOOTH)

        # Configuramos la escena.
        self.resizeGL()

    def resizeGL(self, width=0, height=0):
        # Configuramos las dimensiones de la ventana donde OpenGL renderizara la escena.
        glViewport(0, 0, self.width(), self.height())
        # Configuramos la matriz de proyeccion, la que OpenGL usa para renderizar la escena.
        glMatrixMode(GL_PROJECTION)
        # Cargamos la matriz identidad.
        glLoadIdentity()
        # Configuramos la perspectiva (angulo de vision, relacion de aspecto de la escena, punto mas cercano y mas lejano a renderizar).
        gluPerspective(self.view_angle, float(self.width())/float(self.height()), 0.1, self.view_zmax)
        # Cargamos la matriz de modelado, que usaremos para manipular los objetos dentro de la escena.
        glMatrixMode(GL_MODELVIEW)

    def paintGL(self):
        # Limpiamos el buffer de color y de profundidad.
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

        # Aquí colocamos el codigo para renderizar la escena.

        # Nosotros estamos usando la tecnica de doble buffer. Con esta tecnica OpenGL renderiza la escena en una capa invisible y cuando
        # llamamos a la funcion swapBuffers(), esta intercambia la capa visible con la invisible, entonces ahora si podemos visualizar
        # el resultado del render, y el ciclo vuelve a comenzar.
        # La tecnica de doble buffer evita que se produzcan parpadeos en la imagen.
        self.swapBuffers()
Ahora pasamos a diseñar la interfaz e QtDesigner. Simplemente basta con añadir un QLayout en el lugar donde queremos colocar nuestro QGLWidget.
La gran ventaja de esto es que luego, si queremos rediseñar la interfaz gráfica, agregar o quitar cosas, mover de lugar el widget, no habrá que tocar nada de código, no deberemos preocuparnos porque el widget deje de funcionar, ni nada. Además de que podremos darnos una idea de como quedara el programa en tiempo de diseño.

Ahora solo nos queda cargar la interfaz prediseñada, y añadir nuestro QGLWidget al QLayout, y listo!
import sys

from PyQt4 import QtGui, uic
# Cargamos nuestra clase para renderizar la escena en OpenGL.
from OGLScene import OGLScene

class MD2Example(QtGui.QMainWindow):
    def __init__(self):
        QtGui.QMainWindow.__init__(self)
        # Cargamos la interfaz grafica
        uic.loadUi('md2.ui', self)
        # y le agregamos QGLWidget.
        self.hlyOGLScene.addWidget(OGLScene(self))

# Ponemos en marcha nuesrtro programa.
if __name__ == "__main__":
    app = QtGui.QApplication(sys.argv)
    example = MD2Example()
    example.show()
    app.exec_()
Aquí les dejo la documentación de QGLWidget y un exelente tutorial de OpenGL.

http://doc.qt.nokia.com/stable/qglwidget.html
http://nehe.gamedev.net/

Renderización y animación del modelo

Bien, ahora que ya conocemos el formato MD2 y como levantar OpenGL solo nos queda renderizar el modelo.
Primero veamos como renderizar un único frame.
Lo primero que tenemos que hacer es seleccionar los frames a renderizar y luego ir trazando las caras del modelo mediante los comandos OpenGL.
Dado que las coordenadas de los vértices están comprimidas como bytes (valores enteros de 0 a 255) bastara con aplicarle escala y translación para convertirlas al formato que acepa OpenGL. La transformación es tan simple como aplicar la fomula algebraica:

$\begin{bmatrix} S_x & 0 & 0 & T_x \\ 0 & S_y & 0 & T_y \\ 0 & 0 & S_z & T_z \end{bmatrix}\cdot \begin{bmatrix} x \\ y \\ x \\ 1 \end{bmatrix}=\begin{bmatrix} x' \\ y' \\ x' \end{bmatrix}$

Una vez que obtenemos la posición real de los puntos debemos interpolar el frame actual con el siguiente. Pero, ¿Que significa eso de "interpolar"? Cuando, en este caso en particular, hablamos de interpolar, nos estamos refiriendo a interpolación lineal. La idea es que cuando tenemos un conjunto finito de puntos (los vértices que conforman el modelo en un determinado frame), tomamos dos puntos que forman parte de ese conjunto y queremos hallar un punto intermedio entre esos 2 puntos, matemáticamente sería:

Siendo $S=\left \{s_1,s_2,s_3,...,s_n \right \}$ para $n<N$

Con $A \in S$ y $B \in S$

Entonces:

$C=k(B-A)+A$ para $k \in [0,1]$

Esto lo que hace es crear una animación mas suave, agregando frames intermedios entre los frames claves. El valor de k es un numero float entre 0 y 1 que se pude tomar como:

$k=\frac{step}{total\_steps}$ para $step \in [0,total\_steps]$

total_steps es el numero de pasos entre el frame actual y el frame siguiente, por ejemplo si entre cada frame clave queremos agregar 5 frames adicionales, entonces total_steps será igual a 6.
def draw_mesh(self, n_frame=0, interpolation=0.0):
    # Le decimos a OpenGL que utilice la textura previamente cargada.
    glBindTexture(GL_TEXTURE_2D, self.gl_texture)

    # Obtenemos el frame actual y el siguiente.
    frame1 = self.frames[n_frame]
    frame2 = self.frames[(n_frame + 1) % self.header['num_frames']]

    # Dibujamos utilizando los comandos OpenGL(es mas rapido).
    for glcmd in self.glcmds:
        # Seleccionamos el tipo de comando con el que vamos a dibujar.
        glBegin((GL_TRIANGLE_FAN , GL_TRIANGLE_STRIP)[glcmd[1] == self.GL_TRIANGLE_STRIP])

        # Por cada vertice...
        for vertex in range(glcmd[0]):
            # Obtenemos los vertices del primer y segundo frame.
            vert1 = frame1[3][glcmd[2][vertex][2]]
            vert2 = frame2[3][glcmd[2][vertex][2]]

            # Agregamos la textura.
            glTexCoord2f(glcmd[2][vertex][0], 1.0 - glcmd[2][vertex][1])

            # Aplicamos las normales a la cara.
            # El formato MD2 se basa en utilizar una tabla de 162 normales precalculadas que nos permite aplicarle normales al modelo.
            glNormal3f \
            (
                anorms.anorms[vert1[3]][0] + interpolation * (anorms.anorms[vert2[3]][0] - anorms.anorms[vert1[3]][0]),
                anorms.anorms[vert1[3]][1] + interpolation * (anorms.anorms[vert2[3]][1] - anorms.anorms[vert1[3]][1]),
                anorms.anorms[vert1[3]][2] + interpolation * (anorms.anorms[vert2[3]][2] - anorms.anorms[vert1[3]][2])
            )

            # Aplicamos las transformaciones al vertice del primer frame.
            v_curr_0 = frame1[0][0] * vert1[0] + frame1[1][0]
            v_curr_1 = frame1[0][1] * vert1[1] + frame1[1][1]
            v_curr_2 = frame1[0][2] * vert1[2] + frame1[1][2]

            # Aplicamos las transformaciones al vertice del segundo frame.
            v_next_0 = frame2[0][0] * vert2[0] + frame2[1][0]
            v_next_1 = frame2[0][1] * vert2[1] + frame2[1][1]
            v_next_2 = frame2[0][2] * vert2[2] + frame2[1][2]

            # Trazamos la cara.
            glVertex3f \
            (
                v_curr_0 + interpolation * (v_next_0 - v_curr_0),
                v_curr_1 + interpolation * (v_next_1 - v_curr_1),
                v_curr_2 + interpolation * (v_next_2 - v_curr_2)
            )

        glEnd()
Ahora que ya sabemos como renderizar un frame solo nos queda renderizar la animación completa. Simplemente habrá que crear una función que cada vez que sea llamada desde PaintGL, renderize el frame actual y avance al frame siguiente. Easy, it's not?
def render(self):
    # Precalculamos la interpolacion para cada paso.
    self.float_steps = [float(step) / float(inter_steps) for step in range(inter_steps)]

    # Calculamos el numero de frame y el paso a renderizar.
    frame = int(self.cur_frame / self.inter_steps)
    step = self.cur_frame % self.inter_steps

    # Dibujamos el cuadro.
    self.draw_mesh(frame, self.float_steps[step])

    # Avanzamos un cuadro cada vez.
    self.cur_frame = (self.cur_frame + 1) % self.total_frames
El modelo animado esta sacado de aquí:

http://www.derschmale.com/2008/08/14/wick3d-source-available-md2-animated-model-demo/

Y pueden descargar mas modelos aquí:

http://trinit.es/DescargaDirecta/RecursosVideojuegos/Biblioteca3D/


Por fin hemos llegado al final de este tutorial larguisimo.
Solo quiero aclarar que esto es apenas un resumen, y que el código fuente mas abajo esta bastante mas optimizado que lo que logre plasmar aquí.
Ya sin mas les dejo las fuentes completas del ejemplo -->Aquí<--. Para correr el ejemplo necesitan Python > 2.7 y PyQt > 4.8, el ejemplo esta probado sobre ArchLinux.

No comments:

Post a Comment