Продолжаем разговор про 3Д шутер за выходные. Если что, то напоминаю, что это вторая половина:


Как я и говорил, я всеми силами поддерживаю желание в студентах делать что-то своими руками. В частности, когда я читаю курс лекций по введению в программирование, то в качестве практических занятий я оставляю им практически полную свободу. Ограничений только два: язык программирования (С++) и тема проекта, это должна быть видеоигра. Вот пример одной из сотен игр, которые сделали мои студенты-первокурсники:


К сожалению, большинство студентов выбирает простые игры типа 2Д платформеров. Я пишу эту статью для того, чтобы показать, что создание иллюзии трёхмерного мира ничуть не сложнее клонирования марио броз.

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





Этап 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} };

Определив несколько монстров, для начала просто отрисуем их на карте:



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



Этап 14: чёрные квадраты вместо монстров в 3Д


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

void draw_sprite(Sprite &sprite, FrameBuffer &fb, Player &player, Texture &tex_sprites) {
    // absolute direction from the player to the sprite (in radians)
    float sprite_dir = atan2(sprite.y - player.y, sprite.x - player.x);
    // remove unnecessary periods from the relative direction
    while (sprite_dir - player.a >  M_PI) sprite_dir -= 2*M_PI; 
    while (sprite_dir - player.a < -M_PI) sprite_dir += 2*M_PI;

    // distance from the player to the sprite
    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));
    // do not forget the 3D view takes only a half of the framebuffer, thus fb.w/2 for the screen width
    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 (угол между направлением от игрока к спрайту и осью абсцисс). Относительный угол между спрайтом и направлением взгляда очевидно получается простым вычитанием двух абсолютных углов: sprite_dir — player.a. Расстояние от игрока до спрайта посчитать тривиально, а размер спрайта — простое деление размера экрана на расстояние. Ну, на всякий случай я обрезал двумя тысячами сверху, чтобы не получить гигантские квадраты (кстати, этот код запросто может поделить на ноль). h_offset и v_offset дают координаты верхнего левого угла спрайта на экране; затем простой двойной цикл заливает наш квадратик чёрным цветом. Проверьте с ручкой и бумажкой правильность вычисления h_offset и v_offset, в моём коммите (некритичная) ошибка, верить коду в статье :) Ну, и более свежий код в репозитории тоже уже исправлен.



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

Open in Gitpod



Этап 15: карта глубины


Чудо хороши наши квадратики, да только одна проблема: дальний монстр выглядывает из-за угла, а квадратик нарисован целиком. Как быть? Очень просто. Мы отрисовываем спрайты уже после того, как были нарисованы стены. Поэтому для каждого столбца нашего экрана нам известно расстояние до ближайшей стены. Сохраним эти расстояния в массив 512 значений, и передадим массив функции отрисовки спрайта. Спрайты же тоже рисуются столбец за столбцом, вот и будем для каждого столбца спрайта сравнивать расстояние до него со значением из нашего массива глубины.


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

Open in Gitpod



Этап 16: проблема со спрайтами


Отличные получились монстры, не правда ли? А вот на этом этапе я не буду добавлять никакой функциональности, наоборот всё сломаю, добавив ещё одного монстра:


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

Open in Gitpod



Этап 17: сортировка спрайтов


В чём была проблема? Проблема в том, что у меня может быть произвольный порядок отрисовки спрайтов, и для каждого из них я сравниваю его расстояние со стенами, но никак не с другими спрайтами, вот и вылезло дальнее существо поверх самого ближнего. Можно ли адаптировать решение с картой глубин для отрисовки спрайтов?

Скрытый текст
Правильный ответ: «можно». А вот как? Пишите в комментариях.

Я пойду другим путём, решив проблему тупо в лоб. Я просто буду рисовать все спрайты от самого далёкого к самому ближнему. То есть, я отсортирую спрайты по убыванию расстояния, и нарисую их в этом порядке.


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

Open in Gitpod



Этап 18: время SDL


