Design Patterns and Video Games

OpenGL 2D Facade (8): Facade

I have enough content to start a facade: I refactorize all the code to satisfy the properties of the Facade pattern and get a more robust and extensible implementation.

This post is part of the OpenGL 2D Facade series

The Facade pattern

As a reminder, the Facade pattern objective is twofold:

Proposed facade

I propose the following facade:

OpenGL 2D Facade

The GUIFacade abstract class contains the API seen by the user of the facade:

The OpenGLGUIFacade is the implementation of the facade using OpenGL. We can see many attributes that correspond to the variables we used in the previous program. There are also two private methods, not part of the facade API: I split some methods to avoid large ones. It is a common and highly recommended practice in software design.

The GUIFacadeFactory class allows the creation of a facade, only knowing its name as a string. That way, the user of the facade never sees the implementation of the facade, not even the name of the main class. Note that this class follows the Abstract Factory pattern.

Use the facade

A good way to understand the implementation of a facade is to see its use in an example.

We first assume that some variables contain the properties of the level created by Tiled (as in the previous program):

levelWidth = 30
levelHeight = 20
rawLevel = np.array([
    ... all the tile codes created by Tiled ...
], dtype=np.int32)

We use the facade factory to create the facade:

guiFacade = GUIFacadeFactory().createInstance("OpenGL")

Note that, whatever the implementation of the facade, all the following code must always work.

We create the main window:

screenWidth = 32 * levelWidth
screenHeight = 32 * levelHeight
guiFacade.createWindow(
    "OpenGL 2D Facade - https://www.patternsgameprog.com/",
    screenWidth, screenHeight
)

The size of the main window depends on the level and tile sizes. It is not the best approach; we'll see in a future post how to create the main window with a fixed size, and render levels of any size using views.

We load the texture image file and set the size of tiles:

guiFacade.setTexture("grass.png", 32, 32)

The following converts the raw level data created by Tiled into a format supported by the facade, and sends it using setGridMesh():

textureTilesPerRow = guiFacade.textureWidth // guiFacade.tileWidth
rawLevel = rawLevel.reshape((levelHeight, levelWidth))
level = np.empty((levelWidth, levelHeight, 2), dtype=np.int32)
for y in range(levelHeight):
    for x in range(levelWidth):
        tileId = rawLevel[y, x] - 1
        level[x, y, 0] = tileId % textureTilesPerRow
        level[x, y, 1] = tileId // textureTilesPerRow

guiFacade.setGridMesh(level)

We launch the game:

guiFacade.run()

When the game is over, we capture the screen content (for debugging):

capture = guiFacade.captureScreenContent(0, 0, screenWidth, screenHeight)
Image.fromarray(capture).save("capture.png")

Finally, we delete all the GUI data and leave the program:

guiFacade.quit()

Implementation

Files and Folders

I organize the code into files and folders in a common way, where each python file correspond to a class and each folder to a package:

OpenGL 2D Facades files

The __init__.py files tell Python that the folder they are in is a package (so we can import files from it, as for libraries).

At the root, we find two files:

In the gui folder, there are the following files:

In the gui\opengl folder, there is the implementation of the facade using OpenGL in the OpenGLGUIFacade.py file.

The implementation of the GUIFacade abstract class is straightforward: all methods are abstract and raise a NotImplementedErrorexception. For instance, for the createWindow() method:

@abstractmethod
def createWindow(self, title: str, width: int, height: int):
    raise NotImplementedError()

Note that we set a type for arguments: it prevents errors and will save you a lot of debugging time. If you use an EDI like PyCharm, it understands these types, and warm you if you use wrong value types. You can also run mypy in an Anaconda console for an exhaustive type checking:

mypy --ignore-missing-imports run.py

The GUIFacadeFactory class is simple:

class GUIFacadeFactory:

    def createInstance(self, name: str) -> GUIFacade:
        if name == "OpenGL":
            return OpenGLGUIFacade()
        raise ValueError("Invalid facade type {}".format(name))

All the following sub-sections describe the implementation of the methods of the OpenGLGUIFacade class.

Constructor

In the constructor of the OpenGLGUIFacade class, we define all the attributes with default values:

def __init__(self):
    # Main properties
    self.__screenWidth: int = 0
    self.__screenHeight: int = 0

    # Shader properties
    self.__shaderProgramId: int = -1  # Shader program ID

    # Mesh properties
    self.__vaoId: int = -1  # Vertex Array Object ID
    self.__vboIds: List[int] = -1  # Vertex Buffer Object IDs
    self.__faceCount = 0  # Number of faces in the mesh

    # Texture properties
    self.__textureId: int = -1
    self.__textureWidth: int = 0
    self.__textureHeight: int = 0

    # Tile properties
    self.__tileWidth: int = 0
    self.__tileHeight: int = 0
    self.__textureTileWidth: float = 0
    self.__textureTileHeight: float = 0
    self.__screenTileWidth: float = 0
    self.__screenTileHeight: float = 0

Notes:

