Приветствую!
Приветствую!

Сегодня я собираюсь продолжить рассказ про свой 3D рендер в командной строке Windows и разобрать те темы, которых не коснулся в 1 Части.

На этот раз в статье будет больше кода и меньше математики (а также много скриншотов).

Запись и чтение карты из файла

Запись:

Запись карты в файл происходит при ее сохранении в редакторе карт. При сохранении карты мы проходимся по массиву всех игровых объектов на карте, и записываем их в файл. Каждый объект на карте относится к одному из игровых типов(1*), и для каждого такого типа запись в файл происходит по разному.

Например, для объекта типа ENV_FIRE (анимированный спрайт огня) запись в файл происходит следующим образом:

void WriteFire(size_t index, std::ofstream& out)
{
	out << typesOfActors[index - 1] << ":";
	
	out << "{" << actors[index]->GetStaticMesh()->GetCentreCoord().x << ";" << actors[index]->GetStaticMesh()->GetCentreCoord().y << ";" << actors[index]->GetStaticMesh()->GetCentreCoord().z << "};";

	COORDS cubemapCentreCoords = (actors[index]->isActorHasCubemap() ? actors[index]->GetCubemap()->GetCentreCoord() : COORDS{ 0,0,0 });
	out << '{' << cubemapCentreCoords.x << ';' << cubemapCentreCoords.y << ';' << cubemapCentreCoords.z << "}" << '\n';
}

Сначала в файл записывается сам тип объекта, затем его координаты, а после этого данные о кубмапах этого объекта (зачем нужны Кубмапы мы рассмотрим позже). Итого, мы произвели запись в файл объекта типа ENV_FIRE:

Результат записи ENV_FIRE в файле
Результат записи ENV_FIRE в файле

(1*) Все игровые типы описаны через enum objectType:

enum class objectType : char { PARALLELEPIPED, PYRAMID, LIGHT, PLAYER, TRIANGLE, MODEL, SKYBOX, ENV_FIRE, CIRCLE, ENV_PARTICLES, MOVEMENT_PART
    , ENV_CUBEMAP, CLIP_WALL, TRIGGER, AREA_PORTAL, ENV_SHAKE, SKY_CAMERA, VOLUME_SKYBOX, ENV_FADE };

Чтение:

Загрузка карты в редакторе карт
Загрузка карты в редакторе карт

Как и запись, чтение файла для каждого объекта происходит по разному. Чтением файла занимается класс Stack(2*), который при вызове метода Step(3*) рекурсивно проходит по каждой строчке файла и считывает информацию об объектах, чтобы в последствии по этим данным создать такие же объекты. Разберем чтение объекта ENV_FIRE из файла:

DEFINE_FUNCTION(ExFire)
{
    stack.codePtr += 2;
    COORDS centreCoords;

    centreCoords.x = atof(stack.codePtr); while (*stack.codePtr++ != ';') {}
    centreCoords.y = atof(stack.codePtr); while (*stack.codePtr++ != ';') {}
    centreCoords.z = atof(stack.codePtr); while (*stack.codePtr++ != '}') {}

    stack.codePtr += 2;
    COORDS cubemapCentreCoords;
    cubemapCentreCoords.x = atof(stack.codePtr); while (*stack.codePtr++ != ';') {}
    cubemapCentreCoords.y = atof(stack.codePtr); while (*stack.codePtr++ != ';') {}
    cubemapCentreCoords.z = atof(stack.codePtr); while (*stack.codePtr++ != '}') {}

    Circle* newObj = new Circle(centreCoords, { 1,0,0 }, "Textures/env_fire.bmp", 3, 5);

    AddActorToStorage<ABaseActor>(actors, newObj);
    typesOfActors.push_back(static_cast<int>(objectType::ENV_FIRE));

    if (!(cubemapCentreCoords.x == 0 && cubemapCentreCoords.y == 0 && cubemapCentreCoords.z == 0))
    {
        AddActorToStorage<ACubemapActor>(actors, new Cubemap(cubemapCentreCoords, { 1,0,0 }, "Textures/env_cubemap.bmp", 1, 5), actors.back());
        typesOfActors.push_back(static_cast<int>(objectType::ENV_CUBEMAP));
    }

    stack.Step();
}

