Рубрики
3D мир Программирование

OpenGL 6: класс шейдерной программы

В данной заметке добавляются два новых класса для работы с uniform-буфером и шейдерной программой

Введение

Предыдущие заметки использовали один шейдер и одну uniform-переменную, что позволяло обходиться интерфейсом, предоставляемым OpenGL. Данный подход сильно усложняет разработку, когда шейдеров становится два и более.

Работа с Uniform-переменными может быть весьма трудоемкой, так как требует обновления данных в случае смены активного шейдера, а так же организацию вызова функции загрузки для каждой отдельной переменной. Для решения данной проблемы может использовать Uniform-буфер.

Uniform-буфер

Uniform-буфер — это общая между шейдерами область памяти. Следовательно обновление данных при смене шейдера не требуется.

Uniform-блок — описание памяти по которому шейдер будет трактовать доступные данные.

Блок и буфер должны быть связаны через индекс из массива точек привязки (binding points), изображенный на рисунке 1.

Рисунок 1 — Связи между uniform-блоками и uniform-буферами

Дополним перечисление (enum) из файла include/Buffers.h:

// Тип буфера
enum BUFFER_TYPE {  VERTEX = GL_ARRAY_BUFFER
                  , ELEMENT = GL_ELEMENT_ARRAY_BUFFER
                  , UNIFORM = GL_UNIFORM_BUFFER
                 };

Важное замечание: для таких uniform-буферов конфигурация атрибутов не требуется (glVertexAttribPointer).

Связь uniform-блока с индексом точки привязки осуществляется путем единоразового вызова для каждой шейдерной программы (после компиляции шейдера) функции glUniformBlockBinding, которая принимает три аргумента:

  • program — дескриптор шейдерной программы;
  • uniformBlockIndex — дескриптор uniform-блока;
  • uniformBlockBinding — индекс блока привязки.

Для получения дескриптора uniform-блока используется функция glGetUniformBlockIndex, которая принимает следующие аргументы:

  • program — дескриптор шейдерной программы;
  • uniformBlockName — строка с именем uniform-блока.

Начиная с версии OpenGL 4.2 появилась возможность указать в шейдере точку привязки, что избавляет от необходимости вызывать две предыдущие функции. Пример указания точки привязки в шейдере:

layout(binding = 2) uniform Lights { ... };

Дополнительно можно указать в скобках параметр std140, который указывает драйверу правила, в соответствии с которыми данные будут размещаться внутри буфера. При отсутствии этого параметра решение о расположении переменных принимает драйвер.

Важное замечание: в uniform-блоках используется выравнивание по 16 байт, следовательно использовать спецификатор alignas(16) перед типом переменной.

Пример выравнивания:

struct Light
{
    alignas(16) glm::vec3 position;
    alignas(16) glm::vec3 color;
    float power;
};

Из примера можно сделать вывод, что последнее поле можно не выравнивать для экономии памяти.

Связь uniform-буфера с индексом точки привязки осуществляется путем единоразового вызова (после первой загрузки данных в буфер) функции glBindBufferBase, которая принимает в качестве аргументов:

  • target — тип буфера;
  • index — индекс блока привязки;
  • buffer — дескриптор созданного uniform-буфера.

С помощью функции glBindBufferRange можно привязать часть буфера, которая задается двумя дополнительными аргументами:

  • offset — отступ в байтах;
  • size — размер в байтах.

Данные в uniform-буфер можно загрузить с помощью функции glBufferData, которая рассматривалась во второй заметке.

Примечание: можно создать пустой буфер заданного размера для последующей привязки, передав в качестве указателя на загружаемые данные NULL.

Функция glBufferSubData позволяет записать в буфер лишь часть данных, определяемую вторым (offset) и третьим (size) параметрами.

Для удобства работы и последующей привязки создадим класс UBO, наследник от BO:

// Объект uniform-буфера
class UBO : public BO
{
    public:
        UBO(int size, int binding); // Создает пустой uniform-буфер заданного размера с автоматической привязкой
        UBO(const void *data, int size, int binding); // Создает пустой uniform-буфер заданного размера с автоматической привязкой

        void rebind(int binding); // Перепривязка
        void loadSub(const void *data, int size, int offset = 0); // Загрузка с отступом};

Важное замечание: необходимо поменять область видимости у дескриптора и типа буфера в классе BO на protected, чтобы метод UBO::rebind мог их использовать.

Реализация конструкторов и метода перепривязки:

// Создает пустой uniform-буфер заданного размера с автоматической привязкой
UBO::UBO(int size, int binding) : BO(UNIFORM, 0, size)
{
    rebind(binding);
}

// Создает пустой uniform-буфер заданного размера с автоматической привязкой
UBO::UBO(const void *data, int size, int binding) : BO(UNIFORM, data, size)
{
    rebind(binding);
}

// Перепривязка
void UBO::rebind(int binding)
{
    glBindBufferBase(type, binding, handler); 
}

// Загрузка с отступом
void UBO::loadSub(const void *data, int size, int offset)
{
    use();
    glBufferSubData(type, offset, size, data);
}

Важное замечание: необходимо добавить вызов метода BO::use внутри метода BO::load, иначе он не будет корректно работать загрузка для uniform-буфера.

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

В файле src/main.cpp изменим uniform-переменную и добавим наш класс:

