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

OpenGL 2: графический конвейер, буферы и шейдеры

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

Введение

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

Содержание заметки:

Структура графического конвейера

На рисунке 1 представлена структура графического конвейера OpenGL четвертой версии.

Рисунок 1 — структура графического конвейера

На рисунке 1 серым цветом обозначены непрограммируемые этапы, а белым обозначены программируемые. Пунктирная линия ведет к блоку с входными данными, под этапом изображен результат выполнения. Стоит заметить что схема является упрощенной — некоторые этапы объединены. Рассмотрим конвейер подробнее:

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

Шейдеры

Шейдер — подпрограмма, выполняемая на стороне графического устройства в составе конвейера. В данном курсе заметок используются в основном графические шейдеры.

Шейдеры в OpenGL пишутся на языке GLSL. Синтаксис этого языка разбирается в отдельной заметке (в разработке).

В рамках OpenGL существует понятие «шейдерной программы» — объект, внутри которого компонуются различные типы шейдеров. Используется для простого подключения комбинаций различных типов шейдеров.

Необходимо создать директорию для хранения шейдеров «shaders«.

Исходный текст вершинного шейдера «shaders/shader.vert«:

#version 330 core 

layout(location = 0) in vec3 pos; 

void main() 
{ 
    gl_Position.xyz = pos; 
    gl_Position.w = 1.0; 
} 

На первой строке указывается версия OpenGL в соответствии с которой компилируется шейдер, на второй указывается расположение данных, привязанное к индексу атрибута (рассматривается в разделе вершинных буферов), после идет определение функции main. В рамках функции main входные данные копируются в переменную gl_Position, которая определяет результат преобразований вершины.

Исходный текст фрагментного шейдера «shaders/shader.frag«:

#version 330 core 

out vec3 color; 

void main() 
{ 
    color = vec3(1, 0, 0); 
} 

Первая строка аналогична вершинному шейдеру, а во второй задается переменная, которая является результатом обработки фрагмента — цвет. В рамках функции main задается красный цвет.

Перед компиляцией шейдеров необходимо считать файл с помощью функции:

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;
}

Алгоритм загрузки и компиляции шейдеров можно представить в виде следующих этапов:

  1. создать необходимые шейдеры (вершинный и фрагментный);
  2. считать шейдеры из файла;
  3. загрузить исходные тексты в память графического устройства;
  4. скомпилировать шейдеры;
  5. проверить ошибки компиляции;
  6. создать шейдерную программу;
  7. скопировать шейдеры в шейдерную программу;
  8. проверить шейдерную программу;
  9. удалить шейдеры.

Для создания шейдера используется функция glCreateShader, которая возвращает дескриптор шейдера и принимает в качестве аргумента тип шейдера:

  • GL_VERTEX_SHADER — вершинный;
  • GL_FRAGMENT_SHADER — фрагментный;
  • GL_GEOMETRY_SHADER — геометрический;
  • GL_COMPUTE_SHADER — вычислительный;
  • GL_TESS_CONTROL_SHADER — управления тесселяцией;
  • GL_TESS_EVALUATION_SHADER — вычисления тесселяции.

Что бы загрузить исходные тексты шейдера необходимо вызвать функцию glShaderSource, которая обладает аргументами: дескриптор шейдера, количество элементов и адрес массива с исходными текстами шейдера (например построчно) и адрес массива с длинами строк (в случае построчной записи).

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

Следующий фрагмент предназначен для проверки ошибок:

glGetShaderiv(ДЕСКРИПТОР, GL_COMPILE_STATUS, &result);
glGetShaderiv(ДЕСКРИПТОР, GL_INFO_LOG_LENGTH, &infoLogLength);
if (infoLogLength > 0)
{
    char* errorMessage = new char[infoLogLength + 1];
    glGetShaderInfoLog(ДЕСКРИПТОР, infoLogLength, NULL, errorMessage);
    std::cout << errorMessage;
    delete[] errorMessage;
}

Функция glCreateProgram создает шейдерную программу и возвращает её дескриптор.

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

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

Для удаления шейдеров служит функция glDeleteShader, принимающая дескриптор шейдера, а для удаления шейдерной программы — glDeleteProgram.

Загрузка шейдеров скомпонована в функцию LoadShaders, которая возвращает дескриптор шейдерной программы:

