Мульти-поточная загрузка и выгрузка текстур в OpenGL C++

от автора

Я в течение полугода собирал по крупицам информацию о том каким образом реализовать не тормозящую основной тред рендера загрузку текстур. Абсолютно необходимая фича для проектов, где необходимо на лету подгружать и выгружать текстуры, например, при генерации бесконечно большого открытого мира. В результате получился следующий рецепт…

Загрузка и изменение размера изображений реализованы через stb_image.

Так как мы работаем с мульти-поточностью, прежде всего нам понадобится безопасная (для мульти-поточности) очередь, реализацию которой я оставляю без комментариев:

#pragma once  #include <queue> #include <mutex> #include <condition_variable> #include <optional>  template<class T> class SafeQueue { public: SafeQueue(void) : q(), m(), c() {}  ~SafeQueue(void) {}  void enqueue(T t) { std::lock_guard<std::mutex> lock(m); q.push(t); c.notify_one(); }  T dequeue(void) { std::unique_lock<std::mutex> lock(m); while (q.empty()) { c.wait(lock); } T val = q.front(); q.pop(); return val; }  std::optional<T> pop(void) { std::unique_lock<std::mutex> lock(m); if (q.empty()) { return {}; } T val = q.front(); q.pop(); return val; }  int size() { std::lock_guard<std::mutex> lock(m); return q.size(); }  private: std::queue<T> q; mutable std::mutex m; std::condition_variable c; };

Статический метод LoadFromFile класса Texture будет ответственным за выдачу идентификаторов текстур в памяти видеокарты в обмен на path к текстуре и два флага: srgb и force_uncompressed. Выданный идентификатор можно использовать сразу, пока текстура не загружена визуально это будет выглядеть как чёрный прямоугольник.

#pragma once  #include <string> #include <vector> #include <map> #include <set> #include "SafeQueue.hpp"  class Texture { public: struct UploadData { unsigned int gl_id{0}; std::string path{""}; bool srgb{true}; bool force_uncompressed{false}; unsigned char* data; unsigned int width; unsigned int height; unsigned int nrComponents; };  unsigned int id; std::string type;  static std::recursive_mutex mutex; static SafeQueue<UploadData> QueueToLoad; static std::map<std::string, unsigned int> path2id; static std::map<unsigned int, std::string> id2path; static std::map<unsigned int, bool> loaded_state; static std::set<unsigned int> need_to_unload_after_unmap;  static unsigned int LoadFromFile(const std::string &path, bool srgb = true, bool force_uncompressed = false);  static unsigned int LoadCubemap(std::vector<std::string> faces); [[noreturn]] static void ProcessQueueToLoad(); static void Unload(unsigned int gl_id); };

mutex — мютекс для контроля очереди загрузки текстур,

UploadData — структура для сохранения данных текстуры в безопасной очереди,

QueueToLoad — очередь этих данных,

path2id — таблица путей к идентификаторам в памяти видеокарты,

id2path — таблица обратная предыдущей,

loaded_state — таблица состояний текстур (false — еще грузится, true — уже загружена),

need_to_unload_after_unmap — коллекция идентификаторов текстур, которые понадобилось выгрузить, но пока что это сделать не возможно, так как текстура все еще загружается.

#include "Texture.hpp" #include "Exception.hpp" #include <glad/glad.h> #include <stb_image/stb_image.h> #include <stb_image/stb_image_resize.h> #include <iostream> #include <algorithm> #include <filesystem> #include <thread> #include "Engine.hpp"  std::recursive_mutex Texture::mutex; SafeQueue<Texture::UploadData> Texture::QueueToLoad; std::map<std::string, unsigned int> Texture::path2id; std::map<unsigned int, std::string> Texture::id2path; std::map<unsigned int, bool> Texture::loaded_state; std::set<unsigned int> Texture::need_to_unload_after_unmap;  bool IsPowerOfTwo(int x) { return (x != 0) && ((x & (x - 1)) == 0); }  int closest(std::vector<int> const &vec, int value) { for (auto i = vec.rbegin(); i != vec.rend(); ++i) if ((*i) <= value) return (*i); return 1; } 