    // Расположение Uniform-переменной
    GLuint vp_uniform = glGetUniformLocation(shaderProgram, "vp");
    GLuint model_uniform = glGetUniformLocation(shaderProgram, "model");

    // Uniform-буферы.
    UBO cameraUB(sizeof(glm::mat4)*2, 0);

В цикле отрисовки добавим загрузку данных о камере и поменяем переменную, передаваемую в метод GrouptedModel::render:

...
        cameraUB.loadSub(&Camera::current().getProjection(), sizeof(glm::mat4), 0);
        cameraUB.loadSub(&Camera::current().getView(), sizeof(glm::mat4), sizeof(glm::mat4));
...
        scene.render(model_uniform); 
...

В файле shaders/shader.vert:

...
layout(std140, binding = 0) uniform Camera
{
    mat4 projection;
    mat4 view;
};

uniform mat4 model;
...
void main() 
{ 
    gl_Position = projection * view * model * vec4(pos, 1.0);
...

Данные, хранимые в буфере можно использовать как в вершинном, так и в фрагментном шейдере, для этого нужно при объявлении задать разные имена для объектов uniform-блока. Пример:

В вершинном шейдере:
layout(binding = 2) uniform Lights { ... } lights_v;
В фрагментном шейдере:
layout(binding = 2) uniform Lights { ... } lights_f;

Либо не объявлять имя объекта uniform-блока, тогда поля используются как простые глобальные переменные (без необходимости использовать имя объекта перед именем поля):

В вершинном шейдере:
layout(binding = 2) uniform Lights { ... };
В фрагментном шейдере:
layout(binding = 2) uniform Lights { ... };

Текущая версия доступна на теге v0.1 в репозитории 06.

Класс шейдерной программы

С увеличением числа шейдеров работа с переменными становится неудобной.

Создадим класс шейдера в файле include/Shader.h:

// Класс шейдерной программы
class ShaderProgram
{
    public:
        ShaderProgram();
        ShaderProgram(const ShaderProgram &copy);
        ~ShaderProgram();
        ShaderProgram& operator=(const ShaderProgram& other);

        void use(); // Использование шейдеров
        void load(GLuint type, const char* filename); // Функция для загрузки шейдеров
        void link(); // Формирование программы из загруженных шейдеров
        GLuint getUniformLoc(const char* name); // Возвращает местоположение uniform-переменной
        GLuint bindUniformBlock(const char* name, int binding); // Привязка uniform-блока
        void bindTextures(const char* textures_base_shader_names[], int count); // Инициализация текстур на шейдере
    private:
        GLuint program; // Дескриптор
        static std::map<int, int> handler_count; // Получение количества использований по дескриптору шейдера (Shared pointer)
        std::map<const char*, GLuint> uniformLocations; // Местоположения uniform-переменных
};

Метод ShaderProgram::load — видоизмененная версия функции из файла src/main.cpp, которую можно удалить:

// Функция чтения шейдера из файла
std::string readFile(const char* filename)
{
    std::string text;
	std::ifstream file(filename, std::ios::in); // Открываем файл на чтение
	// Если файл доступен и успешно открыт
    if (file.is_open()) 
    { 
		std::stringstream sstr; // Буфер для чтения
		sstr << file.rdbuf(); // Считываем файл
		text = sstr.str(); // Преобразуем буфер к строке
		file.close(); // Закрываем файл
	}

    return text;
}

// Функция для загрузки шейдеров
void ShaderProgram::load(GLuint type, const char* filename)
{
    // Создание дескрипторов шейдера
    GLuint handler = glCreateShader(type);

    // Переменные под результат компиляции
    GLint result = GL_FALSE;
    int infoLogLength;

    // Считываем текст вершинного шейдера
    std::string code = readFile(filename);
    const char* pointer = code.c_str(); // Преобразование к указателю на const char, так как функция принимает массив си-строк

    // Компиляция кода вершинного шейдера
    glShaderSource(handler, 1, &pointer, NULL);
    glCompileShader(handler);

    // Проверка результата компиляции
    glGetShaderiv(handler, GL_COMPILE_STATUS, &result);
    glGetShaderiv(handler, GL_INFO_LOG_LENGTH, &infoLogLength);
    if (infoLogLength > 0)
    {
        char* errorMessage = new char[infoLogLength + 1];
        glGetShaderInfoLog(handler, infoLogLength, NULL, errorMessage);
        std::cout << errorMessage;
        delete errorMessage;
    }

    // Привязка скомпилированного шейдера
    glAttachShader(program, handler);
    
    // Освобождение дескриптора шейдера
    glDeleteShader(handler);
}

// Формирование программы из загруженных шейдеров
void ShaderProgram::link()
{
    // Переменные под результат компиляции
    GLint result = GL_FALSE;
    int infoLogLength;

    // Формирование программы из привязанных шейдеров
    glLinkProgram(program);

    // Проверка программы
    glGetProgramiv(program, GL_LINK_STATUS, &result);
    glGetProgramiv(program, GL_INFO_LOG_LENGTH, &infoLogLength);
    if (infoLogLength > 0)
    {
        char* errorMessage = new char[infoLogLength + 1];
        glGetProgramInfoLog(program, infoLogLength, NULL, errorMessage);
        std::cout << errorMessage;
        delete errorMessage;
    }

    // Используем шейдерную программу объекта из которого вызван метод
    this->use(); 
}

Словарь (map) используется для оптимизации вызовов функции glGetUniformLocation, вызываемой через метод ShaderProgram::getUniformLoc:

// Возвращает местоположение uniform-переменной
GLuint ShaderProgram::getUniformLoc(const char* name) 
{
    GLuint result; // Результат
    // Если такую переменную ещё не искали - найдем, иначе вернем уже известный дескриптор
    if (!uniformLocations.count(name))
        uniformLocations[name] = result = glGetUniformLocation(program, name);
    else
        result = uniformLocations[name];

    return result;
}

Аналогично классу текстуры — ведется подсчет экземпляров класса и удаление шейдерной программы в случае, если ей ни один объект не пользуется:

std::map<int, int> ShaderProgram::handler_count; // Получение количества использований по дескриптору ШП (Shared pointer)

ShaderProgram::ShaderProgram()
{
    program = glCreateProgram();
    handler_count[program] = 1;
}

ShaderProgram::ShaderProgram(const ShaderProgram &copy) : program(copy.program)
{
    handler_count[program]++;
}

ShaderProgram::~ShaderProgram()
{
    if (!--handler_count[program]) // Если количество ссылок = 0
    {
        // Удаление шейдерной программы
        glDeleteProgram(program);
    }
}

// Оператор присваивания
ShaderProgram& ShaderProgram::operator=(const ShaderProgram& other)
{
    // Если это разные шейдерные программы
    if (program != other.program)
    {
        this->~ShaderProgram(); // Уничтожаем имеющуюся
        // Заменяем новой
        program = other.program;
        handler_count[program]++;
    }
    return *this;
}

Метод ShaderProgram::use является оберткой для функции glUseProgram и устанавливает шейдерную программу как активную:

void ShaderProgram::use()
{
    glUseProgram(program);
}

Для привязки uniform-блока используется метод ShaderProgram::bindUniformBlock:

void ShaderProgram::bindUniformBlock(const char* name, int binding) 
{
    glUniformBlockBinding( program
                         , glGetUniformBlockIndex(program, name)
                         , binding);
}

Для упрощения работы с текстурами необходимо перенести метод Texture::init_textures в класс ShaderProgram::bindTextures:

// Инициализация текстур на шейдере
void ShaderProgram::bindTextures(const char* textures_base_shader_names[], int count)
{
    // Цикл по всем доступным текстурам
    for (int i = 0; i < count; i++)
        glUniform1i(getUniformLoc(textures_base_shader_names[i]), i);
}

Пример использования класса в файле src/main.cpp:

    // Базовый шейдер
    ShaderProgram base;
    // Загрузка и компиляция шейдеров
    base.load(GL_VERTEX_SHADER, "shaders/shader.vert");
    base.load(GL_FRAGMENT_SHADER, "shaders/shader.frag");
    base.link();
    // Установим значения текстур
    const char* textures_base_shader_names[] = {"tex_diffuse"};
    base.bindTextures(textures_base_shader_names, sizeof(textures_base_shader_names)/sizeof(const char*));

Старую функцию удаления шейдера из конца функции main необходимо убрать.

Текущая версия доступна на теге v0.2 в репозитории 06.

Заключение

В ходе заметки был рассмотрен новый вид uniform-данных — uniform-буфер и разработан класс для удобной работы с ним.

Помимо этого был разработан класс для работы с шейдерными программами и произведена оптимизация функции взятия местоположения uniform-переменных.

Проект доступен в публичном репозитории: 06
Библиотеки: dependencies

3 ответа к “OpenGL 6: класс шейдерной программы”

Доброго времени!
Не могли бы помочь с шейдерной программой шума Перлина
Мне надо из Java перевести в Visual c++ 2019 Opengl

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

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

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