Приветствую, Хабравчане.

Синопсис.

Прошло несколько лет, с последней попытки написать движок 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.