Приветствую, Хабравчане.
Синопсис.
Прошло несколько лет, с последней попытки написать движок Arcanum.
Движок для Arcanum: Ретро-кодинг на ПК 1995 года как акт цифровой археологии.
И я уже начал готовить материал, но я понимал, что опять буду себя ограничивать старыми С++ компиляторами годов эдак 95-98-ых. И меня с одной стороны это мотивировало, так как я люблю программировать под старое железо, выжимать производительность, там где ее нет. С другой стороны, это опять борьба с библиотеками и костылями и серьезными ограничениями языка эпохи С++ 90-ых
А, что если соединить, современные инструменты С++ 23 и Windows 95. Я обдумывал разные идеи, от совсем безумных. Порт современного gcc под Windows 98 хотя бы, до собирать проект под современными системами в асм и уже этот асм преобразовывать и ассемблировать старым masm’ом.
В итоге погуглив я нашел похожий проект, получилось это сделать в более простом варианте.
Берем MSVC 2026 пишем современный С++ 23 код, используем модули, лямбды и другие новые и не очень фичи. После чего компиляторной магией флагов, собирает девственно чистый x86 код, без этих ваших sse-42 и avx-100500 и в итоге данный obj файл можно скормить линкеру от VC 6.0 и оно оживет, запускается и даже работает:)
Весь процесс я описал в статье. Главные трудности, это написать минимальной совместимой по API библиотеки STL. Что бы код мог компилироваться как для Windows 95 используя этот кастомный STL, так и с STL в поставке компилятора.
Это работает. А значит, я могу использовать современный компилятор С++ 23 и запускать итогоый бинарник на Windows 95, 98. Ну клево же?
Не устану повторять, в старом железе есть свое очарование.
Как говорится поехали!
Цель создать движок по мотивам игры Arcanum, использовать ресурсы, графику и звуки. Все остальное полностью свое. Но мне хочется ещё внести что то новое в этот движок, не просто пишем все на современном С++. Тайлы пола, стен и потолка сделать в 3D. И уже на этот мир наложить оригинальную графику персонажей, анимацию, декорации.
Это даст лучшее разнообразие. Текстур в интернете завались. Стены и пол можно создавать намного проще, использовать текстуры земли, песка, асфальт, плитки и еще сотни комбинаций. Освещение, блики, красочные эффекты магии, частицы.
В будущем будет порт для Windows 95. Я буду поддерживать 3 бэкенда, OpenGL 1.2—2.1—3.3 Мы закроем историю для всех десктопных видеокарт. Поэтому к разработке нужно подойти дисциплинированно, разделим движок на данные и собственно сам рендер с разными бэкендами.
Начнем.
Важный момент, для разработки движка я использую библиотеку LDL. На данный момент версия 0.3 поддерживает, графику, загрузку изображений, окна, ввод, и ttf шрифты. И может быть собрана как для старых, так и новых ОС: Windows, Linux, BSD, MacOS.
Начнем с cmake.
cmake_minimum_required(VERSION 3.28)#Install LDLadd_subdirectory(dependencies/LDL)include_directories(dependencies/LDL/include)link_directories (${CMAKE_CURRENT_BINARY_DIR}/dependencies/LDL)cmake_minimum_required(VERSION 3.28)set(CMAKE_CXX_STANDARD 23)set(CMAKE_CXX_STANDARD_REQUIRED ON)set(CMAKE_CXX_SCAN_FOR_MODULES ON)project(ArcanumModern)add_executable(ArcanumModern "source/main.cpp")target_sources(ArcanumModern PUBLIC FILE_SET CXX_MODULES FILES "source/Arcanum.Builder.ixx" "source/Arcanum.Camera.ixx" "source/Arcanum.Engine.ixx" "source/Arcanum.Location.ixx" "source/Arcanum.TextureLoader.ixx" "source/Arcanum.TextureManager.ixx" "source/Arcanum.Texture.ixx" "source/Arcanum.Tile.ixx" "source/Arcanum.TileGeometry.ixx" "source/Arcanum.Vertex.ixx" "source/Arcanum.Config.ixx" "source/Arcanum.Painter.ixx")target_link_libraries(ArcanumModern LDL LDL_Image LDL_Ttf)file(COPY "data" DESTINATION "${CMAKE_CURRENT_BINARY_DIR}")
Простой и аккуратный. В самом репозитории, лежит специальный батник, который после клонирования репы, следует запустить и он выкачает библиотеку LDL, в каталог dependencies.
dependencies_download.bat
git clone https://github.com/JordanCpp/LDL.git dependencies/LDLrem git clone https://github.com/madler/zlib.git dependencies/zlibpause
Zlib нам пока не нужен, его мы будем использовать, когда понадобятся оригинальные ресурсы игры.
Начнем с первого файла, Arcanum.Engine.ixx В нем мы реализуем цикл игры, отрисуем окно, инициализируем OpenGL 1.2
Весь наш цикл выглядит так.
void Run(){LDL::Event event;while (_window.IsRunning() && _result.IsOk()){while (_window.GetEvent(event)){if (event.Type == LDL_EventIsQuit || event.IsKeyPressed(LDL_KeyEscape)){_window.StopEvent();}Input(event);}_painter.Clear();_camera.Update();_painter.Update(_camera.GetModelView());_painter.DrawTiles(_builder.Build(_location));_window.Present();}if (_result.IsFail()){std::cout << "LDL Error: " << _result.Message() << std::endl;}}
Вызываем painter, это наш рендер, обновляем его, обновляем камеру, рисуем тайлы пола и выводим на экран.
Если вы хотите увидеть, что в итоге, не дочитывая статью. В итоге получится такая карта.
Двигать клавишами WASD. Приблизить Q отдалить E.
Карта с травой. Я выбрал не очень удачную текстуру, вся карта выглядит слишком похоже. Нужно искать бесшовную.