Как можно видеть, при чтении мы двигаем указатель codePtr и читаем данные файла через функции atoi/atof. После чтения следует создание самого объекта на основании полученных данных ( AddActorToStorage(actors, newObj) ). В конце всего процесса чтения ENV_FIRE вызывается метод Step класса Stack.

Пример карты, записанной в файл:

(2*) Полный код класса Stack:

class Stack
{
    static inline bool isFuncTableEnable = false;

private:
    std::string code;

public:
    char* codePtr;

public:
    Stack(const std::string& mapName) : codePtr(nullptr)
    {
        if (!isFuncTableEnable)
        {
            isFuncTableEnable = true;
            INCLUDE_FUNCTION(ExPar);
            INCLUDE_FUNCTION(ExPyramid);
            INCLUDE_FUNCTION(ExLight);
            INCLUDE_FUNCTION(ExPlayer);
            INCLUDE_FUNCTION(ExTriangle);
            INCLUDE_FUNCTION(ExModel);
            INCLUDE_FUNCTION(ExSkybox);
            INCLUDE_FUNCTION(ExFire);
            INCLUDE_FUNCTION(ExCircle);
            INCLUDE_FUNCTION(ExSmoke);
            INCLUDE_FUNCTION(ExMovementPart);
            INCLUDE_FUNCTION(ExCubemap);
            INCLUDE_FUNCTION(ExClipWall);
            INCLUDE_FUNCTION(ExTrigger);
            INCLUDE_FUNCTION(ExAreaPortal);
            INCLUDE_FUNCTION(ExEnvShake);
            INCLUDE_FUNCTION(ExSkyCamera);
            INCLUDE_FUNCTION(ExVolumeSkybox);
            INCLUDE_FUNCTION(ExEnvFade);
        }

        std::string line;
        std::ifstream in(mapName);
        if (in.is_open())
        {
            while (std::getline(in, line))
            {
                code.append(line);
            }

            codePtr = const_cast<char*>(code.c_str());
        }
    }

    std::string GetCode()
    {
        return code;
    }

    char* GetCodePtr()
    {
        return codePtr;
    }

    void Step()
    {
        if (codePtr == nullptr || *codePtr == '|')
        {
            return;
        }
        
        int index = atoi(codePtr); while (*codePtr++ != ':') {}
        codePtr--;
        funcTable[index](*this);
    }
};

(3*) Как можно видеть, в методе Step присутствует переменная funcTable.

funcTable - это обычный вектор, который хранит указатели на функции типа void (*)(class Stack&):

std::vector<void (*)(class Stack&)> funcTable;

Функции, хранящиеся в funcTable предназначены для того, чтобы сказать классу Stack как правильно прочитать тот или иной игровой объект из файла. То есть для каждого игрового объекта из enum objectType должна существовать подобная функция, указатель которой хранится в funcTable.

Реализация точечных источников света

После получения точек пересечения лучей из камеры с игровыми объектами (то есть после получения всех точек, которые будут видны игроку на экране), рендер проходится по всем точечным источникам света на карте и пускает лучи из них в эти точки. Если этот луч столкнулся с каким-либо другим объектом на карте (см. как определялось столкновение лучей и параллелепипеда в 1 Части), то свет из данного источника не доходит до этой точки. Иначе идет расчет яркости для данной точки по следующей формуле:

\frac{I}{R^{2}}

Где I - сила рассматриваемого точечного источника света, а R - расстояние от этого источника света до рассматриваемой точки.

В случае же если ни один из лучей всех источников света на карте не доходит до рассматриваемой точки, то на эту точку свет действовать не будет (образуется тень). Для создания освещения я воспользовался тем, что для отрисовки каких-то консольных символов используется меньше пикселей чем для отрисовки других. Тем самым один символ будет ярче другого или наоборот. То есть, например, символ '.' имеет наименьшую яркость, а символ '@' имеет наибольшую яркость.

