ARTICLE AD BOX
I'm making a platformer game using PyGame, and I'm going for a tile-based approach to each level, with a class that holds an entire 2D array of Tiles in a level-like layout, calling it a TileMap. When running the game, however, I'm expecting 120+ FPS, which should be expected as it's not a particularly demanding application to run.
However, I did start noticing that the game ran at 45-50 FPS, 60 at a stretch, which surprised me. Using a function with the modules cProfile, pstats and io that essentially functions as a debug message, I was shown that each frame, the TileMap.draw() function was calling the draw() method for every Tile in the camera's viewport (1536x960 on a 1920x1200 resolution screen, 125% zoom) - this was in total, 1519 times, so 1519 blits. This was extremely slow, taking up 38% of the time I needed each frame (3 out of 8 ms) to last if I wanted to hit 120 FPS. Therefore, I designed a method in the TileMap that, once all the tiles are set (this happens externally so you won't see the chunks being loaded inside the TileMap upon initialisation), the setter will also call TileMap.gen_chunks(), assigning the return list[pygame.Surface] to TileMap.chunks. Each chunk is the size of my screen, so for a level that is 35 by 30 tiles (each tile is 32 pixels), the entire level was covered in only two chunks.
I expected this to significantly improve my performance, after all, it's only two blits. I wrote a draw_fast() function - just in case it didn't work out I kept the old draw() function - and the debug message now showed me that there were only two blits taking place, like I expected. But for an unknown reason, it's taking 13 ms every single frame, which is almost double the target dt for 120 FPS alone. What have I done wrong? See my code below:
tilemap.py
from data.constants import * from world.tile import Tile, TileType from world.camera import Camera import logic.geometry as geometry import pygame import math #tilemap class, used for a large group of tiles class TileMap: def __init__(self, width: int, height: int): self.width = width self.height = height self.tiles = self.gen_tiles() #indexed [y][x] self.tile_size = TILE_SIZE self.start_tile : tuple[int, int] | None = None self.end_tile : tuple[int, int] | None = None self.chunks : list[pygame.Surface] = [] def gen_tiles(self): tiles : list[list[Tile]] = [] for row in range(self.height): row_list : list[Tile] = [] for col in range(self.width): row_list.append(Tile(TileType.EMPTY, pygame.Vector2(col * TILE_SIZE, row * TILE_SIZE))) tiles.append(row_list) return tiles def get_tile(self, grid_x: int, grid_y: int): if not(0 <= grid_x < self.width and 0 <= grid_y < self.height): return None return self.tiles[grid_y][grid_x] def set_tile(self, grid_x: int, grid_y: int, tile_type: TileType, direction: int): if not(0 <= grid_x < self.width and 0 <= grid_y < self.height): return False #failed self.tiles[grid_y][grid_x].type = tile_type self.tiles[grid_y][grid_x].direction = direction if tile_type == TileType.START: self.start_tile = (grid_x, grid_y) if tile_type == TileType.END: self.end_tile = (grid_x, grid_y) self.tiles[grid_y][grid_x].rect = self.tiles[grid_y][grid_x].gen_rect() self.tiles[grid_y][grid_x].surface = self.tiles[grid_y][grid_x].gen_surface() return True #success! def world_to_grid(self, world_pos: VectorConvertible): world_pos_vc = geometry.to_vector(world_pos) return (int(world_pos_vc.x / self.tile_size), int(world_pos_vc.y / self.tile_size)) def grid_to_world(self, grid_x: int, grid_y: int): return pygame.Vector2(grid_x * TILE_SIZE, grid_y * TILE_SIZE) def get_nearby_tiles(self, rect: pygame.Rect): nearby_tiles : list[Tile] = [] rect_topleft = self.world_to_grid(rect.topleft); rect_bottomright = self.world_to_grid(rect.bottomright) min_tile_x = max(0, rect_topleft[0] - NEARBY_TILE_PADDING) min_tile_y = max(0, rect_topleft[1] - NEARBY_TILE_PADDING) max_tile_x = min(self.width - 1, rect_bottomright[0] + NEARBY_TILE_PADDING) max_tile_y = min(self.height - 1, rect_bottomright[1] + NEARBY_TILE_PADDING) for row in range(min_tile_y, max_tile_y + 1): for col in range(min_tile_x, max_tile_x + 1): range_row = int(pygame.math.clamp(row, 0, self.height-1)) range_col = int(pygame.math.clamp(col, 0, self.width-1)) tile = self.tiles[range_row][range_col] if tile.type != TileType.EMPTY: nearby_tiles.append(tile) return nearby_tiles def get_respawn_pos(self): if self.start_tile != None: return self.tiles[self.start_tile[1]][self.start_tile[0]].rect.center def gen_chunks(self, camera: Camera): chunk_size = camera.get_viewport().size chunk_tile_size = (math.ceil(chunk_size[0] / TILE_SIZE), math.ceil(chunk_size[1] / TILE_SIZE)) chunks : list[pygame.Surface] = [] for x in range(math.ceil(chunk_tile_size[0] / self.width)): for y in range(math.ceil(chunk_tile_size[1] / self.height)): chunk_surf = pygame.Surface(chunk_size, pygame.SRCALPHA).convert_alpha() chunk_width = self.width if not self.width * (x + 1) > chunk_tile_size[0] else self.width * (x + 1) - chunk_tile_size[0] chunk_height = self.height if not self.height * (y + 1) > chunk_tile_size[1] else self.height * (y + 1) - chunk_tile_size[1] for tilex in range(chunk_width): for tiley in range(chunk_height): tile = self.tiles[tiley][tilex] tile.draw(chunk_surf) chunks.append(chunk_surf) return chunks def get_overlapping_chunks(self, rect: pygame.Rect): colliding_chunks : list[pygame.Surface] = [] for chunk in self.chunks: if rect.colliderect(chunk.get_rect()): colliding_chunks.append(chunk) return colliding_chunks def draw_fast(self, surface: pygame.Surface, camera: Camera): colliding_chunks = self.get_overlapping_chunks(camera.viewport.copy()) for chunk in colliding_chunks: chunk_rect = chunk.get_rect() surface.blit(chunk, chunk_rect.topleft) def draw(self, surface: pygame.Surface, camera: Camera): tile_grid_minx, tile_grid_miny = self.world_to_grid(camera.pos) max_world_pos = camera.pos + pygame.Vector2(camera.viewport.width, camera.viewport.height) tile_grid_maxx, tile_grid_maxy = self.world_to_grid(max_world_pos) for row in range(tile_grid_miny, tile_grid_maxy + 1): for col in range(tile_grid_minx, tile_grid_maxx + 1): draw_row = int(pygame.math.clamp(row, 0, self.height-1)) draw_col = int(pygame.math.clamp(col, 0, self.width-1)) tile = self.tiles[draw_row][draw_col] # Temporarily offset tile for drawing original_pos = tile.rect.topleft tile.rect = camera.world_rect_to_screen(tile.rect) tile.draw(surface) tile.rect.topleft = original_pos def __iter__(self): return self.tiles.__iter__() def __len__(self): return self.tiles.__len__()