Продолжаем изучение OpenGL: VBO, VAO и шейдеры

14 сентября 2015

В прошлый раз мы сделали заготовку кроссплатформенного проекта на C++, в котором используется OpenGL. Мы определились с тем, как будем управлять зависимостями, какую систему сборки и IDE будем использовать. Мы даже научились рисовать окошки при помощи GLFW и перемножать матрички, используя GLM. Как оказалось, в этом нет большой науки. Сегодня же мы поговорим о самом OpenGL, так как, напомню, в предыдущем примере мы использовали устаревшие процедуры glBegin, glEnd и подобные, и в наши дни так делать не нужно.

GLXW

Для начала нам потребуется подключить к проекту еще одну библиотеку, GLXW. Делается это аналогично тому, как мы это делали для GLFW и GLM — через сабмодули Git. GLXW является лоадером OpenGL. Дело в том, что доступ к функциям OpenGL осуществляется через подгрузку динамических библиотек. На разных платформах эти динамические библиотеки загружаются по-разному, да и находятся в разных местах. Собственно, лоадеры предназначены для того, чтобы скрыть эти различия от программиста. Таких лоадеров написано очень много. Например, во многих туториалах используется GLEW. Мной был выбран GLXW, так как он использовался в примерах, по которым я начинал разбираться. Также на FreeNode зависает автор GLXW (ник exDM69), а также человек (ник dav1d), который хостит статическую версию GLXW по адресу http://glxw.dav1d.de/glxw.git. GLXW на GitHub использует Python для генерации заголовочных файлов. Статическая версия от dav1d делает это по cron, и потому не требует установки Python. В общем и целом, хотя бы понятно, куда обращаться за помощью в случае чего. Наконец, с GLXW пока не было никаких проблем. Раз он просто работает, почему бы на нем и не остановиться?

Инициализация GLXW:

// ...

glfwMakeContextCurrent(window);

if(glxwInit()) {
  std::cerr << "Failed to init GLXW" << std::endl;
  glfwDestroyWindow(window);
  glfwTerminate();
  return 1;
}

// ...

Ранее многократно отмечалось, что glBegin, glEnd и прочие функции нынче признаны устаревшими (так называемый immediate mode). Но как же прикажете что-то без них рисовать? Чтобы ответить на этот вопрос, придется для начала понять, что такое VBO и VAO. Противоположность immediate mode, о которой далее пойдет речь, называется retained mode.

VBO

Vertex Buffer Object (VBO) — это такое средство OpenGL, позволяющее загружать определенные данные в память GPU. Например, если вы хотите сообщить GPU координаты вершин, цвета или нормали, нужно создать VBO и положить эти данные в него.

Пример загрузки координат треугольника:

static const GLfloat globVertexBufferData[] = {
  -1.0f, -1.0f,  0.0f,
   1.0f, -1.0f,  0.0f,
   0.0f,  1.0f,  0.0f,
};

// ...

GLuint vbo;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(
  GL_ARRAY_BUFFER, sizeof(globVertexBufferData),
  globVertexBufferData, GL_STATIC_DRAW
);

Процедура glGenBuffers возвращает заданное количество неиспользуемых в настоящее время айдишников VBO. В терминологии OpenGL идентификаторы принято называть именами, поэтому далее будем стараться вместо «айдишники» говорить «имена». Заметьте, что никакого создания буферов здесь пока не происходит.

Далее процедурой glBindBuffer мы связываем (bind) полученное на предыдущем шаге имя с определенной точкой связывания (binding point). Мы хотим сохранить в буфере координаты вершин, и должны использовать для этого точку связывания GL_ARRAY_BUFFER. Обратите внимание, здесь снова ни о каком создании буферов речи пока не идет. Мне лично проще всего думать о glBindBuffer, как о команде «ок, сейчас мы будем что-то делать вот с этим VBO».

Наконец, при вызове glBufferData происходит выделение памяти и загрузка в нее наших данных. Последний аргумент процедуры называется usage и задает предполагаемый паттерн использования буфера. Часть STATIC говорит о том, что мы не собираемся модифицировать данные, а часть DRAW — о том, что данные будут использованы для отрисовки чего-то. Этот аргумент не приводит к каким-то действительным ограничениям на использование буфера. Но он является подсказкой для реализации OpenGL, и может существенно влиять на производительность приложения. Так что, лучше указывать в нем что-то правдоподобное. Заметьте, что первым аргументом указывается binding point, а не конкретный VBO. То есть, выбор самого VBO был сделан на предыдущем шаге.

Когда VBO больше не нужен, его можно удалить следующим образом:

glDeleteBuffers(1, &vbo);

Итак, мы сохранили данные в буфере. Зачем, спрашивается, нужны еще какие-то VAO?

VAO

Vertex Arrays Object (VAO) — это такая штука, которая говорит OpenGL, какую часть VBO следует использовать в последующих командах. Чтобы было понятнее, представьте, что VAO представляет собой массив, в элементах которого хранится информация о том, какую часть некого VBO использовать, и как эти данные нужно интерпретировать. Таким образом, один VAO по разным индексам может хранить координаты вершин, их цвета, нормали и прочие данные. Переключившись на нужный VAO мы можем эффективно обращаться к данным, на которые он «указывает», используя только индексы.

Насколько я смог разобраться, то, что лежит в VAO по каким-то индексам, правильно называется «vertex attribute array». Название часто сокращают до «vertex attributes» или просто «attributes». Соответственно, отдельный элемент массива называется атрибутом (attribute, без s на конце). При этом каждый атрибут состоит из компонетов (components). Например, координаты вершины (один атрибут) задаются тремя координатами (три компонента типа float). Чтобы нарисовать треугольник, нужно задать три вершины, поэтому нужен массив из трех атрибутов. Усложняется ситуация тем, что авторы туториалов могут использоваться свою собственную терминологию, вроде attribute list или подобную. Еще мне встречались просто опечатки, когда в тексте написано attribute, когда по контексту явно должно быть attributes. Стоит ли говорить, что такой бардак несколько усложняет изучение OpenGL? :)

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

