Рейкастинг для самых маленьких: геймдев на реальных проектах

Создайте собственный легендарный Wolfenstein 3D всего в 500 строчек кода! Простой рейкастинг для чайников и начинающих игроделов.

Рейкастинг для самых маленьких

В этой статье вы шаг за шагом начнете создавать 3D игру с лабиринтом и чудовищами а-ля легендарный Wolfenstein 3D. Это великолепный способ обучения – делать интересные штуки, одновременно узнавая новое. Чтобы стать крутым программистом, нужно писать код для собственного удовольствия.

Посмотрите это видео, чтобы иметь общее представление о том, как в итоге будет выглядеть наш рейкастинг:

Обратите внимание: здесь не будет полноценной игры, только фундамент движка и рейкастинг для отрисовки уровня. Допилить шедевр вы вполне можете сами. Например, можно сделать что-то подобное:

Часть 1. Сырой 3D-рендеринг

Проект основан на SLD2, но GUI и обработка событий появятся позже. Весь рендеринг можно сделать без всяких графических библиотек.

Начинаем работу с одним лишь C++ комплятором. Если у вас на компьютере нет компилятора, но есть GitHub-аккаунт, вы можете выполнить код прямо в браузере!

Gitpod создает виртуальную машину и открывает редактор и консоль. Чтобы запустить компиляцию, кликните в области терминала и нажмите клавишу вверх на клавиатуре. Вы увидите последнюю команду. Просто нажмите Enter, и произойдет чудо.

Шаг 1. Сохранение картинки

Первое, что нужно научиться делать, – загружать изображения и сохранять их в стандартном формате. Возьмем этот файл:

Рейкастинг для самых маленьких

Полный код на C++ доступен в этом коммите. Он не очень длинный, поэтому давайте посмотрим на него прямо здесь:

#include <iostream>
#include <fstream>
#include <vector>
#include <cstdint>
#include <cassert>

uint32_t pack_color(const uint8_t r, const uint8_t g, const uint8_t b, const uint8_t a=255) {
    return (a<<24) + (b<<16) + (g<<8) + r;
}

void unpack_color(const uint32_t &color, uint8_t &r, uint8_t &g, uint8_t &b, uint8_t &a) {
    r = (color >>  0) & 255;
    g = (color >>  8) & 255;
    b = (color >> 16) & 255;
    a = (color >> 24) & 255;
}

void drop_ppm_image(const std::string filename, const std::vector<uint32_t> &image, const size_t w, const size_t h) {
    assert(image.size() == w*h);
    std::ofstream ofs(filename);
    ofs << "P6\n" << w << " " << h << "\n255\n";
    for (size_t i = 0; i < h*w; ++i) {
        uint8_t r, g, b, a;
        unpack_color(image[i], r, g, b, a);
        ofs << static_cast<char>(r) << static_cast<char>(g) << static_cast<char>(b);
    }
    ofs.close();
}

int main() {
    const size_t win_w = 512; // image width
    const size_t win_h = 512; // image height
    std::vector<uint32_t> framebuffer(win_w*win_h, 255); // the image itself, initialized to red

    for (size_t j = 0; j<win_h; j++) { // fill the screen with color gradients
        for (size_t i = 0; i<win_w; i++) {
            uint8_t r = 255*j/float(win_h); // varies between 0 and 255 as j sweeps the vertical
            uint8_t g = 255*i/float(win_w); // varies between 0 and 255 as i sweeps the horizontal
            uint8_t b = 0;
            framebuffer[i+j*win_w] = pack_color(r, g, b);
        }
    }

    drop_ppm_image("./out.ppm", framebuffer, win_w, win_h);

    return 0;
}

Открыть в Gitpod

Что вы должны понять на данный момент?

  • Цвета хранятся в виде четырехбайтных целочисленных значений с типом uint32_t. Каждый байт – это компонент цвета: R, G, B или A. Работать с ними позволяют функции pack_color() и unpack_color().
  • Двумерная картинка переходит в одномерный массив. Для доступа к пикселю (x, y) следует использовать выражение image[x + y*width] (вместо image[x][y]).

