Свой 3d движок на Python [Часть 1]

Эээм, а почему Python?

Pygame library
Pygame library

Прочитав заголовок вы сразу скажете то что Python — это язык не для игр! И окажетесь правы! Python — реально не был ориентирован под игры, но благодаря своему простому синтаксису в нем появился pygame и PyOpenGL и конечно же Ursina.

Pygame — библиотека Python основанная на SDL 2 для создания игр.

Именно с помощью Pygame мы сможем рисовать примитивы на экране, загружать текстуры и работать с логикой игры!

Ну и как будем это делать?

Pygame позволяет рисовать только 2D примитивы и для опыта я решил сделать 3D движок.

В этой статье у меня была задача сделать движок на котором можно будет создать что‑то типо Doom. Но как? Давайте разберём все способы рисовать 3D:

  • RayCasting — метод отрисовки 2.5D использующий лучи.

  • RayMarching — метод отрисовки 3D использующий шаги.

  • Полигоны — стандартный метод отрисовки 3D

Так‑как у нас Python я решил выбрать RayCasting.

RayCasting работает очень легко — мы пускаем лучи и чем он длиннее тем меньше рисуем объект. Но как нам это сделать в Python?

Начинаем!

Пример Raycasting.
Пример Raycasting.

Нам понадобится только сам Python (3.7 и выше), pygame, и еще две библиотеки для скорости. Давайте их скачаем:

pip install pygame numpy numba

В начале создадим основу для pygame:

# main.py
import pygame
from settings import *  # Импорт констант из файла настроек

# Инициализация pygame
pygame.init()
# Создание окна с размерами из настроек
sc = pygame.display.set_mode((WIDTH, HEIGHT))
# Создание объекта для контроля FPS
clock = pygame.time.Clock()

# Основной игровой цикл
while True:
    # Обработка событий
    for event in pygame.event.get():
        # Выход при закрытии окна
        if event.type == pygame.QUIT:
            exit()
    
    # Заливка экрана черным цветом
    sc.fill(BLACK)
    
    # Здесь будет основной код отрисовки игры
    
    # Обновление экрана
    pygame.display.flip()
    # Ограничение FPS до 60 кадров в секунду
    clock.tick(60)

Дальше добавим игрока и его класс:

# player.py
from settings import *
import pygame

class Player:
    def __init__(self):
        # Начальная позиция и угол поворота
        self.x, self.y = player_pos
        self.angle = player_angle

    @property
    def pos(self):
        # Текущая позиция игрока
        return (self.x, self.y)

    def movement(self):
        keys = pygame.key.get_pressed()

        # Движение вперед/назад (W/S)
        if keys[pygame.K_w]:
            self.y -= player_speed
        if keys[pygame.K_s]:
            self.y += player_speed

        # Движение влево/вправо (A/D)
        if keys[pygame.K_a]:
            self.x -= player_speed
        if keys[pygame.K_d]:
            self.x += player_speed

        # Поворот (стрелки влево/вправо)
        if keys[pygame.K_LEFT]:
            self.angle -= 0.02
        if keys[pygame.K_RIGHT]:
            self.angle += 0.02
pygame.draw.circle(sc, GREEN, player_pos, 12)

Теперь добавим к нашему главному файлу main импорт этого класса и передвижение игрока (player.movement())

#main.py
import pygame
from settings import *
from player import Player


pygame.init()
sc = pygame.display.set_mode( (WIDTH,HEIGHT) )
clock = pygame.time.Clock()
player = Player()

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            exit()
    player.movement()
    sc.fill(BLACK)

    pygame.draw.circle(sc, GREEN, player.pos, 12)
    
    pygame.display.flip()
    clock.tick(60)

Как кидать лучи ?

Как нам отобразить направление луча? Наш игрок — это зеленая точка, назовем точка O.

Первый этап
Первый этап

А нам нужно нарисовать отрезок до красной точки P. Нам всегда известно основное направление нашего игрока то есть наш angle, а расстояние, мы сами задаем!

Находим OP
Находим OP

Используя простейшую тригонометрию мы получаем такую формулу для координат P:

Xo +d * cos(a)Yp = Yo + d* sin(a)Выводим формулу!
Выводим формулу!

Пускаем луч!

С помощью math мы получим синус и косинус угла игрока, а направление зададим отрисовкой линии, длина которой будет равна ширине экрана.

    pygame.draw.line(sc, GREEN, player.pos, (player.x +WIDTH * math.cos(player.angle),
                                             player.y +WIDTH * math.sin(player.angle)) )