Создание и удаление VAO происходит по аналогии с VBO:

GLuint vao;
glGenVertexArrays(1, &vao);

// ...

glDeleteVertexArrays(1, &vao);

Используются VAO примерно следующим образом:

glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, nullptr);

glBindBuffer(GL_ARRAY_BUFFER, 0); // unbind VBO
glBindVertexArray(0); // unbind VAO

C bind и unbind, надеюсь, все понятно.

Процедура glVertexAttribPointer говорит, откуда брать данные для массива атрибутов, а также в каком формате эти данные находятся. Номер массива атрибутов, переданный первым аргументом этой процедуре, еще понадобится нам чуть ниже, а также когда мы дойдем до работы с шейдерами. Второй аргумент задает размер компонента. В данном случае — три float’а на одну вершину. Третий аргумент задает тип компонента. Оставшиеся аргументы называются normalized, stride и pointer. Сейчас они нам не очень интересны.

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

while(glfwWindowShouldClose(window) == GL_FALSE) {
  glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

  glUseProgram(programId); // на это пока не обращайте внимания

  glBindVertexArray(vao);
  glEnableVertexAttribArray(0);
  glDrawArrays(GL_TRIANGLES, 0, 3);
  glDisableVertexAttribArray(0);
   
  glfwSwapBuffers(window);
  glfwPollEvents();
}

Как видите, в нем выбирается интересующий нас VAO при помощи уже знакомой нам процедуры glBindVertexArray.

Вызываемая далее процедура glEnableVertexAttribArray «включает» указанный vertex attribute array. На предыдущем шаге мы решили, что у него будет номер ноль, поэтому здесь используем тот же номер. Как можно без труда догадаться, процедура glDisableVertexAttribArray совершает обратное действие. Нужно это включение и выключение, как мне объяснили в IRC, для оптимизации. Отключая ненужные атрибуты, мы не передаем лишних данных шейдерам (о них ниже). По умолчанию все атрибуты находятся в выключенном состоянии.

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

Вот так замысловато происходит передача координат вершин в OpenGL! Однако остается открытым вопрос, что же делает glUseProgram?

Шейдеры

Шейдер — это программа, предназначенная для выполнения на определенном этапе rendering pipeline. Шейдеры пишутся на специальном языке GLSL (OpenGL Shading Language). Как у всех программ, у шейдеров есть какой-то вход и какой-то выход. Какие именно — зависит от типа шейдера. Существует много типов шейдеров, но в рамках данной заметки нам потребуется только два. Это vertex shader и fragment shader.

Vertex shader работает с отдельными вершинами. На вход данный шейдер получает координаты вершин и их же он должен отдавать на выход. Наш vertex shader на языке GLSL будет выглядеть так:

#version 330 core

layout(location = 0) in vec3 vertexPos;

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

В части location = 0 используется тот самый номер, который мы передавали первым аргументом при вызове glVertexAttribPointer. То есть, это номер массива атрибутов, в котором мы решили хранить вершины примитива. Шейдер говорит, что входную вершину он будет брать оттуда. Соответственно, атрибуты должны быть включены вызовом glEnableVertexAttribArray. Выход же мы должны записать в переменную gl_Position. Тут есть небольшое неудобство, связанное с тем, что в программе мы использовали координаты xyz, а в шейдере на выход должны отдавать уже xyzw. Поэтому выходному w присваиваем значение 1 вручную.

Fragment shader работает с фрагментами. С ним все немного сложнее. Пока нам достаточно знать, что, помимо прочего, на выход он должен отдавать цвета пикселей. В самом простом варианте fragment shader выглядит так:

#version 330 core

out vec3 color;

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

Здесь просто для любого входа возвращается один и тот же цвет.

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

Поскольку шейдеры предназначены для различных этапов rendering pipeline, они также объединяются в program object. Делается это как-то так:

GLuint prepareProgram(bool *errorFlagPtr) {
  *errorFlagPtr = false;

  // ... здесь происходит компиляция шейдеров ...

  GLuint programId = glCreateProgram();
  glAttachShader(programId, vertexShaderId);
  glAttachShader(programId, fragmentShaderId);
  glLinkProgram(programId);

  *errorFlagPtr = checkProgramLinkStatus(programId);
  if(*errorFlagPtr) return 0;

  glDeleteShader(vertexShaderId);
  glDeleteShader(fragmentShaderId);

  return programId;
}

Удалять шейдеры после их присоединения к программе совершенно легально. Затем, когда в основном цикле мы говорим:

glUseProgram(programId);

… подцепляются соответствующие шейдеры. Vertex shader никак не меняет переданные ему координаты, а fragment shader окрашивает примитив в заданный цвет:

Работа с VBO, VAO и шейдерами в OpenGL

Приведенный скриншот как бы намекает нам, что код работает как под Linux, так и под Windows. Кроме того, код также был проверен на работоспособность под MacOS. Для разнообразия я решил раскрасить треугольники в разные цвета. Заметьте также, что теперь размер треугольника меняется вместе с изменением размера окна. В предыдущей заметке это было не так. В качестве упражнения можете разобраться, как это работает. Там нет ничего сложного.

В IRC мне настоятельно советовали проверять, работает ли код с GPU от NVidia. Но, к сожалению, графических карт этого производителя у меня под рукой не имеется. Думается, на таком простом коде проблем возникнуть не должно.

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

Дополнение: Продолжаем изучение OpenGL: управление камерой при помощи мыши и клавиатуры

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.