Предисловие

Приветствую IT-специалистов, решивших прочитать мою статью, я - python разработчик любитель (изучающий язык уже 5 лет, но не имеющий коммерческого опыта), который решил создать игру на очень медленном языке программирования. В процессе разработки я сталкиваюсь с различными проблемами и в мой мозг приходят весьма интересные решения для оптимизации, которыми я бы хотел поделиться с теми, кто тоже захотел написать крутую игру на python.

Оптимизация делится на оптимизацию времени исполнения и на оптимизацию использования памяти, в этой статье рассматриваются оптимизации, нацеленные в первую очередь на первый пункт. Так же хотел бы отметить, что методы оптимизации, описанные в статье могут быть несовместимыми либо с вашим проектом либо друг с другом, поэтому внимательно изучите тему каждой оптимизации, возможно вы сможете модифицировать метод под свои условия и получите более хорошие результаты.

1 - оптимизация мат. вычислений путём jit-компиляции

Python знаменит своим количеством библиотек для самых разных потребностей, одной из таких библиотек является numba - библиотека для jit-компиляции.

JIT-компиляция (Just In Time) - метод компиляции фрагментов кода прямо во время исполнения программы для повышения скорости работы (моё определение).

Всё что нужно сделать, это вынести математические вычисления в отдельные функции, которые нужно всего лишь задекорировать с помощью numba.njit:

import numba

@numba.njit(cache=True)
def linear_interpolation(X0: int, X1: int, p: float) -> float:
  return x0 + (x1-x0)*p # example linear interpolation method - math calculations

Обратите внимание, на то, что я передал параметр cache=True в декоратор, а также использовал аннотации в коде - это ускоряет время компиляции при повторных использованиях функции.

2 - установка разрешённых событий

Следующим небольшим решением для оптимизации будет установка разрешённых событий в pygame.event. Дело в том, что по умолчанию система ивентов pygame отслеживает достаточно много событий, часть которых вы, возможно, даже не используете. Чтобы убрать их обработку и не выполнять ненужные действия достаточно лишь одной дополнительной строки кода:

import pygame as pg
pg.event.set_allowed([pg.QUIT])
# в список поместите события, которые отслеживаются в вашем коде

3 - оптимизация отрисовки

В pygame есть метод blit, который помогает отрисовать то, что вам нужно, там, где вам нужно. Однако, использование этого метода слишком много раз за цикл может сказаться на производительности. В качестве решения этой проблемы можно "собирать" текстуры в текстурные чанки и рисовать сразу их, уменьшая количество вызовов blit на каждом кадре

Пример неоптимизированного кода:

import pygame as pg

window = pg.display.set_mode((500, 500))

img50x50 = pg.image.load("image.png") # изображение 50 на 50 пикселей

while True:
    window.fill((0, 0, 0))
    # проверка на QUIT event через генератор
    [pg.quit() & exit(0) for event in pg.event.get() if event.type == pg.QUIT] 
    for x in range(10):
        for y in range(10):
            window.blit(img50x50, (x*50, y*50))
    pg.display.flip()

в данном примере метод blit вызывается 100 раз за кадр, а в вашем коде он может вызываться ещё чаще, ниже пример "сборки" текстур:

import pygame as pg

window = pg.display.set_mode((500, 500))

img50x50 = pg.image.load("image.png") # изображение 50 на 50 пикселей

# собираем текстуры на одну большую текстуру
chunked = pg.Surface((500, 500))
for x in range(10):
    for y in range(10):
        chunked.blit(img50x50, (x*50, y*50))

while True:
    window.fill((0, 0, 0))
    [pg.quit() & exit(0) for event in pg.event.get() if event.type == pg.QUIT] # проверка на QUIT event
    window.blit(chunked, (0, 0)) # вызываем blit 1 раз для отрисовки
    pg.display.flip()

Во втором примере blit вызывается всего 1 раз, пусть и для более большой текстуры, ЭТО ПОЧТИ В 100 РАЗ БЫСТРЕЕ ЧЕМ В ПЕРВОМ ПРИМЕРЕ!

4 - использование numpy для отрисовки

