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

Загрузка и изменение размера изображений реализованы через 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

Комментарии (23)


  1. Ritan
    18.11.2022 13:39
    +1

    А как предполагается проверять, что текстуру уже можно использовать, если никакой связи с загружающим потоком нет? И я не уверен, что такое использование не противоречит стандарту - OpenGL очень не любит(по факту - не допускает) модификацию стейта из более чем одного потока


    1. Reuniko Автор
      18.11.2022 13:42
      -1

      Я не проверяю, сразу использую. Пока текстура не загружена отображается чёрный прямоугольник. Этот рецепт работает на 10+ разных компьютерах.
      Если очень хочется добавить в свой цикл рендера еще один if, можете проверять таблицу loaded_state.

      Статью дополнил, спасибо.


  1. Cheater
    18.11.2022 13:53
    +1

    Что-то очередь какая-то грустная с глобальными локами, почему не реализовали Lock free queue?


    1. Reuniko Автор
      18.11.2022 14:00
      +2

      Хотелось бы мне ответить: для простоты понимания рецепта! Но на самом деле я просто дилетант в мультипоточности. Да и просадки в производительности я не чувствую, там в очередь добавляются байтики из основного треда практически мгновенно и почти никогда не файтятся со вторым потоком.


  1. OldFisher
    18.11.2022 14:11

    Принудительный ресайз до степени двойки? Прямо-таки прошлым веком повеяло, седой стариной... Так-то с версии 2.0 можно уже не страдать: https://www.khronos.org/opengl/wiki/NPOT_Texture


    1. Reuniko Автор
      18.11.2022 14:26
      -1

      Теоретически да. Практически вылетают краши из апи OpenGL, которые невозможно понять, тем более пофиксить. Проще и надежнее опереться на "старый век". Но при желании вы просто можете проигнорировать блок этого кода.


  1. AllKnowerHou
    18.11.2022 16:22

    Какой прирост скорости в сравнении многопотока и одного потока?


    1. Reuniko Автор
      18.11.2022 19:59

      Не знаю. Тут решается не проблема скорости, а чтобы рендер игрового цикла не фризился когда понадобилась новая текстура.


  1. IGR2014
    18.11.2022 16:38

    Может, лучше glFlush ?

    "glFinish() has the same effect as glFlush(), with the addition that glFinish() will block until all commands submitted have been executed."


    1. Ritan
      18.11.2022 17:33

      Да там вообще не нужно ни то ни другое. Всё равно синхронизации с основным потоком нет - какая разница когда команды будут выполнены


      1. Reuniko Автор
        18.11.2022 19:56

        Если из этого рецепта убрать glFinish, то последняя заказанная текстура с некоторой вероятностью не будет прогружаться или будет прогружаться визуально некорректно.


    1. Reuniko Автор
      18.11.2022 19:57

      Может быть glFlush лучше чем glFinish. Если кто-нибудь попробует затестит это, пусть отпишется тут о результатах =)


      1. IGR2014
        18.11.2022 23:14

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


  1. zergon321
    19.11.2022 06:02

    Подгрузку и выгрузку текстур в многопоточном режиме было бы очень легко реализовать на Go с каналами и горутинами


  1. AetherNetIO
    19.11.2022 08:07
    +2

    Ожидал описания, что в потоке на котором gl context происходит загрузка, в рабочих потоках подготовка или как оно тут сделано.

    Вообще правильно:

    • подготовку делать многопоточно

    • В потоке рендера делать только glTexImage. Это минимальная нагрузка на CPU и никакой на GPU.

    • Никакие glFinish, который замораживает поток до момента, пока GPU всё не нарисует, не нужны. glFinish проблема не в заморозке CPU потока, а в том, что после его разморозки GPU queue пустая, и пока начнёт заполняться, GPU простаивает. Убиваете производительность.

    • glFlush скидывает очередь команд с CPU на GPU. Очень драйверозависимая штука, может как тормознуть (adreno), так и чуть ускорить (mali).

    • А вообще, лучше сделать дочерний glContext. В родительском грузить текстуры, в дочернем рендерить (или наоборот, не помню). GLuint между родственниками шарится.


    1. Reuniko Автор
      19.11.2022 10:51

      Я делал в потоке рендера glTexImage2D...
      если он загружает картинку из памяти видеокарты, не сжатую и после него нет генерации мипмапов, то да, скорость мгновенная... но стоит добавить ему флаг сжимать текстуру или генерировать мипмапу после него -- начинаются фризы в рендер цикле, играть становится не возможно. Я пытался сам генерировать мипмапы, но упёрся в странный баг кривой загрузки мипмапы размера 2*2, убил пару дней на него и сдался, пошел другим путём. Самостоятельно сжимать картинку на цпу я даже не стал пытаться.

      glFinish я вызываю во втором потоке, скорость работы которого не критична. Я не знаю точно что оно такое и как оно работает, но без него последняя загруженная текстура с некоторой вероятностью не отобразится или отобразится не корректно.


      1. AetherNetIO
        19.11.2022 10:53

        Ну так компрессию всегда делают в оффлайне и заливают в раньайме уже пожатое :) пожать в какой-нибудь dxt1 очень ресурсоёмкая задача.

        Проблема с фризами только из-за этого, наверняка. Попробуйте уже сжатое грузить.


        1. Reuniko Автор
          19.11.2022 11:01

          Сжатие на видеокарте шакалит текстуру очень заметно, но и память экономит значительно, поэтому мне не вариант хранить только сжатое. Я даю пользователю выбор какие текстуры грузить — сжатые или не сжатые. Соответственно на винче хранятся PNG'шки без потери качества.


          1. AetherNetIO
            19.11.2022 11:21
            +1

            А пользователь на стадии препроцесса не может это решить? Для игр всё препроцессится.


  1. Apoheliy
    19.11.2022 10:03
    +1

    • Texture::UploadData копируется/передаётся по значению, при этом поле data не дублируется - возможны проблемы с учётом ресурсов. И при освобождении структуры поле data не чистится. Т.к. блок данных может быть большой (и копировать его затратно) можно запретить копирование структуры. Например, в очереди хранить shared_ptr на структуру. Или разбираться с мувиками;

    • Метод dequeue может вызвать блокировку, если встать в ожидание и данных больше не будет.

    Вообще: с чем связано такое желание работать в бесконечном цикле, без выхода, без подчистки ресурсов? Обычно игры (даже с бесконечным миром) рано или поздно завершаются.


    1. Reuniko Автор
      19.11.2022 10:46
      -2

      Фишку с UploadData можно улучшить (особенно если вы пишете академический диплом), а можно оставить и так, память не течёт.

      Блокировка второго потока, ответственного за загрузку текстур, в ситуации когда загружать нечего... желательна.

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


      1. KanuTaH
        19.11.2022 12:52
        +3

        Я точно не знаю как это работает

        Ну и плохо. После завершения main() ваша "безопасная очередь" будет уничтожена вместе со всеми своими примитивами синхронизации, а поток, который на них ждет, еще некоторое время завершен не будет. Уничтожение того же заблокированного мутекса - это undefined behavior, любое обращение из потока к уже уничтоженному объекту очереди - тоже. Просто у вас появится гейзенбаг - игра иногда на некоторых системах может начать падать при выходе. Потоки должны быть корректно завершены до завершения main().

        но оно работает!

        Это пока.


        1. Reuniko Автор
          20.11.2022 14:44

          Всё верно. Для решения этого потенциального краша можно добавить какой-нибудь бинарный флаг типа exit в структуру UploadData и обработать его соответственно.