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
As a reminder, the Facade pattern objective is twofold:
I propose the following facade:
The GUIFacade
abstract class contains the API seen by the user of the facade:
createWindow()
method creates the main window with a given title and size;setTexture()
method defines the texture image that contains the tileset, given an image file and the size of tiles;setGridMesh()
method defines all the tiles to draw. The grid
argument is a Numpy array with shape (width, height, 2) that contains the coordinates in the tileset from each tile of a grid;run()
method contains the main loop of the game;captureScreenContent()
method returns the pixels of a rendering area as a Numpy array;quit()
method deletes all GUI data and ends the program.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.
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()
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:
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:
grass.png
: the image tileset;run.py
: the main program that contains the code described in the previous section.In the gui
folder, there are the following files:
GUIFacade.py
: the abstract facade class;GUIFacadeFactory.py
: the facade factory class;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 NotImplementedError
exception. 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.
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:
__
: it is a common solution in Python to create pseudo-private attributes. Note that they are not truly private, there is still a way to access them (it is complicated). It is impossible to have private members in Python.textureId
is -1. It is to get an expected behavior if we forgot to set an attribute.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.
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
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)
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)
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
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 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)
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
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()
In the next post, we'll see how to load a tmx file.