Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров5.6K

Эээм а почему 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
2d -> 3d

Введём понятие поля зрения (FOV — Field of View) — это угловой сектор, в пределах которого будут испускаться лучи.

Для работы с лучами понадобятся следующие параметры:

  • num_rays — количество лучей в пределах FOV.

  • Начальный угол первого луча: a - FOV / 2.

  • Конечный угол последнего луча: 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 и сделаем редактор карт.

Смотреть далее ->

Теги:
Хабы:
+7
Комментарии8

Публикации

Работа

Data Scientist
41 вакансия

Ближайшие события