Это карта с текстурой песка. Fallout style. С песком выглядит лучше,

Можем приблизить:

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

Оригинал:

Вернемся к коду:
Так как в будущем предстоит порт. Я минимизирую количество зависимостей. Для графики я использую, GlmLite с основным минимальным функционалом библиотеки GLM. API полностью совместим, если вы добавите include’ы оригинальной библиотеки, все соберется и будет так же работать.
Сначала подготовим камеру.
Для того, что бы нам сохранить для всех бэкендов, единое управление камерой и OpenGL матрицами. Я использую матрицы из GLM.
А уже каждый бэкенд, может их грузить функциями или напрямую в шейдер.
class Camera{public:Camera(const glm::vec3& size) :_zoomSpeed(0.1f),_zoom(1.0f),_cameraPos(0.0f, 0.0f),_cameraSize(size.x, size.y),_cameraSpeed(0.1f){}~Camera(){}const glm::vec2& GetPos(){return _cameraPos;}const glm::vec2& GetSize(){return _cameraSize;}void Up(){_cameraPos.y -= _cameraSpeed;}void Down(){_cameraPos.y += _cameraSpeed;}void Left(){_cameraPos.x -= _cameraSpeed;}void Right(){_cameraPos.x += _cameraSpeed;}float GetZoom(){return _zoom;}void ZoomInc(){_zoom += _zoomSpeed;}void ZoomDec(){_zoom -= _zoomSpeed;}void Update(){_modelView = glm::mat4(1.0f);_modelView = glm::translate(_modelView, glm::vec3(_cameraPos.x, _cameraPos.y, 0.0f));_modelView = glm::scale(_modelView, glm::vec3(_zoom, _zoom, 1.0f));_modelView = glm::scale(_modelView, glm::vec3(1.0f, 0.5f, 1.0f));_modelView = glm::rotate(_modelView, glm::radians(45.0f), glm::vec3(0.0f, 0.0f, 1.0f));}const glm::mat4& GetModelView(){return _modelView;}private:float _cameraSpeed;float _zoomSpeed;float _zoom;glm::vec2 _cameraPos;glm::vec2 _cameraSize;glm::mat4 _modelView;};
Мы используем простые типы, матрицу модели и float’ы
По названиям легко понять, к чему относятся. Если нам нужно изменить положение, мы к текущему масштабированию прибавляем или вычитаем скорость.
void Update(){_modelView = glm::mat4(1.0f);_modelView = glm::translate(_modelView, glm::vec3(_cameraPos.x, _cameraPos.y, 0.0f));_modelView = glm::scale(_modelView, glm::vec3(_zoom, _zoom, 1.0f));_modelView = glm::scale(_modelView, glm::vec3(1.0f, 0.5f, 1.0f));_modelView = glm::rotate(_modelView, glm::radians(45.0f), glm::vec3(0.0f, 0.0f, 1.0f));}
Метод Update каждый кадр обновляет матрицу модели, берет текущее установленное масштабирование и координаты.
Методы scale и rotate. Создают изометрическую матрицу. Тот самый 2.5D вид. Но в аркануме вся графика уже пререндерена, у нас же тайлы это 3D. В будущем мы можем не только вывести тайлы, но и задавать неровности, впадины, холмики, короче неровности пола. Что уже прибавляет визуала.
В оригинальной игре, если нужно показать неровность, ее рисовали. Рисовали сами тайлы и после накладывали. И все равно, мир оригинального арканума, плоский.
Переходим к самой локации.
Я разделил понятия тайла. То есть есть логический тайл на карте и данные тайла для отрисовки.
class Tile{public:Tile(const std::string& textureName) :_textureName(textureName){}const std::string& GetTextureName() const{return _textureName;}private:std::string _textureName;};
Это просто класс со строкой в которой указывается путь до текстуры.
class Location{public:Location(){_size = LDL::Vec2i(10, 10);_tiles.reserve(_size.x * _size.y);for (size_t i = 0; i < _size.x * _size.y; i++){_tiles.emplace_back(Tile("data/floor_grass3&soil.jpg"));}}const LDL::Vec2i& GetSize(){return _size;}const std::vector<Tile>& GetTiles(){return _tiles;}private:LDL::Vec2i _size;std::vector<Tile> _tiles;};
Это уже локация, которой мы можем указать количество тайлов и настроить для каждого тайла свою текстуру.
Мы с вами в самом начале. Потому, еще нет отдельного текстового файла карты, который мы будем грузить. Сейчас пока это очень небольшой прототип. Потому, некоторые вещи я жестко задаю, но в будущем естественно буду переписывать.
Для того, что бы отрисовать карту, на нужно для каждого тайла создать геометрию для видеокарты.
Так как я испорчен C#’ом, естественно у меня появляются классы, builder, constructor и т.д
struct Bound{float xMin;float xMax;float yMin;float yMax;float z;};class Builder{public:Builder(TextureManager& textureManager) :_textureManager(textureManager){}void Fill(TileGeometry& tile, GLuint id, float xMin, float xMax, float yMin, float yMax, float z){tile.Id = id;tile.Vertices[0].Pos = { xMin, yMin, z };tile.Vertices[0].Tex = { 0.0f, 0.0f };tile.Vertices[1].Pos = { xMin, yMax, z };tile.Vertices[1].Tex = { 0.0f, 1.0f };tile.Vertices[2].Pos = { xMax, yMin, z };tile.Vertices[2].Tex = { 1.0f, 0.0f };tile.Vertices[3].Pos = { xMax, yMin, z };tile.Vertices[3].Tex = { 1.0f, 0.0f };tile.Vertices[4].Pos = { xMin, yMax, z };tile.Vertices[4].Tex = { 0.0f, 1.0f };tile.Vertices[5].Pos = { xMax, yMax, z };tile.Vertices[5].Tex = { 1.0f, 1.0f };}Bound Calc(int x, int y, float tileSize){Bound t;t.xMin = x * tileSize;t.xMax = t.xMin + tileSize;t.yMin = y * tileSize;t.yMax = t.yMin + tileSize;t.z = 0.0f;return t;}const std::vector<TileGeometry>& Build(Location& location, float tileSize = 1.0f){const auto& size = location.GetSize();_tiles.assign(size.x * size.y, TileGeometry{});std::mdspan src(std::data(location.GetTiles()), size.y, size.x);std::mdspan dst(std::data(_tiles), size.y, size.x);for (int y = 0; y < size.y; ++y){for (int x = 0; x < size.x; ++x){auto& dstTile = dst[y, x];auto& srcTile = src[y, x];auto t = Calc(x, y, tileSize);auto id = _textureManager.Get(srcTile.GetTextureName());Fill(dstTile, id.GetId(), t.xMin, t.xMax, t.yMin, t.yMax, t.z);}}return _tiles;}private:TextureManager& _textureManager;std::vector<TileGeometry> _tiles;};
В чем суть. Билдеру передается в конструктор менеджер текстур, что бы по имени текстуры он мог ее загрузить.
Далее вызывая метод Build, мы получаем от локации, массив тайлов. Идем по ним и в зависимости от порядка тайла создаем сетку тайлов. И заворачиваем их в класс TileGeometry.
constexpr size_t VertexCount = 6;using VertexContainer = std::array<Vertex, VertexCount>;struct TileGeometry{GLuint Id;VertexContainer Vertices;};
Как раз этот класс содержит всю информацию о себе. Координаты в пространстве, текстура и ее координаты текстурирования, как в итоге текстура будет наложена на треугольники. Тайл это два треугольника.

