Введение
Все мы помним старые игры, в которых впервые появилось трехмерное измерение.
Основоположником 3д игр стала игра Wolfenstein 3D, выпущенная в 1992 году
а за ней и Doom 1993 года.
Эти две игры разработала одна компания: «id Software»
Она создала свой движок специально для этой игры, и в итоге получилась 3д игра, что считалось практически невозможным на те времена.
Но что будет если я скажу что это не 3д игра, а всего лишь симуляция и игра выглядит на самом деле примерно вот так?
На самом деле здесь используется технология Ray Casting, третьего измерения тут просто не существует.
Что же такое этот самый RayCasting, который даже в наши времена актуален, но уже используется не для игр, а для технологии трассировки лучей в современных играх.
Если переводить на русский, то:
Метод бросания лучей(Ray Casting) - один из методов рендеринга в компьютерной графике, при котором сцена строится на основе замеров пересечения лучей с визуализируемой поверхностью.
Мне стало интересно на сколько это сложно реализовать.
И я принялся за написание технологии RayCasting.
Буду делать его на связке python + pygame
Pygame позволяет рисовать на плоскости простые 2D фигуры, и путем танцами с бубном вокруг них я и буду делать 3D иллюзию
Реализация Ray Casting
Для начала создаем простейшую карту с помощью символов, чтобы разделять при отрисовке где блок а где пустое место.
Рисуем карту в 2D, и игрока с возможностью управления и расчетом точки взгляда.
player.delta = delta_time()
player.move(enableMoving)
display.fill((0, 0, 0))
pg.draw.circle(display, pg.Color("yellow"), (player.x, player.y), 0)
drawing.world(player)
class Drawing:
def __init__(self, surf, surf_map):
self.surf = surf
self.surf_map = surf_map
self.font = pg.font.SysFont('Arial', 25, bold=True)
def world(self, player):
rayCasting(self.surf, player)
def rayCasting(display, player):
inBlockPos = {'left': player.x - player.x // blockSize * blockSize,
'right': blockSize - (player.x - player.x // blockSize * blockSize),
'top': player.y - player.y // blockSize * blockSize,
'bottom': blockSize - (player.y - player.y // blockSize * blockSize)}
for ray in range(numRays):
cur_angle = player.angle - halfFOV + deltaRays * ray
cos_a, sin_a = cos(cur_angle), sin(cur_angle)
vl, hl = 0, 0
Движение будет осуществляться путем сложения косинуса угла зрения по горизонтали и синуса угла зрения по вертикали
class Player:
def init(self)
self.x = 0
self.y = 0
self.angle = 0
self.delta = 0
self.speed = 100
self.mouse_sense = settings.mouse_sensivity
def move(self, active):
self.rect.center = self.x, self.y
key = pygame.key.get_pressed()
key2 = pygame.key.get_pressed()
cos_a, sin_a = cos(self.angle), sin(self.angle)
if key2[pygame.K_LSHIFT]:
self.speed += 5
if self.speed >= 200:
self.speed = 200
else:
self.speed = 100
if key[pygame.K_w]:
dx = cos_a * self.delta * self.speed
dy = sin_a * self.delta * self.speed
if key[pygame.K_s]:
dx = cos_a * self.delta * -self.speed
dy = sin_a * self.delta * -self.speed
if key[pygame.K_a]:
dx = sin_a * self.delta * self.speed
dy = cos_a * self.delta * -self.speed
if key[pygame.K_d]:
dx = sin_a * self.delta * -self.speed
dy = cos_a * self.delta * self.speed
Получаем такой результат:
Далее мы должны представить нашу карту в виде сетки. И во всем промежутке угла обзора бросать некоторое количество лучей, чем их больше, тем лучше будет картинка, но меньше кадров в секунду.
Каждый луч должен находить пересечение с каждой вертикальной и горизонтальной линией сетки. Как только он находит столкновение с блоком, он рисует его нужных размеров и прекращает свое движение, далее цикл переходит к следующему лучу.
Так же нужно рассчитать расстояние до вертикальных и горизонтальных линий с которыми пересекся луч.
Вспоминаем школьную тригонометрию и рассмотрим это на примере вертикальных линий
Нам известна сторона k – это расстояние игрока до блока
a – это угол каждого луча
Далее просто добавляем длину, так как мы знаем размер нашего блока сетки.
И когда луч врежется в стену цикл остановиться.
Потом применяем это ко всем осям с небольшими изменениями
Для горизонтальных линий тоже самое только с синусом.
В расстояние луча записываем горизонтальное или вертикальное расстояние, в зависимости от того, что находиться ближе
Добавляем пару переменных высоты, глубины, размера которые высчитываются из достаточно простых формул
def rayCasting(display, player):
inBlockPos = {'left': player.x - player.x // blockSize * blockSize,
'right': blockSize - (player.x - player.x // blockSize * blockSize),
'top': player.y - player.y // blockSize * blockSize,
'bottom': blockSize - (player.y - player.y // blockSize * blockSize)}
for ray in range(numRays):
cur_angle = player.angle - halfFOV + deltaRays * ray
cos_a, sin_a = cos(cur_angle), sin(cur_angle)
vl, hl = 0, 0
#Вертикали
for k in range(mapWidth):
if cos_a > 0:
vl = inBlockPos['right'] / cos_a + blockSize / cos_a * k + 1
elif cos_a < 0:
vl = inBlockPos['left'] / -cos_a + blockSize / -cos_a * k + 1
xw, yw = vl * cos_a + player.x, vl * sin_a + player.y
fixed = xw // blockSize * blockSize, yw // blockSize * blockSize
if fixed in blockMap:
textureV = blockMapTextures[fixed]
break
#Горизонтали
for k in range(mapHeight):
if sin_a > 0:
hl = inBlockPos['bottom'] / sin_a + blockSize / sin_a * k + 1
elif sin_a < 0:
hl = inBlockPos['top'] / -sin_a + blockSize / -sin_a * k + 1
xh, yh = hl * cos_a + player.x, hl * sin_a + player.y
fixed = xh // blockSize * blockSize, yh // blockSize * blockSize
if fixed in blockMap:
textureH = blockMapTextures[fixed]
break
ray_size = min(vl, hl) * depthCoef
toX, toY = ray_size * cos(cur_angle) + player.x, ray_size * sin(cur_angle) + player.y
pg.draw.line(display, pg.Color("yellow"), (player.x, player.y), (toX, toY))
Рисуем прямоугольники по центру экрана, положение по горизонтали будет зависеть от номера луча, а высота будет равна отношению заданного коэффициента на длину луча.
#def rayCasting
ray_size += cos(player.angle - cur_angle)
height_c = coef / (ray_size + 0.0001)
c = 255 / (1 + ray_size ** 2 * 0.0000005)
color = (c, c, c)
block = pg.draw.rect(display, color, (ray * scale, half_height - height_c // 2, scale, height_c))
И вот получается уже какая-никакая иллюзия 3D измерения.
Текстуры
1 блок имеет 4 стороны и каждую бы должны покрыть текстурой.
Каждую сторону мы разделяем на полоски с маленькой шириной, главное чтобы количество лучей падающих на блок совпадало с количеством полосок на стороне, и делим на количество этих полосок нашу текстуру и поочередно отрисовываем полоску из текстуры на полоску на блоке.
Так ширина будет варьироваться в зависимости от удаленности стороны блока. А положение полоски рассчитывается путем умножения отступа на размер текстуры.
Если луч падает на вертикаль то отступ высчитываем от верхней точки, если на горизонталь то от левой точки.
#def rayCasting
if hl > vl:
ray_size = vl
mr = yw
textNum = textureV
else:
ray_size = hl
mr = xh
textNum = textureH
mr = int(mr) % blockSize
textures[textNum].set_alpha(c)
wallLine = textures[textNum].subsurface(mr * textureScale, 0, textureScale, textureSize)
wallLine = pg.transform.scale(wallLine, (scale, int(height_c))).convert_alpha()
display.blit(wallLine, (ray * scale, half_height - height_c // 2))
Добавляем еще возможность отрисовки нескольких текстур на одной карте путем добавления на карту специальных знаков, каждому будет присваиваться своя текстура.
Вот пример как выглядит 2-ой уровень в игре в виде кода:
textMaplvl2 = [
"111111111111111111111111",
"1111................1111",
"11.........1....11...111",
"11....151..1....31...111",
"1111............331...11",
"11111.....115..........1",
"1111.....11111....1113.1",
"115.......111......333.1",
"15....11.......11......1",
"11....11.......11..11111",
"111...................51",
"111........1......115551",
"11111...11111...11111111",
"11111%<@1111111111111111",
]
В итоге получаем адекватное отображение текстур:
Коллизия
Где же такое видано что мы можем проходить через блоки…
Добавляем коллизию. К каждой позиция блока добавляем так называемый коллайдер и такой же коллайдер добавляем игроку. Если он продолжит идти так как шел и такими темпами на следующем кадре по предсказанию зайдет в блок, то мы просто зануляем ускорение по нужной оси.
Для этого чуть допишем класс Player. Я решил еще сразу добавить управление камерой с помощью мыши. Вот как по итогу стал выглядеть этот класс:
class Player:
def __init__(self):
self.x = 0
self.y = 0
self.angle = 0
self.delta = 0
self.speed = 100
self.mouse_sense = settings.mouse_sensivity
#collision
self.side = 50
self.rect = pygame.Rect(*(self.x, self.y), self.side, self.side)
def detect_collision_wall(self, dx, dy):
next_rect = self.rect.copy()
next_rect.move_ip(dx, dy)
hit_indexes = next_rect.collidelistall(collision_walls)
if len(hit_indexes):
delta_x, delta_y = 0, 0
for hit_index in hit_indexes:
hit_rect = collision_walls[hit_index]
if dx > 0:
delta_x += next_rect.right - hit_rect.left
else:
delta_x += hit_rect.right - next_rect.left
if dy > 0:
delta_y += next_rect.bottom - hit_rect.top
else:
delta_y += hit_rect.bottom - next_rect.top
if abs(delta_x - delta_y) < 50:
dx, dy = 0, 0
elif delta_x > delta_y:
dy = 0
elif delta_y > delta_x:
dx = 0
self.x += dx
self.y += dy
def move(self, active):
self.rect.center = self.x, self.y
key = pygame.key.get_pressed()
key2 = pygame.key.get_pressed()
cos_a, sin_a = cos(self.angle), sin(self.angle)
if key2[pygame.K_LSHIFT]:
self.speed += 5
if self.speed >= 200:
self.speed = 200
else:
self.speed = 100
self.mouse_control(active=active)
if key[pygame.K_w]:
dx = cos_a * self.delta * self.speed
dy = sin_a * self.delta * self.speed
self.detect_collision_wall(dx, dy)
if key[pygame.K_s]:
dx = cos_a * self.delta * -self.speed
dy = sin_a * self.delta * -self.speed
self.detect_collision_wall(dx, dy)
if key[pygame.K_a]:
dx = sin_a * self.delta * self.speed
dy = cos_a * self.delta * -self.speed
self.detect_collision_wall(dx, dy)
if key[pygame.K_d]:
dx = sin_a * self.delta * -self.speed
dy = cos_a * self.delta * self.speed
self.detect_collision_wall(dx, dy)
def mouse_control(self, active):
if active:
if pygame.mouse.get_focused():
diff = pygame.mouse.get_pos()[0] - half_width
pygame.mouse.set_pos((half_width, half_height))
self.angle += diff * self.delta * self.mouse_sense
Геймплей
Спавним цвета на карте, и делаем так, чтобы игрок мог взять их, и красить любые блоки. Для того что понять находиться персонаж рядом с блоком или нет пишем хитрую цепочку условий:
for blockNow in blockMapTextures:
questBlock = False
if (blockNow[0] - blockSize // 2 < player.x < blockNow[0] + blockSize * 1.5 and blockNow[1] < player.y < blockNow[1] + blockSize) or \
(blockNow[1] - blockSize // 2 < player.y < blockNow[1] + blockSize * 1.5 and blockNow[0] < player.x < blockNow[0] + blockSize):
if countOfDraw < len(blocksActive) and doubleDrawOff:
display.blit(
pg.transform.scale(ui['mouse2'], (ui['mouse2'].get_width() // 2, ui['mouse2'].get_height() // 2)),
(130, 750))
if event.type == pg.MOUSEBUTTONDOWN and pg.mouse.get_pressed()[2]:
if blockMapTextures[blockNow] == '<':
questBlock = True
if questBlock == False:
try:
tempbackup_color.clear()
tempbackup.clear()
coloredBlocks.clear()
block_in_bag.pop(-1)
tempbackup.append(blockMapTextures[blockNow])
tempbackup_color.append(blocks_draw_avaliable[list(blocks_draw_avaliable.keys())[-1]])
print('tempbackup_color : ', tempbackup_color)
blockMapTextures[blockNow] = blocks_draw_avaliable[list(blocks_draw_avaliable.keys())[-1]]
coloredBlocks.append(blockNow)
blocks_draw_avaliable.pop(list(blocks_draw_avaliable.keys())[-1])
countOfDraw += 1
doubleDrawOff = False
doubleBack = False
except:
print('Error in color drawing')
Грубо говоря, мы условно увеличиваем диапазон координат которые захватывает один блок, и постоянно смотрим, заходит ли игрок в эти координаты. У каждого блока, получается, есть некая область вокруг(без углов) размером в несколько десятков пикселей, и при заходе в нее, считается что ты рядом с определенным блоком.
Я уверен что есть способ лучше чтобы обнаружить блок рядом с игроком, но я решил не придумывать колесо и сделал, как сделал).
Далее реализуем систему квестов и смену уровней в зависимости от того выполнен квест или нет. А так же переключатель уровней, с картинкой для сюжета в начале каждого уровня.
def lvlSwitch():
settings.textMap = levels.levelsList[str(settings.numOfLvl)]
with open("game/settings/settings.json", 'w') as f:
settings.sett['numL'] = settings.numOfLvl
js.dump(settings.sett, f)
print(settings.numOfLvl)
main.tempbackup.clear()
main.coloredBlocks.clear()
main.blocksActive.clear()
main.tempbackup_color.clear()
main.block_in_bag.clear()
main.blocks_draw_avaliable.clear()
main.countOfDraw = 0
main.blockClickAvaliable = 0
def switcher():
global lvlSwitches
main.display.blit(ui[f'lvl{settings.numOfLvl+1}'], (0,0))
main.timer = False
if pg.key.get_pressed()[pg.K_SPACE]:
level5_quest.clear()
main.doubleQuest = True
settings.numOfLvl += 1
lvlSwitch()
main.timer = True
level5_quest.clear()
lvlSwitches = False
def quest(lvl):
global lvlSwitches
tmp = []
for blockNeed in blockQuest:
if blockQuest[blockNeed] == '@':
if blockMapTextures[blockNeed] == '3':
tmp.append(1)
if settings.numOfLvl == 5:
level5_quest.add(1)
if blockQuest[blockNeed] == '!':
if blockMapTextures[blockNeed] == '2':
tmp.append(2)
if settings.numOfLvl == 5:
level5_quest.add(2)
if blockQuest[blockNeed] == '$':
if blockMapTextures[blockNeed] == '4':
tmp.append(3)
if settings.numOfLvl == 5:
level5_quest.add(3)
if blockQuest[blockNeed] == '%':
if blockMapTextures[blockNeed] == '5':
tmp.append(4)
if settings.numOfLvl == 5:
level5_quest.add(4)
Реализуем пару механик:
Первая механика – банально поставить нужный цвет в нужную ячейку. Объяснений не требуется.
Вторая механика – телепортация создается новая карта в виде листа и блоки в ней раз в какое то время перемешиваются, создается ощущения телепортаций цветов.
def randomColorBlockMap(textMap):
timer = t.perf_counter()
text = textMap
newTextMap = []
generatedMap = []
for row in text:
roww = []
for column in row:
roww.append(column)
newTextMap.append(roww)
textsForShuffle = []
for row in text:
for column in row:
if column != '.' and column != '<' and column != '$' and column != '%' and column != '@' and column != '!':
textsForShuffle.append(column)
xy_original = []
for y, row in enumerate(text):
for x, column in enumerate(row):
if column != '.' and column != '<' and column != '$' and column != '%' and column != '@' and column != '!':
if (x*blockSize, y*blockSize) not in list(settings.blockQuest.keys()):
xy_original.append([x,y])
xy_tmp = xy_original
for y, row in enumerate(newTextMap):
for x, column in enumerate(row):
if column != '.' and column != '<' and column != '$' and column != '%' and column != '@' and column != '!':
if (x*blockSize, y*blockSize) not in list(settings.blockQuest.keys()):
ch = rn.choice(textsForShuffle)
newTextMap[y][x] = ch
textsForShuffle.remove(ch)
for row in newTextMap:
generatedMap.append(''.join(row))
initMap(generatedMap)
Третья механика – добавляем ЧБ фильтр на каждую текстуру…
def toBlack():
settings.textures['2'] = pygame.image.load('textures/colorYellowWallBlack.png').convert()
settings.textures['3'] = pygame.image.load('textures/colorBlueWallBlack.png').convert()
settings.textures['4'] = pygame.image.load('textures/colorRedWallBlack.png').convert()
settings.textures['5'] = pygame.image.load('textures/colorGreenWallBlack.png').convert()
settings.textures['<'] = pygame.image.load('textures/robotBlack.png').convert()
ui['3'] = pygame.image.load("textures/blue_uiBlack.png")
ui['2'] = pygame.image.load("textures/yellow_uiBlack.png")
ui['4'] = pygame.image.load("textures/red_uiBlack.png")
ui['5'] = pygame.image.load("textures/green_uiBlack.png")
Дальше я сделал меню в виде класса, чтобы удобно добавлять опции когда это будет нужно.
class Menu:
def __init__(self):
self.option_surface = []
self.callbacks = []
self.current_option_index = 0
def add_option(self, option, callback):
self.option_surface.append(f1.render(option, True, (255, 255, 255)))
self.callbacks.append(callback)
def switch(self, direction):
self.current_option_index = max(0, min(self.current_option_index + direction, len(self.option_surface) - 1))
def select(self):
self.callbacks[self.current_option_index]()
def draw(self, surf, x, y, option_y):
for i, option in enumerate(self.option_surface):
option_rect = option.get_rect()
option_rect.topleft = (x, y + i * option_y)
if i == self.current_option_index:
pg.draw.rect(surf, (0, 100, 0), option_rect)
b = surf.blit(option, option_rect)
pos = pygame.mouse.get_pos()
if b.collidepoint(pos):
self.current_option_index = i
for event in pg.event.get():
if pg.mouse.get_pressed()[0]:
self.select()
Реализуем сохранения:
try:
with open("game/settings/settings.json", 'r') as f:
sett = js.load(f)
except:
with open("game/settings/settings.json", 'w') as f:
sett = {
'FOV' : pi / 2,
'numRays' : 400,
'MAPSCALE' : 10,
'numL' : 1,
'mouse_sensivity' : 0.15
}
js.dump(sett, f)
numOfLvl = sett['numL']
textMap = levels.levelsList[str(numOfLvl)]
mouse_sensivity = sett['mouse_sensivity']
И в заключении мини философскую историю с глубоким смыслом и неожиданную концовку.
Заключение
Вот и получается игра с 2.5D измерением, сотнями лучей, маленьким FPS и незамысловатым геймплеем, на которую потребовалось всего 4 библиотеки, 68 текстур, и 1018 строчек кода.
Также вы всегда можете ознакомиться с полным кодом этого проекта или скачать игру у меня на GitHub.
Надеюсь этой статьей я вам чем то помог и вы нашли данную информацию в какой-то степени полезной. Спасибо за внимание <3
Комментарии (7)
da-nie
23.07.2023 06:14+5На самом деле здесь используется технология Ray Casting, третьего измерения тут просто не существует.
Так. Сначала показан Wolf-3D, а потом DooM. Это утверждение, полагаю, относится и к DooM? В таком случае, разочарую — в DooM используется BSP-дерево, и третье измерение, внезапно, существует и в нём тоже можно перемещаться сколько угодно! :) Это уже вторая статья про Ray Casting за последние пару месяцев, и опять автор не удосужился почитать, как же сделан DooM (у меня есть подозрение, что вы родилисль сильно позже, чем этот DooM был уже почти забыт)! Ну пожалуйста, не надо фантазий. Да поиграйте ж вы в этот DooM, наконец. Сравните со своим движком и задайтесь вопросом, как же это у вас такие сцены, как в DooM, не получаются физически?
Мне стало интересно на сколько это сложно реализовать.
Несложно. Но лучше реализуйте экранный портал. Он тоже несложный, но 3D у вас будет уровня DooM и Duke Nukem 3D.
Вот и получается игра с 2.5D измерением, сотнями лучей, маленьким FPS
А вы на I5 запускали? :) Шутка. А сравните ваш код и подход с кодом из книги Шикин, Боресков "Компьютерная графика. Полигональные модели". Там этот метод разобран детально. А то что-то у вас как-то FPS даже для питона не очень.
Сколько вы, кстати, лучей-то запускаете? А то у меня нехорошее предчувствие появилось, что их число у вас не равно разрешению по X экрана.Кстати, как дела со скольжением при столкновении с блоком (когда игрок упирается в блок под некоторым углом)? :) Помнится, правильно сделать это скольжение была весёлая задачка. :)
В итоге получаем адекватное отображение текстур:
А где же текстуры? Вот эти цветные вертикальные линии? Ну нет, это не серьёзно...
Vasyutka
23.07.2023 06:14+2как-то слишком много сурового русского фидбека. :) А книжка - ну понятно что автор не из тех, кто может закрыть глаза и вспомнить как выглядели отдельные страницы Шикина и Борескова, да еще и как пахли. И всю историю эволюции wolf3d->doom->и затем уже полноценные полигональные 3д движки (с вставкой спрайтов иногда от бедности). А чтобы поностальгировать, для сведения олдскул - сама книжка (хотя мне кажется, что это и правда очень хорошее историческое чтиво для кого угодно кто немного занимается компьютерной графикой):
Hidden text
da-nie
23.07.2023 06:14+1Да ну бросьте, где же тут суровость? :) Скорее, сарказм.
ну понятно что автор не из тех, кто может закрыть глаза и вспомнить как выглядели отдельные страницы
Конечно, нет. Автор, думаю, только-только узнал, что может сделать Ray Casting и получить хоть какое-то 3D и спешит поделиться этой технологией. :)
Но я ему завидую — у него всё впереди. :) Даже плюсиков наставил ему, вот.
benvito Автор
23.07.2023 06:14разочарую — в DooM используется BSP-дерево, и третье измерение, внезапно, существует и в нём тоже можно перемещаться сколько угодно!
Правильно подметили, спасибо! Действительно, даже не удосужился почитать про эту игру побольше, просто визуально уж больно похож он на Ray Casting) Впредь постараюсь поглубже вникать в тему.
А то что-то у вас как-то FPS даже для питона не очень.
Эту всю штуку можно оптимизировать, в том числе и с помощью любого JIT компилятора, но проблема в том, что на пайтоне они уж очень не дружат с ООП и с некоторыми видами данных, а чинить всю эту тему мне не очень то и хотелось. Как-нибудь на досуге ознакомлюсь с книгой.
Сколько вы, кстати, лучей-то запускаете? А то у меня нехорошее предчувствие появилось, что их число у вас не равно разрешению по X экрана.
Это все можно настроить, конечно. А так, разрешение экрана 1600 по ширине, запускаю в основном 400 лучей, 4 к 1 получается, путем проб и ошибок выяснилось, что лучше запускать число лучей такое, чтобы это число имело общий знаменатель с шириной экрана, как то так.
Кстати, как дела со скольжением при столкновении с блоком (когда игрок упирается в блок под некоторым углом)?
О да, в точку! Но уже честно не помню как, но это проблему я как то сгладил, конечно присутствуют иногда глитчи, но в основном игрок именно скользит по стенам.
А где же текстуры? Вот эти цветные вертикальные линии?
По факту, по игровой задумке, такой игре не нужны были "вау" текстуры, но там помимо цветных блоков есть еще текстура робота.
Спасибо за такую полную критику, а также за то, что верно подмечали ошибки и недочеты!
playermet
23.07.2023 06:14Про рейкастинг на хабре (да и вообще в сети) уже много статей было, и во всех примерно одно и то же, только языки отличаются. Хотелось бы увидеть какое-то отклонение от "нормы" - округлые стены, полупрозрачность, освещение, или еще что-нибудь интересное придумать. Алгоритм то позволяет делать игры на железе 30-летней давности, в его чистом виде мощности современных ПК почти не задействованы. Интересно посмотреть, какие прикольные штуки можно получить если заменить рейкастинг на ray marching.
HemulGM
Отличный, кстати, пример того, как питон плохо справляется с работой, когда простые вычисления приходится делать ему самому, а не полагаться на C/C++ библиотеки.