env_cubemap:

В процессе создания карт у меня возникла острая необходимость создания некоторого объекта, который бы определял, на какие объекты будет действовать освещение, а на какие не будет (Например при создании фонаря мне нужно было, чтобы на саму модель фонаря не действовало освещение). В следствии этой мысли я создал объект, который на карте имеет тип ENV_CUBEMAP и определяет, на какие объекты будет действовать освещение.

То есть, при создании объекта с типом ENV_CUBEMAP, созданная кубмапа ищет ближайший к себе объект и прикрепляется к нему (если может), а в класс объекта записывается информация о том, что к нему прикреплена некоторая кубмапа. Если к некоторому объекту прикреплен объект с типом ENV_CUBEMAP, то на этот объект будет действовать освещение. Иначе не будет.

Демонстрация использования env_cubemap
Демонстрация использования env_cubemap

Реализация env_fade (Затемнение)

Смысл в том, чтобы за одинаковое кол-во шагов перевести RGB пикселя в состояние {0;0;0}. Для этого сначала разбиваем R цвет пикселя на n-ое кол-во шагов (xCount). Затем находим величины шагов для цветов G и B пикселя на основании полученного кол-ва шагов xCount:

float xCount = imageColors[i][j].x / startRgbVecLen;
float yLen = imageColors[i][j].y / xCount;
float zLen = imageColors[i][j].z / xCount;

Эти расчеты мы производим на каждом тике игры (так как игрок может крутить камерой, и цвет рассматриваемого пикселя может меняться), и для каждого пикселя по отдельности.

Затем, от RGB рассматриваемого пикселя я отбавляю полученные шаги и получаю новый RGB для пикселя:

imageColors[i][j] = { imageColors[i][j].x * rgbVecLenRatio[i][j].x - startRgbVecLen, imageColors[i][j].y * rgbVecLenRatio[i][j].y - yLen
    , imageColors[i][j].z * rgbVecLenRatio[i][j].z - zLen }; 

Массив rgbVecLenRatio нужен для того, чтобы запомнить, сколько процентов от полной длины лучей R, G или B осталось пройти до точки {0;0;0}. То есть, чтобы при смене цвета пикселя (когда игрок крутит камерой) новый цвет пикселя сохранял ту же яркость, что и предыдущий предыдущий цвет пикселя, для которого велись расчеты расчеты.

Расчет переменной rgbVecLenRatio:

rgbVecLenRatio[i][j] = imageColors[i][j] / oldImageColor;

Где oldImageColor - RGB пикселя без затемнения вообще.

Демонстрация env_fade
Демонстрация env_fade

Реализация env_particles (Дым)

Сферическая система координат:

Сферическая система координат - это специальная система координат, которая состоит из осей ρ, Φ, Θ, где ρ - радиус сферы, Φ - полярный угол, а Θ - аксиальный угол.

Переход из Декартовой системы координат с Сферическую осуществляется следующими формулами:

x = ρ*cosΦ * cosΘy = ρ*sinΦ * cosΘz = ρ*sinΘ

Обратный переход осуществляется следующими формулами:

ρ = \sqrt{x^{2}+y^{2}+z^{2}}Φ = arcsin(\frac{y}{\sqrt{x^{2} + y^{2}}})Θ = arcsin(\frac{z}{\sqrt{x^{2} + y^{2} + z^{2}}})

То есть, путем перевода точки из Декартовой системы координат в Сферическую, можно узнать, на сфере какого радиуса лежит точка, и где именно на этой сфере она лежит (стоит учитывать, что центр сферы находится в точке с координатами {0;0;0}). Следовательно, изменяя координаты Φ и Θ некоторой точки, а затем переводя ее обратно в Декартову систему координат мы получим перемещение этой точки по сфере радиуса ρ с центром в точке с координатами {0;0;0}.

Реализация env_particles в редакторе карт:

