Пишем собственный воксельный движок
Время на прочтение
11 мин
Количество просмотров 20K
Примечание: полный исходный код этого проекта выложен здесь: [source].
Когда проект, над которым я работаю, начинает выдыхаться, я добавляю новые визуализации, дающие мне мотивацию двигаться дальше.
После выпуска первоначального концепта Task-Bot [перевод на Хабре] я почувствовал, что меня ограничивает двухмерное пространство, в котором я работал. Казалось, что оно сдерживает возможности емерджентного поведения ботов.
Предыдущие неудачные попытки изучения современного OpenGL поставили передо мной мысленный барьер, но в конце июля я каким-то образом наконец пробил его. Сегодня, в конце октября, у меня уже достаточно уверенное понимание концепций, поэтому я выпустил собственный простой воксельный движок, который будет средой для жизни и процветания моих Task-Bots.
Я решил создать собственный движок, потому что мне требовался полный контроль над графикой; к тому же я хотел себя испытать. В каком-то смысле я занимался изобретением велосипеда, но этот процесс мне очень понравился!
Конечной целью всего проекта была полная симуляция экосистемы, где боты в роли агентов манипулируют окружением и взаимодействуют с ним.
Так как движок уже довольно сильно продвинулся вперёд и я снова перехожу к программированию ботов, я решил написать пост о движке, его функциях и реализации, чтобы в будущем сосредоточиться на более высокоуровневых задачах.
Концепция движка
Движок полностью написан с нуля на C++ (за некоторыми исключениями, например, поиска пути). Для рендеринга контекста и обработки ввода я использую SDL2, для отрисовки 3D-сцены — OpenGL, а для управления симуляцией — DearImgui.
Я решил использовать воксели в основном потому, что хотел работать с сеткой, которая имеет множество преимуществ:
- Создание мешей для рендеринга хорошо мне понятно.
- Возможности хранения данных мира более разнообразны и понятны.
- Я уже создавал системы для генерации рельефа и симуляции климата на основе сеток.
- Задачи ботов в сетке легче параметризировать.
Движок состоит из системы данных мира, системы рендеринга и нескольких вспомогательных классов (например, для звука и обработки ввода).
В статье я расскажу о текущем списке возможностей, а также подробнее рассмотрю более сложные подсистемы.
Класс World
Класс мира служит базовым классом для хранения всей информации мира. Он обрабатывает генерацию, загрузку и сохранение данных блоков.
Данные блоков хранятся во фрагментах (chunks) постоянного размера (16^3), а мир хранит вектор фрагментов, загруженный в виртуальную память. В больших мирах практически необходимо хранить в памяти только определённую часть мира, поэтому я и выбрал такой подход.
class World{
public:
World(std::string _saveFile){
saveFile = _saveFile;
loadWorld();
}
//Data Storage
std::vector<Chunk> chunks; //Loaded Chunks
std::stack<int> updateModels; //Models to be re-meshed
void bufferChunks(View view);
//Generation
void generate();
Blueprint blueprint;
bool evaluateBlueprint(Blueprint &_blueprint);
//File IO Management
std::string saveFile;
bool loadWorld();
bool saveWorld();
//other...
int SEED = 100;
int chunkSize = 16;
int tickLength = 1;
glm::vec3 dim = glm::vec3(20, 5, 20);
//...
Фрагменты хранят данные блоков, а также некоторые другие метаданные, в плоском массиве. Изначально я реализовал для хранения фрагментов собственное разреженное октодерево, но оказалось, что время произвольного доступа слишком высоко для создания мешей. И хотя плоский массив неоптимален с точки зрения памяти, он обеспечивает возможность очень быстрого построения мешей и манипуляций с блоками, а также доступ к поиску пути.
class Chunk{
public:
//Position information and size information
glm::vec3 pos;
int size;
BiomeType biome;
//Data Storage Member
int data[16*16*16] = {0};
bool refreshModel = false;
//Get the Flat-Array Index
int getIndex(glm::vec3 _p);
void setPosition(glm::vec3 _p, BlockType _type);
BlockType getPosition(glm::vec3 _p);
glm::vec4 getColorByID(BlockType _type);
};
Если я когда-нибудь реализую многопоточное сохранение и загрузку фрагментов, то преобразование плоского массива в разреженное октодерево и обратно может быть вполне возможным вариантом для экономии памяти. Здесь ещё есть пространство для оптимизации!
Моя реализация разреженного октодерева сохранилась в коде, поэтому можете спокойно ею воспользоваться.
Хранение фрагментов и работа с памятью
Фрагменты видимы только тогда, когда они находятся в пределах расстояния рендеринга текущей позиции камеры. Это значит, что при движении камеры нужно динамически загружать и составлять в меши фрагменты.
Фрагменты сериализованы при помощи библиотеки boost, а данные мира хранятся как простой текстовый файл, в котором каждый фрагмент — это строка файла. Они генерируются в определённом порядке, чтобы их можно было «упорядочить» в файле мира. Это важно для дальнейших оптимизаций.
В случае большого размера мира основным узким местом является считывание файла мира и загрузка/запись фрагментов. В идеале нам нужно выполнять только одну загрузку и передачу файла мира.
Для этого метод World::bufferChunks()
удаляет фрагменты, которые находятся в виртуальной памяти, но невидимы, и интеллектуально загружает новые фрагменты из файла мира.
Под интеллектуальностью подразумевается, что он просто решает, какие новые фрагменты нужно загрузить, сортируя их по их позиции в файле сохранения, а затем выполняя один проход. Всё очень просто.
void World::bufferChunks(View view){
//Load / Reload all Visible Chunks
evaluateBlueprint(blueprint);
//Chunks that should be loaded
glm::vec3 a = glm::floor(view.viewPos/glm::vec3(chunkSize))-view.renderDistance;
glm::vec3 b = glm::floor(view.viewPos/glm::vec3(chunkSize))+view.renderDistance;
//Can't exceed a certain size
a = glm::clamp(a, glm::vec3(0), dim-glm::vec3(1));
b = glm::clamp(b, glm::vec3(0), dim-glm::vec3(1));
//Chunks that need to be removed / loaded
std::stack<int> remove;
std::vector<glm::vec3> load;
//Construct the Vector of chunks we should load
for(int i = a.x; i <= b.x; i ++){
for(int j = a.y; j <= b.y; j ++){
for(int k = a.z; k <= b.z; k ++){
//Add the vector that we should be loading
load.push_back(glm::vec3(i, j, k));
}
}
}
//Loop over all existing chunks
for(unsigned int i = 0; i < chunks.size(); i++){
//Check if any of these chunks are outside of the limits
if(glm::any(glm::lessThan(chunks[i].pos, a)) || glm::any(glm::greaterThan(chunks[i].pos, b))){
//Add the chunk to the erase pile
remove.push(i);
}
//Don't reload chunks that remain
for(unsigned int j = 0; j < load.size(); j++){
if(glm::all(glm::equal(load[j], chunks[i].pos))){
//Remove the element from load
load.erase(load.begin()+j);
}
}
//Flags for the Viewclass to use later
updateModels = remove;
//Loop over the erase pile, delete the relevant chunks.
while(!remove.empty()){
chunks.erase(chunks.begin()+remove.top());
remove.pop();
}
//Check if we want to load any guys
if(!load.empty()){
//Sort the loading vector, for single file-pass
std::sort(load.begin(), load.end(),
[](const glm::vec3& a, const glm::vec3& b) {
if(a.x > b.x) return true;
if(a.x < b.x) return false;
if(a.y > b.y) return true;
if(a.y < b.y) return false;
if(a.z > b.z) return true;
if(a.z < b.z) return false;
return false;
});
boost::filesystem::path data_dir( boost::filesystem::current_path() );
data_dir /= "save";
data_dir /= saveFile;
std::ifstream in((data_dir/"world.region").string());
Chunk _chunk;
int n = 0;
while(!load.empty()){
//Skip Lines (this is dumb)
while(n < load.back().x*dim.z*dim.y+load.back().y*dim.z+load.back().z){
in.ignore(1000000,'n');
n++;
}
//Load the Chunk
{
boost::archive::text_iarchive ia(in);
ia >> _chunk;
chunks.push_back(_chunk);
load.pop_back();
}
}
in.close();
}
}
Your browser does not support HTML5 video.
Пример загрузки фрагментов при малом расстоянии рендеринга. Артефакты искажения экрана вызваны ПО записи видео. Иногда возникают заметные пики загрузок, в основном вызванные созданием мешей
Кроме того, я задал флаг, сообщающий, что рендерер должен заново создать меш загруженного фрагмента.
Класс Blueprint и editBuffer
editBuffer — это сортируемый контейнер bufferObjects, содержащий информацию о редактировании в мировом пространстве и пространстве фрагментов.
//EditBuffer Object Struct
struct bufferObject {
glm::vec3 pos;
glm::vec3 cpos;
BlockType type;
};
//Edit Buffer!
std::vector<bufferObject> editBuffer;
Если при внесении изменений в мир записывать их в файл сразу же после внесения изменения, то нам придётся передавать весь текстовый файл целиком и записывать КАЖДОЕ изменение. Это ужасно с точки зрения производительности.
Поэтому сначала я записываю все изменения, которые нужно внести, в editBuffer при помощи метода addEditBuffer (который также вычисляет позиции изменений в пространстве фрагментов). Прежде чем записывать их в файл, я сортирую изменения по порядку фрагментов, которым они принадлежат по расположению их в файле.
Запись изменений в файл заключается в одной передаче файла, загрузке каждой строки (т.е. фрагмента), для которого имеются изменения в editBuffer, внесении всех изменений и записи его во временный файл, пока editBuffer не станет пустым. Это выполняется в функции evaluateBlueprint()
, которая достаточно быстра.
bool World::evaluateBlueprint(Blueprint &_blueprint){
//Check if the editBuffer isn't empty!
if(_blueprint.editBuffer.empty()){
return false;
}
//Sort the editBuffer
std::sort(_blueprint.editBuffer.begin(), _blueprint.editBuffer.end(), std::greater<bufferObject>());
//Open the File
boost::filesystem::path data_dir(boost::filesystem::current_path());
data_dir /= "save";
data_dir /= saveFile;
//Load File and Write File
std::ifstream in((data_dir/"world.region").string());
std::ofstream out((data_dir/"world.region.temp").string(), std::ofstream::app);
//Chunk for Saving Data
Chunk _chunk;
int n_chunks = 0;
//Loop over the Guy
while(n_chunks < dim.x*dim.y*dim.z){
if(in.eof()){
return false;
}
//Archive Serializers
boost::archive::text_oarchive oa(out);
boost::archive::text_iarchive ia(in);
//Load the Chunk
ia >> _chunk;
//Overwrite relevant portions
while(!_blueprint.editBuffer.empty() && glm::all(glm::equal(_chunk.pos, _blueprint.editBuffer.back().cpos))){
//Change the Guy
_chunk.setPosition(glm::mod(_blueprint.editBuffer.back().pos, glm::vec3(chunkSize)), _blueprint.editBuffer.back().type);
_blueprint.editBuffer.pop_back();
}
//Write the chunk back
oa << _chunk;
n_chunks++;
}
//Close the fstream and ifstream
in.close();
out.close();
//Delete the first file, rename the temp file
boost::filesystem::remove_all((data_dir/"world.region").string());
boost::filesystem::rename((data_dir/"world.region.temp").string(),(data_dir/"world.region").string());
//Success!
return true;
}
Класс blueprint содержит editBuffer, а также несколько методов, позволяющих создавать editBuffers конкретных объектов (деревьев, кактусов, хижин, и т.д.). Затем blueprint можно преобразовать в позицию, в которую нужно поместить объект, а далее просто записать его в память мира.
Одна из самых больших сложностей при работе с фрагментами заключается в том, что изменения в нескольких блоках между границами фрагментов могут оказаться монотонным процессом со множеством арифметики по модулю и разделения изменений на несколько частей. Это основная проблема, с которой блестяще справляется класс blueprint.
Я активно использую его на этапе генерации мира, чтобы расширить «бутылочное горлышко» записи изменений в файл.
void World::generate(){
//Create an editBuffer that contains a flat surface!
blueprint.flatSurface(dim.x*chunkSize, dim.z*chunkSize);
//Write the current blueprint to the world file.
evaluateBlueprint(blueprint);
//Add a tree
Blueprint _tree;
evaluateBlueprint(_tree.translate(glm::vec3(x, y, z)));
}
Класс world хранит собственный blueprint изменений, внесённых в мир, чтобы при вызове bufferChunks() все изменения записывались на жёсткий диск за один проход, а затем удалялись из виртуальной памяти.
Рендеринг
Рендерер по своей структуре не очень сложен, но для понимания требует знаний OpenGL. Не все его части интересны, в основном это обёртки функциональности OpenGL. Я довольно долго экспериментировал с визуализацией, чтобы получить то, что мне понравится.
Так как симуляция происходит не от первого лица, я выбрал ортографическую проекцию. Её можно было реализовать в формате псевдо-3D (т.е. предварительно спроецировать тайлы и наложить их в программном рендерере), но это показалось мне глупым. Я рад, что перешёл к использованию OpenGL.
Базовый класс для рендеринга называется View, он содержит большинство важных переменных, управляющих визуализацией симуляции:
- Размер экрана и текстуры теней
- Объекты шейдеров, множители приближения камеры, матрицы и т.п.
- Булевы значения для почти всех функций рендерера
- Меню, туман, глубина резкости, зернистость текстур и т.п.
- Цвета для освещения, тумана, неба, окна выбора и т.п.
Кроме того, существует несколько вспомогательных классов, выполняющих сам рендеринг и обёртывание OpenGL!
- Класс Shader
- Загружает, компилирует, компонует и использует шейдеры GLSL
- Класс Model
- Содержит VAO (Vertex Arrays Object) данных фрагментов для отрисовки, функцию создания мешей и метод render.
- Класс Billboard
- Содержит FBO (FrameBuffer Object), в который выполняется рендеринг — полезно для создания эффектов постобработки и наложения теней.
- Класс Sprite
- Отрисовывает ориентированный относительно камеры четырёхугольник, загружаемый из файла текстуры (для ботов и предметов). Также может обрабатывать анимации!
- Класс Interface
- Для работы с ImGUI
- Класс Audio
- Очень рудиментарная поддержка звука (если вы скомпилируете движок, нажмите “M”)
Высокая глубина резкости (DOF). При больших расстояниях рендеринга может быть тормозной, но я всё это делал на своём ноутбуке. Возможно, на хорошем компьютере тормоза будут незаметны. Я понимаю, что это напрягает глаза и сделал так просто ради интереса.
На изображении выше показаны некоторые параметры, которые можно изменять в процессе манипуляций. Также я реализовал переключение в полноэкранный режим. На изображении виден пример спрайта бота, отрендеренного как текстурированный четырёхугольник, направленный в сторону камеры. Домики и кактусы на изображении построены при помощи blueprint.
Создание мешей фрагментов
Изначально я использовал наивную версию создания мешей: просто создавал куб и отбрасывал вершины, не касающиеся пустого пространства. Однако такое решение было медленным, и при загрузке новых фрагментов создание мешей оказывалось даже более узким «бутылочным горлышком», чем доступ к файлу.
Основной проблемой было эффективное создание из фрагментов рендерящихся VBO, но мне удалось реализовать на C++ собственную версию «жадного создания мешей» (greedy meshing), совместимую с OpenGL (не имеющую странных структур с циклами). Можете с чистой совестью пользоваться моим кодом.
void Model::fromChunkGreedy(Chunk chunk){
//... (this is part of the model class - find on github!)
}
В целом, переход к greedy meshing снизил количество отрисовываемых четырёхугольников в среднем на 60%. Затем, после дальнейших мелких оптимизаций (индексирования VBO) количество удалось снизить ещё на 1/3 (с 6 вершин на грань до 4 вершин).
При рендеринге сцены из 5x1x5 фрагментов в окне, не развёрнутом на весь экран, я получаю в среднем около 140 FPS (с отключенным VSYNC).
Хотя меня вполне устраивает такой результат, мне бы по-прежнему хотелось придумать систему для отрисовки некубических моделей из данных мира. Её не так просто интегрировать при greedy meshing, поэтому над этим стоит подумать.
Шейдеры и выделение вокселей
Реализация GLSL-шейдеров — одна из самых интересных, и в то же время самых раздражающих частей написания движка из-за сложности отладки на GPU. Я не специалист по GLSL, поэтому многому приходилось учиться на ходу.
Реализованные мной эффекты активно используют FBO и сэмплирование текстур (например, размытие, наложение теней и использование информации о глубинах).
Мне всё ещё не нравится текущая модель освещения, потому что она не очень хорошо обрабатывает «темноту». Надеюсь, это будет исправлено в дальнейшем, когда я буду работать над циклом смены дня и ночи.
Также я реализовал простую функцию выбора вокселей при помощи модифицированного алгоритма Брезенхэма (это ещё одно преимущество использования вокселей). Она полезна для получения пространственной информации в процессе работы симуляции. Моя реализация работает только для ортографических проекций, но можете ею воспользоваться.
«Выделенная» тыква.
Игровые классы
Создано несколько вспомогательных классов для обработки ввода, отладочных сообщений, а также отдельный класс Item с базовой функциональностью (который будет в дальнейшем расширен).
class eventHandler{
/*
This class handles user input, creates an appropriate stack of activated events and handles them so that user inputs have continuous effect.
*/
public:
//Queued Inputs
std::deque<SDL_Event*> inputs; //General Key Inputs
std::deque<SDL_Event*> scroll; //General Key Inputs
std::deque<SDL_Event*> rotate; //Rotate Key Inputs
SDL_Event* mouse; //Whatever the mouse is doing at a moment
SDL_Event* windowevent; //Whatever the mouse is doing at a moment
bool _window;
bool move = false;
bool click = false;
bool fullscreen = false;
//Take inputs and add them to stack
void input(SDL_Event *e, bool &quit, bool &paused);
//Handle the existing stack every tick
void update(World &world, Player &player, Population &population, View &view, Audio &audio);
//Handle Individual Types of Events
void handlePlayerMove(World &world, Player &player, View &view, int a);
void handleCameraMove(World &world, View &view);
};
Мой обработчик событий (event handler) некрасив, зато функционален. С радостью приму рекомендации по его улучшению, особенно по использованию SDL Poll Event.
Последние примечания
Сам движок — это просто система, в которую я помещаю своих task-bots (подробно о них я расскажу в следующем посте). Но если вам показались интересными мои методы, и вы хотите узнать больше, то напишите мне.
Затем я портировал систему task-bot (настоящее сердце этого проекта) в 3D-мир и значительно расширил её возможности, но подробнее об этом позже (однако код уже выложен онлайн)!
Дисклеймер: несмотря на многие ограничения и практически отсутствующий инструментарий, этот проект все-таки является 3д-движком, т. к. позволяет в режиме реального времени визуализировать трехмерную сцену. Unity3d используется исключительно как способ доступа к нужным мне технологиям. Штатный рендер Unity не используется.
Немного теории
Рендер трехмерного мира являет собой чрезвычайно сложную задачу, как с точки зрения объема работы, так и с используемого математического аппарата. В связи с этим проще и эффективнее доработать старое двигло с учетом новых технологий, чем писать с нуля движок. Даже самые новые игровые движки, типа CryEngeich, Unity 2015-2020, EU4-5 содержат в своей основе год бородатых годов. А может и не содержат, свечку не держал, исходники не видел. Итак, позволить себе создание нового 3д движка могут или крупные компании или, напротив, инди-студии, которым нечего терять можно и можно пуститься во все тяжкие
Самый распространенным способ описание трехмерной модели объекта является полигональная модель. Модель задается в виде массива вершин и отношений между ними:
- пара вершин образует ребро
- три ребра образуют треугольник
- множество треугольников образуют трехмерную поверхность
Этот способ позволяет описывать объекты любой сложности и размеров, но полностью игнорирует внутренне устройство объектов — потому что полигональная модель описывает только форму поверхности. Так же реализация динамического изменения модели очень сложна, неочевидна и имеет ограничения.
Другой способ задание трехмерной сцены — это использование так называемых вокселей — трехмерных писелей. В этом случае трехмерная сцена задается как трехмерный массив цветов. Это решение кажется довольно интуитивным, но из-за многих проблем не получило должного распространения. Основным ограничениями являются:
- затраты видеопамяти при рендере пропорциональны размеру сцены
- фундаментальная угловатость мира, связанная с тем, что сцена задается в виде «кубиков»
- невозможность текстурирования объектов
- сохраненное на диске состояние сцены занимает большой объем памяти
Однако, воксели имеют уникальные положительные стороны:
- возможность легко изменять объекты на сцене
- физически корректная реализация распространения света в полупрозрачных телах
- полный контроль за процессом рендеринга из-за его простоты
- возможность создания физической симуляции мира на уровне поведения отдельных частиц
Как я дошел до жизни такой
Предистория разработки этого движка началась ещё в 2003 году, когда в журнале Game. exe была выпущена статья о разработке компьютерной игры «Периметр: Геометрия войны». В моей памяти навсегда остались описание деформируемого в реальном времени ландшафта, по которому медленно ползут танки, которые обстреливают гаубицы на искусственных насыпях. »Периметр» навеки стал для меня эталоном стратегии, пускай даже сама игра была не столько хороша, как в моих мечтах. Идея использовать ландшафт в качестве основного элемента игрового процесса крепко поселилась в голове. И, когда я в 2019 году узрел на Хабре статьи, посвященные использованию вычислительных шейдеров я понял — настало то самое время. Идея создать свой воксельный движок не имела конкретной цели (вроде создания игры), но начав я уже не мог остановится.
Базовый принцип работы
Сцена для рендера представляет собой Texture3D, т. е. трехмерный массив, где индекс обозначает положение вокселя в трехмерном пространстве, а хранится в нем его цвет в формате RGBA (Red, Green, Blue, Alpha). Размер сцены составляет 256x256x256 вокселей. Для каждого пикселя экрана из точки положения наблюдателя (камеры) выпускается луч, в направлении данного пикселя. Далее в цикле происходит «шагание» по лучу, где читается цвет из трехмерной текстуры, где индекс — это округленные координаты текущей точки. Если прозрачность цвета близка к нулю — то цикл продолжается дальше. Если прозрачность цвета больше нуля — то пиксель закрашивается в этот цвет и цикл прерывается. Вот такой простой алгоритм, дающий, тем не менее рендер в реальном времени. На самом деле я его позаимствовал его из этой статьи.
Так как я статью не понял, у меня получился свой собственный рабочий алгоритм.
Первые шаги
Что бы не тянуть интригу — мне удалось сделать с нуля рабочий рендер 3d сцены на Юнити, собрав при этом чуть ли не все возможные проблемы.
Самого старого варианта, с белым шаром в пустом пространстве, не сохранилось. Однако, и на этом скриншоте видны характерные черты воксельного рендера — угловатость пейзажа и его низкое разрешение. Но картинка есть! Я не знаю, кто будет читать эту статью и настолько мой читатель будет грамотен в вопросах компьютерной графики. Поэтому, я должен сказать, что сделанное мной даже на этапе скриншота — значительный успех. На каждом шагу меня подстерегали неразрешимые проблемы, любая из которых могла меня навсегда остановить, а хитроумным решениям для обхода глюков несть числа. Результатом является стабильный рендер картинки с частотой 30 герц и смутные перспективы на будущее технологии. Останавливаться на достигнутом было слишком просто, к тому же такой результат не годился для практического применения.
Реализация
Иметь алгоритм — это хорошо, но иметь конкретную реализацию — несравнимо лучше. Для простоты я использовал игровой движок Unity. Мой движок состоит из двух основных компонентов — скрипты для управления процессом рендера и compute shader (вычислительный шейдер) для самого процесса рендера. Вычислительный шейдер — это очень полезная и оригинальная технология, которая позволяет запускать на видеокарте любой выполнимый код и возвращать результат обратно на сторону процессора. В отличии от процессора, видеокарта при помощи compute shader может параллельно обрабатывать миллионы потоков, что идеально подходит под задачу. Однако, сложность заключается в том, что вычислительными шейдерами трудно управлять и ещё сложнее отлаживать.
Просто рендера непрозрачных тел не хватает для красивой и убедительной картинки, поэтому следующий логический шаг — это добавление к рендеру освещения. Собственно, освещение — это и есть основная задача 3д-движков. Все графические технологии направлены на улучшение освещения и приближения его к реальной физике распространения света. Для моего случая вполне допустимо использовать простые законы геометрической оптики. Алгоритм:
- из источника света выпускается луч для каждого вокселя, каждый воксель обрабатывается параллельно
- в цикле происходит шагание по лучу и проверяется наличие препятствий для распространения света. В случае их отсутствия воксель считается освещенным. В противном случае он будет неосвещенным
- данные о освещенности записываются в специальную текстуру, называемую картой теней. Она имеет такое же разрешение, что и текстура изображения.
- цвет пре-рендера сцены умножается на карту теней.
На самом деле этот алгоритм имеет оптимизацию, позволяющую его выполнять в экранном пространстве. Это означает, что для каждого пикселя сцены берется воксель, который виден в этом пикселе и вычисляется его освещенность. В этом случае затраты вычислений на видеокарте будут пропорциональны разрешению экрана, дальности обзора и дальности действия источников света. Для большой сцены этот метод будет значительно снижать количество вычислений.
Однако, данный метод можно улучшить, добавив отображение нескольких источников света.
На скриншоте видно две цветные тени от источников света. В общем случае такой эффект некорректен с точки зрения оптики, поскольку тени должны быть черными, а цвет должно иметь освещенное пространство. Дальнейшая работа над освещением — это использование более сложной модели освещения. Прошлая модель не учитывала расстояние от источника света и угол его падения. Новая учитывает это, что позволяет получить более красивую картинку. Для пояснения принципа работы следует рассказать, что такое нормаль. Нормалью называется вектор, который перпендикулярен поверхности и имеет длину, равную единице. По закона оптики, интенсивность освещения поверхности тела пропорциональна скалярному произведению нормали поверхности и луча света. Для создания этого механизма я для каждого вокселя вычислял его нормаль путем проверки его соседей на прозрачность. В итоге я получал карту нормалей всей сцены, которую использовал для дальнейших расчетов. В итоге, лучи света, падающие полого к поверхности, хуже освещают поверхность, чем те, которые падают перпендикулярно. Это создает более правдоподобную картинку и визуально скругляет острые грани.
На рендер на последнем скриншоте — это, пожалуй, предел того, что можно вытащить из расчет освещения в экранным пространстве. Этот метод очень производителен и дает красивую картинку. К моей великой печали, он не позволяет работать с полупрозрачными объектами, а это одно из главных потенциальных достоинств воксельной технологии. Я испробовал много СПОБСобов обойти это ограничение, но лишь доказал, что это невозможно. печальный результат можно увидеть на картинке ниже.
Объемное освещение
Когда я понял фундаментальные ограничения старой системы, то было сложно справится с печалью. Требовалось радикально отойти от старого алгоритма, что бы продвинутся дальше. Мой новый алгоритм (названный «Accumulation de perte de lumière» или «Pertalum») имитирует реальное распространение света. Суть этого алгоритма заключается в использовании особой трехмерной текстуры, названной картой светопотерь. При расчете лучей в эту текстуру сохраняется цвет, который равен потери яркости луча при прохождении через данный воксель. Эти вычисления применяются для всех вокселей сцены. Да, для всех 16 миллионов. Результатом была довольно красивая картинка ценой чудовищных вычислительных затрат.
При запуска получилась приятная картинка и неприятное явление в лице загрузки видеокарты на 100%. Я долго думал как уменьшить загрузку, пока не придумал поистине гениальный план.
Я купил новую видеокарту! RTX2060 (на то время она была весьма мощной).
Однако, христианское милосердие не давало мне забыть о несчастных, которые не имели мощной видюшеньки и я решил продолжить оптимизацию расчета освещения. Самым очевидным вариантом было бросать лучи из источника света во все стороны и для каждого затронутого ими вокселя производить расчет освещения. Это дало небольшой прирост производительности, за счет отбрасывание многократной обработки одного и того же вокселя. Картинку смотрите ниже.
Можно видеть так называемыелучи Бога— безо всяких дополнителньных махинаций, которые нужны в обычном движке.
На данном этапе движок выполняет максимум того, что можно выжать с существующих технологий. Дальнейшее развитие требует совершенно новых решений. Для оптимизации воксельной сцены используется так называемое восьмидрево — древовидная структура данных, в которой сцена разбивается на 8 элементов. Каждый элемент, если он не пустой, так же разбивается на восемь элементов и так до тех пор, пока не будет достигнут некий минимальный размер элемента. Если элемент оказывается пустым (содержит только прозрачные воксели) то он обрабатывается как один воксель и на нем разбиение прекращается. Как вы можете догадаться, до полноценной реализации этого у меня руки не дошли.
Реализация и беды с ней
Создание и отладка двигла — это отдельное сказание, по эпичности сравнимое с Махабарахтой и Рагнарёком. За каждым скриншотом скрывается драма
Разработка движка началась с Unity3d. Звучит немного абсурдно, но этой самый простой способ получить доступ к необходимым мне технологиям. Разумеется, я изучил возможности главного конкурента Unity — Unreal Ingeich (ну ладно, Engine) 4. В Анрыле написание, компиляция и запуск compute shader занимают гораздо больше усилий и сомнительных плюсах. Работа с вычислительными шейдерами в Юнити укладывается в простую последовательность:
- написать шейдер
- сохранить его в папке Resources
- в скрипте загрузить его из этой папки
- назначить переменные загруженному шейдеру
- запустить его
В Анрыле для этого надо выполнить множество сложных и неочевидных операций и написать уйму кода, который делает неизвестно что (во всяком случае для меня) и неизвестно как (аналогично). Возможно, подход Анрыла имеет свой плюс. Или даже два плюса (++).
Как я уже писал в прошлой части, Compute Shader — это технология, которая позволяет выполнять на видеокарте любой произвольный код и даже возвращать его на сторону центрального процессора. При обычной работе видеокарта обрабатывает 3д-сцену, формирует картинку и отправляет её в кадровый буффер, после чего она оправляется на монитор. Использование вычислительных шейдеров превращает видеокарту в отдельное вычислительное устройство. Видеокарта из-за особенностей архитектуры идеально приспособлена для параллельного выполнения множества однотипных операций над данными. Например, обработка текстуры (фильтрация, наложение эффектов и т. д.). Возьмем текстуру размером 1024х1024. В этом случае ComputeShader (далее — CS) позволяет параллельно обрабатывать все пиксели, благодаря чему этот процесс завершается за считанные миллисекунды. Центральный процессор может обрабатывать текстуру в 5-10 потоков и завершит работу за несколько секунд, чего недостаточно для создания эффектов в режиме реального времени. Однако, даже CS будет тормозить при слишком большом количестве потоков.
Сам CS представляет собой код, написанный на языке HLSL (англ. высокоуровневый язык шейдеров). Это один из диалектов си, с вырезанной работой с памятью и добавленной векторной арифметикой. CS состоит из глобальных переменных и ядер (kernel). Кернель — это обычная функция, которая имеет параметр id, который имеет уникальное значение для каждого потока. Шейдер запускается на стороне скрипта с указанным количеством потоков. Подробная информация о этой технологии тайной не является и доступна по первым ссылкам в гугле.
Беды
Если бы у меня CS и другие технологии работали точно так же, как и в руководстве — я бы добился бы такого же результата за две недели, а не за три месяца. Среди самых неожиданных проблем:
- Нельзя в скрипте создать трехмерную текстуру (Texture3D) размером больше чем 256х256х170. В противном случае движок будет выдавать черный экран вместо рендера, даже если эта текстура НИГДЕ не используется. При этом никаких ошибок и предупреждений Unity не выдает и посему решить эту проблему не удалось, лишь обойти.
- Для использования трехмерной текстуры необходимо дополнительно указать, что она именно трехмерная, причем в руководстве это нигде не указано. В противном случае шейдер будет выдавать ошибку, что нет третьего измерения у текстуры.
- В CS в текстуру нельзя сохранять отрицательные значения цвета.
- Полностью отсутствуют средства отладки шейдера, поэтому для определения проблем приходится создавать свои инструменты
- Юнити последних версий имеет дурную привычку обновлятся, что приводит к крушению всего проекта. Решилось переходом на старую версию.
- На ноль делить нельзя. Даже если очень хочется.
Физическая симуляция
Воспоминания о разрушаемом ландшафте не давали мне покоя и я решил воплотить физическую симуляцию мира на уровне отдельных частиц. Я знал что задача была решаема и с головой пустился в разработку. Для этого пришлось отказаться от представления мира в виде обычной трехмерной текстуры. Вместо сцену я представил в виде списка частиц длиной 256х256х256. Частица представляет собой структуру, которая содержит в себе:
- Цвет (куда уж без него?)
- Позицию в пространстве
- Текущую скорость
- Массу
Для визуализации оравы частиц я использовал дополнительный кернель, в котором их копировал их в другую текстуру по индексу, соответсвующую их пространственным координатам. Далее кернели рендера отсисовывают эту промежуточную текстуру. Для того, что бы можно было найти частицу по её положению, я завел ещё одну текстуру, в котором по индексу хранил номер частицы в общем списке. Фактически я реализовал указатель в языке, который их не поддерживал.
Принцип работы физической симуляции был таков:
- в отдельном кернеле каждая частица меняет свою позицию на величину, равную своей скорости
- Частица передает импульс другим частицам, которые она могла задеть.
Я смело предположил, что это должно работать. А на самом деле — нет. Все, что у меня получилось — это сделать падение под действием гравитации и обнаружение столкновений.
Редактор
В процессе работы я сталкивался с тем, что мне надо прямо во время работы добавить или удалить объекты на сцене. Это значит, что пришло время создать 3д-редактор на основе моего движка! На этот раз дело шло безо всяких эпических превозмоганий, некоторую сложность составило создание отката рисования (undo-redo, как в любом редакторе). Захват всей сцены требовам большого объема памяти и на несколько секунд тормозил рендер. Я решил эту проблему сохраняя в памяти только измененный фрагмент сцены.
Сейчас редактор имеет следующие функции:
- Рисование на сцене/добавление новых вокселей.
- Изменение режимов рисования (глобальное/экранное, непрерывное/дискретное).
- Отмена изменений
- Сохранение в файл (расширение. tempete) и загрузка из файла сцены.
Ну наверное, все. На этой ноте я прекратил работу над проектом, что бы заняться другим, более важным для себя. Если кому-нибудь нужны исходники — пишите.
И главное: время танков, ползущих по динамически изменяемому ландшафту к гаубицам на искусственных насыпях, обязательно придет.
#лонгрид #движок #воксели #разработка
Примечание: полный исходный код этого проекта выложен здесь: [source].
Когда проект, над которым я работаю, начинает выдыхаться, я добавляю новые визуализации, дающие мне мотивацию двигаться дальше.
После выпуска первоначального концепта Task-Bot [перевод на Хабре] я почувствовал, что меня ограничивает двухмерное пространство, в котором я работал. Казалось, что оно сдерживает возможности емерджентного поведения ботов.
Предыдущие неудачные попытки изучения современного OpenGL поставили передо мной мысленный барьер, но в конце июля я каким-то образом наконец пробил его. Сегодня, в конце октября, у меня уже достаточно уверенное понимание концепций, поэтому я выпустил собственный простой воксельный движок, который будет средой для жизни и процветания моих Task-Bots.
Я решил создать собственный движок, потому что мне требовался полный контроль над графикой; к тому же я хотел себя испытать. В каком-то смысле я занимался изобретением велосипеда, но этот процесс мне очень понравился!
Конечной целью всего проекта была полная симуляция экосистемы, где боты в роли агентов манипулируют окружением и взаимодействуют с ним.
Так как движок уже довольно сильно продвинулся вперёд и я снова перехожу к программированию ботов, я решил написать пост о движке, его функциях и реализации, чтобы в будущем сосредоточиться на более высокоуровневых задачах.
Концепция движка
Движок полностью написан с нуля на C++ (за некоторыми исключениями, например, поиска пути). Для рендеринга контекста и обработки ввода я использую SDL2, для отрисовки 3D-сцены — OpenGL, а для управления симуляцией — DearImgui.
Я решил использовать воксели в основном потому, что хотел работать с сеткой, которая имеет множество преимуществ:
- Создание мешей для рендеринга хорошо мне понятно.
- Возможности хранения данных мира более разнообразны и понятны.
- Я уже создавал системы для генерации рельефа и симуляции климата на основе сеток.
- Задачи ботов в сетке легче параметризировать.
Движок состоит из системы данных мира, системы рендеринга и нескольких вспомогательных классов (например, для звука и обработки ввода).
В статье я расскажу о текущем списке возможностей, а также подробнее рассмотрю более сложные подсистемы.
Класс World
Класс мира служит базовым классом для хранения всей информации мира. Он обрабатывает генерацию, загрузку и сохранение данных блоков.
Данные блоков хранятся во фрагментах (chunks) постоянного размера (16^3), а мир хранит вектор фрагментов, загруженный в виртуальную память. В больших мирах практически необходимо хранить в памяти только определённую часть мира, поэтому я и выбрал такой подход.
class World{
public:
World(std::string _saveFile){
saveFile = _saveFile;
loadWorld();
}
//Data Storage
std::vector<Chunk> chunks; //Loaded Chunks
std::stack<int> updateModels; //Models to be re-meshed
void bufferChunks(View view);
//Generation
void generate();
Blueprint blueprint;
bool evaluateBlueprint(Blueprint &_blueprint);
//File IO Management
std::string saveFile;
bool loadWorld();
bool saveWorld();
//other...
int SEED = 100;
int chunkSize = 16;
int tickLength = 1;
glm::vec3 dim = glm::vec3(20, 5, 20);
//...
Фрагменты хранят данные блоков, а также некоторые другие метаданные, в плоском массиве. Изначально я реализовал для хранения фрагментов собственное разреженное октодерево, но оказалось, что время произвольного доступа слишком высоко для создания мешей. И хотя плоский массив неоптимален с точки зрения памяти, он обеспечивает возможность очень быстрого построения мешей и манипуляций с блоками, а также доступ к поиску пути.
class Chunk{
public:
//Position information and size information
glm::vec3 pos;
int size;
BiomeType biome;
//Data Storage Member
int data[16*16*16] = {0};
bool refreshModel = false;
//Get the Flat-Array Index
int getIndex(glm::vec3 _p);
void setPosition(glm::vec3 _p, BlockType _type);
BlockType getPosition(glm::vec3 _p);
glm::vec4 getColorByID(BlockType _type);
};
Если я когда-нибудь реализую многопоточное сохранение и загрузку фрагментов, то преобразование плоского массива в разреженное октодерево и обратно может быть вполне возможным вариантом для экономии памяти. Здесь ещё есть пространство для оптимизации!
Моя реализация разреженного октодерева сохранилась в коде, поэтому можете спокойно ею воспользоваться.
Хранение фрагментов и работа с памятью
Фрагменты видимы только тогда, когда они находятся в пределах расстояния рендеринга текущей позиции камеры. Это значит, что при движении камеры нужно динамически загружать и составлять в меши фрагменты.
Фрагменты сериализованы при помощи библиотеки boost, а данные мира хранятся как простой текстовый файл, в котором каждый фрагмент — это строка файла. Они генерируются в определённом порядке, чтобы их можно было «упорядочить» в файле мира. Это важно для дальнейших оптимизаций.
В случае большого размера мира основным узким местом является считывание файла мира и загрузка/запись фрагментов. В идеале нам нужно выполнять только одну загрузку и передачу файла мира.
Для этого метод World::bufferChunks()
удаляет фрагменты, которые находятся в виртуальной памяти, но невидимы, и интеллектуально загружает новые фрагменты из файла мира.
Под интеллектуальностью подразумевается, что он просто решает, какие новые фрагменты нужно загрузить, сортируя их по их позиции в файле сохранения, а затем выполняя один проход. Всё очень просто.
void World::bufferChunks(View view){
//Load / Reload all Visible Chunks
evaluateBlueprint(blueprint);
//Chunks that should be loaded
glm::vec3 a = glm::floor(view.viewPos/glm::vec3(chunkSize))-view.renderDistance;
glm::vec3 b = glm::floor(view.viewPos/glm::vec3(chunkSize))+view.renderDistance;
//Can't exceed a certain size
a = glm::clamp(a, glm::vec3(0), dim-glm::vec3(1));
b = glm::clamp(b, glm::vec3(0), dim-glm::vec3(1));
//Chunks that need to be removed / loaded
std::stack<int> remove;
std::vector<glm::vec3> load;
//Construct the Vector of chunks we should load
for(int i = a.x; i <= b.x; i ++){
for(int j = a.y; j <= b.y; j ++){
for(int k = a.z; k <= b.z; k ++){
//Add the vector that we should be loading
load.push_back(glm::vec3(i, j, k));
}
}
}
//Loop over all existing chunks
for(unsigned int i = 0; i < chunks.size(); i++){
//Check if any of these chunks are outside of the limits
if(glm::any(glm::lessThan(chunks[i].pos, a)) || glm::any(glm::greaterThan(chunks[i].pos, b))){
//Add the chunk to the erase pile
remove.push(i);
}
//Don't reload chunks that remain
for(unsigned int j = 0; j < load.size(); j++){
if(glm::all(glm::equal(load[j], chunks[i].pos))){
//Remove the element from load
load.erase(load.begin()+j);
}
}
//Flags for the Viewclass to use later
updateModels = remove;
//Loop over the erase pile, delete the relevant chunks.
while(!remove.empty()){
chunks.erase(chunks.begin()+remove.top());
remove.pop();
}
//Check if we want to load any guys
if(!load.empty()){
//Sort the loading vector, for single file-pass
std::sort(load.begin(), load.end(),
[](const glm::vec3& a, const glm::vec3& b) {
if(a.x > b.x) return true;
if(a.x < b.x) return false;
if(a.y > b.y) return true;
if(a.y < b.y) return false;
if(a.z > b.z) return true;
if(a.z < b.z) return false;
return false;
});
boost::filesystem::path data_dir( boost::filesystem::current_path() );
data_dir /= "save";
data_dir /= saveFile;
std::ifstream in((data_dir/"world.region").string());
Chunk _chunk;
int n = 0;
while(!load.empty()){
//Skip Lines (this is dumb)
while(n < load.back().x*dim.z*dim.y+load.back().y*dim.z+load.back().z){
in.ignore(1000000,'n');
n++;
}
//Load the Chunk
{
boost::archive::text_iarchive ia(in);
ia >> _chunk;
chunks.push_back(_chunk);
load.pop_back();
}
}
in.close();
}
}
Your browser does not support HTML5 video.
Пример загрузки фрагментов при малом расстоянии рендеринга. Артефакты искажения экрана вызваны ПО записи видео. Иногда возникают заметные пики загрузок, в основном вызванные созданием мешей
Кроме того, я задал флаг, сообщающий, что рендерер должен заново создать меш загруженного фрагмента.
Класс Blueprint и editBuffer
editBuffer — это сортируемый контейнер bufferObjects, содержащий информацию о редактировании в мировом пространстве и пространстве фрагментов.
//EditBuffer Object Struct
struct bufferObject {
glm::vec3 pos;
glm::vec3 cpos;
BlockType type;
};
//Edit Buffer!
std::vector<bufferObject> editBuffer;
Если при внесении изменений в мир записывать их в файл сразу же после внесения изменения, то нам придётся передавать весь текстовый файл целиком и записывать КАЖДОЕ изменение. Это ужасно с точки зрения производительности.
Поэтому сначала я записываю все изменения, которые нужно внести, в editBuffer при помощи метода addEditBuffer (который также вычисляет позиции изменений в пространстве фрагментов). Прежде чем записывать их в файл, я сортирую изменения по порядку фрагментов, которым они принадлежат по расположению их в файле.
Запись изменений в файл заключается в одной передаче файла, загрузке каждой строки (т.е. фрагмента), для которого имеются изменения в editBuffer, внесении всех изменений и записи его во временный файл, пока editBuffer не станет пустым. Это выполняется в функции evaluateBlueprint()
, которая достаточно быстра.
bool World::evaluateBlueprint(Blueprint &_blueprint){
//Check if the editBuffer isn't empty!
if(_blueprint.editBuffer.empty()){
return false;
}
//Sort the editBuffer
std::sort(_blueprint.editBuffer.begin(), _blueprint.editBuffer.end(), std::greater<bufferObject>());
//Open the File
boost::filesystem::path data_dir(boost::filesystem::current_path());
data_dir /= "save";
data_dir /= saveFile;
//Load File and Write File
std::ifstream in((data_dir/"world.region").string());
std::ofstream out((data_dir/"world.region.temp").string(), std::ofstream::app);
//Chunk for Saving Data
Chunk _chunk;
int n_chunks = 0;
//Loop over the Guy
while(n_chunks < dim.x*dim.y*dim.z){
if(in.eof()){
return false;
}
//Archive Serializers
boost::archive::text_oarchive oa(out);
boost::archive::text_iarchive ia(in);
//Load the Chunk
ia >> _chunk;
//Overwrite relevant portions
while(!_blueprint.editBuffer.empty() && glm::all(glm::equal(_chunk.pos, _blueprint.editBuffer.back().cpos))){
//Change the Guy
_chunk.setPosition(glm::mod(_blueprint.editBuffer.back().pos, glm::vec3(chunkSize)), _blueprint.editBuffer.back().type);
_blueprint.editBuffer.pop_back();
}
//Write the chunk back
oa << _chunk;
n_chunks++;
}
//Close the fstream and ifstream
in.close();
out.close();
//Delete the first file, rename the temp file
boost::filesystem::remove_all((data_dir/"world.region").string());
boost::filesystem::rename((data_dir/"world.region.temp").string(),(data_dir/"world.region").string());
//Success!
return true;
}
Класс blueprint содержит editBuffer, а также несколько методов, позволяющих создавать editBuffers конкретных объектов (деревьев, кактусов, хижин, и т.д.). Затем blueprint можно преобразовать в позицию, в которую нужно поместить объект, а далее просто записать его в память мира.
Одна из самых больших сложностей при работе с фрагментами заключается в том, что изменения в нескольких блоках между границами фрагментов могут оказаться монотонным процессом со множеством арифметики по модулю и разделения изменений на несколько частей. Это основная проблема, с которой блестяще справляется класс blueprint.
Я активно использую его на этапе генерации мира, чтобы расширить «бутылочное горлышко» записи изменений в файл.
void World::generate(){
//Create an editBuffer that contains a flat surface!
blueprint.flatSurface(dim.x*chunkSize, dim.z*chunkSize);
//Write the current blueprint to the world file.
evaluateBlueprint(blueprint);
//Add a tree
Blueprint _tree;
evaluateBlueprint(_tree.translate(glm::vec3(x, y, z)));
}
Класс world хранит собственный blueprint изменений, внесённых в мир, чтобы при вызове bufferChunks() все изменения записывались на жёсткий диск за один проход, а затем удалялись из виртуальной памяти.
Рендеринг
Рендерер по своей структуре не очень сложен, но для понимания требует знаний OpenGL. Не все его части интересны, в основном это обёртки функциональности OpenGL. Я довольно долго экспериментировал с визуализацией, чтобы получить то, что мне понравится.
Так как симуляция происходит не от первого лица, я выбрал ортографическую проекцию. Её можно было реализовать в формате псевдо-3D (т.е. предварительно спроецировать тайлы и наложить их в программном рендерере), но это показалось мне глупым. Я рад, что перешёл к использованию OpenGL.
Базовый класс для рендеринга называется View, он содержит большинство важных переменных, управляющих визуализацией симуляции:
- Размер экрана и текстуры теней
- Объекты шейдеров, множители приближения камеры, матрицы и т.п.
- Булевы значения для почти всех функций рендерера
- Меню, туман, глубина резкости, зернистость текстур и т.п.
- Цвета для освещения, тумана, неба, окна выбора и т.п.
Кроме того, существует несколько вспомогательных классов, выполняющих сам рендеринг и обёртывание OpenGL!
- Класс Shader
- Загружает, компилирует, компонует и использует шейдеры GLSL
- Класс Model
- Содержит VAO (Vertex Arrays Object) данных фрагментов для отрисовки, функцию создания мешей и метод render.
- Класс Billboard
- Содержит FBO (FrameBuffer Object), в который выполняется рендеринг — полезно для создания эффектов постобработки и наложения теней.
- Класс Sprite
- Отрисовывает ориентированный относительно камеры четырёхугольник, загружаемый из файла текстуры (для ботов и предметов). Также может обрабатывать анимации!
- Класс Interface
- Для работы с ImGUI
- Класс Audio
- Очень рудиментарная поддержка звука (если вы скомпилируете движок, нажмите “M”)
Высокая глубина резкости (DOF). При больших расстояниях рендеринга может быть тормозной, но я всё это делал на своём ноутбуке. Возможно, на хорошем компьютере тормоза будут незаметны. Я понимаю, что это напрягает глаза и сделал так просто ради интереса.
На изображении выше показаны некоторые параметры, которые можно изменять в процессе манипуляций. Также я реализовал переключение в полноэкранный режим. На изображении виден пример спрайта бота, отрендеренного как текстурированный четырёхугольник, направленный в сторону камеры. Домики и кактусы на изображении построены при помощи blueprint.
Создание мешей фрагментов
Изначально я использовал наивную версию создания мешей: просто создавал куб и отбрасывал вершины, не касающиеся пустого пространства. Однако такое решение было медленным, и при загрузке новых фрагментов создание мешей оказывалось даже более узким «бутылочным горлышком», чем доступ к файлу.
Основной проблемой было эффективное создание из фрагментов рендерящихся VBO, но мне удалось реализовать на C++ собственную версию «жадного создания мешей» (greedy meshing), совместимую с OpenGL (не имеющую странных структур с циклами). Можете с чистой совестью пользоваться моим кодом.
void Model::fromChunkGreedy(Chunk chunk){
//... (this is part of the model class - find on github!)
}
В целом, переход к greedy meshing снизил количество отрисовываемых четырёхугольников в среднем на 60%. Затем, после дальнейших мелких оптимизаций (индексирования VBO) количество удалось снизить ещё на 1/3 (с 6 вершин на грань до 4 вершин).
При рендеринге сцены из 5x1x5 фрагментов в окне, не развёрнутом на весь экран, я получаю в среднем около 140 FPS (с отключенным VSYNC).
Хотя меня вполне устраивает такой результат, мне бы по-прежнему хотелось придумать систему для отрисовки некубических моделей из данных мира. Её не так просто интегрировать при greedy meshing, поэтому над этим стоит подумать.
Шейдеры и выделение вокселей
Реализация GLSL-шейдеров — одна из самых интересных, и в то же время самых раздражающих частей написания движка из-за сложности отладки на GPU. Я не специалист по GLSL, поэтому многому приходилось учиться на ходу.
Реализованные мной эффекты активно используют FBO и сэмплирование текстур (например, размытие, наложение теней и использование информации о глубинах).
Мне всё ещё не нравится текущая модель освещения, потому что она не очень хорошо обрабатывает «темноту». Надеюсь, это будет исправлено в дальнейшем, когда я буду работать над циклом смены дня и ночи.
Также я реализовал простую функцию выбора вокселей при помощи модифицированного алгоритма Брезенхэма (это ещё одно преимущество использования вокселей). Она полезна для получения пространственной информации в процессе работы симуляции. Моя реализация работает только для ортографических проекций, но можете ею воспользоваться.
«Выделенная» тыква.
Игровые классы
Создано несколько вспомогательных классов для обработки ввода, отладочных сообщений, а также отдельный класс Item с базовой функциональностью (который будет в дальнейшем расширен).
class eventHandler{
/*
This class handles user input, creates an appropriate stack of activated events and handles them so that user inputs have continuous effect.
*/
public:
//Queued Inputs
std::deque<SDL_Event*> inputs; //General Key Inputs
std::deque<SDL_Event*> scroll; //General Key Inputs
std::deque<SDL_Event*> rotate; //Rotate Key Inputs
SDL_Event* mouse; //Whatever the mouse is doing at a moment
SDL_Event* windowevent; //Whatever the mouse is doing at a moment
bool _window;
bool move = false;
bool click = false;
bool fullscreen = false;
//Take inputs and add them to stack
void input(SDL_Event *e, bool &quit, bool &paused);
//Handle the existing stack every tick
void update(World &world, Player &player, Population &population, View &view, Audio &audio);
//Handle Individual Types of Events
void handlePlayerMove(World &world, Player &player, View &view, int a);
void handleCameraMove(World &world, View &view);
};
Мой обработчик событий (event handler) некрасив, зато функционален. С радостью приму рекомендации по его улучшению, особенно по использованию SDL Poll Event.
Последние примечания
Сам движок — это просто система, в которую я помещаю своих task-bots (подробно о них я расскажу в следующем посте). Но если вам показались интересными мои методы, и вы хотите узнать больше, то напишите мне.
Затем я портировал систему task-bot (настоящее сердце этого проекта) в 3D-мирр и значительно расширил её возможности, но подробнее об этом позже (однако код уже выложен онлайн)!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
Imports Microsoft.DirectX Imports Microsoft.DirectX.Direct3D 'код костыльный, но пишу пока все для наглядности Public Class Form1 Public device As Device = Nothing Dim vb As VertexBuffer = Nothing Dim S_verts(7200000) As CustomVertex.PositionNormalColored Dim angle As Double = 0 'угол для вращения камеры Dim camX, camY, camZ As Double Dim cubes_count As Integer 'количество существующих кубов Dim cWorld(100, 100, 20) As Integer 'сам мир Sub cWorld_gen(ByVal chance As Integer) cubes_count = 0 For z = 0 To 9 For y = 0 To 9 For x = 0 To 9 If random(1, 100) <= chance Then cWorld(x, y, z) = Color.FromArgb(255, random(0, 255), random(0, 255), random(0, 255)).ToArgb 'наличие цвета говорит о существовании куба Else cWorld(x, y, z) = 0 End If Next Next Next End Sub Sub build_world() Dim num As Integer = -1 For z = 0 To 9 For y = 0 To 9 For x = 0 To 9 If cWorld(x, y, z) <> 0 Then num = num + 1 add_cube(x, y, z, num) End If Next Next Next vb.SetData(S_verts, 0, LockFlags.None) ' Label1.Text = cubes_count & " " & S_verts.Length End Sub Sub initGRPH() Dim presentParams As New PresentParameters() presentParams.Windowed = True presentParams.SwapEffect = SwapEffect.Discard 'presentParams.BackBufferHeight = 320 'presentParams.BackBufferWidth = 480 device = New Device(0, DeviceType.Hardware, Me, CreateFlags.SoftwareVertexProcessing, presentParams) device.RenderState.Lighting = False device.RenderState.CullMode = Cull.None device.RenderState.FillMode = FillMode.Solid device.RenderState.ZBufferEnable = True device.RenderState.ZBufferFunction = Compare.Greater vb = New VertexBuffer(GetType(CustomVertex.PositionNormalColored), 7200000, device, Usage.Dynamic Or Usage.WriteOnly, CustomVertex.PositionNormalColored.Format, Pool.Default) End Sub '/////////////////// человеческий генератор целых чисел Function random(ByVal cmin As Int32, ByVal cmax As Int32) As Int32 Dim result As Int32 result = cmin + Int((cmax + 1 - cmin) * Rnd()) Return result End Function Sub prerandom() Randomize(DateAndTime.Second(DateAndTime.Now) + (DateAndTime.Minute(DateAndTime.Now) * 60) + (DateAndTime.Hour(DateAndTime.Now) * 3600)) End Sub '/////////////////// Sub getCountCubes() 'получение количества существующих кубов после генерации For z = 0 To 9 For y = 0 To 9 For x = 0 To 9 If cWorld(x, y, z) <> 0 Then cubes_count = cubes_count + 1 End If Next Next Next ReDim S_verts((cubes_count * 36)) 'переопределяю массив End Sub Function create_cube(ByVal x As Double, ByVal y As Double, ByVal z As Double, ByVal size As Double, ByVal cl As Integer) As CustomVertex.PositionNormalColored() Dim verts(36) As CustomVertex.PositionNormalColored 'функция создания массива одного квадрата (нормали не прописаны) Dim tn As Integer '1 - pered tn = 0 verts(tn).Position = New Vector3(x, y, z) verts(tn).Color = cl tn = 2 verts(tn).Position = New Vector3(x + size, y, z) verts(tn).Color = cl tn = 1 verts(tn).Position = New Vector3(x, y + size, z) verts(tn).Color = cl tn = 3 verts(tn).Position = New Vector3(x + size, y, z) verts(tn).Color = cl tn = 5 verts(tn).Position = New Vector3(x + size, y + size, z) verts(tn).Color = cl tn = 4 verts(tn).Position = New Vector3(x, y + size, z) verts(tn).Color = cl '2-zad tn = 6 verts(tn).Position = New Vector3(x, y, z + size) verts(tn).Color = cl tn = 7 verts(tn).Position = New Vector3(x + size, y, z + size) verts(tn).Color = cl tn = 8 verts(tn).Position = New Vector3(x, y + size, z + size) verts(tn).Color = cl tn = 9 verts(tn).Position = New Vector3(x + size, y, z + size) verts(tn).Color = cl tn = 10 verts(tn).Position = New Vector3(x + size, y + size, z + size) verts(tn).Color = cl tn = 11 verts(tn).Position = New Vector3(x, y + size, z + size) verts(tn).Color = cl '3-lev tn = 12 verts(tn).Position = New Vector3(x, y, z) verts(tn).Color = cl tn = 13 verts(tn).Position = New Vector3(x, y, z + size) verts(tn).Color = cl tn = 14 verts(tn).Position = New Vector3(x, y + size, z) verts(tn).Color = cl tn = 15 verts(tn).Position = New Vector3(x, y + size, z) verts(tn).Color = cl tn = 16 verts(tn).Position = New Vector3(x, y, z + size) verts(tn).Color = cl tn = 17 verts(tn).Position = New Vector3(x, y + size, z + size) verts(tn).Color = cl '4-verh tn = 18 verts(tn).Position = New Vector3(x, y, z) verts(tn).Color = cl tn = 19 verts(tn).Position = New Vector3(x + size, y, z + size) verts(tn).Color = cl tn = 20 verts(tn).Position = New Vector3(x, y, z + size) verts(tn).Color = cl tn = 21 verts(tn).Position = New Vector3(x, y, z) verts(tn).Color = cl tn = 22 verts(tn).Position = New Vector3(x + size, y, z) verts(tn).Color = cl tn = 23 verts(tn).Position = New Vector3(x + size, y, z + size) verts(tn).Color = cl '5-prav tn = 24 verts(tn).Position = New Vector3(x + size, y, z) verts(tn).Color = cl tn = 25 verts(tn).Position = New Vector3(x + size, y + size, z) verts(tn).Color = cl tn = 26 verts(tn).Position = New Vector3(x + size, y, z + size) verts(tn).Color = cl tn = 27 verts(tn).Position = New Vector3(x + size, y + size, z) verts(tn).Color = cl tn = 28 verts(tn).Position = New Vector3(x + size, y + size, z + size) verts(tn).Color = cl tn = 29 verts(tn).Position = New Vector3(x + size, y, z + size) verts(tn).Color = cl '6-niz tn = 30 verts(tn).Position = New Vector3(x, y + size, z + size) verts(tn).Color = cl tn = 32 verts(tn).Position = New Vector3(x, y + size, z) verts(tn).Color = cl tn = 31 verts(tn).Position = New Vector3(x + size, y + size, z + size) verts(tn).Color = cl tn = 33 verts(tn).Position = New Vector3(x + size, y + size, z + size) verts(tn).Color = cl tn = 35 verts(tn).Position = New Vector3(x, y + size, z) verts(tn).Color = cl tn = 34 verts(tn).Position = New Vector3(x + size, y + size, z) verts(tn).Color = cl Return verts End Function Sub add_cube(ByVal x As Integer, ByVal y As Integer, ByVal z As Integer, ByVal num As Integer) Dim verts() As CustomVertex.PositionNormalColored = create_cube(x - 5, y - 5, z - 5, 1, cWorld(x, y, z)) For i = 0 To 35 'добавление куба к общему массиву вершин S_verts((num * 36) + i) = verts(i) Next End Sub Sub setupCamera() device.Transform.Projection = Matrix.PerspectiveFovLH((Math.PI / 4), Me.Width / Me.Height, 5.0F, 100.0F) device.Transform.View = Matrix.LookAtLH(New Vector3(0, 3, 20.0F), New Vector3(), New Vector3(0, 1, 0)) 'device.Transform.World = Matrix.RotationAxis(New Vector3(angle / (Math.PI * 2.0F), angle / (Math.PI * 4.0F), angle / (Math.PI * 6.0F)), angle / (Math.PI)) device.Transform.World = Matrix.RotationAxis(New Vector3(camX, camY, angle / (Math.PI * 6.0F)), angle / (Math.PI)) If CheckBox1.Checked Then angle = angle + 0.1 End Sub Private Sub Form1_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load prerandom() 'создание генератора чисел initGRPH() 'инициализация устройства cWorld_gen(1) 'генератор мира с шансом 1 к 100 getCountCubes() 'получение количества существующих кубов build_world() 'строительство кубов lightWork() 'создание источника света gp.Enabled = True 'включение таймера отрисовки End Sub Private Sub gp_Tick(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles gp.Tick camX = (MousePosition.X * 2) camY = (MousePosition.Y * 2) render() End Sub Sub lightWork() device.Lights(0).Type = LightType.Point device.Lights(0).Position = New Vector3() device.Lights(0).Diffuse = Color.White device.Lights(0).Attenuation0 = 0.2F device.Lights(0).Range = 1000.0F device.Lights(0).Update() device.Lights(0).Enabled = True End Sub Sub render() device.Clear(ClearFlags.Target, Color.Black, 1.0F, 0) setupCamera() 'create_triangle() 'lightWork() device.BeginScene() device.VertexFormat = CustomVertex.PositionNormalColored.Format device.SetStreamSource(0, vb, 0) device.DrawPrimitives(PrimitiveType.TriangleList, 0, cubes_count * 12) device.EndScene() device.Present() End Sub Private Sub Form1_MouseClick(ByVal sender As Object, ByVal e As System.Windows.Forms.MouseEventArgs) Handles Me.MouseClick cWorld_gen(random(1, 100)) getCountCubes() build_world() End Sub End Class |
Introduction
This series of posts will focus on creating a voxel engine, from scratch, based on BabylonJS for the low-level 3D routines support.
To begin, here is in the video below, the first target we will have to reach, in order to manage the rendering of the world.
So what is a voxel ?
To keep it simple, a voxel is in 3D what a pixel is in 2D. It is value in grid, in a 3D space.
Strictly speaking, the voxel is like a pixel, in the meaning that it has only one value, its color.
Voxel engines generally have a little more flexibility in the degree to which the display of a voxel is done. It can display a cube single colored, or textured as in Minecraft.
So displaying cubes is not a big deal, isn’t it ?
Short answer : Yes… and no.
A 3D engine, in order to keep a good frame rate, can apply many optimizations to the 3D scene to render.
It can hide the non visible objects, or simplify the objects according to the camera distance.
The problem with voxels is that you will have a very large quantity of cube, so even if you try to hide some of them, you will quickly struggle in rendering speed.
Moreover, a cube is a simple geometric shape, and therefore, simplifying this object cannot be done without severely deforming it. Remove one node and the cube becomes anything you want except… a simpler cube.
So okay, but where to start then?
Let’s start with something basic, which is to define some target functionalities that we are going to implement.
We’re going to take our inspiration from the way Minecraft handles the rendering of worlds in the game, at least in the early versions of the game.
We will try to use as few technical terms as possible, just the bare minimum required, in order to keep all the explanations understandable to everyone.
World structure
The world
A world represents a set of voxels that it will be possible to display.The world is divided into regions.
The region
A region represents a piece of the world. Each region has the same number of voxels. A region is also represented by a 3D coordinate. A region is composed by a data chunk.
A chunk
A chunk is composed of a set of voxels, in a 3-dimensional grid, where each dimension is the same size. This can be simplified as a cube filled with small cubes.
Let’s assume for example that a data chunk is composed of 3 dimensions of size 32. A region thus has 32*32*32 voxels, a total of 32768 voxels.
If our world has 100*100 regions per layer and let’s say 3 layers of height, we will have a total of 100*100*3 regions, so 30000 regions.
Our world will thus have a total of 100*100*3*32768 = 983 040 000 voxels. Our very small world already has close to a billion potential voxels.
Block definition
Our voxel, in our engine, will be presented as a block, more complex in structure than a simple 3D point.
export type Block = {
name : string; // Block name
guid : string; // Unique global Id
uid : number; // Unique local id
sidesTex : [ // Array of textures
string, // BACK
string, // FRONT
string, // RIGHT
string, // LEFT
string, // TOP
string // BOTTOM
];
size: [ // Edges size
number, // WIDTH
number, // HEIGHT
number // DEPTH
];
type : string; // GAZ, LIQUID, BLOCK
opacity : number;
speed : number; // 0 - 1
};
Enter fullscreen mode
Exit fullscreen mode
So we have the smallest usable unit.
Each block will need some data to represent each side, for optimization purpose. Let’s define an enum to represents sides.
export enum Side {
Left = 1 ,
Right = 2 ,
Forward = 4 ,
Backward = 8 ,
Top = 16 ,
Bottom = 32 ,
Z_Axis = 3 ,
X_Axis = 12 ,
Y_Axis = 48 ,
All = 63
}
Enter fullscreen mode
Exit fullscreen mode
Chunk definition
A chunk will store different kind of data, including the full and the optimized version of the blocks.
export type Chunk = {
position : Vector3 ; // 3D position in the world
size : number ; // Size of the chunk, default will be 32
data : Array<number> ; // The original data
dataSize : number ; // The number of non empty blocks
rcData : Array<number> ; // An optimized version of visible only visible data
rcDataSize : number ; // The number of visible blocks
hasRc : boolean ; // Define if a chunk has been optimized or not
};
Enter fullscreen mode
Exit fullscreen mode
1D array or the power of flattening everything
When dealing with Typescript / Javascript, it is easy to deal with array of array. It seems common to proceed like this.
But here, we need to keep in mind that performance will decrease rapidly as soon as we add new features, so we need to avoid wasting our precious frame per second by taking the easy way out.
Using a one-dimensional array to simulate a 3-dimensional access will always be faster. We will therefore use functions to simplify our work.
/**
* Convert a vector 3 coordinate to a flat array index
* @param x {number} The x coordinate
* @param y {number} The y coordinate
* @param z {number} The z coordinate
* @param size {number} The size of each dimension, the size is the same for each one
*/
export function vector3ToArrayIndex(x: number, y: number, z: number, size: number = 32) {
return (size * size * x) + (size * y) + z;
}
/**
* Convert a flat array index to a 3D coordinate representation
* @param index {number} The array index
* @param size {number} The size of x,y,z dimension
*/
export function arrayIndexToVector3(index: number, size: number = 32) {
return new BABYLON.Vector3(
(index / (size * size)) >> 0,
((index / size) % size) >> 0,
(index % size) >> 0
);
}
Enter fullscreen mode
Exit fullscreen mode
This will conclude our introduction. In the next post, we will see how to render our blocks using Babylon Js, and the minimum of 3D terminology necessary to understand the next posts.
Enjoy !
Изначально я использовал наивную версию создания мешей: просто создавал куб и отбрасывал вершины, не касающиеся пустого пространства. Однако такое решение было медленным, и при загрузке новых фрагментов создание мешей оказывалось даже более узким «бутылочным горлышком», чем доступ к файлу.
Основной проблемой было эффективное создание из фрагментов рендерящихся VBO, но мне удалось реализовать на C++ собственную версию «жадного создания мешей» (greedy meshing),
Тут я не совсем понял, что именно в итоге получается. Вот картинка:
http://i.piccy.info/i9/3ed4fab4365bebb9fb194162e75f4b84/1573063991/63783/1346249/collider_mesh.jpg Голубыми линиями показана сетка, которая рендерится, а зелёными ? упрощённая модель для физического движка. У тебя в итоге получается какой вариант?
Когда решал, стоит ли заморачиваться и оптимизировать сетку для ренедринга, для проверки взял и продублировал всю геометрию. Вершин и треугольников стало в 2 раза больше, но fps не уменьшился. Я тогда сделал вывод, что если стану сетку упрощать, то генерировать её буду дольше, а fps нихрена в итоге не увеличится.
Люди, кто в этом разбирается, помогите разобраться. Вот узнал о этой технологии и хочу понять как с ней работать. Покажите хотя бы как отобразить один воксель. А то читал исходники одного движка, так там просто голову сносит(один ASM, C). Помогите плз!
Если ты никогда не видел воксельный двжок, то поиграй в Red Alert 2 или Tiberian Sun. Явная демонстрация воксельной технологии. На то время это был лучший вариант представить 3D графику в стратегии и в тоже время не нагружать железо. Сегодня я в этом смысла не вижу, ибо полноценная 3Д модель выглядет гораздо лучше нежели воксельная. + воксельная модель експортируется из 3Д модели макса.
Вот тебе и пример воксельного движка
Все эти юниты я сделал сам (кроме 3 что сверху справа), експортом из макса. Тот кто играл в тибериевую сагу сразу поймёт что это за юниты и сможет оценить качество вокселей по отношению к полноценным 3Д моделям. Вот теже самые авианосци
Вот так вот выглядет воксель игры RA2
Где то так он выглядет в игре (в данном случае в редакторе карт игры). На соотншения размеров с апокалипсисом в редакторе внимания не обращайте, в игре он будет меньше, это небольшие тонкости движка. Для воксельного движка нужно специальним образом оптемизировать текстуры, иначе юнит будет смотрется слишком детализированным.
А вот так он выглядет в максе
Разницу в качестве видно невооружонным взглядом
А вот сообственно как он выглядит на движке SAGE в CNC4
И самое главное. Движок Westwood 2D (воксельный) прекрасно работал на видяхах ATI X800RX и NVidia GeForce 8600GTX. Но на Nvidia Quadro FX570m он лагает неподетски, и мне кажется что в дальнейшем с вокселями проблем будет неменьше, ибо реализовывать их поддержку будут всё меньше и меньше. Применение новых фич тоже неполучится. Физику разрушения прописывать ты не будешь, а создавать воксельную анимацию занятие невесёлое + отсутствие safeborders. Юнит привязывается к клетке карты. Если его габариты выходят за размеры этой клетки, то юниты будут ползать сквозь друг друга и ты им ничего не сделаешь.
Актуальность вокселя сохраняется в медицине и научных разработках для отображения отсканированных объектов с помощью рентгена, но в играх он уже не актуален.