This constructor and the pseudo-private syntax is not mandatory: you can code in Python without it. Anyway, I highly recommend it, as it saves valuable time when the program becomes large.

Create the window

The creation of the window is as before, except that we save the screen size in attributes:

def createWindow(self, title: str, width: int, height: int):
    os.environ['SDL_VIDEO_CENTERED'] = '1'
    pygame.display.set_mode((width, height), pygame.DOUBLEBUF | pygame.OPENGL)
    pygame.display.set_caption(title)
    glClearColor(0.0, 0.0, 0.0, 1.0)
    self.__screenWidth = width
    self.__screenHeight = height

Load and set the texture

The loading and setting of the texture are also as before. Note that we set all the attributes that depend on the texture properties (texture size, tile size):

def setTexture(self, fileName: str, tileWidth: int, tileHeight: int):
    # Load texture image
    image = Image.open(fileName)
    assert image.mode == "RGBA"
    imageArray = np.array(image)

    # Create texture from image
    self.__textureId = glGenTextures(1)
    glPixelStorei(GL_UNPACK_ALIGNMENT, 4)
    glBindTexture(GL_TEXTURE_2D, self.__textureId)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST)
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST)
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image.size[0], image.size[1],
                 0, GL_RGBA, GL_UNSIGNED_BYTE, imageArray)

    # Tile properties
    self.__tileWidth = tileWidth
    self.__tileHeight = tileHeight
    self.__textureWidth = image.size[0]
    self.__textureHeight = image.size[1]
    self.__textureTileWidth = float(tileWidth) / float(self.__textureWidth)
    self.__textureTileHeight = float(tileHeight) / float(self.__textureHeight)
    self.__screenTileWidth = 2 * float(tileWidth) / float(self.__screenWidth)
    self.__screenTileHeight = 2 * float(tileHeight) / float(self.__screenHeight)

Set the grid mesh

In this method, we build the Numpy arrays with vertices, faces, and UV coordinates. Then, we call the setMeshData() private method to build the Vertex Array Object (VAO). I split these procedures into two methods to avoid a large one.

Note the first lines of the method: it checks that the shape of the grid argument is correct. It is not mandatory, but I recommend it: there is a high chance that one day we will call this kind of method with a wrong array shape. Thanks to these checks, we will immediately know that there is a problem. If not, we shall find ourselves in the unpleasant situation in which we have to debug for a long time...

def setGridMesh(self, grid: np.ndarray):
    assert grid.ndim == 3
    gridWidth = grid.shape[0]
    gridHeight = grid.shape[1]
    assert grid.shape[2] == 2

    vertices = np.empty([gridWidth, gridHeight, 4, 2], dtype=np.float32)
    uvMap = np.empty([gridWidth, gridHeight, 4, 2], dtype=np.float32)
    faces = np.empty([gridWidth, gridHeight, 4], dtype=np.uint)
    faceCount = 0
    for y in range(gridHeight):
        for x in range(gridWidth):
            spriteScreenX1 = -1 + x * self.__screenTileWidth
            spriteScreenY1 = 1 - y * self.__screenTileHeight
            spriteScreenX2 = spriteScreenX1 + self.__screenTileWidth
            spriteScreenY2 = spriteScreenY1 - self.__screenTileHeight
            vertices[x, y, 0] = [spriteScreenX1, spriteScreenY2]
            vertices[x, y, 1] = [spriteScreenX1, spriteScreenY1]
            vertices[x, y, 2] = [spriteScreenX2, spriteScreenY1]
            vertices[x, y, 3] = [spriteScreenX2, spriteScreenY2]

            spriteTextureX1 = grid[x, y, 0] * self.__textureTileWidth
            spriteTextureY1 = grid[x, y, 1] * self.__textureTileHeight
            spriteTextureX2 = spriteTextureX1 + self.__textureTileWidth
            spriteTextureY2 = spriteTextureY1 + self.__textureTileHeight
            uvMap[x, y, 0] = [spriteTextureX1, spriteTextureY2]
            uvMap[x, y, 1] = [spriteTextureX1, spriteTextureY1]
            uvMap[x, y, 2] = [spriteTextureX2, spriteTextureY1]
            uvMap[x, y, 3] = [spriteTextureX2, spriteTextureY2]

            faces[x, y, 0] = faceCount * 4
            faces[x, y, 1] = faceCount * 4 + 1
            faces[x, y, 2] = faceCount * 4 + 2
            faces[x, y, 3] = faceCount * 4 + 3
            faceCount += 1

    self.__setMeshData(vertices, faces, uvMap)

Set the mesh data

It is the private method called by setGridMesh(). As for attributes, the prefix __ tells Python that this method is private (or at least, it informs your users that they must not call it, as it may change without any notice). Most of its content is as before, except for the first lines that perform many checks, and the setting of attributes:

def __setMeshData(self, vertices: np.ndarray, faces: np.ndarray, uvMap: np.ndarray):
    assert vertices.ndim == 4
    gridWidth = vertices.shape[0]
    gridHeight = vertices.shape[1]
    assert vertices.shape[2] == 4
    assert vertices.shape[3] == 2
    assert vertices.dtype == np.float32

    assert faces.ndim == 3
    assert faces.shape[0] == gridWidth
    assert faces.shape[1] == gridHeight
    assert faces.shape[2] == 4
    assert faces.dtype == np.uint

    assert uvMap.ndim == 4
    assert uvMap.shape[0] == gridWidth
    assert uvMap.shape[1] == gridHeight
    assert uvMap.shape[2] == 4
    assert uvMap.shape[3] == 2
    assert uvMap.dtype == np.float32

    # Create one Vertex Array Object (VAO)
    self.__vaoId = glGenVertexArrays(1)
    # We will be working on this VAO
    glBindVertexArray(self.__vaoId)

    # Create three Vertex Buffer Objects (VBO)
    self.__vboIds = glGenBuffers(3)
    # Commodity variables to memorize the id of each VBO
    vertexVboId = self.__vboIds[0]
    uvVboId = self.__vboIds[1]
    indexVboId = self.__vboIds[2]

    # Copy vertices data to GPU
    glBindBuffer(GL_ARRAY_BUFFER, vertexVboId)
    vertices = np.ascontiguousarray(vertices.flatten())
    glBufferData(GL_ARRAY_BUFFER, 4 * len(vertices), vertices, GL_STATIC_DRAW)
    glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, None)

    # Copy UV coords data to GPU
    glBindBuffer(GL_ARRAY_BUFFER, uvVboId)
    uvMap = np.ascontiguousarray(uvMap.flatten())
    glBufferData(GL_ARRAY_BUFFER, 4 * len(uvMap), uvMap, GL_STATIC_DRAW)
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 0, None)

    # Copy index data to GPU
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexVboId)
    faces = np.ascontiguousarray(faces.flatten())
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, 4 * len(faces), faces, GL_STATIC_DRAW)
    self.__faceCount = gridWidth * gridHeight

Create the shaders

You should recognize the content of this private method, except for the setting of attributes:

def __createShaders(self):
    # Release focus on VBO and VAO
    glBindBuffer(GL_ARRAY_BUFFER, 0)
    glBindVertexArray(0)

    # Create vertex shader
    vertexShaderCode = '''#version 330
    layout (location=0) in vec4 vertex;
    layout (location=1) in vec2 inputUV;
                       out vec3 vertexColor;
                       out vec2 outputUV;
    void main() {
        gl_Position = vertex;
        outputUV = inputUV;
    }'''
    vertexShaderId = glCreateShader(GL_VERTEX_SHADER)
    glShaderSource(vertexShaderId, vertexShaderCode)
    glCompileShader(vertexShaderId)

    # Create fragment shader
    fragmentShaderCode = '''#version 330
    in vec2 outputUV;
    out vec4 color;
    uniform sampler2D textureColors;
    void main() {
        color = texture(textureColors, outputUV);
        //color = vec4(outputUV.x, outputUV.y, 0.0, 1.0);
    }'''
    fragmentShaderId = glCreateShader(GL_FRAGMENT_SHADER)
    glShaderSource(fragmentShaderId, fragmentShaderCode)
    glCompileShader(fragmentShaderId)

    # Create main shader program
    self.__shaderProgramId = glCreateProgram()
    glAttachShader(self.__shaderProgramId, vertexShaderId)
    glAttachShader(self.__shaderProgramId, fragmentShaderId)
    glLinkProgram(self.__shaderProgramId)

    # Print errors (if any)
    print(glGetProgramInfoLog(self.__shaderProgramId))

The main loop

The run() method calls the createShaders() private method to set the shaders, and then run the main game loop:

def run(self):
    self.__createShaders()

    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)
        glUseProgram(self.__shaderProgramId)

        glBindVertexArray(self.__vaoId)
        glEnableVertexAttribArray(0)
        glEnableVertexAttribArray(1)
        glDrawElements(GL_QUADS, 4 * self.__faceCount, GL_UNSIGNED_INT, None)
        glDisableVertexAttribArray(0)
        glDisableVertexAttribArray(1)
        glBindVertexArray(0)

        glUseProgram(0)

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

Capture the screen content

We capture the screen content as before, and return a Numpy array:

def captureScreenContent(self, x1: int, y1: int, x2: int, y2: int) -> np.ndarray:
    glFlush()
    glPixelStorei(GL_PACK_ALIGNMENT, 4)
    data = glReadPixels(x1, y1, x2, y2, GL_RGBA, GL_UNSIGNED_BYTE)
    capture = np.frombuffer(data, dtype=np.uint8)
    capture = capture.reshape(x2 - x1, y2 - y1, 4)
    capture = np.flip(capture, axis=0)
    return capture

Quit the program

We destroy all OpenGL objects, end Pygame, and leave the program:

def quit(self):
    glDeleteBuffers(3, self.__vboIds)
    glDeleteVertexArrays(1, self.__vaoId)
    glDeleteTextures(1, self.__textureId)
    pygame.quit()
    quit()

Final program

Download code and assets

In the next post, we'll see how to load a tmx file.