Saltar a contenido

Gráficos primitivos en OpenGL

Visualizando funciones

En el siguiente ejemplo vamos a utilizar numpy para generar un rango de valores entre dos números que utilizaremos como eje x y calcularemos el valor y aplicando las funciones seno y coseno:

import sys
import math
import numpy as np
sys.path.append('..')
from res.App import App
from OpenGL.GL import *
from OpenGL.GLU import *


class GLUtils:

    @staticmethod
    def InitOrtho(left, right, top, bottom):
        glMatrixMode(GL_PROJECTION)
        glLoadIdentity()
        gluOrtho2D(left, right, top, bottom)

    @staticmethod
    def DrawGraph():
        glPointSize(5)
        glBegin(GL_POINTS)
        for px in np.arange(0, 15, 0.025):
            glColor3f(0, 0, 255)
            glVertex2f(px, math.sin(px))
            glColor3f(0, 255, 0)
            glVertex2f(px, math.cos(px))
        glEnd()


class OpenGLApp(App):

    def Setup(self):
        GLUtils.InitOrtho(0, 600, 400, 0)

    def Render(self):
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
        glMatrixMode(GL_MODELVIEW)
        glLoadIdentity()
        GLUtils.DrawGraph()


if __name__ == '__main__':
    app = OpenGLApp("OpenGL en Python", 900, 600, 60)
    app.Run()

El caso es que el seno y el coseno son funciones cuya posición y siempre está entre 0 y 1. Además estamos generando valores para x entre 0 y 15, ajustemos el espacio de visualización para dibujarlo más de cerca:

GLUtils.InitOrtho(0, 15, -1.5, 1.5)

Visualizando puntos

En el siguiente ejemplo vamos a capturar la posición del ratón en la pantalla (x,y) al hacer clic y en ella vamos a dibujar un punto. Para almacenarlo definiremos una nueva clase Point:

class Point:
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

Partiendo del ejemplo de haces un par de lecciones, definiremos un punto a nivel de instancia en el Setup cuyo valor asignaremos al detectar el clic del ratón sobrescribiendo el mètodo Inputs:

import sys
import math
import pygame as pg
sys.path.append('..')
from res.App import App
from OpenGL.GL import *
from OpenGL.GLU import *


class GLUtils:

    @staticmethod
    def InitOrtho(left, right, top, bottom):
        glMatrixMode(GL_PROJECTION)
        glLoadIdentity()
        gluOrtho2D(left, right, top, bottom)

    @staticmethod
    def PrepareRender():
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
        glMatrixMode(GL_MODELVIEW)
        glLoadIdentity()

    @staticmethod
    def DrawPoint(point, size):
        glPointSize(size)
        glBegin(GL_POINTS)
        glVertex2f(point.x, point.y)
        glEnd()


class OpenGLApp(App):

    def Init(self):
        # Creamos un punto
        self.point = None

    def Setup(self):
        # Configuramos un espacio ortográfico normalizado
        GLUtils.InitOrtho(0, 600, 400, 0)

    def Inputs(self):
        for event in self.events:
            if event.type == pg.MOUSEBUTTONDOWN:
                # Recuperamos la posición del ratón
                x, y = pg.mouse.get_pos()
                # Creamos un punto con la posición
                self.point = Point(x, y)

    def Render(self):
        GLUtils.PrepareRender()
        # Si hay un punto lo dibujamos
        if self.point is not None:
            GLUtils.DrawPoint(self.point, 5)


if __name__ == '__main__':
    app = OpenGLApp("OpenGL en Python", 900, 600, 60)
    app.Run()

Os fijaréis que el punto clicado y el dibujado no concuerdan, evidentemente el problema está en que el tamaño de la ventana de PyGame y el espacio de dibujo de OpenGL son distintos, podríamos equipararlos:

GLUtils.InitOrtho(0, 900, 600, 0)

Otra cosa que podemos hacer es almacenar varios puntos en una lista y dibujarlos todos de golpe:

class GLUtils:

    @staticmethod
    def DrawPoints(points, size):
        glPointSize(size)
        glBegin(GL_POINTS)
        for point in points:
            glVertex2f(point.x, point.y)
        glEnd()


class OpenGLApp(App):

    def Init(self):
        # Creamos una lista de puntos
        self.points = []

    def Inputs(self):
        for event in self.events:
            if event.type == pg.MOUSEBUTTONDOWN:
                # Recuperamos la posición del ratón
                x, y = pg.mouse.get_pos()
                # Creamos un punto con la posición
                point = Point(x, y)
                # Lo añadimos a la lista de puntos
                self.points.append(point)

    def Render(self):
        GLUtils.PrepareRender()

        # Dibujamos todos los puntos
        GLUtils.DrawPoints(self.points, 5)