В начале реализации я подключаю stb_image, а так же два класса Exception (реализацию которого я оставляю на ваше усмотрение) и Engine (который в вашем случае можно убрать). Локальные методы IsPowerOfTwo и closest помогут определить требуется ли изменить разрешение изображения, если в вашем проекте все изображения хранятся в разрешениях степени двойки (512*512, 1024*1024 и т.д.), то эти методы тоже можно проигнорировать.

// should be main thread only unsigned int Texture::LoadFromFile(const std::string &path, bool srgb, bool force_uncompressed) { const std::lock_guard<std::recursive_mutex> lock(mutex);

Начиная реализацию метода загрузки текстуры мы сразу же локаем главный мютекс.

if (path2id.contains(path)) { unsigned int gl_id = path2id.at(path); if (need_to_unload_after_unmap.contains(gl_id)) { need_to_unload_after_unmap.erase(gl_id); } return gl_id; }

Если запрашиваемая текстура уже есть в таблице path2id (то есть уже был запрос на ее загрузку) — то сразу же выдаем ее идентификатор и прекращаем выполнение метода. Заодно, если эту текстуру уже заказали на выгрузку (она присутствует в сэте need_to_unload_after_unmap) — отменяем этот заказ.

if (!std::filesystem::is_regular_file(path)) { throw Exception("File not found: " + path); }

Если файл не найден — кидаем исключение.

unsigned int textureID; glGenTextures(1, &textureID); QueueToLoad.enqueue({textureID, path, srgb, force_uncompressed}); path2id.insert({path, textureID}); id2path.insert({textureID, path}); loaded_state.insert({textureID, false}); return textureID; }

Теперь можно сгенерировать идентификатор для новой текстуры и поставить все заказанные данные в очередь на загрузку. Заодно инициализируются соответствующие записи в таблицах path2id, id2path и loaded_state.

// any thread void Texture::Unload(unsigned int gl_id) { const std::lock_guard<std::recursive_mutex> lock(mutex); if (!loaded_state.contains(gl_id)) return; if (!loaded_state.at(gl_id)) { need_to_unload_after_unmap.insert(gl_id); } else { path2id.erase(id2path.at(gl_id)); id2path.erase(gl_id); loaded_state.erase(gl_id); glDeleteTextures(1, &gl_id); } }  

Статический метод Unload обязуется выгрузить текстуру: если текстура все еще грузится, он добавляет ее идентификатор в сэт need_to_unload_after_unmap, в ином случае — выгружает ее из памяти и удаляет все соответствующие записи в таблицах path2id, id2path и loaded_state.

 glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); window2 = glfwCreateWindow(640, 480, "Second Window", NULL, window); glfwWindowHint(GLFW_VISIBLE, GLFW_TRUE); std::thread([this]() { glfwMakeContextCurrent(window2); Texture::ProcessQueueToLoad(); }).detach();

Статический метод ProcessQueueToLoad ответственен за непосредственно загрузку текстур из файловой системы в видео-память. Его нужно запустить в параллельном потоке, привязав к отдельному скрытому окну window2, разделяющему все ресурсы с основным окном window.

// parallel thead [[noreturn]] void Texture::ProcessQueueToLoad() { stbi_set_flip_vertically_on_load(true); while (true) { auto image = QueueToLoad.dequeue(); auto path = image.path; auto textureID = image.gl_id;

Статический метод ProcessQueueToLoad работает в бесконечном цикле, выбирая из очереди QueueToLoad новые записи. Метод безопасной очереди dequeue приостановит поток пока записей в очереди нет.

int width, height, nrComponents; unsigned char* data = stbi_load(path.c_str(), &width, &height, &nrComponents, 0);