Numpy - библиотека для более эффективных вычислений, которая помогает вычислять много и быстро, так почему же не использовать её в pygame для отрисовки?:

import pygame as pg
import numpy as np
from time import perf_counter
from numba import njit

img = pg.image.load("test.png")
sou = pg.Surface((150, 150))

@njit(cache=True)
def blit(sou_arr, dest_arr, pos) -> np.ndarray:
    X: int = pos[0]+dest_arr.shape[0]
    Y: int = pos[1]+dest_arr.shape[1]

    sou_arr[pos[0]:X, pos[1]:Y] = dest_arr

    return sou_arr

x = 10
y = 20

C = 1_000_000
print("test blit")
start = perf_counter()
for i in range(C):
    sou.blit(img, (10, 20))


# преобразуем Surface в numpy массивы для работы
sou_arr = pg.surfarray.array3d(sou)
dest_arr = pg.surfarray.array3d(img)
blit(sou_arr, dest_arr, (10, 20)) #вызовем функцию 1 раз для того, чтобы numba её скомпилировала и время компиляции не попало в тесты

blit_perf = perf_counter() - start
print("test created blit")
start = perf_counter()
for i in range(C):
    sou = blit(sou_arr, dest_arr, (10, 20))
created_perf = perf_counter() - start
print(f"buit-in blit perf: {blit_perf}\n created blit perf: {created_perf}, performance up: {blit_perf/created_perf}x")

данный кусок кода является неким тестированием встроенной функции blit и написанной на скорую руку функции, "рисующей на массивах". лично мой ПК показал следующие результаты:

buit-in blit perf: 33.87224140000035
created blit perf: 6.5419135999982245, performance up: 5.177726804586587x

обратите внимание, что для такого метода отрисовки требуется преобразование поверхности в ndarray, так же при попытке отрисовать текстуру, вылезающую за пределы поверхности, на которой мы рисуем, будет получено исключение и текстура отображена не будет, но это легко исправить дополнительными if (кода, который рисует вылезающие текстуры, здесь не будет, думаю вы и сами сможете его написать при желании)

Заключение

Я не претендую на то, что придумал феноменальные методы, ускоряющие код в триллионы раз и позволяющие запускать киберпанк на офисном ПК, я всего лишь поделился с вами методами, которые использую сам. Возможно, эти методы уже где-то были описаны и упомянуты, но лично я пришёл к этим методам самостоятельно, читая документации pygame, numpy, numba.

Комментарии (4)


  1. kovserg
    17.11.2023 10:37
    +1

    решил создать игру на очень медленном языке программирования

    То есть путь страданий был выбран осознанно. Что мешает использовать batch-и, а в pygame их нет. А почему именно был выбран pygame? И почему python, а не java с libGDX или lua с love2d?


    1. PlayingPlate6667 Автор
      17.11.2023 10:37
      +1

      Язык python был выбран не просто так, во первых - написать высокопроизводительную большую игру (большую по меркам игр на pygame) это само по себе некий вызов, ну а во вторых я сейчас на первом курсе в универе и пока я пишу игру я получаю практику разработки, проект для портфолио, знания, которые явно не дадут в университете. Третьим и решающим аргументом будет тот факт что нормально я знаю только python, который начал изучать ещё в 8 классе и pascal (ещё со школьного курса, который я кстати прошёл за 2 недели вместо положенных 3 лет) поэтому выбор у меня был, не особо большой. Да, я мог изучить новый язык для разработки но это замедлило меня, а я хочу написать игру максимально быстро насколько это возможно физически.

      А по поводу именно pygame, эта библиотека очень удобно работает со звуком ивентами текстурами и т.д. вдобавок ко всему этому я использую её в связке с modernGL, для использования шейдеров (например свет в игре реализован именно за счёт шейдеров)


      1. kovserg
        17.11.2023 10:37

        Почему pygame и не pyglet например?


        1. PlayingPlate6667 Автор
          17.11.2023 10:37

          Дело в том, что я уже знаком с pygame в отличие от других библиотек (даже modernGL, который используется в проекте, я не изучал), поэтому я решил выбрать тот стек технологий которые я уже знал.