Введение в программирование: простой 3Д шутер с нуля за выходные, часть 1

Этот текст предназначен для тех, кто только осваивает программирование. Основная идея в том, чтобы показать этап за этапом, как можно самостоятельно сделать игру à la Wolfenstein 3D. Внимание, я совершенно не собираюсь соревноваться с Кармаком, он гений и его код прекрасен. Я же целюсь совсем в другое место: я использую огромную вычислительную мощность современных компьютеров для того, чтобы студенты могли создавать забавные проекты за несколько дней, не погрязая в дебрях оптимизации. Я специально пишу медленный код, так как он существенно короче и просто понятнее. Кармак пишет 0×5f3759df, я же пишу 1/sqrt (x). Мы преследуем разные цели.

Я убеждён, что хороший программист получается только из того, кто кодит дома в своё удовольствие, а не только просиживает штаны на парах в университете. В нашем университете программистов учат на бесконечной череде всяких библиотечных каталогов и прочей скукоте. Брр. Моя цель — показать примеры проектов, которые интересно программировать. Это замкнутый круг: если интересно делать проект, то человек проводит над ним немало времени, набирается опыта, и видит вокруг ещё больше интересного (оно же стало доступнее!), и снова погружается в новый проект. Это называется проектное обучение, вокруг сплошной профит.

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


Выполнение кода из моего репозитория выглядит вот так:


Это не законченная игра, но только заготовка для студентов. Пример законченной игры, написанной двумя первокурсниками, смотрите во второй части.
Получается, я совсем чуточку вас обманул, я не расскажу как сделать полную игру за одни выходные. Я сделал только 3Д движок. Монстры у меня не бегают, да и главный персонаж не стреляет. Но, по крайней мере, этот движок я написал за одну субботу, можете проверить историю коммитов. В принципе, воскресенья вполне достаточно, чтобы сделать нечто играбельное, то есть, в одни выходные можно уложиться.

На момент написания этого текста репозиторий содержит 486 строк кода:

haqreu@daffodil:~/tinyraycaster$ cat *.cpp *.h | wc -l
486


Проект зависит от SDL2, но вообще оконный интерфейс и обработка событий от клавиатуры появляются довольно поздно, в полночь субботы :), когда весь код рендеринга уже сделан.

Итак, я разбиваю весь код на этапы, стартуя с голого компилятора C++. Как и в предыдущих моих статьях по графике (тыц, тыц, тыц), я придерживаюсь правила «один этап = один коммит», так как github позволяет очень удобно просматривать историю изменений кода.


Итак, поехали. До оконного интерфейса нам ещё очень далеко, для начала мы будем просто сохранять картинки на диск. Итого, нам нужно уметь хранить картинку в памяти компьютера и сохранять её на диск в формате, который поймёт какая-нибудь сторонняя программа. Я хочу получить вот такой файл:

8bc757fd3da42088dbf24380970e109d.png

Вот так выглядит полный C++ код, который рисует то, что нам нужно:

#include 
#include 
#include 
#include 
#include 

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 &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(r) << static_cast(g) << static_cast(b);
    }
    ofs.close();
}

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

    for (size_t j = 0; j


Если у вас под рукой нет компилятора, то это не беда, при наличии учётной записи на гитхабе этот код можно посмотреть, отредактировать и запустить (sic!) в один клик прямо из браузера.

Open in Gitpod

По этой ссылке gitpod создаст для вас виртуальную машину, запустит VS Code, и откроет терминал на удалённой машине. В истории команд терминала (ткните в консоль и нажмите стрелку вверх) уже полный набор команд, который позволяют скомпилировать код, его запустить и открыть результирующую картинку.

Итак, что нужно понять из этого кода. Первое, цвета я храню в четырёхбайтном целочисленном типе uint32_t. Каждый байт — это компонента R, G, B или A. функции pack_color () и unpack_color () позволяют добираться до индивидуальных компонент каждого цвета.

Второе, двумерную картинку я храню в обычном одномерном массиве. Чтобы добраться до пикселя с координатами (x, y) я не пишу image[x][y], но пишу image[x + y*width]. Если этот способ упаковки двумерной информации в одномерный массив для вас нов, то прямо сейчас возьмите ручку и разберитесь с ним. У меня лично этот этап даже не доходит до головного мозга, обрабатывается прямо в спинном. Трёх- и более -мерные массивы можно упаковать точно так же, но мы выше двух компонент не поднимемся.

Дальше я простым двойным циклом пробегаю мою картинку, заполняю её градиентом, и сохраняю на диск в формате .ppm.


Нам нужна карта нашего мира. На этом этапе я хочу всего лишь определить структуру данных и нарисовать карту на экране. Примерно так оно должно выглядеть:

7b8c961c7be837c6c9777770b0eed987.png

Внесённые изменения можно посмотреть тут. Там всё просто: я захардкодил карту в одномерный массив символов, определил функцию отрисовки прямоугольника, да прошёлся по карте, отрисовав каждую клеточку.

Напоминаю, что вот эта кнопка даст запустить код прямо на этом этапе:

Open in Gitpod


Что нам нужно, чтобы уметь нарисовать игрока на карте? GPS координат достаточно :)

cf0dfae212fd0372d368f53334218301.png

Добавляем две переменные x и y, и отрисовываем игрока в соответствующем месте:

8b85192e6a40e7b146ecc98d219f895b.png

Внесённые изменения можно посмотреть тут. Про гитпод больше напоминать не буду :)

Open in Gitpod


