Title: Use rectangles in the call to display.update in Pygame and Python
The post Improve Pygame performance in Python explores ways that you can improve Pygame performance, mostly by limiting the number of frames per second the program displays. This example looks at another technique: passing update rectangles to the call to pygame.display.update.
Bouncing Balls
The example is based on the post Make a game framework with Python and Pygame, which draws bouncing balls on the window. See that example for details about how it works.
For the current program, I did make one change to the BallSprite class: I added the following get_rect method.
def get_rect(self):
return pygame.Rect(
self.center[0] - self.radius,
self.center[1] - self.radius,
2 * self.radius,
2 * self.radius)
This method uses the ball's center and radius properties to return the ball's bouncing rectangle.
Updating Everything
Here's the program's event loop.
# Process events.
def event_loop(self):
clock = pygame.time.Clock()
test_seconds = 10
for num_sprites in [2, 5, 10, 20, 50]:
self.make_sprites(num_sprites, self.min_radius,
self.max_radius, self.min_velocity, self.max_velocity)
##########
# Test 1 #
##########
# Update the whole window.
stop_time = time.time() + test_seconds
start_process_time = time.process_time()
running = True
while running:
if time.time() > stop_time:
running = False
for event in pygame.event.get():
if event.type == pygame.locals.QUIT:
running = False
break
# See how much time has passed since the last update.
elapsed_ticks = clock.tick(self.max_fps)
elapsed_seconds = elapsed_ticks / 1000
# Update the sprites.
self.update_sprites(elapsed_seconds)
# Draw the sprites.
self.draw_sprites()
# Update the display.
pygame.display.update()
elapsed = time.process_time() - start_process_time
print(f'{num_sprites:2} Sprites, Update All: {elapsed:.4f} seconds')
This code creates a clock and sets test_seconds to 10 so the program will run each test for 10 seconds. It then loops through trials using 2, 5, 10, 20, and 50 balls.
For a given number of balls, the code loops as long as running is True. Inside the loop, it checks the current time and sets running to False if 10 seconds have passed or if it sees a QUIT message indicating the user has clicked the window's close button.
Next, the code calls clock.tick to determine how much time has passed since the last time the loop ran. It passes max_fps to limit the program's frame rate. (In these tests, max_fps was 30 frames per second.) The code calls the update_sprites method to make the sprites update their positions and then calls draw_sprites to make the sprites draw themselves.
Finally, the code calls pygame.display.update to update the entire window.
After the event loop ends, the program displays the number of balls and the amount of CPU time the test used.
Updating Unions
Having finished the first test, the program runs through the same steps again except this time it only updates rectangles that include where the sprites were previously and where they are after their updates. Here's the code that updates the display.
# Save the sprites' current rects.
rects = {sprite:sprite.get_rect() for sprite in self.all_sprites}
# Update the sprites.
self.update_sprites(elapsed_seconds)
# Expand the sprites' rects to include their new locations.
for sprite in self.all_sprites:
rects[sprite] = rects[sprite].union(sprite.get_rect())
# Draw the sprites.
self.draw_sprites()
# Update the display.
pygame.display.update(list(rects.values()))
This code uses a dictionary comprehension to get a dictionary holding the sprites' current rectangles. It then calls update_sprites to make the sprites move. Next, it uses a for loop to update the sprites' rectangles so each is the union of where the sprite was and where it is currently.
The code calls draw_sprites to draw the sprites in their new locations and then calls pygame.display.update to update the display. In this test, it passes that method the sprites' rectangles so only those areas are updated.
Updating Rectangles
The third test uses the following code.
# Save the sprites' current rects.
rects = [sprite.get_rect() for sprite in self.all_sprites]
# Update the sprites.
self.update_sprites(elapsed_seconds)
# Expand the sprites' rects to include their new locations.
rects += [sprite.get_rect() for sprite in self.all_sprites]
# Draw the sprites.
self.draw_sprites()
# Update the display.
pygame.display.update(rects)
This code uses a list comprehension to get the sprites' bounding rectangles. It updates the sprites and then uses a similar list comprehension to add the sprites' new positions to the list. It draws the sprites and then updates the display, passing the pygame.display.update the list of rectangles to update.
Conclusion
The following text shows the program's output. I've added blank lines between the different trials and highlighted the fastest method for each trial in blue.
2 Sprites, Update All: 0.2969 seconds
2 Sprites, Update Unions: 0.2344 seconds
2 Sprites, Update Rects: 0.1250 seconds
5 Sprites, Update All: 0.2969 seconds
5 Sprites, Update Unions: 0.3125 seconds
5 Sprites, Update Rects: 0.2500 seconds
10 Sprites, Update All: 0.2188 seconds
10 Sprites, Update Unions: 0.2812 seconds
10 Sprites, Update Rects: 0.2969 seconds
20 Sprites, Update All: 0.3906 seconds
20 Sprites, Update Unions: 0.4375 seconds
20 Sprites, Update Rects: 0.3281 seconds
50 Sprites, Update All: 0.4375 seconds
50 Sprites, Update Unions: 0.6406 seconds
50 Sprites, Update Rects: 0.9375 seconds
In this run, when there are very few sprites, updating their before and after rectangles is the fastest. When there are more sprites, it's faster to just update the whole display rather than creating and using the rectangle list. In all cases, updating each sprite's before and after rectangles separately was faster than taking the unions of those rectangles.
There is some variability between runs, though. Here's the result of another run.
2 Sprites, Update All: 0.2188 seconds
2 Sprites, Update Unions: 0.1250 seconds
2 Sprites, Update Rects: 0.1875 seconds
5 Sprites, Update All: 0.2188 seconds
5 Sprites, Update Unions: 0.1406 seconds
5 Sprites, Update Rects: 0.2500 seconds
10 Sprites, Update All: 0.2344 seconds
10 Sprites, Update Unions: 0.1562 seconds
10 Sprites, Update Rects: 0.3125 seconds
20 Sprites, Update All: 0.3438 seconds
20 Sprites, Update Unions: 0.3281 seconds
20 Sprites, Update Rects: 0.4844 seconds
50 Sprites, Update All: 0.4844 seconds
50 Sprites, Update Unions: 0.6875 seconds
50 Sprites, Update Rects: 1.1094 seconds
This time updating rectangle unions was fastest until there were a relatively large number of sprites.
Another factor that may play a role is the size of the window. Updating rectangles or unions will probably be faster than updating everything on bigger windows.
If you can generate the rectangles easily and you don't need to update much of the display, then using the rectangles may provide some benefit. If you have a lot of rectangles or the rectangles cover most of the window, then I suspect it will be easier to just update the whole display, either by passing no argument to pygame.display.update or by calling pygame.display.flip. In the worst case, updating the whole window took less than twice as long as the other methods, so at least you're not killing performance by an order of magnitude.
Download the example to experiment with it and to see additional details.
|