Pygame tiled map example
How do you create a tiled map 200 x 200 in Pygame?
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.
*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.
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.
You can download the source files here:
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()