Весь код к этому моменту
#main.py
import pygame
from settings import *
from player import Player
import math


pygame.init()
sc = pygame.display.set_mode( (WIDTH,HEIGHT) )
clock = pygame.time.Clock()
player = Player()

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            exit()
    player.movement()
    sc.fill(BLACK)

    pygame.draw.circle(sc, GREEN, player.pos, 12)
    pygame.draw.line(sc, GREEN, player.pos, (player.x +WIDTH * math.cos(player.angle), player.y +WIDTH * math.sin(player.angle)) )
    
    pygame.display.flip()
    clock.tick(FPS)
# player.py
from settings import *
import pygame

class Player:
    def __init__(self):
        # Начальная позиция и угол поворота
        self.x, self.y = player_pos
        self.angle = player_angle

    @property
    def pos(self):
        # Текущая позиция игрока
        return (self.x, self.y)

    def movement(self):
        keys = pygame.key.get_pressed()

        # Движение вперед/назад (W/S)
        if keys[pygame.K_w]:
            self.y -= player_speed
        if keys[pygame.K_s]:
            self.y += player_speed

        # Движение влево/вправо (A/D)
        if keys[pygame.K_a]:
            self.x -= player_speed
        if keys[pygame.K_d]:
            self.x += player_speed

        # Поворот (стрелки влево/вправо)
        if keys[pygame.K_LEFT]:
            self.angle -= 0.02
        if keys[pygame.K_RIGHT]:
            self.angle += 0.02
        
#settings.py

WIDTH = 600
HEIGHT = 400
HALF_WIDTH = WIDTH // 2
HALF_HEIGHT = HEIGHT // 2
FPS = 60

BLACK = (0,0,0)
GREEN = (0,244,0)

player_speed = 2
player_pos = (HALF_WIDTH,HALF_HEIGHT)
player_angle = 0

Ну вот теперь стало наглядно видно в какую строну смотрит игрок.

Карта нашего мира.

Чтобы игрок мог ходить по миру и преодолевать препятствия мы сделаем карту.

Спойлер

В будущем мы с вами сделаем редактор в котором мы как раз будем создавать нашу игру и карту!

Самый простой способ сделать карту — это задать карту в текстовом виде! В отличии от всех я решил сделать более сложную систему текстовой карты — чтение из файла и несколько вариантов задачи карты!

#map.py
from settings import *

# Инициализация пустого списка для текстовой карты
text_map = []

def init_map():
    f = open("map.txt", "r")
    
    # Чтение первого числа - количества строк карты
    num = int(f.readline())
    
    # Чтение построчно и добавление в text_map
    for i in range(num):
        text_map.append(f.readline())
    

    f.close()


# Инициализация карты из файла
init_map()

# Создание множества для хранения координат стен
world_map = set()

# Парсинг текстовой карты в мировые координаты
for j, row in enumerate(text_map):      # Проход по строкам карты
    for i, char in enumerate(row):       # Проход по символам в строке
        if char == 'W':                  # Если символ - стена ('W')
            # Добавление координат стены в мировых единицах (умножаем на размер тайла)
            world_map.add((i * TILE, j * TILE))
        if char == '1':                  # Если символ - стена ('1')
            # Добавление координат стены в мировых единицах (умножаем на размер тайла)
            world_map.add((i * TILE, j * TILE))

Импортируем нашу карту в main.py - from map import world_map

Насчет парсинга в мировые координаты, он работает вот так:

  • Программа проходит по каждому символу карты

  • При обнаружении 'W' вычисляет координаты стены в пикселях (умножая позицию символа на размер тайла TILE)

  • Координаты сохраняются в множестве world_map для быстрой проверки коллизий

Исправляем проблемки!

Как вы наверняка заметили мы сделали управление как для 2D игры и оно никак не подходит для 3D! И чтобы это исправить обратимся к математике :

Доска функций с канала Stndalone Coder
Доска функций с канала Stndalone Coder

Этот круг представляет нашего игрока. У нас есть луч, направленный под углом α. Поскольку перемещение для каждой клавиши должно быть одинаковым, обозначим его как d.

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

И сейчас нам уже легко поменять управление для 3D!

from settings import *
import pygame
import math