Загрузка изображения из файловой системы в оперативную память (data) реализована с помощью библиотеки stb_image. У вас, разумеется, есть возможность реализовать ее иным образом. Формат не сжатого изображения интуитивно понятен любому программисту и укладывается в размер данных: высота * ширина * количество компонентов в изображении. Количество компонентов это 1, 3 или 4 в зависимости от того монохромно ли изображение, обычное или обычное с прозрачностью.

if (!IsPowerOfTwo(width) || !IsPowerOfTwo(height)) { //std::cout << "size is not power of two! resizing... "; std::vector<int> powers = {1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048}; int new_size = std::max(closest(powers, width), closest(powers, height)); //std::cout << "new_size: " << new_size << "x" << new_size << " "; unsigned char* data_resized = (unsigned char*) malloc(new_size * new_size * nrComponents); stbir_resize_uint8(data, width, height, 0, data_resized, new_size, new_size, 0, nrComponents); //std::cout << "resized! ";  stbi_image_free(data); data = data_resized; width = new_size; height = new_size; resolution = new_size; }

Этот блок кода изменяет размер изображения, если оригинальный размер изображения не является степенью двойки. Вы можете пропустить этот блок, если все ваши изображения идеального размера.

image.width = width; image.height = height; image.nrComponents = nrComponents; image.data = data;

^_^

bool compress = ::engine.rendererOptions.GetTextureCompression() == RendererOptions::TextureCompression::ENABLED; if (image.force_uncompressed) { compress = false; }

В моём случае потребовалось вычислять глобально требуется ли сжимать изображения при загрузке в видеопамять. Я делаю это через запрос в мой глобальный класс engine, вы можете пропустить эту стоку кода, заменив ее праву часть на true. Или еще проще написать:
bool compress = !image.force_uncompressed;

GLenum internalformat; GLenum format; if (image.nrComponents == 1) { internalformat = compress ? GL_COMPRESSED_RED : GL_RED; format = GL_RED; } else if (image.nrComponents == 3) { if (!image.srgb) { internalformat = compress ? GL_COMPRESSED_RGB : GL_RGB; } else { internalformat = compress ? GL_COMPRESSED_SRGB : GL_SRGB; } format = GL_RGB; } else if (image.nrComponents == 4) { if (!image.srgb) { internalformat = compress ? GL_COMPRESSED_RGBA : GL_RGBA; } else { internalformat = compress ? GL_COMPRESSED_SRGB_ALPHA : GL_SRGB_ALPHA; } format = GL_RGBA; }

Предварительное вычисление чиселок internalformat и format (в зависимости от желаемого формата, желаемого сжатия и количества компонентов в изображении), которые необходимо передать дальше в гльную функцию glTexImage2D.

glBindTexture(GL_TEXTURE_2D, image.gl_id); glTexImage2D(GL_TEXTURE_2D, 0, internalformat, image.width, image.height, 0, format, GL_UNSIGNED_BYTE, image.data); glGenerateMipmap(GL_TEXTURE_2D);  glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

Загрузка изображения! Следом генерация мипмапов и немного текстурной магии. Так как всё это происходит в параллельном потоке без локов каких-либо мютексов — основной поток рендера работает стабильно без фризов.

stbi_image_free(image.data);

На этом моменте данные изображения в оперативной памяти нам больше не нужны.

const std::lock_guard<std::recursive_mutex> lock(mutex); loaded_state.at(image.gl_id) = true; glFinish(); //std::cout << "Texture loaded: " << image.path << std::endl; if (need_to_unload_after_unmap.contains(image.gl_id)) { need_to_unload_after_unmap.erase(image.gl_id); Unload(image.gl_id); }     } }

В конце метода происходит самое интересное:

  • локается мютекс,

  • изменяется состояние загруженного изображения на true,

  • обязательно необходимо вызвать функцию glFinish,

  • если эту текстуру (во время загрузки) заказали выгрузить — выгружаем ее и отменяем заказ на выгрузку.

В заключении желаю вам решаемых задач ;3


ссылка на оригинал статьи https://habr.com/ru/post/700194/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *