Я в течение полугода собирал по крупицам информацию о том каким образом реализовать не тормозящую основной тред рендера загрузку текстур. Абсолютно необходимая фича для проектов, где необходимо на лету подгружать и выгружать текстуры, например, при генерации бесконечно большого открытого мира. В результате получился следующий рецепт...
Загрузка и изменение размера изображений реализованы через 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