class Player:
    def __init__(self):
        # Инициализация позиции и угла поворота игрока
        self.x, self.y = player_pos
        self.angle = player_angle

    @property
    def pos(self):
        # Возвращает текущую позицию в виде кортежа
        return (self.x, self.y)

    def movement(self):
        # Предварительный расчет синуса и косинуса угла поворота
        sin_a = math.sin(self.angle)
        cos_a = math.cos(self.angle)
        
        keys = pygame.key.get_pressed()
        
        # Движение вперед (W) - по направлению взгляда
        if keys[pygame.K_w]:
            self.x += player_speed * cos_a
            self.y += player_speed * sin_a
            
        # Движение назад (S) - против направления взгляда
        if keys[pygame.K_s]:
            self.x -= player_speed * cos_a
            self.y -= player_speed * sin_a
            
        # Движение влево (A) - перпендикулярно направлению (вектор влево)
        if keys[pygame.K_a]:
            self.x += player_speed * sin_a  # Используем sin для перпендикулярного вектора
            self.y -= player_speed * cos_a  # Отрицательный cos для левого направления
            
        # Движение вправо (D) - перпендикулярно направлению (вектор вправо)
        if keys[pygame.K_d]:
            self.x -= player_speed * sin_a  # Отрицательный sin для правого направления
            self.y += player_speed * cos_a  # Положительный cos
            
        # Поворот влево (стрелка влево)
        if keys[pygame.K_LEFT]:
            self.angle -= 0.02
            
        # Поворот вправо (стрелка вправо)
        if keys[pygame.K_RIGHT]:
            self.angle += 0.02

И теперь наше управление стало близко к тому что мы привыкли видеть в шутерах от первого лица!

Переход в 3D!!!

Да и опять теория!