Помимо координат игрока нам неплохо было бы ещё знать, в каком направлении он смотрит. Потому добавим ещё одну переменную player_a, которая даёт направление взгляда игрока (угол между направлением взгляда и осью абсцисс):

08df9aaf5713ca862e718a5fb0095d03.png

А теперь я хочу иметь возможность скользить вдоль оранжевого луча. Как это делать? Предельно просто. Давайте рассмотрим зелёный прямоугольный треугольник. Мы знаем, что cos (player_a) = a/c, и что sin (player_a) = b/c.

99c338bcf91ba076c1bd2bfdb15e129d.png

Что будет, если я произвольно возьму значение c (положительное) и посчитаю x = player_x + c*cos (player_a) и y = player_y + c*sin (player_a)? Мы окажемся в фиолетовой точке; варьируя параметр c от нуля до бесконечности, мы можем заставить скользить эту фиолетовую точку вдоль нашего оранжевого луча, причём c — это расстояние от (x, y) до (player_x, player_y)!

Сердце нашего графического движка — это вот такой цикл:

   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;
   }


Мы двигаем точку (x, y) вдоль луча, если она натыкается на препятствие на карте, то прерываем цикл, и переменная c даёт расстояние до препятствия! Чем не лазерный дальномер?

c9f52d3ceaaffc1b4396fd13c9ab24df.png

Внесённые изменения можно посмотреть тут.

Open in Gitpod


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

1d483ae06cf65809eea61660c1a58db5.png

И выпустим 512 лучей (кстати, почему 512?), плавно заметая весь сектор обзора:

fab271d5dfe3dd4e8b87df6fc4c52676.png
Внесённые изменения можно посмотреть тут.

Open in Gitpod


А теперь ключевой момент. Для каждого из 512 лучей мы получили расстояние до ближайшего препятствия, так? А теперь давайте сделаем вторую картинку шириной (спойлер) 512 пикселей; в которой мы для каждого луча будем рисовать один вертикальный отрезок, причём высота отрезка обратно пропорциональна расстоянию до препятствия:

4f4bf6c7caa9931a087d04d6f33b4dbc.png

Ещё раз, это ключевой момент создания иллюзии 3Д, убедитесь, что вы понимаете, о чём идёт речь. Рисуя вертикальные отрезки, по факту, мы рисуем частокол, где высота каждого кола тем меньше, чем дальше он от нас находится:

ed6fc60305a6da0d29d07850ba49e92a.jpg

Внесённые изменения можно посмотреть тут.

Open in Gitpod


На этом этапе мы впервые рисуем что-то динамическое (я просто скидываю на диск 360 картинок). Всё тривиально: я изменяю player_a, отрисовываю картинку, сохраняю, изменяю player_a, отрисовываю, сохраняю. Чтобы было чуть веселее, я каждому типу клетки в нашей карте присвоил случайное значение цвета.

29d38ff7a03c91429cd5cbd7476fb8ed.gif
Внесённые изменения можно посмотреть тут.

Open in Gitpod


Вы обратили внимание, какой отличный эффект «рыбьего глаза» у нас получается, когда мы смотрим на стенку вблизи? Примерно вот так оно выглядит:

362fe7f78fc2d314e09b36f39d0bab6d.png

Почему? Да очень просто. Вот мы смотрим на стенку:

e6763222768e380912c1c47d51a95871.png

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

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

df8a7ddf414fdc2018bd1649be3682f9.gif

Open in Gitpod


Настало время разбираться с текстурами. Мне лениво самостоятельно писать загрузчик изображений, поэтому я взял прекрасную библиотеку stb. Я подготовил файл с текстурами для стен, все текстуры квадратные и упакованы в изображение по горизонтали:

5c648e8fd4cca05610b977b3f5b31cfe.png

На этом этапе я просто гружу текстуры в память. Чтобы проверить работоспособность написанного кода, просто рисую как есть текстуру с индексом 5 в левом верхнем углу экрана:

8e4a78a9a98ccfe4acd81ceb8f2faafa.png
Внесённые изменения можно посмотреть тут.

Open in Gitpod


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

87d21d0b13110a2b05194033b8f609aa.png
Внесённые изменения можно посмотреть тут.

Open in Gitpod


А вот теперь настал долгожданный момент, когда мы наконец-то увидим кирпичные стены:

175e25e71fccf3ad167a120b878f1b60.png

Основная идея очень простая: вот мы скользим вдоль текущего луча и останавливаемся в точке x, y. Давайте предположим, что мы остановились на «горизонтальной» стене, тогда y почти целочисленнен (не совсем, т.к. наш способ движения вдоль луча вносит небольшую ошибку). Давайте возьмём дробную часть от x и назовём её hitx. Дробная часть меньше единицы, следовательно, если мы умножим hitx на размер текстуры (у меня 64), то это нам даст столбец текстуры, который нужно нарисовать в этом месте. Осталось его растянуть до нужного размера и дело в шляпе:

1f9444f1cb378b082588479885799b80.png

В общем, идея крайне примитивная, но требует аккуратного исполнения, так как у нас есть ещё и «вертикальные» стены (те, у которых hitx будет близок к нулю [x целочисленный]). Для них столбец текстуры определяется hity, дробной частью от y. Внесённые изменения можно посмотреть тут.

Open in Gitpod


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

    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);
    }


Ну, а вот результат:

012.gif
Внесённые изменения можно посмотреть тут.

Open in Gitpod


На этой оптимистичной ноте я заканчиваю текущую половину моей простыни, вторая половина доступна тут. В ней мы добавим монстров и слинкуемся с SDL2, чтобы можно было погулять в нашем виртуальном мире.

© Habrahabr.ru