GLuint LoadShaders(const char *vertex_file, const char *fragment_file)
{
    // Создание дескрипторов вершинного и фрагментного шейдеров
    GLuint vertexShaderID = glCreateShader(GL_VERTEX_SHADER);
    GLuint fragmentShaderID = glCreateShader(GL_FRAGMENT_SHADER);

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

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

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

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

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

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

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

    // Привязка скомпилированных шейдеров
    GLuint programID = glCreateProgram();
    glAttachShader(programID, vertexShaderID);
    glAttachShader(programID, fragmentShaderID);
    glLinkProgram(programID);

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

    // Освобождение дескрипторов шейдеров
    glDeleteShader(vertexShaderID);
    glDeleteShader(fragmentShaderID);

    return programID;
}

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

Буфер вершин

Каждая вершина обрабатывается вершинным шейдером. Для удобства работы с данными в OpenGL начиная с версии 3.2 используются следующие объекты:

  • объект массива вершин (Vertex Array Object, VAO) — это объект, который содержит один или несколько объектов буфера вершин и предназначен для хранения информации о полном визуализируемом объекте;
  • объект вершинного буфера (Vertex Buffer Object, VBO) — это буфер памяти в высокоскоростной памяти графического адаптера, предназначенный для хранения информации о вершинах.

В VAO хранится информация о том, из какого VBO какие атрибуты необходимо прочитать, а также их тип, размер и необходимое смещение до начала данных.

Примечание: существует VBO для хранения индексов (GL_ELEMENT_ARRAY_BUFFER), — применяется для изменения порядка обхода вершин, к VAO возможно присоединить только один такой буфер. Создание буфера индексов рассматривается в следующем разделе.

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

Связь между VAO и VBO представлена на рисунке 2.

Рисунок 2 — Связь между VAO и VBO

Элементы VBO не обязательно должны быть одинаковыми, например содержать только координаты, так как есть возможность указать шаг (stride) и расположить в одном буфере как координаты, так и цвета вершин. Данный шаг обрабатывается входным сборщиком перед запуском вершинного шейдера.

Для хранения векторов и матриц будет использоваться библиотека GLM, скачать её можно с репозитория github. Загруженную библиотеку необходимо поместить в папку dependencies.

Если вы используете Makefile для сборки — дополните переменную CFLAGS:

CFLAGS += -I../dependencies/glm

Либо добавьте в файл .vscode/tasks.json:

...
            "args": [
                "-I${workspaceFolder}/../dependencies/glm",
...

Добавим массив вершин треугольника к программе:

// Вершины треугольника
glm::vec3 verticies[] = {  {-1.0f, -1.0f, 0.0f}
                         , { 1.0f, -1.0f, 0.0f}
                         , { 0.0f,  1.0f, 0.0f}
                        };

Для создания VAO используется функция glGenVertexArrays, которая принимает в качестве аргументов количество создаваемых дескрипторов, а так же адрес массива, в который будут записаны созданные дескрипторы. В рамках заметки создается один объект массива вершин:

GLuint VertexArrayID;
glGenVertexArrays(1, &VertexArrayID); 

Для дальнейшего использования и последующей привязки вершинных буферов необходимо вызвать функцию glBindVertexArray, которой передать дескриптор созданного VAO:

glBindVertexArray(VertexArrayID);

Теперь можно создать необходимый буфер вершин с помощью функции glGenBuffers, которая принимает количество создаваемых аргументов и адрес массива, в который будут записаны дескрипторы. После создания необходимо вызвать функцию glBindBuffer, для привязки буфера к выбранному VAO. Данная функция принимает в качестве аргументов тип буфера (GL_ARRAY_BUFFER — вершинный, GL_ELEMENT_ARRAY_BUFFER — индексный) и дескриптор привязываемого буфера. Пример создания буфера вершин:

GLuint vertexbuffer;
glGenBuffers(1, &vertexbuffer);              
glBindBuffer(GL_ARRAY_BUFFER, vertexbuffer); 

Для загрузки вершин в память графического адаптера используется функция glBufferData, которая принимает следующие параметры:

  • target — предназначение буфера:
    • GL_ARRAY_BUFFER — вершинные атрибуты,
    • GL_ELEMENT_ARRAY_BUFFER — индексы вершин,
    • GL_TEXTURE_BUFFER — текстуры,
    • GL_UNIFORM_BUFFER — uniform-буфер;
  • size — размер копируемых данных в байтах;
  • data — указатель на область памяти, которую необходимо скопировать;
  • usage — влияет на тип памяти, определяется комбинацией двух составляющих:
    • количество модификаций:
      • STATIC — данные будут записаны один раз и использованы множество раз,
      • DYNAMIC — данные будут изменяться и использоваться множество раз,
      • STREAM — данные будут записаны один раз и использоваться пару раз (данные отправляются в специальную область, которая чаще дефрагментируется),
    • доступ:
      • DRAW — для отрисовки,
      • READ — для получение результатов от графического адаптера,
      • COPY — для обмена данными (промежуточный).

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

Пример загрузки объявленных ранее вершин треугольника:

glBufferData(GL_ARRAY_BUFFER, sizeof(verticies), verticies, GL_STATIC_DRAW);

Теперь необходимо настроить связь между буфером и расположением буфера (layout) в шейдере. Делается это последовательностью вызова функций glBindBuffer (уже вызвана после генерации буфера), glVertexAttribPointer, glEnableVertexAttribArray.

Функция glVertexAttribPointer служит для определения спецификации атрибутов вершин и их конечного представления на шейдере, которая требует следующие параметры:

  • index — индекс атрибута;
  • size — количество компонент одного элемента;
  • type — тип одной компоненты;
  • normalized — необходимость нормировать значения при представлении во float (нахождение в диапазоне [0.0;1.0]);
  • stride — шаг (обозначен на рисунке 2);
  • pointer — отступ с начала массива в байтах.

Пример использования вершинного буфера как нулевого атрибута VAO для расположения шейдера:

glVertexAttribPointer(  0         // индекс атрибута
                      , 3         // количество компонент одного элемента
                      , GL_FLOAT  // тип
                      , GL_FALSE  // необходимость нормировать значения
                      , 0         // шаг
                      , (void *)0 // отступ 
                     );

Важное замечание: функция glVertexAttribPointer предоставляет доступ к данным в атрибуте с заданным индексом в виде чисел с плавающей точкой, если требуется доступ к целым, то необходимо использовать glVertexAttribIPointer (где I — латинская заглавная i, во избежание путаницы с малой латинской L). Так же присутствует функция glVertexAttribDPointer, которая дает доступ к данным в виде чисел с плавающей точкой двойной точности.

Осталось включить нужный атрибут с помощью функции glEnableVertexAttribArray, которая принимает индекс необходимого layout:

glEnableVertexAttribArray(0);

Примечание: порядок вызова glVertexAttribPointerglEnableVertexAttribArray более корректен, так как спецификация атрибутов уже определена к моменту их включения, по сравнению glEnableVertexAttribArrayglVertexAttribPointer, но при этом обратный порядок тоже работоспособен.

Когда необходимо будет отправить содержимое VAO в обработку графического конвейера необходимо подключить нужный VAO с помощью ранее рассмотренной функции glBindVertexArray и вызвать функцию glDrawArrays, которая обладает аргументами:

  • mode — порядок обхода вершин;
  • first — индекс первой вершины;
  • count — количество обрабатываемых вершин.

Рассмотрим доступные порядки обхода вершин:

  • GL_POINTS — каждая вершина используется для создания точки;
  • GL_LINES — вершины образуют пары, соединенные линией;
  • GL_LINE_STRIP — каждая следующая вершина образует отрезок с последней обработанной;
  • GL_TRIANGLE_STRIP — вершины группируются в тройки, образующие треугольники;
  • GL_TRIANGLE_FAN — первые три вершины образуют треугольник, каждая новая вершина образует треугольник с первой и последней (выглядит как веер);
  • GL_LINES_ADJACENCY — каждые четыре вершины образуют примитив, при этом первая и последняя передаются геометрическому шейдеру, а две центральные соединяются линией;
  • GL_LINE_STRIP_ADJACENCY — аналогично предыдущему, только каждая следующая вершина образует новый отрезок и использует его как информацию о связности;
  • GL_TRIANGLES_ADJACENCY — группа из шести вершин образует один примитив, где нечетные вершины образуют треугольник, а четные являются информацией о связности;
  • GL_TRIANGLE_STRIP_ADJACENCY — аналогично случаю с линиями, только используются пары новых вершин, где нечетная образует с двумя старыми нечетными треугольник, а четная является информацией о связности;

Хорошим тоном считается отключить буфер после окончания его использования. Пример вызова треугольника для рисования:

glBindVertexArray(VertexArrayID); // Привязка VAO для использования
glDrawArrays(GL_TRIANGLES, 0, 3); // Обработка вершин начиная с 0 в количестве 3
glBindVertexArray(0);             // Отключение VAO

Результат рисования треугольника представлен на рисунке 3.

Рисунок 3 — Результат рисования треугольника

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

Буфер индексов

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

Поменяем содержимое массива вершин для рисования прямоугольника:

glm::vec3 verticies[] = {  {-0.5f, -0.5f, 0.0f}
                         , { 0.5f, -0.5f, 0.0f}
                         , { 0.5f,  0.5f, 0.0f}
                         , {-0.5f,  0.5f, 0.0f}
                        };

Добавим массив индексов, содержащий данные о используемых для примитивов вершинах:

GLuint indices[] = {0, 1, 2, 2, 3, 0};

Создание буфера индексов аналогично созданию буфера вершин:

GLuint elementbuffer;
glGenBuffers(1, &elementbuffer); // Генерация одного объекта буфера вершин
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, elementbuffer); // Привязка элементного буфера
    