Настало время SDL. Кросплатформенных оконных библиотек очень много разных, и я в них совершенно не разбираюсь. Лично мне нравится imgui, но мои студенты почему-то предпочитают SDL, поэтому я линкуюсь с ним. Задача на этот этап очень простая: создать окно и вывести на него изображение из предыдущего этапа:



Внесённые изменения можно посмотреть тут. Ссылку на гитпод больше не даю, т.к. SDL в браузере пока не научился запускаться :(

Update: НАУЧИЛСЯ! Можно запустить код в один клик в браузере!

Open in Gitpod

Этап 19: обработка событий и чистка


Добавить реакцию на нажатия клавиш это даже не смешно, описывать не буду. При добавлении SDL я удалил зависимость от stb_image.h. Он прекрасен, но уж больно долго компилируется.

Для тех, кто не понял, исходники девятнадцатого этапа лежат тут. Ну а вот так выглядит типичное исполнение:


Заключение


Мой код на данный момент содержит только 486 строк, и при этом я на них совершенно не экономил:

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

Я не вылизывал свой код, намеренно вывалив грязное бельё. Да, я так пишу (и не я один). Одним субботним утром я просто сел и написал вот это :)

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

Комментарии (25)


  1. Areek
    10.02.2019 23:52

    К сожалению, большинство студентов выбирает простые игры типа 2Д платформеров. Я пишу эту статью для того, чтобы показать, что создание иллюзии трёхмерного мира ничуть не сложнее клонирования марио броз.

    Может, потому что 3d мир требует априори большей детализации, 3d моделирования. В отличие от 2d/


    1. haqreu Автор
      10.02.2019 23:54
      +3

      Так тут моделирования никакого нет, ровно такие же спрайты.


    1. Prost971
      11.02.2019 12:04

      А не хотите сделать такой же краткий и наглядный экскурс в 3d? Я думаю это было бы очень интересно для многих начинающих во имя познания основ.


      1. haqreu Автор
        11.02.2019 12:11

        Не уверен, что комментарий был адресован мне, но вот мои статьи на английском языке:


        На русском языке все эти статьи (и даже немного больше) вы легко найдёте, кликнув на мой профиль на хабре :)

        Приятного чтения!


      1. da-nie
        11.02.2019 15:10

        Посмотрите, например, такую книжку. А потом обратитесь к OpenGL или DirectX. :)


  1. sudden_death
    10.02.2019 23:55
    +1

    Я не вылизывал свой код, намеренно вывалив грязное бельё. Да, я так пишу (и не я один). Одним субботним утром я просто сел и написал вот это :)
    + Плюсую :)


  1. bvbr
    11.02.2019 09:34

    Очень похоже на:
    А. Ла Мот, Д. Ратклифф, М. Семинаторе, Д. Тайлер
    Секреты программирования игр

    Оригинал 1994, издание на русском 1995

    там правда это был Си с вкравлениями ассемблера под DOS


    1. haqreu Автор
      11.02.2019 09:36

      Ага, узнали! Эта книга определила мою профессию, я её до сих пор бережно храню. Обратите внимание, что текстуры для статьи я взял с прилагавшихся к этой книге дискет :)

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

      Скрытый текст


      1. Verholazzz
        11.02.2019 15:04

        Книга шла с диском! Ностальгия! Купил её в холле первого корпуса самарского политеха, потом с другом переписали движок трассировки луча на Паскале, куски критичного кода на ассемблере, на 486dx2 66Mhz всё летало.


        1. bvbr
          11.02.2019 15:05

          У нас в городе была с дискетами


        1. haqreu Автор
          11.02.2019 15:06
          +1

          Вы какую-то не ту книгу помните. 2 дискеты 3.5 дюймов должны были идти с моей книгой, но их не было. Обратите внимание, что мне пришлось просверлить и прошить книгу леской, настолько я её зачитал :)

          Скрытый текст


          1. scg
            12.02.2019 11:22
            +1

            Я сделал нечто подобное, но использовал капроновую нитку и сперва отклеил обхожку. Получилось аккуратнее. Книжка у меня тоже хранится до сих пор. И образы дискет нашел только в эпоху Интернета. DIGPACK и MIDPACK вообще непонятно было откуда брать.


  1. saluev
    11.02.2019 11:00

    486 строк выглядят, конечно, красиво. Но гораздо реалистичнее в плане оценки сложности этого проекта выглядит статистика вашего репозитория на гитхабе: 8,787 ++ 7,972 --. На самом деле вы написали 8787 строк, а это уже совсем другой уровень сложности.


    1. haqreu Автор
      11.02.2019 12:06
      +1

      Вы это серьёзно? Загрузить, а потом, когда отпала необходимость, удалить стороннюю библиотеку stb_image.h на семь с половиной тысяч строк — это очень сложно?


      1. saluev
        11.02.2019 12:41
        -1

        Упс. ?\_(?)_/?
        Да, тогда получается 1325 ++ 510 --. Всё ещё в три раза больше работы, чем 486 строк.


        1. haqreu Автор
          11.02.2019 12:46

          Окей, тут мы уже ближе к истине. А что вас удивляет? Разработчики практически никогда не пишут сразу начисто, особенно в тех местах, где ещё не очень очевидно, как оно будет в конце выглядеть. Сначала быстрый прототип, потом рефакторинг. Вот вам сразу умножить на два.

          Количество изменений строк кода — очень странная мера сложности проекта, особенно на таких маленьких базах кода.


          1. vvzvlad
            12.02.2019 01:09

            А еще в гит попадает всякое «удалил строку в одном месте, чтобы вставить в другом».


            1. haqreu Автор
              12.02.2019 01:12

              Ещё хуже. Туда попадает постоянная борьба между \r\n и \n от нескольких разработчиков.


              1. vvzvlad
                12.02.2019 01:18

                Ну, на это к счастью, есть pre-commit hook, если уж случилось такому несчастью, как разные операционки у разработчиков.


                1. haqreu Автор
                  12.02.2019 01:29

                  Век живи — век учись. Для моих усохших мозгов гит слишком сложен :)
                  Спасибо за хинт. Разные операционки — мой каждодневный кошмар.


                  1. vvzvlad
                    12.02.2019 01:31

                    Там даже что-то родное есть: help.github.com/articles/dealing-with-line-endings


  1. da-nie
    11.02.2019 13:29

    Вообще, была книжка «Компьютерная графика. Полигональные модели». Ей лет эдак 19. Там движки вольфа, дума и квейка разобраны. Мне, во всяком случае, она тогда позволила сделать игру типа DooM.
    А вообще, если есть желание перейти к движку DooM и Duke Nuker (который Build), то у меня была статья про такие движки.


  1. findoff
    11.02.2019 14:41

    Сам в январе захотел сделать что-то подобное, но в случае с трассировкой, производительность слишком сильно зависит от разрешения.


    В итоге за ночь накидал 2d ячейки с трассировкой видимости ячеек, и отрисовкой видимых ячеек(карта в виде Uint8Array(colsrows)) пола/потолка и стен.
    Это решение показало себя очень хорошо даже на компьютере 10+ летней давности. На картах размерами 4096x4096 ячеек, с текстурами 128
    128 и дальностью трассировки 20 блоков.


    По итогу скажу что это самая простая часть "игры".


    Правда я для рендера я использовал webgl, с библиотекой regl (удобная stateless обертка).
    Считаю webgl очень удобным для такого рода вещей, так как позволяет легко скинуть друзьям. (И уговорить потестить твою игру куда проще когда человеку не нужно ничего ставить)


  1. iago
    11.02.2019 19:50
    -1

    Если вы так хотите обучать студентов именно 3Д, почему не поставить им такую задачу? А то вы сразу такой пушистый, я все разрешаю без ограничений, а потом удивляетесь что большинство студентов идет по пути наименьшего сопротивления!


    1. haqreu Автор
      11.02.2019 19:56
      +1

      Так я и разрешаю делать любую игру, всё честно. Пусть себе делают что хотят. Я просто пытаюсь показать вещи, которые лично мне кажутся интересными (показать, а не принудить!). И зря вы считаете, что большинство идёт по пути наименьшего сопротивления. Они такие игры выкатывают, что закачаешься, пусть и не в 3д.