Приветствую, Хабравчане.
Синопсис.
Прошло несколько лет, с последней попытки написать движок 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 LDL add_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/LDL rem git clone https://github.com/madler/zlib.git dependencies/zlib pause
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.
