OpenGL avanzado en Python con PyGame y PyOpenGL

PyOpenGL es un puente estandarizado entre OpenGL y Python. PyGame es una biblioteca estandarizada para hacer juegos con Python. En este artículo, aprovecharemos los dos y cubriremos algunos temas importantes en OpenGL con Python.

Introducción

Siguiendo con el artículo anterior, Entender OpenGL a través de Python donde sentamos las bases para seguir aprendiendo, podemos saltar a [OpenGL](https: //www.opengl.org/) usando 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 gráficas y de audio prácticas incorporadas y la 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 correctamente y comprenderlo porque todos los perros principales ya están hasta las rodillas en nuevas tecnologías.

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

Inicializar un proyecto usando PyGame

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

1
2
$ 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 tienes problemas con la instalación, la sección "Empezando" de PyGame puede ser un buen lugar para visitar.

Ya que no tiene sentido descargarte 3 libros de teoría de gráficos, usaremos la biblioteca de PyGame para darnos una ventaja. Esencialmente, 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:

1
2
3
4
5
import pygame as pg
from pygame.locals import *

from OpenGL.GL import *
from OpenGL.GLU import *

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

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

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

  • pg.init(): Inicialización de todos los módulos PyGame - esta función es una bendición
  • 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 almacenamiento en 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. Podemos ver el cambio real causado por las transformaciones cuando los dos búferes se intercambian.

Ya que tenemos configurada nuestra ventana gráfica, a continuación debemos especificar lo que veremos, o mejor dicho, dónde se colocará la "cámara", y qué tan lejos y ancho puede ver.

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

Un frustum se define por 4 parámetros clave:

  1. El FOV (campo de visión): ángulo en grados
  2. La relación de aspecto: definida como la relación entre el ancho y la altura
  3. La coordenada z del plano de recorte cercano: la distancia de dibujo mínima
  4. La coordenada z del Plano de recorte lejano: La distancia máxima de dibujo

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

1
2
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:

vista de sectores

Los planos cercano y lejano 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í hay un [gran video] (https://www.youtube.com/watch?v=VqH8kcmD-HI) de cómo se ve la renderización solo dentro del frustum.

Objetos de dibujo {#objetos de dibujo}

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

Bueno, todo esto está muy bien, pero ¿cómo hago un Super Star Destroyer?

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 Star Destroyer, ¡podrías dibujar uno!

Hay algunas formas en que podemos modelar objetos en OpenGL:

  1. Dibujar usando vértices, y dependiendo de cómo OpenGL interprete estos vértices, podemos dibujar con:
    • points: as in literal points that are not connected in any way
    • lines: every pair of vertices constructs a connected line
    • triangles: every three vertices make a triangle
    • quadrilateral: every four vertices make a quadrilateral
    • polygon: you get the point
    • many more...
  2. Dibujar usando las formas y objetos incorporados que fueron minuciosamente modelados por colaboradores de OpenGL
  3. Importación de objetos completamente modelados

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

1
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))

drawing a cube

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

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))

Esto es bastante intuitivo: el punto ‘0’ tiene una arista con ‘1’, ‘3’ y ‘4’. El punto 1 tiene una arista con los puntos 3, 5 y 7, y así sucesivamente.

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

1
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, queremos "colorear" todo lo que se encuentre entre los puntos 0, 3, 6 y 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:

1
2
3
4
5
6
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 código siguiente. 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, por lo 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:

1
2
3
4
5
6
def solidCube():
    glBegin(GL_QUADS)
    for cubeQuad in cubeQuads:
        for cubeVertex in cubeQuad:
            glVertex3fv(cubeVertices[cubeVertex])
    glEnd()

Animación iterativa {#animación iterativa}

Para que nuestro programa sea "killable" necesitamos insertar el siguiente fragmento de código:

1
2
3
4
for event in pg.event.get():
    if event.type == pg.QUIT:
        pg.quit()
        quit()

Es básicamente un oyente que se desplaza a través de los eventos de PyGame, y si detecta que hicimos clic en el botón "matar ventana", sale de 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 tener que iniciar el administrador de tareas cada vez que quieren salir de la aplicación.

En este ejemplo, usaremos doble búfer, lo que 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 tiene que tener el siguiente patrón:

1
2
3
4
5
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 delante de o relaciones in-back-of de todos los objetos generados).
  • pg.display.flip(): Función que actualizaba la ventana con el contenido del búfer activo
  • pg.time.wait(1): Función que pausa el programa por un período de tiempo

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

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

  1. Manejar eventos (en este caso, simplemente salir)
  2. Borre los búferes de color y profundidad para que se puedan volver a dibujar.
  3. Transforma y dibuja objetos
  4. Actualizar la pantalla
  5. IR A 1.

El código debería ser algo como esto:

1
2
3
4
5
6
while True:
    handleEvents()
    glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
    doTransformationsAndDrawing()
    pg.display.flip()
    pg.time.wait(1)

Utilización de matrices de transformación {#utilización de 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:

1
2
3
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.

Vamos a refrescar un poco si estos términos suenan un poco confusos:

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

    This just means we're approximating a 2D plane with a 3D space, this whole transformation is basically like doing a normal rotation around a referral point in 2D space.

  2. Obtenemos el plano xy al aplastar un espacio 3D completo en un plano que tiene z=0 (eliminamos el parámetro z en todos los sentidos)

  3. El centro de rotación es un vértice alrededor del cual rotaremos un objeto determinado (el centro de rotación predeterminado es el vértice de origen (0,0,0))

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

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

1
2
3
4
5
# 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 para los errores en OpenGL de bajo nivel.

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

1
2
3
4
5
6
# 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 con un principio simple Último en entrar, primero en salir (LIFO). Cuando deseamos realizar una traducción a una matriz, primero la duplicamos y luego la empujamos sobre la pila de matrices de transformación.

En otras palabras, aísla todas las transformaciones que estamos realizando en este bloque al crear una matriz local que podemos descartar 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 {#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á encima de la pila de matrices de transformación.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
######### 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, primero se gira el Objeto1, luego se traslada, y primero se traslada el Objeto2 y luego se gira. Los dos últimos conceptos no se utilizarán en el ejemplo de implementación, pero se utilizará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 rota continuamente 1 grado alrededor del vector (1,1,1). Y se puede modificar muy fácilmente para dibujar un cubo de alambre intercambiando cubeQuads con cubeEdges:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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, mostrando la animación del cubo:

pygame cube animation

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.