Итак, двойной цикл проходит по картинке и сохраняет ее образ на диске в ppm-файле.

Шаг 2. Карта

Нарисуем карту мира и определим структуру для хранения данных.

Рейкастинг для самых маленьких

Все изменения для этого шага вы найдете здесь. Все очень просто:

  • карта жестко прописана в одномерном массиве;
  • создана функция draw_rectangle;
  • отрисованы на карте все непустые клетки.

Открыть в Gitpod

Шаг 3. Игрок

Для отрисовки игрока на карте нужно знать его координаты.

Рейкастинг для самых маленьких

Добавим две переменные x и y:

Рейкастинг для самых маленьких

Коммит с изменениями
Открыть в Gitpod

Шаг 4. Дальномер, или первый рейкастинг

Кроме координат игрока хорошо было бы знать, куда он смотрит. Для этого можно ввести переменную player_a, в которой будет храниться угол между направлением взгляда и осью x.

Взгляните на рисунок. Как найти координаты точки, скользящей по оранжевому лучу? Зеленый треугольник нам поможет. Известно, что cos(player_a) = a/c, a sin(player_a) = b/c.

Рейкастинг для самых маленьких

Нужно просто взять положительное значение c и вычислить:

  • x = player_x + c*cos(player_a);
  • y = player_y + c*sin(player_a).

Это координаты пурпурной точки. Изменяя c от 0 до бесконечности, вы можете сдвигать ее по оранжевому лучу. Итак, c – это расстояние от игрока (player_x, player_y) до точки (x, y).

Сердце нашего 3D-движка – вот этот цикл:

float c = 0;   
for (; c<20; c+=.05) {
    float x = player_x + c*cos(player_a);
    float y = player_y + c*sin(player_a);
    if (map[int(x)+int(y)*map_w]!=' ') break;
}

Если точка, двигаясь по лучу зрения, натыкается на препятствие, цикл прерывается. В переменной c остается расстояние до этой стены. Примитивный дальномер (или зачаточный рейкастинг) готов.

Рейкастинг для самых маленьких

Коммит с изменениями
Открыть в Gitpod

Шаг 5. Поле зрения

Один луч – это, конечно, круто, но мало. Наши глаза видят сразу целый сектор, значит, камера должна охватить целый угол fov (field of view):

Рейкастинг для самых маленьких

Для этого мы будем выпускать целых 512 лучей (почему 512?):

Рейкастинг для самых маленьких

Коммит с изменениями
Открыть на Gitpod

Шаг 6. 3D

Этот шаг – ключ к успеху. Для каждого из 512 лучей мы вычисляем дистанцию до ближайшей стены. Теперь можно создать вторую картинку (внимание, спойлер!) размером как раз в 512 пикселей. Для каждой стены мы будем рисовать вертикальный сегмент, высота которого обратно пропорциональна расстоянию.

Рейкастинг для самых маленьких

Это ключевая точка для создания 3D-иллюзии. Фактически мы нарисовали забор, в котором высота каждой доски зависит от расстояния до камеры.

Рейкастинг для самых маленьких

Коммит с изменениями
Открыть в Gitpod

Шаг 7. Первая анимация

Пришла пора для простой динамики: мы будем изменять значение player_a в цикле и рендерить изображение, чтобы получить иллюзию поворота. Чтобы было понятнее, стены покрашены в разные цвета.

Коммит с изменениями
Открыть в Gitpod

Шаг 8. Коррекция искажения "рыбьего глаза"

Вы заметили забавный эффект "рыбьего глаза" на анимации, если смотреть прямо на стену?

Рейкастинг для самых маленьких

Для объяснения возьмем эту картинку:

Рейкастинг для самых маленьких

Пурпурный луч перемещается в поле зрения для визуализации сцены. Длина оранжевого сегмента явно меньше длины пурпурного, поэтому и высота соответствующих им вертикальных сегментов разная.

