Design Patterns and Video Games

OpenGL 2D Facade (2): Mesh data

We get the best performance when sending mesh data to the GPU in a single operation. In this post, I present the numpy library that can create such data.

This post is part of the OpenGL 2D Facade series

Vertices and Python lists

In the previous post, we created rectangles thanks to the enumeration of 2D vertices:

glVertex2f(-0.5, -0.5)
glVertex2f(-0.5, 0.5)
glVertex2f(0.5, 0.5)
glVertex2f(0.5, -0.5)

We could store all these vertices in a Python list of lists:

vertices = [
    [-0.5, -0.5],
    [-0.5, 0.5],
    [0.5, 0.5],
    [0.5, -0.5]
]

Each item of the vertices list is a list with two float numbers. We can read each one using brackets []. For instance, we can print all of them in the following way:

for i in range(4):
    print(vertices[i])

It leads to these messages in the console:

 [-0.5, -0.5]
 [-0.5, 0.5]
 [0.5, 0.5]
 [0.5, -0.5]

To read one float value in each item of the vertices list, we must use brackets a second time. For instance, we can print float values in the following way:

for i in range(4):
    print("x =", vertices[i][0], "y =", vertices[i][1])

We get this display:

x = -0.5 y = -0.5
x = -0.5 y = 0.5
x = 0.5 y = 0.5
x = 0.5 y = -0.5

Numpy arrays

Unfortunately, we can't send Python list to a GPU, but we can with Numpy arrays.

First import Numpy:

import numpy as np

Note that I use an alias np. It means that, instead of writing numpy.func() we only need to write np.func().

We can create Numpy arrays from Python list of lists:

vertices = np.array([
    [-0.5, -0.5],
    [-0.5, 0.5],
    [0.5, 0.5],
    [0.5, -0.5]
], dtype=np.float32)

The second argument dtype=np.float32 tells Numpy that we want IEEE 754 32-bit float numbers, the one expected by OpenGL. There is also another option order that defines the order of values: row-major or column-major. The default value is row-major, the one excepted by OpenGL.

We can access values in a Numpy array with only of pair of brackets. For a 2D-array, the syntax is array[i, j] and is equivalent to list[i][j] with Python lists. We can get the same display as in the previous example with this syntax:

for i in range(4):
    print("x =", vertices[i, 0], "y =", vertices[i, 1])

All Numpy arrays know their size. At any time, you can ask for it with the shape attribute:

print(vertices.shape)

It displays (4, 2).

If you only need the size of one dimension, use the brackets:

vertexCount = vertices.shape[0]
vertexDim = vertices.shape[1]

In our example, vertexCount equals 4, and vertexDim equals 2.

Numpy arrays have many features that allow complex computations. I'll show examples throughout this series.

Vertex indexing

Meshes are made of faces; in our case, since we focus on tileset-based 2D rendering, these faces are rectangles (or quads). Each face has four vertices, like the ones in the Numpy array we created in the previous section.

A first approach to create all these meshes is to define four vertices for each face. For instance, for two faces/quads/rectangles:

vertices = np.array([
    [-0.5, -0.5],    # Face 0 vertex 0
    [-0.5, 0.5],     # Face 0 vertex 1
    [0.5, 0.5],      # Face 0 vertex 2
    [0.5, -0.5],     # Face 0 vertex 3
    [-0.3, -0.7],    # Face 1 vertex 0
    [-0.3, 0.3],     # Face 1 vertex 1
    [0.7, 0.3],      # Face 1 vertex 2
    [0.7, -0.7]      # Face 1 vertex 3
], dtype=np.float32)

It is fine as long as there is no shared vertex between faces. In the opposite case, we lose memory, and more importantly, some processing is more complicated to run.

For instance, if we want to create a grid of tiles:

2D Mesh grid with vertex indexing

You can see that there are three types of vertices:

