Свой 3d движок на Python [Часть 1]
Эээм, а почему Python?

Прочитав заголовок вы сразу скажете то что 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?
Начинаем!

Нам понадобится только сам 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, а расстояние, мы сами задаем!

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

Пускаем луч!
С помощью 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! И чтобы это исправить обратимся к математике :

Этот круг представляет нашего игрока. У нас есть луч, направленный под углом α. Поскольку перемещение для каждой клавиши должно быть одинаковым, обозначим его как 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!!!
Да и опять теория!

Конечный угол последнего луча: .
DELTA_ANGLE
— угловой шаг между соседними лучами (рассчитывается как , если лучи распределены равномерно).
MAX_DEPTH
— максимальная дальность, на которую испускаются лучи (глубина прорисовки).
Таким образом, лучи будут равномерно покрывать заданный сектор FOV, начиная с угла и заканчивая
, с шагом 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)
Конец.
Это конец первой части и вот скриншоты того что у нас получилось :


Список использованных материалов:
Как сделать 3D Игру на Python с Нуля [ Pygame ] — перейти туда
Видео из 2d в 3d — посмотреть видосик
Спасибо за прочтение! Помните в следующей статье мы исправим все баги увеличим FPS и сделаем редактор карт.
Смотреть далее →
Habrahabr.ru прочитано 11998 раз