Ox0zOwra
Ox0zOwra

Reputation: 3

pygame - Player creating bullets causes noticable FPS drops

I need my game to be capable of handling hundreds of bullets on screen. While the game seems not to have large slowdowns with smaller amounts of bullets (under 50 or so), it nonetheless does have them, and the problem becomes even more pronounced when around 100-200 are on the screen at the same time. This raised a particular concern I had: I want enemies to fire massive amounts of bullets aswell, but if the player's projectiles already cause such slowdowns, then there is no way that that will be possible.

Before I begin showing the code in question:

I'm pretty fresh to programming and I am not very certain what exactly causes the issue, so I will try to provide as much relevant code as possible. I will provide more if none of the culprits seem to be the causes of the issue. I apologize in advance if this is not enough information.

The slowdown gets ridiculous mostly with a debug superweapon I'm using, though it is sometimes noticable even when not nearly as many bullets are spawned (28 or a bit more) when I have many apps opened. This is a very small game so far, however, so I don't exactly understand why.

def init_player_weapon_level(self):
    # debug mode
    if self.lev_weapon == -1:
        self.set_weapon_values(100, 5, 100, True)
        self.firing_pattern = [[self.bullet_images["blue"], 0, 0, 90, -24],
                               [self.bullet_images["blue"], 0, 0, 92, -24],
                               [self.bullet_images["blue"], 0, 0, 88, -24],
                               ...]
...

The firing pattern list defined in this function is used below. It's very long. This list is replaced based on the player's weapon level, -1 being the highest. This function is called only when the player object is created or when it's manually initiated with a keypress.

def fire(self, now):
    if self.check_firing_conditions(now):
        self.last_shot = now
        for o in self.firing_pattern:
            bullet = PlayerBullet01(o[0], self.rect.centerx + o[1], self.rect.top + o[2], o[3], o[4])
            all_sprites.add(bullet)
            bullets.add(bullet)
            self.generate_heat(now)

This is the function responsible for firing weapons. It creates as many bullet objects as there are lists of parameters in self.firing_pattern, with an image [0], X [1] and Y [2] spawn offsets (which are then added or subtracted from the player's position to give an illusion that they're fired from the ship's cannons), angle [3] at which the bullet is fired (90 degrees is straight up) and speed [4] at which it traverses (it's negative in the firing patterns because the bullet moves upwards). Since self.firing_pattern has 35 lists of parameters in it (which I omitted as that takes up space), 35 bullets are created all at once, fired with a delay of 100 milliseconds between the shots if the player presses the fire key. This makes exactly 245 bullets appear on the screen before the player's weapon overheats, which causes the weapon to stop firing. It is a lot, but the slowdown seems to be caused when there's already some bullets on the screen.

class PlayerBullet01(pygame.sprite.DirtySprite):
    def __init__(self, image, x, y, deg, speed):
        pygame.sprite.DirtySprite.__init__(self)

        self.image = image
        self.rect = self.image.get_rect()
        self.center = self.rect.center

        self.position = pygame.math.Vector2(x, y)

        self.speed_mod = 0.065
        # deg - 90 because we don't want to rotate the image
        # by the amount of degrees it's supposed to face,
        # we want to rotate it by a much smaller amount
        # we also want to rotate it only once
        # since player bullets probably shouldn't follow
        # any other movement patterns than going straight to the target
        if deg != 0:
            self.image = pygame.transform.rotate(self.image, -deg + 90)
        self.rect = self.image.get_rect()
        self.rect.center = self.center

        # we only want the bullet to move in a particular direction at a particular speed
        self.direction = pygame.math.Vector2(0, 0)
        self.direction += self.move(deg, speed)

        # spawn bullet at the center of the spaceship
        self.rect.centerx = self.position.x
        self.rect.bottom = self.position.y

    def update(self):
        self.dirty = 1
        self.position.x += self.direction.x * self.speed_mod * x_clock.delay
        self.position.y += self.direction.y * self.speed_mod * x_clock.delay
        self.rect.center = (self.position.x, self.position.y)

        # die if out of bounds
        if self.rect.bottom < 0:
            self.kill()

    def move(self, degrees, offset):
        rads = math.radians(degrees)
        x = math.cos(rads) * offset
        y = math.sin(rads) * offset
        return x, y

This is the bullet class. I thought at first that the move function being called during every update slowed down the game (which is in an earlier version of the code, not shown here), but nothing has actually changed.

    hits_enemy = pygame.sprite.groupcollide(enemies, bullets, True, True)
    for hit in hits_enemy:
        create_generic_enemy(GFX_ENEMY)

Finally, this is the line that checks for collisions between the bullets and enemies (of which there are always only 10 on the screen). I disabled collisions for bullets and enemies and it still did not seem to have improved the performance of the game at all.

dirtyupdate = all_sprites.draw(screen)
pygame.display.update(dirtyupdate)

Oh, and this is how the game draws everything on screen in the main game loop.

Last possible culprit: I found a way on Stack Overflow to stop pygame from micro-stuttering. It's a very annoying issue that seemed to happen for no reason whatsoever. It requires using a custom clock. I introduced this code to my game from here: Simple Pygame animation stuttering

previous = time.time() * 1000