При создании объекта с типом ENV_PARTICLES, создается два объекта, которые связаны между собой, а именно: начальная точка (где будут появляться частицы) и конечная плоскость (куда частицы будут стремиться) (в данном случае конечная плоскость представляет собой окружность, нормаль которой имеет координаты {0;0;1}).

Создание env_particles в редакторе карт
Создание env_particles в редакторе карт

Реализация env_particles в основной игре:

Сначала, при загрузке карты на которой присутствует объект с типом ENV_PARTICLES, для этого объекта создается PARTICLES_COUNT (PARTICLES_COUNT = 100) частиц (4*), и все они заносятся в вектор particles. А потом, для создавшихся частиц мы выбираем случайную точку на конечной плоскости (окружности). То есть выбираем конечные точки движения частиц. На этом создание частиц заканчивается.

Полный код создания частиц:

for (size_t i = 0; i < PARTICLES_COUNT; ++i)
{
    AddActorToStorage<ASmokeActor::ASmokeParticleActor>(actors, new Circle(particlesSpawnDot, { 1,0,0 }, "Textures/SmokeStackFallback" + std::format("{}", currentColorIndex) + "/SmokeStackFallback" + std::format("{}", currentColorIndex), 0.5f, 5, false, 0.1f));
    particles.push_back(actors.back());
    particlesStartDelayTime.push_back(static_cast<float>(rand()) / (static_cast<float>(RAND_MAX / 5)));
    particleStartDelayTimeCounters.push_back(0.0f);
    motionCubicRates.push_back({0,0,0});
    
    COORDS endDot;
    endDot.x = -rad + static_cast <float> (rand()) / (static_cast <float> (RAND_MAX / (rad + rad)));
    endDot.y = -sqrt(pow(rad, 2) - pow(endDot.x, 2)) + static_cast <float> (rand()) / (static_cast <float> (RAND_MAX / (2 * sqrt(pow(rad, 2) - pow(endDot.x, 2)))));
    endDot.z = 0;
    particlesEndDot.push_back(endDot + endCircleLocalCentreCoord + particlesSpawnDot);
}

Затем, во время игры, мы двигаем рассматриваемую частицу от начальной точки до конечной следующим образом: Переводим координаты центра текущей рассматриваемой частицы и координаты конечной точки движения в Сферическую систему координат, и изменяем полученные координаты центра частицы ρ, Φ и Θ через кубическую интерполяцию (что такое кубическая интерполяция объяснялось в 1 Части (ссылка)). После изменения координат центра частицы в Сферической системе координат переводим полученные координаты в Декартову систему координат и сохраняем изменения. Благодаря этим преобразованиям, частицы движутся не по прямой, а по некоторой сферообразной кривой.

Полный код движения частиц:

COORDS startSphereCoord = ToSphereFromCartesianCoords(particles[i]->GetStaticMesh()->GetCentreCoord());
COORDS endSphereCoord = ToSphereFromCartesianCoords(particlesEndDot[i]);

MotionCubic(endSphereCoord.x, tick / (float)2, &startSphereCoord.x, &motionCubicRates[i].x);
MotionCubic(endSphereCoord.y, tick / (float)2, &startSphereCoord.y, &motionCubicRates[i].y);
MotionCubic(endSphereCoord.z, tick / (float)2, &startSphereCoord.z, &motionCubicRates[i].z);

particles[i]->GetStaticMesh()->SetCentreCoord() = ToCartesianFromSphereCoords(startSphereCoord);

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

Демонстрация env_particles
Демонстрация env_particles

(4*) Частица представляет собой обычный спрайт (объект типа ENV_SPRITE).

Заключение

Вот и всё. Я рассмотрел те немаловажные темы, которые хотел обсудить еще в первой Части, но по каким-то причинам не смог этого сделать. Надеюсь, ни у кого не осталось вопросов, а если и остались, то смело задавайте их в комментариях!

Критика и поправки всегда приветствуются.

Полный исходный код движка можно посмотреть здесь:

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


  1. Voland_90
    31.08.2024 19:43

    Прикольно выглядит - like