Предисловие
В прошлой статье, с которой мы настоятельно рекомендуем ознакомиться перед прочтением этой, мы рассмотрели:
Программирования в Mindustry
Карту
Луч и основные принципы псевдо-3D
Рисование текстур.
Сегодня же мы углубимся в детали того, как работает эффект рыбьего глаза и немного перестроим псевдо-3D, а так же создадим физику для движка и спрайты.
Мелочи
В прошлой статье мы забыли упомянуть FOV
(Field of view). По сути это угол обзора, в котором все лучи лежат равномерно с разницей, допустим, в ~1⁰.
К текущей статье мы успели заменить мелкую ячейку памяти настроек cell1
, на банк настроек памяти bank12
, bank2
, bank1
(в зависимости от процессора).
Также мы решили убрать редактор карты, ведь он только запутывал людей, хотевших пользоваться нашим псевдо-3D движком, и теперь она создается автоматически.
Ещё изменился внешний вид нашего псевдо-3D движка, теперь он более аккуратный.
Эффект рыбьего глаза, что и почему
В прошлой статье мы частично решили проблему эффекта рыбьего глаза, умножив длину луча на косинус угла между этим лучом и направлением взгляда игрока. Но почему оно возникло, почему мы его решили этим способом, и почему не полностью? В данной главе мы постараемся ответить на эти вопросы.
Эффект рыбьего глаза возникает из-за того, что длинна луча от игрока до стены не равномерна. На примере картинки вы поймёте про что я говорю:
Умножив на косинус мы сделали L1
и L2
равными:
Но вот незадача, псевдо-3D всё ещё выглядит странным:
Это происходит из-за того, что углы между лучами равны, от чего лучей в центре становится больше, чем по бокам:
А вот так оно должно выглядеть:
Один из способов максимально уменьшить влияние этого эффекта — уменьшить угол обзора FOV
. Его оптимальное значение 70⁰.
В нашем же псевдо-3D движке мы решили эту проблему другим способом - переписали небольшую его часть, но об этом я расскажу в следующей главе.
Перестройка движка
Мы решили перестроить небольшую часть движка, отвечающую за распределение лучей, а она потащила за собой другие части. Но всё же переписывали мы не много.
Мы решили отказаться от FOV
в пользу plane
(о нем ниже), чтобы полностью убрать эффект рыбьего глаза. В данной версии будут важны 4 переменные: dirX
, dirY
(направление взгляда игрока) и plane_X
, plane_Y
(поверхность экрана по x и y).
Взгляните на картинку:
Так выглядела абстракция движка (то, как мы это представляли в голове): был FOV
, лучи распределены в нём равномерно, и также был угол направления взгляда игрока player_angle
.
Мы решили заменить это на другую абстракцию:
По сути ничего не изменилось, мы лишь подошли к тому же, но с другой стороны.
Переменная plane
, отвечающая за длину экрана, не является вектором, потому что нам не нужны переменные, содержащие одни и те же значения. Ведь plane
всегда перпендикулярна направлению взгляда игрока, так что для описания вектора экрана можно использовать направление взгляда игрока повёрнутое на 90⁰ и умноженное на длину экрана. Но ниже мы будем в некоторых местах для удобства вставлять переменные по типу plane_x
, plane_y
. Для удобства считайте, что plane
является вектором, а эти переменные - его направление.
В данной версии луч вместо переменной угла направления имеет 2 координаты ray_dir_x
и ray_dir_y
, которые задают направление луча вектором. В прошлой версии движка не было равномерного распределения лучей по плоскому экрану из-за того, что все лучи отличались друг от друга на строго заданный угол, а с ним не получится полностью избежать эффекта рыбьего глаза (вспоминаем написанное выше). Поэтому вместо угла мы решили использовать вектор направления луча. Его нужно просчитать для каждого луча отдельно, а просчитывается он так: ray_dir_x = player_dir_x + plane_x * (ray_number - number_of_rays/2)
ray_dir_y = player_dir_y + plane_y * (ray_number - number_of_rays/2)
Вектор направления игрока легко просчитывается от угла направления взгляда игрока: player_dir_x = cos(player_angle)
player_dir_y = sin(player_angle)
Вектор экрана plane
просчитывается чуть сложней, ведь он на 90⁰ отличается от вектора направления игрока:plane_x = -sin(player_angle)
plane_y = cos(player_angle)
Чтоб не просчитывать синус и косинус по несколько раз, мы упростили формулу до этого:ray_dir_x = player_dir_x - player_dir_y * k
ray_dir_y = player_dir_y + player_dir_x * k
Вот часть псевдокода:
k = (number_of_ray - 88)/176
read dirX at 6 in bank12
read dirY at 7 in bank12
ray_dir_x = dirX - dirY*k
ray_dir_y = dirY + dirX*k
Алгоритм луча из за этого изменения не сильно поменялся, разве что тангенс теперь просчитывается так:tan_of_ray_angle = ray_dir_y/ray_dir_x
Ведь тангенс это противолежащий катет делённый на прилежащий, в роли противолежащего выступает ray_dir_y
, а в роли прилежащего ray_dir_x
Ещё изменилась проверка того, в какую плоскость пускается луч. Например, для проверки по вертикальным линиям процессор теперь смотрит больше ли нуля ray_dir_x
, если да, то луч пускается вправо, если нет - луч пускается влево.
Также изменилось просчитывание косинуса между направлением взгляда и лучом: cos = 1/sqrt(k² + 1²)
Косинус — это прилежащий катет делить на гипотенузу, гипотенуза просчитывается по теореме Пифагора sqrt(k² + 1²)
, а катет равен 1.
И вуаля:
Эффекта не видно.
Спрайты
Спрайты — это те самые противники в Woolfenstein 3d или в Doom, которых вы все видели. По сути они являются текстурками, постоянно смотрящими в нашу сторону, из-за чего они всегда прямоугольные, что упростит их отрисовку.
Их добавление увеличит разнообразие того, что можно создать в данном псевдо-3D движке. Например, с помощью спрайтов можно создавать врагов, колоны, предметы и т.д.
Как это выглядит:
А теперь сложная часть — как это устроено?
Спрайт — это лишь координаты спрайта с номером изображения. Изображение мы тут упустим, нас интересует то, как они рисуются.
Представьте карту, на которой нет ничего, кроме игрока и спрайта, игрок смотрит в 0⁰ (то есть вправо).
Нам дана задача: отрисовать на экране спрайт. Для этого надо найти координаты спрайта на экране:
Ищутся они так:diff_x = sprite_x - player_x
diff_y = sprite_y - player_y
screen_sprite_x = half_display_weight - display_weight*diff_y/diff_x
screen_sprite_y = 88 //откуда взялось 88 мы писали в прошлой статье
diff_x
, diff_y
— расстояние от игрока до спрайта по x и y.
Тут display_weight*diff_y/diff_x
минусуется от half_display_weight
, а не наоборот из-за того, что координатная ось дисплея отзеркалена относительно display_weight*diff_y/diff_x
.
Отлично, но что делать, если игрок не всегда смотрит ровно вправо (в 0⁰)? Нужно повернуть экран, но тогда спрайты не будет адекватно на нём показываться:
Поэтому надо повернуть спрайт относительно своего прошлого положения:
Для поворота воспользуемся матрицей поворота:
[cos(player_angle), -sin(player_angle)]
[sin(player_angle), cos(player_angle)]
Так как спрайты должны поворачиваться в противоположную сторону от того, куда поворачивается игрок, надо изменить матрицу так:
[cos(player_angle), sin(player_angle)]
[sin(player_angle), -cos(player_angle)]
По сути мы отзеркалили матрицу по y.
Упростим её до этого:
[dirX, dirY]
[dirY, -dirX]
Ведь dirX
равен cos(player\_angle)
, а dirY
равен sin(player\_angle)
Вот как оно будет выглядеть в псевдокоде:
read dirX
read dirY
read player_x
read player_y
diff_x = sprite_x - player_x
diff_y = sprite_y - player_y
sx = diff_x*dirX + diff_y*dirY
sy = diff_y*dirX - diff_x*dirY
screen_sprite_x = half_display_weight - display_weight*sy/sx
Теперь рисуем желтый квадрат на координатах спрайта (его координаты на экране X:screen_sprite_x, Y:88
), и вот что видим:
Поворачиваемся на 180⁰ и снова видим спрайт:
Так быть не должно. Это происходит из за того, что когда спрайт позади экрана, он не перестаёт рисоваться:
Он рисуется сзади игрока как будто на втором отзеркаленном вдоль экране. Решить это не трудно: нам нужно узнать, когда спрайт сзади игрока. Спрайт сзади игрока, если sx < 0
, так что делаем эту проверку, и если sx < 0
, то мы просто приравниваем screen_sprite_x
к значению -500
.
На карте возможен только 1 спрайт, но хочется же больше.
Чтобы сделать больше спрайтов, процессор сортирует спрайты по расстоянию до игрока, а потом поочередно рисует стену, спрайт1, спрайт2 и т.д.
Как это было реализовано в mindustry
Для начала расскажу о том, как у нас хранятся спрайты. Есть два банка памяти, обозначим их как bank9
и bank10
, первый хранит ссылки на все спрайты (тут ссылка — это просто номер ячейки другого банка памяти, где хранится спрайт), а второй хранит спрайты. Второй банк памяти хранит каждый спрайт на трёх ячейках сразу:
Расстояние до спрайта
screen_sprite_x
Номер текстурки
Спрайты сортируются быстрой сортировкой Хоара. Ну, как спрайты, сортируются ссылки на спрайты. Значение, по которому они сортируются — расстояние от игрока до спрайта.
Спрайты просчитываются в семи процессорах, каждый читает значение из мелкой ячейки памяти, где записаны координаты спрайтов и номер текстурки. Далее он вычисляет расстояние до спрайта и sprite_x
, после записывает в bank10
то, что просчитал и номер текстурки.
Процессоры, что отрисовывают псевдо-3D, изменились: теперь в цикле рисуются спрайты от самого дальнего до самого ближнего, а перед этим рисуется стена. Такой порядок нужен для того, чтобы у спрайтов нормально отрисовывались прозрачные пиксели.
Физика
Физика — это относительно важная часть псевдо-3D движка. Будет не приятно, если игрок сможет проходить сквозь стены, и именно для этого и нужна физика.
В нашем псевдо-3D движке мы решили физику способом с багами, но «И так сойдет!»
Способ такой: проверяем, если в следующем местоположении игрока по x и текущем по y есть стена, то мы не делаем этот шаг по x. Тоже самое и для y.
Вот часть псевдокода:
xperson1 = xperson
yperson1 = yperson
// Тут код передвижения. Тоесть координаты xperson и yperson изменятся в сторону движения. Мы решили его не показывать для удобства
if map[xperson][yperson1] == 0 {
xperson = xperson1
}
if map[xperson1[yperson] == 0 {
yperson = yperson1
}
Для удобства тут мы jump заменили на if.
Финал
Для красоты финала изменим текстурки, добавим ИИ спрайтам и готово:
В этот раз мы уже имеем что-то, чем можно похвастаться!
Доп. материалы
https://lodev.org/cgtutor/raycasting.html
https://github.com/xdettlaff/mindustry-3d-engine/blob/main/3d_v0.2.2