# this is so it's easy to get out of the game loop
# (you just set GAME_RUNNING to false)
GAME_RUNNING = True
while GAME_RUNNING:
    # CLOCK
    # a pretty advanced clock that stops pygame from stuttering like a -----
    current = time.time() * 1000
    elapsed = current - previous
    previous = current
    x_clock.delay = 1000.0 / MAX_FPS - elapsed
    x_clock.delay = max(int(x_clock.delay), 0)

    ...

    # delays the game by delay
    # using pygame.time.wait() instead of pygame.time.delay()
    # because pygame.time.delay() makes my system fan go -------
    pygame.time.wait(x_clock.delay)

For the record, delay from the x_clock file is imported to class files, too. This is because it's required for these classes to adjust their movement to FPS drops. I don't believe this code is what causes the lag, but I remember that when I used pygame.time.delay() instead of wait(), my fan went nuts.

What causes this slowdown and how do I fix it? I have considered a few possibilities already (one of which may be Pygame not being able to handle this many projectiles), but I have already seen a game where a similar amount of bullets was consistently created on screen and it did not have any slowdowns whatsoever. I couldn't figure it out after looking at that game's code, either.

Upvotes: 0

Views: 339

Answers (1)

Kesslwovv
Kesslwovv

Reputation: 652

I don't know if this is actually the source of the slowdown but the following might work:

use pygame.time.Clock() as your timer. the clock.tick(framerate) (or clock.tick_busy_loop(framerate) which according to the documentation is more precise) will cap the maximal framerate. clock.get_time() will get you the time in miliseconds between the last two call of clock.tick() / clock.tick_busy_loop().

Edit:

1 this is a more efficient __init__ method:

class PlayerBullet01(pygame.sprite.DirtySprite):
    images = {}
    for color in ['blue']:  # and your other bullet colors
        color_dict = {}
        for angle in range(360):
            img = pygame.transform.rotate(bullet_images[color], angle)
            color_dict[angle] = img, img.get_rect()
        images[color] = color_dict

    def __init__(self, color, x, y, deg, speed):
        pygame.sprite.DirtySprite.__init__(self)

        self.image, self.rect = self.images[color][deg]

        self.position = pygame.math.Vector2(x, y)

        self.speed_mod = 0.065
        # deg - 90 because we don't want to rotate the image
        # by the amount of degrees it's supposed to face,
        # we want to rotate it by a much smaller amount
        # we also want to rotate it only once
        # since player bullets probably shouldn't follow
        # any other movement patterns than going straight to the target

        # this code is now unnecessary

        # we only want the bullet to move in a particular direction at a particular speed
        self.direction = pygame.math.Vector2(0, 0)
        self.direction += self.move(deg, speed)

        # spawn bullet at the center of the spaceship
        self.rect.centerx = self.position.x
        self.rect.bottom = self.position.y

by preloading the rotated images it doesn't need to be done 35 times every time the player fires.

2 use the following script to get the runtime of your functions:

from time import time


def timed(name):
    func_timer = FunctionTimer(name)

    def decorator(func):

        def wrapper(*args, **kwargs):
            start_time = time()
            return_value = func(*args, **kwargs)
            func_timer.add_time((time() - start_time) * 1000)
            return return_value

        return wrapper

    return decorator


class FunctionTimer:
    all = []
    old_time = time()
    frame_time = 0

    @classmethod
    def get_frame_summary(cls):
        new_time = time()
        cls.frame_time = new_time - cls.old_time
        cls.old_time = new_time
        print(f'------------------------------\n'
              f'FRAME TIME SUMMARY:\n'
              f'total frame time: {cls.frame_time * 1000:.5}\n'
              f'calls:\n'
              f'name                  calls  average     min         max         total')
        for t in sorted(cls.all, key=lambda x: x.avg_time, reverse=True):
            if len(t.frame_list):
                print(t)

        for t in cls.all:
            t.clear()

    def __init__(self, name):
        self.name = name
        self.frame_list = []
        self.frame_calls = 0
        self.total = 0
        self.min = 0
        self.max = 0
        self.avg = 0
        self.all.append(self)

    def __repr__(self):
        return f'{self.name: <20}  {self.frame_calls: <5}  {self.avg_time: <10.5}  ' \
               f'{self.min_time: <10.5}  {self.max_time: <10.5}  {self.total_time: <10.5}'

    def add_time(self, added_time):
        self.frame_list.append(added_time)
        self.frame_calls += 1

    def clear(self):
        self.frame_list = []
        self.frame_calls = 0

    @property
    def min_time(self):
        self.min = min(self.frame_list)
        return self.min

    @property
    def max_time(self):
        self.max = max(self.frame_list)
        return self.max

    @property
    def avg_time(self):
        try:
            self.avg = sum(self.frame_list) / self.frame_calls
        except ZeroDivisionError:
            self.avg = 0
        return self.avg

    @property
    def total_time(self):
        self.total = sum(self.frame_list)
        return self.total

usage:

@timed('name of the function')
def function(...):
    ...

put the timed decorator before any function you want to time.
call

 FunctionTimer.get_frame_summary()

once in the main loop.
output:

------------------------------
FRAME TIME SUMMARY:
total frame time: [total time during this frame in ms]
calls:
name                  calls  average     min         max         total
[name of the function = parameter you passed into the decorator]  
[amount of times this function was called]  
[average runtime of the function, the output will be sorted by this value]
[minimal runtime]
[maximal runtime]
[total time spent in function (sum of all function call times)]
-- all functions that have been called will print this summary
sorted by their average runtime (descending) --

Upvotes: 0

Related Questions