Es importante separar la función de dibujar un punto y la de dibujar muchos de golpe para optimizar el renderizado.

Mapeado de puntos

En el ejemplo anterior hemos visto cómo adaptar el espacio de renderizado a la ventana de PyGame solventaba el problema de dibujar los puntos en el lugar adecuado, sin embargo en el mundo de los gráficos los vectores suelen venir normalizados con valores entre -1 y 1.

En este ejemplo vamos a mapear los valores del clic del ratón para dibujar sobre un espacio normalizado entre 0 y 1, así que tomando el ejemplo anterior podemos establecer ese espacio:

# Configuramos un espacio ortográfico normalizado
GLUtils.InitOrtho(0, 1, 1, 0)

Ahora voy a crear un método para mapear el punto, es decir, adaptarlo en un rango específo. Por ejemplo, si la posición el ratón se encuentra en (300, 200), justo en mitad de la ventana, la función de mapeado entre 0 y 1 deberá devolver (0.5, 0.5). Lo conseguiremos multiplicando el valor por el factor de redimensión (la relación entre los tamaños de los ejes):

@staticmethod
def MapValue(currentMin, currentMax, newMin, newMax, value):
    mapFactor = (newMax - newMin) / (currentMax - currentMin)
    return value * mapFactor

Después de capturar el punto lo mapeamos para adaptarlo al espacio:

def Inputs(self):
    for event in self.events:
        if event.type == pg.MOUSEBUTTONDOWN:
            # Recuperamos la posición del ratón
            x, y = pg.mouse.get_pos()
            # Creamos un punto con la posición mapeada
            point = Point(GLUtils.MapValue(0, 600, 0, 1, x),
                          GLUtils.MapValue(0, 400, 0, 1, y))
            # Lo añadimos a la lista de puntos
            self.points.append(point)

Sería muy útil almacenar el tamaño del espacio ortográfico en la clase para añadir flexibilidad al código. Podemos hacerlo sobreescribiendo el Init que se llama antes del Setup y está pensado como lugar para inicializar valores:

class OpenGLApp(App):

    def Init(self):
        self.ortoWidth, self.ortoHeight = 1, 1

    def Setup(self):
        GLUtils.InitOrtho(0, self.ortoWidth, self.ortoHeight, 0)

Así al llamar las funciones de mapeado podemos pasar estos valores dinámicamente

point = Point(GLUtils.MapValue(0, self.screenWidth, 0, self.ortoWidth, x),
              GLUtils.MapValue(0, self.screenHeight, 0, self.ortoHeight, y))

Dibujado de líneas

Continuando con el ejemplo anterior vamos a añadir un nuevo método exactamente igual que DrawPoints pero llamado DrawLines donde cambiaremos el tipo de renderizado de GL_POINTS a GL_LINES:

@staticmethod
def DrawLines(points, size):
    glPointSize(size)
    glBegin(GL_LINES)
    # Antes de dibujar el punto mapeamos su posición
    for point in points:
        glVertex2f(point.x, point.y)
    glEnd()

Si lo llamamos justo después de DrawPonts OpenGL trazará una línea cada dos puntos:

def Render(self):
    GLUtils.PrepareRender()
    GLUtils.DrawPoints(self.points, 5)
    GLUtils.DrawLines(self.points, 1)

Sin embargo, si cambiamos el modo a GL_LINE_LOOP trazará líneas contínuas entre cada nuevo vértice y el anterior:

glBegin(GL_LINE_LOOP)

Siempre unirá el último vértice con el primero para intentar crear una especie de polígono, pero eso podemos evitarlo cambiando el modo a GL_LINE_STRIP:

glBegin(GL_LINE_STRIP)

Con algo de ingenio, detectando cuando estamos presionando un botón del ratón con MOUSEBUTTONDOWN y MOUSEBUTTONUP más el evento MOUSEMOTION para detectar el movimiento, podemos crear una pequeña aplicación para dibujar:

class OpenGLApp(App):

    def Init(self):
        self.mouseDown = False

    def Inputs(self):
        for event in self.events:
            # Detectamos cando presionamos el ratón
            if event.type == pg.MOUSEBUTTONDOWN:
                self.mouseDown = True
            # Detectamos cando dehamos de presionarlo
            elif event.type == pg.MOUSEBUTTONUP:
                self.mouseDown = False
            # Si se mueve y estamos presionando guardamos los puntos
            elif event.type == pg.MOUSEMOTION and self.mouseDown:
                x, y = pg.mouse.get_pos()
                point = Point(GLUtils.MapValue(0, self.screenWidth, 0, self.ortoWidth, x),
                              GLUtils.MapValue(0, self.screenHeight, 0, self.ortoHeight, y))
                self.points.append(point)

El problema que al dejar de presionar y volver a hacerlo el último punto y el nuevo también se unen. ¿Cómo podríamos evitar que se unan? Pues podríamos crear un nuevo tipo llamado Line formado por una lista independiente con puntos:

class Line:
    def __init__(self):
        self.points = []

Si en lugar de almacenar los puntos en una lista común lo hacemos en una línea que empieza y acaba al presionar y dejar de presionar el ratón, entonces tendremos diferentes trazos:

def Init(self):
    self.currentLine = None
    self.lines = []

def Inputs(self):
    for event in self.events:
        if event.type == pg.MOUSEBUTTONDOWN:
            self.mouseDown = True
            # Creamos una línea y la añadimos a la lista
            self.currentLine = Line()
            self.lines.append(self.currentLine)
        elif event.type == pg.MOUSEBUTTONUP:
            self.mouseDown = False
        elif event.type == pg.MOUSEMOTION and self.mouseDown:
            x, y = pg.mouse.get_pos()
            point = Point(GLUtils.MapValue(0, self.screenWidth, 0, self.ortoWidth, x),
                          GLUtils.MapValue(0, self.screenHeight, 0, self.ortoHeight, y))
            # Almacenamos el punto en la línea actual
            self.currentLine.points.append(point)

Le pasamos todas las líneas a la función DrawLines:

def Render(self):
    GLUtils.PrepareRender()
    GLUtils.DrawLines(self.lines, 1)

Y las procesamos por separado:

@staticmethod
def DrawLines(lines, size):
    glPointSize(size)
    for line in lines:
        glBegin(GL_LINE_STRIP)
        for point in line.points:
            glVertex2f(point.x, point.y)
        glEnd()

Guardado y carga en un fichero

En esta práctica vamos a extender el código anterior para guardar y cargar el dibujo en un fichero siguiendo una estructura específica.

Hay mil y una formas de escribir y leer datos, yo me voy a decantar por guardar la información en un fichero json:

import json

def WriteFile(self):
    data = []
    # Creamos la estructura json sin objetos
    for line in self.lines:
        points = []
        for point in line.points:
            points.append([point.x, point.y])
        data.append(points)
    # Escribimos la estructura en el fichero
    with open("lines.json", "w") as file:
        json.dump(data, file)
    print("Fichero guardado correctamente")

Ejecutamos el método al presionar la tecla G:

if event.type == pg.KEYDOWN:
    if event.key == pg.K_g:
        self.WriteFile()

Crearemos otro método que lea el contenido al presionar la tecla C:

elif event.key == pg.K_c:
    self.ReadFile()

Se trata del proceso inverso, recuperar la información estructurada en formato json y recrear la lista de líneas en self.lines con sus respectivos puntos Point:

def ReadFile(self):
    # Leemos el fichero con las líneas
    with open("lines.json") as file:
        # Cargamos los datos en formato json
        data = json.load(file)
        # Recorremos las lineas cargadas
        for dataLine in data:
            # Creamos una línea vacía
            line = Line()
            # Para cada linea leída del fichero
            for point in dataLine:
                # Añadimos un nuevo punto
                line.points.append(Point(point[0], point[1]))
            # Vamos añadiendo las líneas con todos sus puntos
            self.lines.append(line)
    print("Fichero cargado correctamente")

Solo nos resta añadir una función para vaciar la lista de líneas al presionar por ejemplo la tecla espacio:

elif event.key == pg.K_SPACE:
    self.lines.clear()

Ya tendremos una aplicación para dibujar, guardar y recuperar el dibujo:

Polígonos, triángulos y quads

Retomando el anterior ejemplo del dibujado de líneas, vamos a aprovecharlo para introducir el dibujado de polígonos.

De hecho ya vimos que si activamos el modo GL_LINE_LOOP OpenGL cierra siempre el espacio entre el último vértice y el primero.

Si creamos un método DrawPolygon para dibujar puntos y activamos el modo GL_POLYGON, OpenGL rellenará el espacio entre los vértices:

class GLUtils:

    # ...

    @staticmethod
    def DrawPolygon(points, size):
        glPointSize(size)
        glBegin(GL_POLYGON)
        for point in points:
            glVertex2f(point.x, point.y)
        glEnd()

class OpenGLApp(App):

    def Init(self):
        self.points = []

    def Inputs(self):
        for event in self.events:
            if event.type == pg.KEYDOWN:
                if event.key == pg.K_SPACE:
                    self.points.clear()
            if event.type == pg.MOUSEBUTTONDOWN:
                x, y = pg.mouse.get_pos()
                point = Point(GLUtils.MapValue(0, self.screenWidth, 0, self.ortoWidth, x),
                              GLUtils.MapValue(0, self.screenHeight, 0, self.ortoHeight, y))
                self.points.append(point)
    def Render(self):
        GLUtils.PrepareRender()
        GLUtils.DrawPolygon(self.points, 1)

Podríamos dibujar el polígono, los puntos y las líneas con otros colores:

@staticmethod
def DrawPoints(points, size):
    glColor(1, 0, 0, 1) # rojo
    glPointSize(size)
    glBegin(GL_POINTS)
    for point in points:
        glVertex2f(point.x, point.y)
    glEnd()

@staticmethod
def DrawLine(points, size):
    glColor(1, 1, 1, 1) # blanco
    glPointSize(size)
    glBegin(GL_LINE_LOOP)
    for point in points:
        glVertex2f(point.x, point.y)
    glEnd()

@staticmethod
def DrawPolygon(points, size):
    glColor(0.2, 0.2, 0.2, 1) # gris
    glPointSize(size)
    glBegin(GL_POLYGON)
    for point in points:
        glVertex2f(point.x, point.y)
    glEnd()
    GLUtils.DrawLine(points, 3)
    GLUtils.DrawPoints(points, 5)

Es interesante observar ese efecto que se genera al no poder "encerrar" el polígono.

La verdad es que en lugar de un polígono podríamos decirle a OpenGL que intente dibujar primitivas como triángulos y cuadrados:

  • GL_TRIANGLES
  • GL_TRIANGLES_STRIP
  • GL_TRIANGLES_FAN
  • GL_QUADS
  • GL_QUADS_STRIP

Si probamos con GL_TRIANGLES:

OpenGL supone que cada tres vértices se forma un triángulo, pero estos triángulos son independientes y como las líneas se dibujan en un bucle unas tras otras no se ajustan a ellos.

Lo podemos resolver dibujando las líneas en subconjuntos de tres vértices generados con numpy:

@staticmethod
def DrawPolygon(points, size):
    glColor(0.2, 0.2, 0.2, 1)  # gris
    glPointSize(size)
    glBegin(GL_TRIANGLES)
    for point in points:
        glVertex2f(point.x, point.y)
    glEnd()
    # Dibujamos las líneas de tres en tres vértices
    for i in np.arange(0, len(points) - 2, 3):
        GLUtils.DrawLine([points[i], points[i + 1], points[i + 2]], 3)
    GLUtils.DrawPoints(points, 5)

Otro modo interesante es GL_TRIANGLE_FAN, éste intentará generar triángulos en forma de aspas de ventilador. No hace falta que dibujemos las líneas, solo observemos el resultado:

@staticmethod
def DrawPolygon(points, size):
    glColor(0.2, 0.2, 0.2, 1) # gris
    glPointSize(size)
    glBegin(GL_TRIANGLE_FAN)
    for point in points:
        glVertex2f(point.x, point.y)
    glEnd()
    GLUtils.DrawPoints(points, 5)

De forma similar funciona el renderizado de cuadrados GL_QUADS, en estos deberíamos dibujar las líneas en subconjuntos de 4 vértices:

@staticmethod
def DrawPolygon(points, size):
    glColor(0.2, 0.2, 0.2, 1)  # gris
    glPointSize(size)
    glBegin(GL_QUADS)
    for point in points:
        glVertex2f(point.x, point.y)
    glEnd()
    # Dibujamos las líneas de cuatro en cuatro vértices
    for i in np.arange(0, len(points) - 3, 4):
        GLUtils.DrawLine(
            [points[i], points[i + 1], points[i + 2], points[i + 3]], 3)
    GLUtils.DrawPoints(points, 5)

Los triángulos y los cuadrados son las primitivas básicas para el dibujado de mallas y constituyen la parte más esencial del renderizado de modelos tridimensionales.


Última edición: 3 de Octubre de 2022