И уже этот массив тайлов, я передаю в Painter. Этот класс отвечает за вывод треугольников и текстурирование на OpenGL 1.2
class Painter{public:Painter(){}~Painter(){}void Update(const glm::mat4& modelView){glLoadMatrixf(glm::value_ptr(modelView));}void Clear(){glClearColor(0.1f, 0.1f, 0.1f, 1.0f);glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);}void DrawTiles(const std::vector<TileGeometry>& tiles){glEnable(GL_TEXTURE_2D);glBegin(GL_TRIANGLES);for (size_t i = 0; i < tiles.size(); i++){for (size_t j = 0; j < tiles[i].Vertices.size(); j++){auto& t = tiles[i];auto& v = tiles[i].Vertices[j];glBindTexture(GL_TEXTURE_2D, t.Id);glTexCoord2f(v.Tex.x, v.Tex.y);glVertex3f(v.Pos.x, v.Pos.y, v.Pos.z);}}glEnd();glDisable(GL_TEXTURE_2D);}private:};
И да, я знаю, что это максимально не эффективно рисовать по одному тайлу. И это в будущем будет исправлено. Но для простоты понимания и первой статьи этого достаточно. Как раз в будущем при оптимизации мы увидим, насколько сможем разогнать старые видеокарты.
Бежим по массиву и сразу рисуем треугольники и на них текстуру. Проще для понимания сделать наверное невозможно.
Остается показать менеджер текстур.
class TextureManager{public:TextureManager(TextureLoader& loader) :_defaultSize(LDL::Vec2i(16, 16)),_defaultPixels(_defaultSize.x * _defaultSize.y * 3),_defaultTexture{ Texture(_defaultSize, LDL_BytesPerPixelFromPixelFormat(LDL_PixelFormatRGB24), _defaultPixels.data()) },_loader(loader){} const Texture& Get(const std::string& path) { auto it = _textures.find(path); if (it != _textures.end()) { return it->second; }if (_loader.Load(path)){auto [newIt, inserted] = _textures.try_emplace(path, _loader.GetSize(), _loader.GetFormat(), _loader.GetPixels());return newIt->second;}std::cout << "Not found: " << path << std::endl;return _defaultTexture; }private:using textureContainer = std::unordered_map<std::string, Texture>;LDL::Vec2i _defaultSize;std::vector<uint8_t> _defaultPixels;const Texture& _defaultTexture;TextureLoader& _loader;textureContainer _textures;};
Алгоритм максимально прост.
Запрашиваем текстуру по имени, если она есть загружаем и создаем текстуру и возвращаем ссылку. Если нет то возвращаем дефолтную текстуру, просто заглушку. Выводим в консоль подробности об ошибке.
В следующих статьях добавим единый логгер.
Теперь про модули С++. Они классные, работают и я от них в восторге.
Краткий ликбез по модулям для тех кто пропустил 6 лет.
module;//Здесь инклюды#include <vector>export module Arcanum.Builder;//Здесь импорт модулейimport Arcanum.Tile;import Arcanum.Location;import Arcanum.TileGeometry;import Arcanum.TextureManager;//Объявляем, что экспортируем классы, типы, методыexport namespace Arcanum{struct Bound{float xMin;float xMax;float yMin;float yMax;float z;};class Builder{public:Builder(TextureManager& textureManager) :_textureManager(textureManager){}Bound Calc(int x, int y, float tileSize){Bound t;t.xMin = x * tileSize;t.xMax = t.xMin + tileSize;t.yMin = y * tileSize;t.yMax = t.yMin + tileSize;t.z = 0.0f;return t;}private:TextureManager& _textureManager;std::vector<TileGeometry> _tiles;};}
Модули не позволяет инклюдам и макросам пролезть в другие модули при его импорте.
При импорте данного класса вам будет доступен только класс Builder. По итоге, меньше файлов, теперь можно писать как на C# в одном файле с реализацией. Так же еще есть интерфейсные модули, партиции,но для моего проекта я их не буду использовать. максимально просто.
В итоге проект выглядит так:

Это первая статья, после длительного перерыва. Но учту все замечания и готов править статью.
Буду рад услышать ваше мнение, критику, предложения и советы.
Ссылка на гитхаб проекта: ArcanumModern.
ссылка на оригинал статьи https://habr.com/ru/articles/1060242/