Pygame tiled map example
2015-07-22
How do you create a tiled map 200 x 200 in Pygame?
Blitting
Firstly, you might want to try generating a bitmap of visible tiles, and then put that on screen. Too slow? Could be. If you have an average screen of 1024 x 1024, then that is 1024 x 1024 * 4 bytes of data, or ~ 4.1Meg. Ideally you want to put all that on screen every 1/60 of a second to achieve a frame rate of 60fps. That amounts to 4.1 * 60 = 246MB/s
Only a measly 246M/s data rate is required. Seems plausible, but apparently you can only get a frame rate of around 10fps (it seems) using this method. Exactly why isn't clear, but there is more than just PCI bandwidth involved, you have to create an in memory image to start with. PCI buses are in the order of tens of GB/s so you would think it would be fast enough, but it seems not. There are several other reasons why this might be slow and that's an investigation for another day.
Enter OpenGL:
*What about DirectX? I hear you say.*No, never head of it.
OpenGL is fast isn't it? Must be. OK, the second try is as follows:
Using OpenGL's immediate mode:
For one tile to be rendered, you need something like this:
:::python3
glBegin(GL_QUADS)
glTexCoord2d(s,t)
glVertex2f(x, y)
glTexCoord2d(s,t)
glVertex2f(x, y)
glTexCoord2d(s,t)
glVertex2f(x, y)
glTexCoord2d(s,t)
glVertex2f(x, y)
glEnd(GL_QUADS)
That is the pseudo-code for one "quad". This is fine, until such time as you need a map of 200 x 200. Then you need a loop to emit 40,000 of them to the graphics card. Not so bad? Well try it, you might find it's quite slow. [ try increasing the map dimensions a bit too ] `
Why is it so slow? Well, Per frame, there are now
- 40,000 quads being sent to the graphics driver, consisting of 10 function calls each. So that's 400,000 function calls per frame. And that's 400,000 * 60 = 24 Million function calls per second. Hmm.`
- The driver must do some work to send all this data to the card, and it's done every frame.
The solution
First, if you only have a few quads, i.e. hundreds, then it doesn't matter, the "immediate mode" of OpenGL is a good solution in these situations. "World of goo" seems to use this approach. If you run this game under dxexplorer, you will find that there are only up to about a thousand triangles on the screen at once.
In general, for anything OpenGL that requires a lot of geometry try as best as you can to send the minimum amount of information to the graphics driver on each frame. This leads us to using VBO's or Vertex Buffer Objects. The data that is your vertex coordinates and texture coordinates are now allocated on the graphics card and are not uploaded each frame. You upload the data once, and modify parts of the data stored in graphics memory. Most modern cards support this feature.
Sample code
You can download the source files here:
http://bazaar.launchpad.net/~jspashett/+junk/vbo_tiling/files/7?start_revid=7
import pygame
from pygame.locals import *
from OpenGL.GL import *
from OpenGL.GLU import *
from OpenGL.arrays import vbo
from numpy import array
import sys, ctypes, random, collections, math
# Tuple types
ScreenDim = collections.namedtuple('ScreenDim', ['w', 'h'])
Point = collections.namedtuple('Point', ['x', 'y'])
class VBODemo:
""" VBODemo main class, demonstrating a tiled map."""
MIP_MAP = True # Use mip mapped textures, or not
TILE_CHANGES_PER_FRAME = 100 # How many tiles are changed each frame
FRAME_LIMITER = 30 # 5000 # The frame rate limiter
TILE_EDGE = 50 # The length of a tile edge
SIZE_X = 100 # number of tiles across
SIZE_Y = 100 # Number of tiles down
TOTAL_TILES = SIZE_X * SIZE_Y
ATLAS_DIM = Point(5,1) # Don't change the Y coordinate, it's not implemented
def __init__(self):
self.last_frame_time = 0
self.screendim = ScreenDim(800, 600) # Dimensions of the screen
self.origin = Point(self.screendim.w / 2, self.screendim.h / 2) # THe origin of the screen set to the centre
self.zoom = 1 # Current zoon
self.d_zoom = 0.99 # Zoom speed
self.rotation = 0 # Current rotation
random.seed() # New balls please.
# Initialise pygame
pygame.init()
pygame.display.set_caption('VBO Test')
self.main_clock = pygame.time.Clock()
OpenGL.ERROR_CHECKING = False
pygame.display.set_mode(self.screendim, pygame.OPENGL|pygame.DOUBLEBUF)
self.check_opengl()
self.render_setup()
self.atlas_load()
self.generate_map()
self.generate_texture_vbo()
def check_opengl(self):
print "GL_VERSION:", glGetString(GL_VERSION)
print "GL_VENDOR:", glGetString(GL_VENDOR)
print "GL_RENDERER:", glGetString(GL_RENDERER)
extension_list = glGetString(GL_EXTENSIONS)
print "GL_EXTENSIONS:", glGetString(GL_EXTENSIONS)
if not glMapBuffer:
raise RuntimeError("Your graphics card does not support glMapBuffer.")
def render_setup(self):
glClearColor(0.3, 0.3, 0.3, 1.0)
glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
gluOrtho2D(0, self.screendim.w, 0, self.screendim.h);
glMatrixMode(GL_MODELVIEW);
# Move screen origin to centre.
glTranslatef(self.origin.x, self.origin.y, 0) # Reposition screen origin
#set up texturing
glEnable(GL_TEXTURE_2D)
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
def atlas_load(self):
""" Load the text atlas. """
brick = pygame.image.load('tex.png')
img_data = pygame.image.tostring(brick, 'RGBA', 0)
w = brick.get_width()
h = brick.get_height()
self.texture_id = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, self.texture_id)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
if self.MIP_MAP == True: # Use mip maping
# Enable those bad boy mip maps
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_NEAREST)
if gluBuild2DMipmaps(GL_TEXTURE_2D, 3, w, h, GL_RGBA, GL_UNSIGNED_BYTE, img_data):
raise RuntimeError("Error building mipmaps")
else: # Use bilinear filtering.
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data)
def generate_map(self):
""" Make a map of self.SIZE_X by self.SIZE_Y """
TILE_EDGE = self.TILE_EDGE
tiles = list()
# Calculate the centre of the tile map
map_origin = Point( (self.SIZE_X * TILE_EDGE / 2), (self.SIZE_Y * TILE_EDGE / 2) )
for y in xrange(self.SIZE_Y):
y_trans = y * TILE_EDGE - map_origin.y
for x in xrange(self.SIZE_X):
x_trans = x * TILE_EDGE - map_origin.x
tiles.append([
[0 + x_trans, 0 + y_trans],
[0 + x_trans, TILE_EDGE + y_trans],
[TILE_EDGE + x_trans, TILE_EDGE + y_trans],
[TILE_EDGE + x_trans, 0 + y_trans],
])
self.box_vbo = vbo.VBO( array(tiles, 'f') )
def generate_texture_vbo(self):
# Set default texture coordinates to the left vertical sliver of the atlas.
# This helps later becuase the Y co-ordinate doesn't need to be changed.
tex_list = [
0, 1,
0, 0,
0, 0,
0, 1
] * self.TOTAL_TILES
self.tex_buff = glGenBuffers(1) # Lets forget to free this buffer here.
glBindBuffer(GL_ARRAY_BUFFER, self.tex_buff)
array_type = (GLfloat * len(tex_list))
glBufferData(GL_ARRAY_BUFFER, array_type(*tex_list), GL_STATIC_DRAW)
def change_tiles(self):
# NOTE - vbo.mapVBO can be used, however it seems to return an array of bytes, which isn't what is required
# do not know how to cast this into an array of floats. So instead the manual opengl api style of mapping is used.
# The vbo.mapVBO function is also classed as experimental.
# Select the texture vbo
glBindBuffer(GL_ARRAY_BUFFER, self.tex_buff)
# Map the vbo into memory
# THIS FUNCTION CAN BLOCK your thread.
int_dataptr = glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY)
# Make a python pointer from the integer address
ptr = ctypes.cast(int_dataptr, ctypes.POINTER(ctypes.c_float))
for times in range(self.TILE_CHANGES_PER_FRAME):
# Choose a tile at random
tile_index = random.randrange(self.TOTAL_TILES)
# Caluculate it's offset in the array. 4 texture coordinates s, t each, make 8 floats per tile
tile_ofs = tile_index * 8
# Select a texture
TILE_WIDTH = 1.0 / self.ATLAS_DIM.x
i = random.randrange(self.ATLAS_DIM.x)
s1, s2 = (i * TILE_WIDTH, TILE_WIDTH + i * TILE_WIDTH )
# Modify the s texture coordinates to select the correct texture. The t co-ordiante stays the same
# as the texture atlas is only a horizontal strip
ptr[tile_ofs + 0] = s1
ptr[tile_ofs + 2] = s1
ptr[tile_ofs + 4] = s2
ptr[tile_ofs + 6] = s2
glUnmapBuffer(GL_ARRAY_BUFFER)
def render_loop(self):
while True:
# Pygame processing
for event in pygame.event.get():
if event.type == QUIT or (event.type == KEYDOWN and \
event.key == K_ESCAPE):
pygame.quit()
sys.exit()
glPushMatrix()
# Zooming
# Calculate a zoom out level based on the geometric average of the map edge plus a factor
ZOOM_OUT_LEVEL = 0.2 / math.sqrt(self.TOTAL_TILES)
if self.zoom < ZOOM_OUT_LEVEL * self.TILE_EDGE or self.zoom > 8:
self.d_zoom = 1 / self.d_zoom
glScale(self.zoom, self.zoom, 1);
self.zoom *= self.d_zoom
# Slight rotation
glRotate(self.rotation, 0, 0, 1)
self.rotation += 0.1/self.zoom #0.1 # too fast cuases skickness
# Clear the render buffer, and z buffer
glClear(GL_COLOR_BUFFER_BIT)
# Select atlas texture
glBindTexture(GL_TEXTURE_2D, self.texture_id)
# Select the map mesh vbo
self.box_vbo.bind()
# Enable vertex render from vbo, and texture render from vbo
glEnableClientState(GL_VERTEX_ARRAY)
glEnableClientState(GL_TEXTURE_COORD_ARRAY)
# Set geometry array configuartion
# There are two coordinates per vertex, each being a float, with no stride
glVertexPointer(2, GL_FLOAT, 0, self.box_vbo)
# Select the texture VBO
glBindBuffer(GL_ARRAY_BUFFER, self.tex_buff)
# Set texture array configuration
glTexCoordPointer(2, GL_FLOAT, 0, None)
# Draw the mesh using the selected mesh and texture arrays
glDrawArrays(GL_QUADS, 0, self.TOTAL_TILES * 4)
#
glDisableClientState(GL_VERTEX_ARRAY)
glDisableClientState(GL_TEXTURE_COORD_ARRAY)
glPopMatrix()
# Modify the texture array by mapping it to processor memory
self.change_tiles();
pygame.display.flip()
self.main_clock.tick(self.FRAME_LIMITER)
if pygame.time.get_ticks() - self.last_frame_time > 1000:
print "Frame rate:", self.main_clock.get_fps(), "Frame limit", self.FRAME_LIMITER
self.last_frame_time = pygame.time.get_ticks()
demo = VBODemo()
demo.render_loop()