Скорректировать это искажение очень просто.

Открыть в Gitpod

Часть 2. Текстуры

Шаг 9. Загрузка

Простой рейкастинг готов, переходим к текстурам. Чтобы не писать собственный велосипед-загрузчик, можно использовать замечательную библиотеку stb. Все текстуры собраны в одном файле в виде горизонтального спрайта из квадратов.

Рейкастинг для самых маленьких

Загрузим их в память. Чтобы убедиться, что все нормально работает, отрисуем один текстурный квадрат с индексом 5 в левом углу экрана:

Рейкастинг для самых маленьких

Коммит с изменениями
Открыть в Gitpod

Шаг 10. Начало использования

Сначала просто раскрасим стены в разные цвета, определенные верхним левым пикселем соответствующей текстуры.

Рейкастинг для самых маленьких

Коммит с  изменениями
Открыть в Gitpod

Шаг 11. Текстуры для стен

Рейкастинг для самых маленьких

Идея очень проста. Предположим, что дальномер столкнулся с "горизонтальной" стеной. Тогда y почти целое число (наш рейкастинг не совершенен). Возьмем дробную часть xhitx. Она меньше единицы. Если умножить ее на размер текстуры, можно получить соответствующий столбец в текстурном квадрате. Затем просто растягиваем колонну до нужной высоты:

Рейкастинг для самых маленьких

Идея простая, но она требует аккуратной реализации. У нас есть еще "вертикальные" стены, у которых hitx стремится к нулю. В этом случае нужно использовать hity по аналогии.

Коммит с изменениями
Открыть в Gitpod

Шаг 12. Время рефакторинга!

До этого момента у нас был огромный .cpp-файл (185 строк), неудобный для чтения. Разобьем его на несколько маленьких. К сожалению, это увеличило размер кода, но теперь с ним проще работать. Например, для анимации достаточно написать вот такой цикл:

for (size_t frame=0; frame<360; frame++) {
    std::stringstream ss;
    ss << std::setfill('0') << std::setw(5) << frame << ".ppm";
    player.a += 2*M_PI/360;

    render(fb, map, player, tex_walls);
    drop_ppm_image(ss.str(), fb.img, fb.w, fb.h);
}

Коммит с изменениями
Открыть в Gitpod

Часть 3. Население мира

Шаг 13. Монстры

Монстров в нашем мире можно представить с помощью координат и идентификатора соответствующей текстуры:

struct Sprite {
    float x, y;
    size_t tex_id;
};

[..]

std::vector<Sprite> sprites{ {1.834, 8.765, 0}, {5.323, 5.365, 1}, {4.123, 10.265, 1} };

Создадим нескольких монстров и добавим их на карту.

Рейкастинг для самых маленьких

Коммит с изменениями
Открыть в Gitpod

Шаг 14. Плейсхолдеры

Теперь отрисуем спрайты в 3D-пространстве. Для каждого нужно знать позицию на экране и размер. Пока вместо реальной картинки используем просто черные квадраты-плейсхолдеры:

void draw_sprite(Sprite &sprite, FrameBuffer &fb, Player &player, Texture &tex_sprites) {
    // абсолютное направление от игрока до спрайта (в радианах)
    float sprite_dir = atan2(sprite.y - player.y, sprite.x - player.x);
    // удаление лишних оборотов
    while (sprite_dir - player.a >  M_PI) sprite_dir -= 2*M_PI; 
    while (sprite_dir - player.a < -M_PI) sprite_dir += 2*M_PI;

    // расстояние от игрока до спрайта
    float sprite_dist = std::sqrt(pow(player.x - sprite.x, 2) + pow(player.y - sprite.y, 2)); 
    size_t sprite_screen_size = std::min(2000, static_cast<int>(fb.h/sprite_dist));
    // не забывайте, что 3D вид занимает только половину кадрового буфера,
    // таким образом, fb.w/2 для ширины экрана
    int h_offset = (sprite_dir - player.a)*(fb.w/2)/(player.fov) + (fb.w/2)/2 - sprite_screen_size/2;
    int v_offset = fb.h/2 - sprite_screen_size/2;

    for (size_t i=0; i<sprite_screen_size; i++) {
        if (h_offset+int(i)<0 || h_offset+i>=fb.w/2) continue;
        for (size_t j=0; j<sprite_screen_size; j++) {
            if (v_offset+int(j)<0 || v_offset+j>=fb.h) continue;
            fb.set_pixel(fb.w/2 + h_offset+i, v_offset+j, pack_color(0,0,0));
        }
    }
}