2d -> 3d» /><div>2d → 3d</div><p>Введём понятие <strong>поля зрения (FOV — Field of View)</strong> — это угловой сектор, в пределах которого будут испускаться лучи.</p><p>Для работы с лучами понадобятся следующие параметры: </p><ul><li><p><code><strong>num_rays</strong></code> — количество лучей в пределах FOV.</p></li><li><p><strong>Начальный угол</strong> первого луча:  <img alt=.

  • Конечный угол последнего луча:  a + FOV / 2.

  • DELTA_ANGLE — угловой шаг между соседними лучами (рассчитывается как FOV / (num_rays - 1), если лучи распределены равномерно).

  • MAX_DEPTH — максимальная дальность, на которую испускаются лучи (глубина прорисовки).

  • Таким образом, лучи будут равномерно покрывать заданный сектор FOV, начиная с угла a - FOV / 2 и заканчивая a + FOV / 2, с шагом DELTA_ANGLE.

    Введем все это в настройки:

    # ray casting settings
    FOV = math.pi / 3
    HALF_FOV = FOV / 2
    NUM_RAYS = 120
    MAX_DEPTH = 800
    DELTA_ANGLE = FOV / NUM_RAYS

    И сделаем файл ray_casting.py:

     #Начальные импорты
    import pygame
    from settings import *
    from map import world_map
    
    

    Теперь напишем функцию которая будет принимать наш экран, позицию и угол игрока:

    def ray_casting(sc, player_pos, player_angle):
        pass

    И заполним её:

    import pygame
    import math
    from numba import njit
    from settings import *
    from map import world_map
    
    def ray_casting(sc, player_pos, player_angle):
        cur_angle = player_angle - HALF_FOV
        #получаем позицию точки O
        xo, yo = player_pos
    
        #Проходимся по всем лучам
        for ray in range(NUM_RAYS):
            #Синус и косинус направления 
            sin_a = math.sin(cur_angle)
            cos_a = math.cos(cur_angle)
            #Идем в глубь карты
            for depth in range(MAX_DEPTH):
                x = xo + depth * cos_a
                y = yo + depth * sin_a
                pygame.draw.line(sc, DARKGRAY, player_pos, (x, y), 2)
    #            if (x // TILE * TILE, y // TILE * TILE) in world_map:
    #                depth *= math.cos(player_angle - cur_angle)
    #                proj_height = min(PROJ_COEFF / (depth + 0.0001), HEIGHT)
    #                c = 255 / (1 + depth * depth * 0.0001)
    #                color = (c // 2, c, c // 3)
    #                pygame.draw.rect(sc, color, (ray * SCALE, HALF_HEIGHT - proj_height // 2, SCALE, proj_height))
    #                break
            cur_angle += DELTA_ANGLE

    Ну что посмотрим на все что мы сделали сбоку!

    Схема с боку
    Схема с боку

    Нарисуем стену и её проекцию на наш экран. Искомую высоту будем находить из подобия треугольников которые вы видите на рисунке:

    Дополненная схема сбоку
    Дополненная схема сбоку
    DIST = NUM_RAYS / (2 * math.tan(HALF_FOV))
    PROJ_COEFF = 3 * DIST * TILE
    SCALE = WIDTH // NUM_RAYS

    Теперь используем все сделанное ранее в нашем отрисовщике:

                if (x // TILE * TILE, y // TILE * TILE) in world_map:
                    depth *= math.cos(player_angle - cur_angle)
                    proj_height = min(PROJ_COEFF / (depth + 0.0001), HEIGHT)
                    c = 255 / (1 + depth * depth * 0.0001)
                    color = (c // 2, c, c // 3)
                    pygame.draw.rect(sc, color, (ray * SCALE, HALF_HEIGHT - proj_height // 2, SCALE, proj_height))
                    break
    Весь божечки код
    #main.py
    import pygame
    from settings import *
    from player import Player
    import math
    from map import text_map,world_map
    from ray_casting import ray_casting
    
    pygame.init()
    sc = pygame.display.set_mode( (WIDTH,HEIGHT) )
    clock = pygame.time.Clock()
    player = Player()
    
    while True:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                exit()
        player.movement()
        sc.fill(BLACK)
    
        ray_casting(sc,player.pos,player.angle)
    
        pygame.draw.circle(sc, GREEN, (int(player.x),int(player.y)), 12)
        pygame.draw.line(sc, GREEN, player.pos, (player.x +WIDTH * math.cos(player.angle), player.y +WIDTH * math.sin(player.angle)) )
    
        for x,y in world_map:
            pygame.draw.rect(sc, DARKGRAY, (x, y, TILE, TILE), 2)
        
        pygame.display.flip()
        clock.tick(FPS)
    
    from settings import *
    import pygame
    import math
    
    class Player:
        def __init__(self):
            # Инициализация позиции и угла поворота игрока
            self.x, self.y = player_pos
            self.angle = player_angle
    
        @property
        def pos(self):
            # Возвращает текущую позицию в виде кортежа
            return (self.x, self.y)
    
        def movement(self):
            # Предварительный расчет синуса и косинуса угла поворота
            sin_a = math.sin(self.angle)
            cos_a = math.cos(self.angle)
            
            keys = pygame.key.get_pressed()
            
            # Движение вперед (W) - по направлению взгляда
            if keys[pygame.K_w]:
                self.x += player_speed * cos_a
                self.y += player_speed * sin_a
                
            # Движение назад (S) - против направления взгляда
            if keys[pygame.K_s]:
                self.x -= player_speed * cos_a
                self.y -= player_speed * sin_a
                
            # Движение влево (A) - перпендикулярно направлению (вектор влево)
            if keys[pygame.K_a]:
                self.x += player_speed * sin_a  # Используем sin для перпендикулярного вектора
                self.y -= player_speed * cos_a  # Отрицательный cos для левого направления
                
            # Движение вправо (D) - перпендикулярно направлению (вектор вправо)
            if keys[pygame.K_d]:
                self.x -= player_speed * sin_a  # Отрицательный sin для правого направления
                self.y += player_speed * cos_a  # Положительный cos
                
            # Поворот влево (стрелка влево)
            if keys[pygame.K_LEFT]:
                self.angle -= 0.02
                
            # Поворот вправо (стрелка вправо)
            if keys[pygame.K_RIGHT]:
                self.angle += 0.02
    import pygame
    import math
    from numba import njit
    from settings import *
    from map import world_map
    
    def ray_casting(sc, player_pos, player_angle):
        cur_angle = player_angle - HALF_FOV
        #получаем позицию точки O
        xo, yo = player_pos
    
        #Проходимся по всем лучам
        for ray in range(NUM_RAYS):
            #Синус и косинус направления 
            sin_a = math.sin(cur_angle)
            cos_a = math.cos(cur_angle)
            #Идем в глубь карты
            for depth in range(MAX_DEPTH):
                x = xo + depth * cos_a
                y = yo + depth * sin_a
                pygame.draw.line(sc, DARKGRAY, player_pos, (x, y), 2)
                if (x // TILE * TILE, y // TILE * TILE) in world_map:
                    depth *= math.cos(player_angle - cur_angle)
                    proj_height = min(PROJ_COEFF / (depth + 0.0001), HEIGHT)
                    c = 255 / (1 + depth * depth * 0.0001)
                    color = (c // 2, c, c // 3)
                    pygame.draw.rect(sc, color, (ray * SCALE, HALF_HEIGHT - proj_height // 2, SCALE, proj_height))
                    break
            cur_angle += DELTA_ANGLE
    # Импорт всех настроек из файла settings.py
    from settings import *
    
    # Инициализация пустого списка для текстовой карты
    text_map = []
    
    def init_map():
        f = open("map.txt", "r")
        
        # Чтение первого числа - количества строк карты
        num = int(f.readline())
        
        # Чтение построчно и добавление в text_map
        for i in range(num):
            text_map.append(f.readline())
        
    
        f.close()
    
    
    # Инициализация карты из файла
    init_map()
    
    print(text_map)
    
    # Создание множества для хранения координат стен
    world_map = set()
    
    # Парсинг текстовой карты в мировые координаты
    for j, row in enumerate(text_map):      # Проход по строкам карты
        for i, char in enumerate(row):       # Проход по символам в строке
            if char == 'W':                  # Если символ - стена ('W')
                # Добавление координат стены в мировых единицах (умножаем на размер тайла)
                world_map.add((i * TILE, j * TILE))
            if char == '1':                  # Если символ - стена ('1')
                # Добавление координат стены в мировых единицах (умножаем на размер тайла)
                world_map.add((i * TILE, j * TILE))
    import math
    
    # game settings
    WIDTH = 1200
    HEIGHT = 800
    HALF_WIDTH = WIDTH // 2
    HALF_HEIGHT = HEIGHT // 2
    FPS = 60
    TILE = 100
    
    # ray casting settings
    FOV = math.pi / 3
    HALF_FOV = FOV / 2
    NUM_RAYS = 120
    MAX_DEPTH = 800
    DELTA_ANGLE = FOV / NUM_RAYS
    DIST = NUM_RAYS / (2 * math.tan(HALF_FOV))
    PROJ_COEFF = 3 * DIST * TILE
    SCALE = WIDTH // NUM_RAYS
    
    # player settings
    player_pos = (HALF_WIDTH, HALF_HEIGHT)
    player_angle = 0
    player_speed = 2
    
    # colors
    WHITE = (255, 255, 255)
    BLACK = (0, 0, 0)
    RED = (220, 0, 0)
    GREEN = (0, 220, 0)
    BLUE = (0, 0, 255)
    DARKGRAY = (40, 40, 40)
    PURPLE = (120, 0, 120)
    7
    WWWWWWWWWWWW
    W..........W
    W...WW.....W
    W..........W
    W..WW......W
    W..........W
    W....WWW...W
    WWWWWWWWWWWW

    Добавляем мелкие детали перед концом!

    Небо! Давайте добавим небо!

    Для этого нарисуем два прямоугольника. Один — это земля, а другой это небо

        pygame.draw.rect(sc, BLUE, (0, 0, WIDTH, HALF_HEIGHT))
        pygame.draw.rect(sc, DARKGRAY, (0, HALF_HEIGHT, WIDTH, HALF_HEIGHT))

    И да давайте уберем этот 2D! Уберите или закоментите эти строки :

    
        pygame.draw.circle(sc, GREEN, (int(player.x),int(player.y)), 12)
        pygame.draw.line(sc, GREEN, player.pos, (player.x +WIDTH * math.cos(player.angle), player.y +WIDTH * math.sin(player.angle)) )
        # pygame.draw.circle(sc, GREEN, (int(player.x), int(player.y)), 12)
        # pygame.draw.line(sc, GREEN, player.pos, (player.x + WIDTH * math.cos(player.angle),
        #                                          player.y + WIDTH * math. sin(player.angle)), 2)
        # for x,y in world_map:
        #     pygame.draw.rect(sc, DARKGRAY, (x, y, TILE, TILE), 2)

    Конец.

    Это конец первой части и вот скриншоты того что у нас получилось :

    1
    1
    2
    2

    Список использованных материалов:

    1. Как сделать 3D Игру на Python с Нуля [ Pygame ] — перейти туда

    2. Видео из 2d в 3d — посмотреть видосик

    Спасибо за прочтение! Помните в следующей статье мы исправим все баги увеличим FPS и сделаем редактор карт.

    Смотреть далее →

    Habrahabr.ru прочитано 11998 раз