Загрузка индексов в используемый элементный буфер
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

Буфер индексов привязан к VAO и нет отдельной необходимости подключать его каждый раз для рисования примитива.

Для отправки данных на отрисовку используется функция glDrawElements, которая принимает в качестве аргументов режим обхода вершин (аналогично glDrawArrays), количество индексов, тип данных (GL_UNSIGNED_BYTE, GL_UNSIGNED_SHORT, GL_UNSIGNED_INT) и отступ с начала массива индексов (в байтах).

Результат рисования прямоугольника представлен на рисунке 4.

Рисунок 4 — Результат рисования прямоугольника

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

Освобождение памяти

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

Аналогично необходимо поступить с функцией glDeleteVertexArrays для удаления VAO.

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

Классы VAO и VBO

Для оптимизации работы с выделением и освобождением VAO и VBO в приложении необходимо обернуть вызовы функций в классы.

Рассмотрим класс VAO:

// Объект массива вершин
class VAO
{
    public:
        VAO(); // Создает VAO и активирует его
        ~VAO(); // Уничтожает VAO
        VAO(const VAO & copy); // Конструктор копирования
        VAO& operator=(const VAO & other); // Оператор присваивания

        void use(); // Активация VAO
        static void disable(); // Деактивация активного VAO

    private:
        GLuint handler; // Дескриптор
        static std::map<GLuint, GLuint> handler_count; // Счетчик использований дескриптора
};

Класс имеет конструктор без параметров, который создает VAO, сохраняет дескриптор и активирует созданный объект. Дополнительными методами класса являются: use для активации выбранного VAO, и disable для отключения, реализованная как статический метод (для вызова которого не требуется конкретный объект класса). Помимо этого реализован механизм подсчета копий (std::map, конструктор копирования и оператор присваивания) для каждого дескриптора, в случае если количество использований подойдет к нулю — память и дескриптор будут освобождены.

По хорошему, если два объекта на сцене имеют одинаковые модели, то у них должен быть общий VAO, который вызывается на отрисовку. Например данные объекты с различными текстурами или трансформацией. Cловарь (std::map) в составе класса позволяет вести подсчет и не удалить VAO при удалении объекта на сцене.

На данный момент разобраны два вида буферов: вершинный и индексный. Добавим их в перечисление (enum):

enum BUFFER_TYPE {  VERTEX = GL_ARRAY_BUFFER
                  , ELEMENT = GL_ELEMENT_ARRAY_BUFFER
                 };

Данное перечисление (enum) используется для конструкторов нового класса BO:

// Объект вершинного буфера
class BO
{
    public:
        BO(BUFFER_TYPE type); // Создает пустой буфер заданного типа
        BO(BUFFER_TYPE type, const void *data, int size); // Создает и загружает туда данные
        ~BO(); // Уничтожает буфер
        BO(const BO & copy); // Конструктор копирования
        BO& operator=(const BO & other); // Оператор присваивания

        void load(const void *data, int size, GLuint mode = GL_STATIC_DRAW); // Загрузка данных в буфер
        void use(); 

    protected:
        GLuint handler; // Дескриптор
        BUFFER_TYPE type; // Тип буфера
    private:
        static std::map<GLuint, GLuint> handler_count; // Счетчик использований дескриптора
};

Данный класс назван просто BO — Buffer Object, так как может выполнять роль как Vertex, так и Element буферов, а в дальнейшем от него будет унаследован класс UBO — Uniform Buffer Object. Класс BO осуществляет работу с различными видами буферов, которые определяются перечислением BUFFER_TYPE в конструкторах. Второй конструктор помимо типа принимает данные и их размер для загрузки в память. Так же имеется метод load, позволяющий загрузить данные в буфер. У данного класса так же реализована система по контролю за используемыми дескрипторами и своевременному освобождению памяти.

Реализация методов и рабочая версия доступна на теге v0.5 в репозитории 02.

Заключение

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

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

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

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

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

Управление cookie