Разберем этот код. Для начала взгляните на рисунок:

Рейкастинг для самых маленьких

В самом начале мы вычисляем угол sprite_dir – между линией от игрока к спрайту и осью x. Относительный угол между направлением взгляда игрока и спрайтом легко посчитать по формуле sprite_dir - player.a. Расстояние между игроком и спрайтом находится элементарно, а размер самого спрайта рассчитывается путем простого деления на расстояние.

Для спрайтов установлен максимальный размер в 2000 пикселей, иначе они могут быть очень большими. h_offset и v_offset – это координаты верхнего левого угла спрайта на экране.

Для отрисовки спрайта используется простой вложенный цикл.

Проверьте все эти вычисления самостоятельно. В этом коммите спрятался один (некритичный) баг. Он будет исправлен далее.

Рейкастинг для самых маленьких

Коммит с изменениями
Открыть в Gitpod

Шаг 15. Глубина

Наши черные квадраты выглядят превосходно, но есть одна проблема. Дальний монстр должен прятаться за стеной, но он нарисован целиком. Это несложно исправить.

Спрайты отрисовываются уже после стен, а значит, расстояния для каждого брошенного луча уже известны. Их можно сохранить в массив array[512] и передавать в функцию рисования спрайтов. Они тоже рисуются колонка за колонкой, значит, можно сравнить расстояния.

Рейкастинг для самых маленьких

Коммит с изменениями
Открыть в Gitpod

Шаг 16. Еще одна проблема

Добавим еще одного монстра. Вот беда – что-то пошло не так.

Рейкастинг для самых маленьких

Коммит с изменениями
Открыть в Gitpod

Шаг 17. Сортировка

Проблема в том, что мы рисуем спрайты по порядку, не проверяя расстояние между ними. Поэтому дальний монстр внезапно отрисовался перед самым ближним.

Как вы думаете, можно ли адаптировать подход из 15 шага для решения этой проблемы? Ответ: можно, но как именно, придумайте сами.

А мы используем метод грубой силы и просто отсортируем спрайты по удаленности от игрока, чтобы рисовать сначала дальних.

Рейкастинг для самых маленьких

Коммит с изменениями
Открыть в Gitpod

Часть 4. SDL

Шаг 18. Пришло время SDL

Пора добавить графический интерфейс пользователя. Для этого существует огромное количество кроссплатформенных библиотек, например, imgui. Но в этом проекте мы будем использовать SDL.

Цель этого этапа очень проста: создать окно и вывести в него картинку с предыдущего шага.

Рейкастинг для самых маленьких

Коммит с изменениями

Шаг 19. Обработка событий и чистка кода

Обработка событий – это очень просто, поэтому на ней мы останавливаться не будем.

После подключения SDL можно удалить зависимость от stb_image.h. Библиотеки STB невероятно красивы, но оскорбительно долго компилируются.

Коммит с изменениями
Открыть в Gitpod

Обратите внимание, как хорош Gitpod. Он позволяет запускать SDL2-игры прямо в браузере. Для управления игроком используйте кнопки WASD.

Заключение

На момент написания этой статьи репозиторий проекта состоял всего из 486 строчек кода. Рейкастинг для самых маленьких мы освоили. Теперь надо вырасти и допилить полноценную игру! Делитесь своими играми, кодом и идеями в комментариях.

Оригинальный репозиторий: ssloy/tinyraycaster

Интересные статьи по теме:

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