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

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()