OpenGL avanzado en Python con PyGame y PyOpenGL

     

    Introducción

    Siguiendo el artículo anterior, Comprender OpenGL a través de Python, donde hemos establecido las bases para un mayor aprendizaje, podemos saltar a OpenGL utilizando PyGame y PyOpenGL.

    PyOpenGL es la biblioteca estandarizada que se usa como puente entre Python y las API de OpenGL, y PyGame es una biblioteca estandarizada que se usa para crear juegos en Python. Ofrece bibliotecas de audio y gráficos útiles incorporadas y lo usaremos para representar el resultado más fácilmente al final del artículo.

    Como se mencionó en el artículo anterior, OpenGL es muy antiguo, por lo que no encontrará muchos tutoriales en línea sobre cómo usarlo y comprenderlo correctamente, porque todos los principales expertos ya están familiarizados con las nuevas tecnologías.

    En este artículo, veremos varios temas fundamentales que necesitará saber:

    • Inicializar un proyecto con PyGame
    • Objetos de dibujo
    • Animación iterativa
    • Utilizando matrices de transformación
    • Ejecución de transformación múltiple
    • Ejemplo de implementación

    Inicializar un proyecto con PyGame

    En primer lugar, necesitamos instalar PyGame y PyOpenGL si aún no lo ha hecho:

    $ python3 -m pip install -U pygame --user
    $ python3 -m pip install PyOpenGL PyOpenGL_accelerate
    

    Nota: Puede encontrar una instalación más detallada en el artículo anterior de OpenGL.

    Si tiene problemas con la instalación, PyGame “Empezando” La sección puede ser un buen lugar para visitar.

    Dado que no tiene sentido descargar 3 libros de teoría gráfica sobre ti, usaremos la biblioteca PyGame para darnos una ventaja. Básicamente, solo acortará el proceso desde la inicialización del proyecto hasta el modelado y la animación reales.

    Para empezar, necesitamos importar todo lo necesario tanto de OpenGL como de PyGame:

    import pygame as pg
    from pygame.locals import *
    
    from OpenGL.GL import *
    from OpenGL.GLU import *
    

    A continuación, llegamos a la inicialización:

    pg.init()
    windowSize = (1920,1080)
    pg.display.set_mode(display, DOUBLEBUF|OPENGL)
    

    Si bien la inicialización es solo de tres líneas de código, cada una merece al menos una explicación simple:

    • pg.init(): Inicialización de todos los módulos de PyGame: esta función es un regalo del cielo
    • windowSize = (1920, 1080): Definición de un tamaño de ventana fijo
    • pg.display.set_mode(display, DOUBLEBUF|OPENGL): Aquí, especificamos que usaremos OpenGL con doble búfer

    El almacenamiento en búfer doble significa que hay dos imágenes en un momento dado: una que podemos ver y otra que podemos transformar como mejor nos parezca. Vemos el cambio real causado por las transformaciones cuando se intercambian los dos búferes.

    Ya que tenemos configurada nuestra ventana gráfica, a continuación debemos especificar qué veremos, o más bien dónde se colocará la “cámara” y qué tan lejos y ancho puede ver.

    Esto se conoce como tronco – que es solo una pirámide recortada que representa visualmente la vista de la cámara (lo que puede y no puede ver).

    Un frustum se define mediante 4 parámetros clave:

    • El FOV (campo de visión): Ángulo en grados
    • La relación de aspecto: Definido como la relación entre el ancho y el alto
    • La coordenada z del plano de recorte cercano: La distancia mínima de dibujo
    • La coordenada z del plano de recorte lejano: La distancia máxima de dibujo

    Entonces, continuemos e implementemos la cámara con estos parámetros en mente, usando código OpenGL C:

    void gluPerspective(GLdouble fovy, GLdouble aspect, GLdouble zNear, GLdouble zFar);
    gluPerspective(60, (display[0]/display[1]), 0.1, 100.0)
    

    Para comprender mejor cómo funciona un frustum, aquí hay una imagen de referencia:

    Los aviones cercanos y lejanos se utilizan para un mejor rendimiento. Siendo realistas, renderizar cualquier cosa fuera de nuestro campo de visión es un desperdicio de rendimiento de hardware que podría usarse para renderizar algo que realmente podemos ver.

    Entonces, todo lo que el jugador no puede ver se almacena implícitamente en la memoria, aunque no esté visualmente presente. Aquí está un gran video de cómo se ve el renderizado solo dentro del frustum.

    Objetos de dibujo

    Después de esta configuración, imagino que nos estamos haciendo la misma pregunta:

    Bueno, todo esto está muy bien, pero ¿cómo puedo hacer un Super Destructor Estelar?

    Bueno … con puntos. Cada modelo en el objeto OpenGL se almacena como un conjunto de vértices y un conjunto de sus relaciones (qué vértices están conectados). Entonces, teóricamente, si supieras la posición de cada punto que se usa para dibujar un Super Destructor Estelar, ¡podrías dibujar uno!

    Hay algunas formas en que podemos modelar objetos en OpenGL:

    • Dibujando usando vértices, y dependiendo de cómo OpenGL interprete estos vértices, podemos dibujar con:
      • puntos: como en puntos literales que no están conectados de ninguna manera
      • líneas: cada par de vértices construye una línea conectada
      • triangulos: cada tres vértices forma un triángulo
      • cuadrilátero: cada cuatro vértices forma un cuadrilátero
      • polígono: tú entiendes
      • mucho mas…
    • Dibujar usando las formas y objetos incorporados que fueron cuidadosamente modelados por los colaboradores de OpenGL
    • Importación de objetos completamente modelados

    Entonces, para dibujar un cubo, por ejemplo, primero necesitamos definir sus vértices:

    cubeVertices = ((1,1,1),(1,1,-1),(1,-1,-1),(1,-1,1),(-1,1,1),(-1,-1,-1),(-1,-1,1),(-1, 1,-1))
    

    Luego, necesitamos definir cómo están todos conectados. Si queremos hacer un cubo de alambre, necesitamos definir las aristas del cubo:

    cubeEdges = ((0,1),(0,3),(0,4),(1,2),(1,7),(2,5),(2,3),(3,6),(4,6),(4,7),(5,6),(5,7))
    

    Esto es bastante intuitivo: el punto 0 tiene una ventaja con 1, 3y 4. El punto 1 tiene un borde con puntos 3, 5y 7, y así.

    Y si queremos hacer un cubo sólido, entonces necesitamos definir los cuadriláteros del cubo:

    cubeQuads = ((0,3,6,4),(2,5,6,3),(1,2,5,7),(1,0,4,7),(7,4,6,5),(2,3,0,1))
    

    Esto también es intuitivo: para hacer un cuadrilátero en la parte superior del cubo, querríamos “colorear” todo entre los puntos 0, 3, 6y 4.

    Tenga en cuenta que hay una razón real por la que etiquetamos los vértices como índices de la matriz en la que están definidos. Esto hace que escribir código que los conecte sea muy fácil.

    La siguiente función se utiliza para dibujar un cubo cableado:

    def wireCube():
        glBegin(GL_LINES)
        for cubeEdge in cubeEdges:
            for cubeVertex in cubeEdge:
                glVertex3fv(cubeVertices[cubeVertex])
        glEnd()
    

    glBegin() es una función que indica que definiremos los vértices de una primitiva en el siguiente código. Cuando terminamos de definir la primitiva, usamos la función glEnd().

    GL_LINES es una macro que indica que dibujaremos líneas.

    glVertex3fv() es una función que define un vértice en el espacio, hay algunas versiones de esta función, así que en aras de la claridad, veamos cómo se construyen los nombres:

    • glVertex: una función que define un vértice
    • glVertex3: una función que define un vértice usando 3 coordenadas
    • glVertex3f: una función que define un vértice usando 3 coordenadas de tipo GLfloat
    • glVertex3fv: una función que define un vértice usando 3 coordenadas de tipo GLfloat que se colocan dentro de un vector (tupla) (la alternativa sería glVertex3fl que usa una lista de argumentos en lugar de un vector)

    Siguiendo una lógica similar, se utiliza la siguiente función para dibujar un cubo sólido:

    def solidCube():
        glBegin(GL_QUADS)
        for cubeQuad in cubeQuads:
            for cubeVertex in cubeQuad:
                glVertex3fv(cubeVertices[cubeVertex])
        glEnd()
    

    Animación iterativa

    Para que nuestro programa sea “eliminable”, debemos insertar el siguiente fragmento de código:

    for event in pg.event.get():
        if event.type == pg.QUIT:
            pg.quit()
            quit()
    

    Básicamente es solo un oyente que se desplaza por los eventos de PyGame y, si detecta que hicimos clic en el botón “cerrar ventana”, cierra la aplicación.

    Cubriremos más eventos de PyGame en un artículo futuro; este se presentó de inmediato porque sería bastante incómodo para los usuarios y para ustedes mismos tener que iniciar el administrador de tareas cada vez que quieran salir de la aplicación.

    En este ejemplo, usaremos búfer doble, lo que solo significa que usaremos dos búferes (puede pensar en ellos como lienzos para dibujar) que se intercambiarán en intervalos fijos y darán la ilusión de movimiento.

    Sabiendo esto, nuestro código debe tener el siguiente patrón:

    handleEvents()
    glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
    doTransformationsAndDrawing()
    pg.display.flip()
    pg.time.wait(1)
    
    • glClear: Función que borra los búferes especificados (lienzos), en este caso, el búfer de color (que contiene información de color para dibujar los objetos generados) y el búfer de profundidad (un búfer que almacena las relaciones delante o detrás de todos los objetos generados).
    • pg.display.flip(): Función que actualizó la ventana con el contenido del búfer activo
    • pg.time.wait(1): Función que detiene el programa por un período de tiempo

    glClear tiene que ser usado porque si no lo usamos, solo estaremos pintando sobre un lienzo ya pintado, que en este caso es nuestra pantalla y vamos a terminar con un lío.

    A continuación, si queremos actualizar continuamente nuestra pantalla, como una animación, tenemos que poner todo nuestro código dentro de un while bucle en el que nosotros:

    • Manejar eventos (en este caso, simplemente saliendo)
    • Limpiar las zonas de influencia de color y profundidad para que se puedan volver a dibujar
    • Transforma y dibuja objetos
    • Actualizar la pantalla
    • GOTO 1.

    El código debería verse así:

    while True:
        handleEvents()
        glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
        doTransformationsAndDrawing()
        pg.display.flip()
        pg.time.wait(1)
    

    Utilizando matrices de transformación

    En el artículo anterior explicamos cómo, en teoría, necesitamos construir una transformación que tenga un punto de referencia.

    OpenGL funciona de la misma manera, como se puede ver en el siguiente código:

    glTranslatef(1,1,1)
    glRotatef(30,0,0,1)
    glTranslatef(-1,-1,-1)
    

    En este ejemplo, hicimos una rotación del eje z en el plano xy con el centro de rotación siendo (1,1,1) por 30 grados.

    Repasemos un poco si estos términos suenan un poco confusos:

    • La rotación del eje z significa que estamos girando alrededor del eje z

      Esto solo significa que estamos aproximando un plano 2D con un espacio 3D, toda esta transformación es básicamente como hacer una rotación normal alrededor de un punto de referencia en el espacio 2D.

    • Obtenemos el plano xy aplastando todo un espacio 3D en un plano que tiene z=0 (eliminamos el parámetro z en todos los sentidos)
    • El centro de rotación es un vértice alrededor del cual giraremos un objeto dado (el centro de rotación predeterminado es el vértice de origen (0,0,0))

    Pero hay un problema: OpenGL entiende el código anterior recordando y modificando constantemente una matriz de transformación global.

    Entonces, cuando escribe algo en OpenGL, lo que está diciendo es:

    # This part of the code is not translated
    # transformation matrix = E (neutral)
    glTranslatef(1,1,1)
    # transformation matrix = TxE
    # ALL OBJECTS FROM NOW ON ARE TRANSLATED BY (1,1,1)
    

    Como puede imaginar, esto plantea un gran problema, porque a veces queremos utilizar una transformación en un solo objeto, no en todo el código fuente. Esta es una razón muy común de errores en OpenGL de bajo nivel.

    Para combatir esta característica problemática de OpenGL, se nos presentan matrices de transformación de empuje y estallido: glPushMatrix() y glPopMatrix():

    # Transformation matrix is T1 before this block of code
    glPushMatrix()
    glTranslatef(1,0,0)
    generateObject() # This object is translated
    glPopMatrix()
    generateSecondObject() # This object isn't translated
    

    Estos funcionan de una manera simple Último en entrar primero en salir (LIFO) principio. Cuando deseamos realizar una traducción a una matriz, primero la duplicamos y luego la colocamos encima de la pila de matrices de transformación.

    En otras palabras, aísla todas las transformaciones que estamos realizando en este bloque creando una matriz local que podemos desechar una vez que hayamos terminado.

    Una vez que se traduce el objeto, sacamos la matriz de transformación de la pila, dejando el resto de las matrices intactas.

    Ejecución de transformación múltiple

    En OpenGL, como se mencionó anteriormente, las transformaciones se agregan a la matriz de transformación activa que está en la parte superior de la pila de matrices de transformación.

    Esto significa que las transformaciones se ejecutan en orden inverso. Por ejemplo:

    ######### First example ##########
    glTranslatef(-1,0,0)
    glRotatef(30,0,0,1)
    drawObject1()
    ##################################
    
    ######## Second Example #########
    glRotatef(30,0,0,1)
    glTranslatef(-1,0,0)
    drawObject2()
    #################################
    

    En este ejemplo, Object1 se gira primero, luego se traduce, y Object2 primero se traduce y luego se gira. Los dos últimos conceptos no se usarán en el ejemplo de implementación, pero se usarán prácticamente en el próximo artículo de la serie.

    Ejemplo de implementación

    El siguiente código dibuja un cubo sólido en la pantalla y lo gira continuamente 1 grado alrededor del (1,1,1) vector. Y se puede modificar muy fácilmente para dibujar un cubo de alambre intercambiando el cubeQuads con el cubeEdges:

    import pygame as pg
    from pygame.locals import *
    
    from OpenGL.GL import *
    from OpenGL.GLU import *
    
    cubeVertices = ((1,1,1),(1,1,-1),(1,-1,-1),(1,-1,1),(-1,1,1),(-1,-1,-1),(-1,-1,1),(-1,1,-1))
    cubeEdges = ((0,1),(0,3),(0,4),(1,2),(1,7),(2,5),(2,3),(3,6),(4,6),(4,7),(5,6),(5,7))
    cubeQuads = ((0,3,6,4),(2,5,6,3),(1,2,5,7),(1,0,4,7),(7,4,6,5),(2,3,0,1))
    
    def wireCube():
        glBegin(GL_LINES)
        for cubeEdge in cubeEdges:
            for cubeVertex in cubeEdge:
                glVertex3fv(cubeVertices[cubeVertex])
        glEnd()
    
    def solidCube():
        glBegin(GL_QUADS)
        for cubeQuad in cubeQuads:
            for cubeVertex in cubeQuad:
                glVertex3fv(cubeVertices[cubeVertex])
        glEnd()
    
    def main():
        pg.init()
        display = (1680, 1050)
        pg.display.set_mode(display, DOUBLEBUF|OPENGL)
    
        gluPerspective(45, (display[0]/display[1]), 0.1, 50.0)
    
        glTranslatef(0.0, 0.0, -5)
    
        while True:
            for event in pg.event.get():
                if event.type == pg.QUIT:
                    pg.quit()
                    quit()
    
            glRotatef(1, 1, 1, 1)
            glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
            solidCube()
            #wireCube()
            pg.display.flip()
            pg.time.wait(10)
    
    if __name__ == "__main__":
        main()
    

    Al ejecutar este fragmento de código, aparecerá una ventana de PyGame, renderizando la animación del cubo:

    Conclusión

    Hay mucho más que aprender sobre OpenGL: iluminación, texturas, modelado avanzado de superficies, animación modular compuesta y mucho más.

    Pero no se preocupe, todo esto se explicará en los siguientes artículos que enseñan al público sobre OpenGL de la manera correcta, desde cero.

    Y no te preocupes, en el próximo artículo dibujaremos algo semi-decente.

    .

    Etiquetas:

    Deja una respuesta

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