With vertex indexing, we can describe such shares. It requires two arrays: the first one with vertex coordinates, and the second one with vertex indices.

Let's take the two previous rectangles to create such an index. There is no shared vertex, so I hope it is easier to understand.

The vertex array with coordinates in the same, and the corresponding faces/vertex indices is the following one:

faces = np.array([
    [0, 1, 2, 3],
    [4, 5, 6, 7]
], dtype=np.uint)

The first four indices (0, 1, 2, 3) point to the first four vertices. The last four indices (4, 5, 6, 7) point to the last four vertices.

Render from vertex index

We can render the rectangles using the vertices coordinates and the faces vertex indices:

glBegin(GL_QUADS)
for faceIndex in range(faces.shape[0]):
    for vertexIndex in faces[faceIndex]:
        color = colors[vertexIndex]
        glColor3f(color[0], color[1], color[2])
        vertex = vertices[vertexIndex]
        glVertex2f(vertex[0], vertex[1])
glEnd()

Lines 1 and 8 start then end the rendering of rectangles.

Line 2 iterates through the faces. Since we have two faces in our example, faceIndex gets value 0 then 1.

Line 3 iterates through vertex indices in the current face. During the first iteration, when faceIndex equals 0, vertexIndex gets values 0, 1, 2, and then 3. During the second iteration, when faceIndex equals 1, vertexIndex gets values 4, 5, 6, and then 7.

Lines 4-5 set the color of the next vertex. I created another Numpy array colors with (red, green, blue) values for each vertex.

Lines 6-7 render a vertex. Note that could also write:

glVertex2f(vertices[vertexIndex, 0], vertices[vertexIndex, 1])

Final program

This program renders the same rectangle as before but uses vertex indices. Note that you can play with the content of the Numpy arrays to change the rendering:

import os
import pygame
import numpy as np
from OpenGL.GL import glClear, GL_COLOR_BUFFER_BIT, glBegin, GL_QUADS, glEnd, glColor3f, glVertex2f, glFlush, \
    glClearColor

os.environ['SDL_VIDEO_CENTERED'] = '1'
windowSize = (1280, 800)
pygame.display.set_mode(windowSize, pygame.DOUBLEBUF | pygame.OPENGL)
pygame.display.set_caption("OpenGL 2D Facade - https://www.patternsgameprog.com/")
glClearColor(0.0, 0.0, 0.0, 1.0)

# All components of our scene
vertices = np.array([
    [-0.5, -0.5],
    [-0.5, 0.5],
    [0.5, 0.5],
    [0.5, -0.5],
    [-0.3, -0.7],
    [-0.3, 0.3],
    [0.7, 0.3],
    [0.7, -0.7]
], dtype=np.float32)

colors = np.array([
    [0.0, 0.0, 1.0],
    [0.0, 0.0, 1.0],
    [0.0, 0.0, 1.0],
    [0.0, 0.0, 1.0],
    [0.0, 1.0, 0.0],
    [0.0, 1.0, 0.0],
    [1.0, 0.0, 0.0],
    [1.0, 0.0, 0.0]
], dtype=np.float32)

faces = np.array([
    [0, 1, 2, 3],
    [4, 5, 6, 7]
], dtype=np.uint)
faceCount = faces.shape[0]

# Main loop
clock = pygame.time.Clock()
running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
            break
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False
                break

    glClear(GL_COLOR_BUFFER_BIT)

    glBegin(GL_QUADS)

    for faceIndex in range(faceCount):
        for vertexIndex in faces[faceIndex]:
            color = colors[vertexIndex]
            glColor3f(color[0], color[1], color[2])
            vertex = vertices[vertexIndex]
            glVertex2f(vertex[0], vertex[1])

    glEnd()

    glFlush()

    pygame.display.flip()
    clock.tick(60)

pygame.quit()
quit()

In the next post, I'll show how to send this data into OpenGL structures called "Vertex Array Objects".