Урок №4. Рисуем наш первый треугольник в OpenGL

  Дмитрий Бушуев  | 

  Обновл. 8 Июн 2020  | 

 5668

 ǀ   25 

В OpenGL все объекты находятся в 3D-пространстве, но окно программы или экран компьютера являются 2D-массивом пикселей, поэтому большая часть работы OpenGL заключается в преобразовании всех 3D-координат в 2D-пиксели, отображаемые на вашем экране. Поэтому в этом уроке мы с вами создадим свой первый треугольник в OpenGL и по пути рассмотрим все концепции, которые требуются для выполнения данного задания.

Графический конвейер

Процесс преобразования 3D-координат в 2D-пиксели управляется графическим конвейером (англ. «graphics pipeline») OpenGL. Графический конвейер можно разделить на две большие части:

   первая часть преобразует ваши 3D-координаты в 2D-координаты;

   вторая часть преобразует 2D-координаты в реальные цветные пиксели.

Графический конвейер принимает в качестве входных данных набор 3D-координат и преобразует их в цветные 2D-пиксели на вашем экране. Если рассмотреть его функционирование более детально, то работу конвейера можно разделить на несколько шагов, где каждый следующий шаг в качестве входных данных использует выходные данные из предыдущего шага. Все эти этапы являются узкоспециализированными (т.к. имеют только одну конкретную функцию) и могут легко выполняться параллельно. В силу изначального проектирования современных видеокарт с акцентом на параллельные вычисления, они имеют тысячи небольших процессорных ядер для быстрой обработки ваших данных в графическом конвейере. Процессорные ядра запускают небольшие программы на графическом процессоре для каждого шага конвейера. Эти программы называются шейдерами (англ. «shaders»).

Некоторые из этих шейдеров могут быть настроены самим разработчиком, что позволяет нам написать свои варианты шейдеров (вместо указанных по умолчанию). Благодаря этому мы получаем возможность гораздо более точного контроля над определёнными частями конвейера и, вследствие того, что они работают на графическом процессоре, экономим драгоценное процессорное время. Также стоит упомянуть, что шейдеры пишут на языке GLSL (англ. «OpenGL Shading Language»).

Ниже вы найдёте абстрактное представление всех этапов графического конвейера. Обратите внимание, что на этапах, обозначенных синими секциями, мы можем использовать наши собственные шейдеры:

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

Этап №0: В качестве входных данных для графического конвейера мы передаём массив Vertex Data[], содержащий список из трёх 3D-координат, из которых впоследствии будет образован треугольник; эти данные представляют собой набор вершин.

Вершина (англ. «Vertex») — это набор данных, задающих точку в 3D-пространстве. Информация об этой вершине представлена с помощью атрибутов вершин (англ. «vertex attributes»), которые могут содержать данные любого типа, но, для простоты, предположим, что каждая вершина состоит только из своей 3D-позиции в пространстве и некоторого значения цвета.

Примечание: Чтобы OpenGL знал, что делать с вашим набором координат и значением цвета и во что они должны быть сформированы, необходимо ему подсказать, объекты какого типа мы ожидаем от него получить. Хотим ли мы, чтобы эти данные были представлены в виде набора точек, набора треугольников или, возможно, только одной длинной линии? Подобные подсказки называются примитивами (англ. «primitives») и передаются OpenGL при вызове любой из команд рисования. Одними из таких подсказок-примитивов являются GL_POINTS, GL_TRIANGLES и GL_LINE_STRIP.

Этап №1: Первым этапом конвейера является вершинный шейдер (англ. «vertex shader»), принимающий в качестве входных данных отдельно взятую вершину. Основная цель вершинного шейдера — это преобразование 3D-координат в другие 3D-координаты (об этом мы поговорим чуть позже), а также вершинный шейдер позволяет нам выполнить базовую обработку атрибутов вершин.

Этап №2: Этап сборки примитива в качестве входных данных принимает все вершины (или одну вершину, если тип примитива задан как GL_POINTS) из вершинного шейдера, которые образуют примитив, и собирает все точки в заданной для примитива форме; в нашем случае данной формой является треугольник.

Этап №3: Выходные данные этапа сборки примитива передаются в геометрический шейдер (англ. «geometry shader»). Геометрический шейдер принимает в качестве входных данных набор вершин, образующих примитив, и имеет возможность генерировать другие фигуры, добавляя дополнительные вершины для формирования новых (или других) примитивов. В этом примере он генерирует второй треугольник из заданной формы.

Этап №4: Затем выходные данные геометрического шейдера передаются на этап растеризации (англ. «rasterization stage»), на котором результирующие примитивы сопоставляются с соответствующими пикселями на экране пользователя, в результате чего получаются фрагменты для использования фрагментным шейдером. Перед запуском фрагментных шейдеров выполняется обрезка. Обрезка удаляет все фрагменты, которые находятся вне вашего поля зрения, повышая производительность конечной программы.

Фрагмент в OpenGL — это все данные, необходимые OpenGL для визуализации одного пикселя.

Этап №5: Основная цель фрагментного шейдера (англ. «fragment shader») — это вычислить конечный цвет пикселя. Как правило, на этом этапе и возникают все продвинутые эффекты OpenGL. Обычно фрагментный шейдер содержит данные о 3D-сцене, которые он может использовать для вычисления конечного цвета пикселя (например, свет, тени, цвет света и т.д.).

Этап №6: После того, как все соответствующие значения цвета будут определены, конечный объект пройдёт ещё один этап, который называется альфа-тест и этап смешивания (англ. «blending stage»). На данном этапе проверяется соответствующее значение глубины (и трафарета) фрагмента и далее они используются для проверки того, находится ли полученный фрагмент впереди или позади других объектов и должен ли он вследствие этого быть отброшен. На данном этапе также проверяется и наличие альфа-значений (которые задают непрозрачность объекта) и, исходя из этого, объекты смешиваются соответствующим образом. В итоге, даже если выходной цвет пикселя уже был вычислен в фрагментном шейдере, конечный цвет пикселя при рендеринге нескольких треугольников всё равно может стать совершенно другим.

Как вы можете видеть, графический конвейер представляет собой довольно сложный единый объект, состоящий из множества различных настраиваемых частей. Однако, в большинстве случаев, нам придётся работать только с вершинным и фрагментным шейдерами. Геометрический шейдер является необязательным и, обычно, остаётся с настройками по умолчанию. Существует также ещё несколько этапов графического конвейера, а именно: этап тесселяции (англ. «tesselation stage») и цикл трансформации фидбека, которые мы не изобразили на нашей схеме (оставим это на потом).

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

Ввод вершин


Чтобы что-то нарисовать, мы сначала должны передать OpenGL некоторые входные данные, описывающие вершины будущего объекта. Исходя из того, что OpenGL — это библиотека 3D-графики, то все координаты, которые мы указываем в OpenGL, являются точками в 3D-пространстве (с координатами X, Y, Z). OpenGL не просто преобразует все ваши 3D-координаты в 2D-пиксели на экране; OpenGL обрабатывает 3D-координаты только тогда, когда они находятся в определённом диапазоне между -1.0 и 1.0 по всем трём осям (x, y и z). Все координаты в этом, так называемом, нормализованном диапазоне координат устройства в конечном итоге будут видны на вашем экране (а все координаты за пределами этой области — нет).

Поскольку мы хотим визуализировать один треугольник, то нам нужно указать в общей сложности три вершины, причём каждая из этих вершин имеет по 3 координаты. Для этого представим их в нормализованном диапазоне координат устройства (видимой области OpenGL), используя массив значений типа float:

Поскольку OpenGL работает в 3D-пространстве, а рендерить мы собираемся 2D-треугольник, то значением третьей координаты каждой вершины будет ноль (Z=0.0). Таким образом, глубина треугольника остаётся неизменной, что делает его похожим на 2D-объект.

Нормализованные Координаты Устройства (англ. «NDC» от «Normalized Device Coordinates»)

После того как ваши координаты вершин были обработаны в вершинном шейдере, они должны быть представлены в нормализованных координатах устройства, которые представляют собой небольшое пространство, где значения X, Y и Z варьируются от -1.0 до 1.0. Любые координаты, которые выходят за пределы этого диапазона, отбрасываются/обрезаются и не будут отображены на вашем экране. Ниже вы можете увидеть треугольник, который мы задали в пределах нормализованных координат устройства (игнорируя ось z):

В отличие от обычных координат экрана, положительные точки оси Y находятся в направлении вверх, а координаты точки (0, 0) — в центре фигуры, а не в левом верхнем углу. В конечном итоге вам нужно, чтобы все (преобразованные) координаты оказались в данном координатном пространстве, иначе они не будут видны.

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

Ввод вершин (продолжение)

После определения информации, необходимой для указания вершин, мы бы хотели отправить её в качестве входных данных на первый этап графического конвейера — вершинный шейдер. Это делается путем выделения памяти на графическом процессоре, где мы собираемся хранить данные вершин, конфигурированием того, как OpenGL должен интерпретировать эту память и указанием того, каким образом отправлять данные на видеокарту. Затем, вершинный шейдер обрабатывает столько вершин из своей памяти, сколько мы ему скажем.

Объект Вершинного Буфера (VBO)


Управление этой памятью происходит с помощью, так называемых, объектов вершинного буфера (VBO от англ. «Vertex Buffer Objects»), которые могут хранить большое количество вершин в памяти графического процессора. Преимущество использования этих буферных объектов заключается в том, что мы можем отправлять большие партии данных сразу на видеокарту и хранить их там (при наличии достаточного объёма памяти), без необходимости отправлять данные по одной вершине за раз. Отправка данных от центрального процессора на видеокарту происходит относительно медленно, поэтому нужно стараться отправить как можно больше данных за один раз. Как только данные поступят в память видеокарты, вершинный шейдер будет иметь к ним почти мгновенный доступ, что делает его чрезвычайно быстрым инструментом.

Объект вершинного буфера — это наше первое появление OpenGL-объекта. Как и любой другой объект в OpenGL, этот буфер имеет уникальный идентификатор, соответствующий данному буферу, поэтому мы можем создать его с идентификатором буфера, используя функцию glGenBuffers():

В OpenGL имеется большое разнообразие типов буферных объектов, например, объект вершинного буфера имеет тип GL_ARRAY_BUFFER. Также мы можем связываться сразу с несколькими буферами, имеющие другой тип буфера. Например, ничего не мешает нам привязать вновь созданный буфер к целевому типу GL_ARRAY_BUFFER с помощью функции glBindBuffer():

С этого момента любые вызовы буфера, которые мы делаем с параметром GL_ARRAY_BUFFER, будут использоваться для конфигурирования текущего связанного буфера, которым в вышеприведенном фрагменте кода является буфер VBO. Затем мы можем вызвать функцию glBufferData() и скопировать ранее определённые данные вершин в память буфера:

Функция glBufferData() специально предназначенная для копирования пользовательских данных в текущий связанный буфер. Рассмотрим детальнее её вызов:

   Аргумент №1: Тип буфера, в который мы хотим скопировать данные: объект вершинного буфера, привязанный в данный момент к целевому типу GL_ARRAY_BUFFER.

   Аргумент №2: Определяет размер данных (в байтах), которые мы хотим передать в буфер; достаточно применить простой оператор sizeof к данным вершины.

   Аргумент №3: Данные, которые мы хотим отправить.

Четвёртый аргумент определяет то, как мы хотим, чтобы видеокарта управляла переданными ей данными. Это может иметь 3 формы:

   GL_STREAM_DRAW — данные указываются только один раз и используются графическим процессором не более нескольких раз.

   GL_STATIC_DRAW — данные указываются только один раз и используются много раз.

   GL_DYNAMIC_DRAW — данные часто изменяются и используются много раз.

Данные о позиции нашего треугольника не изменяются, используются много раз и остаются неизменными для каждого вызова рендеринга, поэтому в нашем случае как нельзя лучше подойдёт тип использования GL_STATIC_DRAW. Если бы, например, у нас был бы буфер с данными, которые часто изменялись бы, то выбор типа использования GL_DYNAMIC_DRAW гарантировал бы, что видеокарта поместит данные в память, которая позволяет быстрее записывать информацию.

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

Вершинный шейдер

Вершинный шейдер — это один из шейдеров, который может быть написан любым программистом (даже нами). Современный OpenGL требует, чтобы мы настроили, по крайней мере, вершинные и фрагментные шейдеры, если собираемся выполнить какой-нибудь рендеринг, поэтому я предлагаю вам кратко пройтись по основным моментам данного вопроса и создать два очень простых шейдера для рисования нашего первого треугольника.

Первое, что нам нужно сделать, — это написать вершинный шейдер на языке шейдеров GLSL, а затем скомпилировать этот шейдер, чтобы мы могли использовать его в нашем приложении. Ниже вы найдёте исходный GLSL-код очень простого вершинного шейдера:

Как вы можете видеть, язык GLSL очень похож на язык C. Каждый шейдер начинается с объявления его версии. Начиная с OpenGL 3.3 и выше, номера версий GLSL соответствуют версии OpenGL (например, GLSL версии 420 соответствует версии OpenGL 4.2). Мы также недвусмысленно упоминаем, что используем функции из core-profile.

Далее мы объявляем все входные атрибуты вершин в вершинном шейдере с помощью ключевого слова in. Прямо сейчас нас интересуют только данные местоположения, поэтому используем только один вершинный атрибут. Для этого в GLSL имеется векторный тип данных, который содержит от 1 до 4 переменных типа float (количество переменных задаётся постфиксной цифрой). Поскольку каждая вершина имеет 3D-координату, то мы создаём входную переменную типа vec3 с именем aPos. А также специально устанавливаем местоположение входной переменной через layout (location = 0) — позже вы увидите, для чего это нам понадобилось.

Примечание о векторе: В графическом программировании довольно часто используется математическое понятие вектора, поскольку он чётко представляет позицию/направление объекта в пространстве любой размерности и обладает полезными математическими свойствами. Максимальный размер вектора в GLSL равен 4, и значение каждой его компоненты может быть получено с помощью выражений vec.x, vec.y, vec.z и vec.w, где каждое из них представляет собой соответствующую координату в пространстве. Обратите внимание, что компонента vec.w не используется в качестве представления позиции точки в пространстве (напоминаю, что мы имеем дело с 3D-пространством, а не с 4D), но используется для перспективного деления. Мы обсудим векторы гораздо более подробно в следующих уроках.

Чтобы задать выходные данные вершинного шейдера, мы должны присвоить данные позиции предопределённой переменной gl_Position, которая, на самом деле, имеет тип vec4. В самом конце функции main() всё, что мы задаём через gl_Position, будет использоваться в качестве выходных данных вершинного шейдера. Поскольку нашим входным объектом является вектор размерности 3, то мы должны привести его к вектору размерности 4. Это можно сделать, вставив значения типа vec3 в конструктор типа vec4 и установив его w-компоненту равную 1.0f (для чего это делается — будет объяснено в следующих уроках).

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

Компилирование вершинного шейдера


Мы берём исходный код для вершинного шейдера и помещаем его в константную строку C в верхней части нашего кода:

Для того, чтобы OpenGL мог использовать шейдер, он должен динамически cкомпилировать его из своего исходного кода во время выполнения программы. Первое, что нам нужно сделать, — это создать объект шейдера, с ссылающимся на него идентификатором. Для этого тип переменной вершинного шейдера объявим как unsigned int и создадим сам шейдер с помощью функции glCreateShader():

Далее, передаём тип шейдера, который мы хотим создать, в качестве аргумента для функции glCreateShader(). Поскольку нам нужен вершинный шейдер, то в качестве аргумента передаём GL_VERTEX_SHADER.

Затем прикрепляем исходный код шейдера к объекту шейдера и компилируем его:

Рассмотрим детальнее вызов функции glShaderSource():

   Аргумент №1: Объект шейдера для компиляции.

   Аргумент №2: Количество строк, которые мы передаём в качестве исходного кода (в нашем случае, это одна строка).

   Аргумент №3: Фактический исходный код вершинного шейдера.

   Аргумент №4: Этот аргумент мы оставляем равным NULL.

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

Сначала мы определяем целое число для обозначения успешного исхода компиляции и контейнер для хранения сообщений об ошибках (если таковые имеются). Затем, с помощью функции glGetShaderiv(), мы проверяем, была ли компиляция успешной. Если компиляция не удалась, то мы должны извлечь сообщение об ошибке, используя функцию glGetShaderInfoLog(), и вывести его на экран:

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

Фрагментный шейдер

Фрагментный шейдер (или ещё «шейдер фрагментов») — это второй и последний шейдер, который мы создадим для рендеринга треугольника. Фрагментный шейдер — это всё, что нам нужно для вычисления выходного цвета пикселей. Чтобы было проще, ограничимся тем, что фрагментный шейдер всегда будет выводить только оранжевый цвет:

Примечание: Цвета в компьютерной графике представлены в виде массива из 4-х значений RGBA:

   R (от англ. «Red») = красный;

   G (от англ. «Green») = зеленый;

   B (от англ. «Blue») = синий;

   A (от англ. «Alpha») = альфа-компонент (непрозрачность).

При определении цвета в OpenGL или GLSL мы устанавливаем цветовую силу каждого компонента в диапазоне от 0.0 до 1.0. Если, например, мы установим красный цвет равным 1.0 и зелёный цвет равным 1.0, то получим смесь обоих цветов — жёлтый цвет. Учитывая эти 3 цветовых компонента, мы можем генерировать более 16 миллионов различных цветов!

Фрагментному шейдеру требуется только одна выходная переменная — вектор размерности 4, который задаёт конечный выходной цвет, который мы должны вычислить сами. Мы можем объявить выходное значение с именем FragColor, используя ключевое слово out. Затем мы просто присваиваем vec4 для вывода цвета в виде оранжевого цвета с альфа-значением 1.0 (значение 1.0 соответствует полной непрозрачности).

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

Теперь оба шейдера скомпилированы, и единственное, что нам остаётся сделать, — это связать два шейдерных объекта в шейдерную программу, которую мы можем использовать для рендеринга. Обязательно по аналогии с прошлым разом проверьте данный фрагмент кода на наличие ошибок компиляции!

Шейдерная программа


Объект шейдерной программы — это финальная связанная версия нескольких шейдеров, объединённых вместе. Чтобы использовать недавно скомпилированные шейдеры, мы должны связать их с объектом шейдерной программы и затем, при рендеринге объектов, активировать её. Шейдеры активированной шейдерной программы будут использоваться при выполнении вызовов рендеринга.

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

Создать объект шейдерной программы очень просто:

Функция glCreateProgram() создаёт программу и возвращает идентификатор ссылки на вновь созданный объект шейдерной программы. Теперь нам нужно прикрепить ранее скомпилированные шейдеры к объекту программы и с помощью функции glLinkProgram() связать их:

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

Точно так же, как и в случае с компиляцией шейдеров, мы можем проверить, не произошли ли какие-либо ошибки при связывании шейдерной программы, и, в случае чего, получить соответствующее сообщение. Однако, вместо того, чтобы использовать функции glGetShaderiv() и glGetShaderInfoLog() мы теперь используем:

Результатом является программный объект, который мы можем активировать, вызвав функцию glUseProgram() с вновь созданным программным объектом в качестве его аргумента:

Теперь, после активации glUseProgram(), каждый вызов шейдера и рендеринга будет использовать данный программный объект (и, следовательно, шейдеры).

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

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

Связывание атрибутов вершин

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

Данные нашего вершинного буфера отформатированы следующим образом:

Рассмотрим это детальнее:

   Данные о местоположении (т.е. координаты) вершин хранятся в виде 32-битных (4 байта) значений типа с плавающей запятой.

   Каждая координата состоит из 3-х таких значений.

   Между каждым набором из 3-х значений нет пробела (или других значений). Значения плотно упакованы в массиве.

   Первое значение данных местоположения вершин находится в начале буфера.

С помощью этих знаний мы можем сообщить OpenGL, как он должен интерпретировать данные вершин (для каждого атрибута вершины), используя функцию glVertexAttribPointer():

Функция glVertexAttribPointer() имеет довольно много параметров, поэтому давайте внимательно пройдёмся по ним:

   Аргумент №1: Указываем, какой атрибут вершины мы хотим настроить. Помните, что мы указали расположение позиции вершинного атрибута в шейдере вершин при помощи layout (location = 0). Благодаря этому, местоположение вершинного атрибута устанавливается в 0, и, поскольку мы хотим передать данные в этот атрибут вершины, мы передаём в качестве параметра значение 0.

   Аргумент №2: Определяем размер вершинного атрибута. Он имеет тип vec3, поэтому состоит из 3-х значений.

   Аргумент №3: Указываем тип данных GL_FLOAT (т.к. vec* в GLSL состоит из значений типа с плавающей запятой).

   Аргумент №4: Указываем, хотим ли мы, чтобы наши данные были нормализованы. Если мы вводим целочисленные типы данных (int, byte) и установили для них значение GL_TRUE, то целочисленные unsigned данные приводятся к 0 (или к -1 для типов данных signed) и к 1 при преобразовании в тип float. Это не имеет отношения к нашему примеру, поэтому мы оставим значение данного аргумента как GL_FALSE.

   Аргумент №5: Известен как шаг (англ. «stride») и говорит нам о пространстве между последовательными атрибутами вершин. Поскольку следующий набор данных местоположения вершины находится на расстоянии 3*sizeof(float), то указываем это значение как шаг. Обратите внимание, что, поскольку мы знаем, что массив плотно упакован (нет никакого пространства между следующим значением атрибута вершины), мы могли бы также указать величину шага как 0, чтобы позволить OpenGL самому определить шаг (это работает только тогда, когда значения плотно упакованы). Всякий раз, когда мы имеем дело с большим количеством вершинных атрибутов, нужно тщательно определить расстояние между каждым атрибутом (примеры подобных случаев мы рассмотрим несколько позже).

   Аргумент №6: Имеет тип void* и поэтому такое странное у него оформление. Это смещение того места, где начинаются данные в буфере. Поскольку данные находятся в начале массива данных, то это значение равно 0. Позже мы рассмотрим этот параметр более подробно.

Примечание: Каждый атрибут вершины берёт свои данные из памяти, управляемой VBO. Из какого конкретно VBO он будет брать свои данные (у вас же может быть несколько VBO), определяется тем, какой VBO привязан к GL_ARRAY_BUFFER в момент вызова функции glVertexAttribPointer(). Поскольку ранее определённый VBO перед вызовом glvertexattribpointer() все ещё является связанным, то вершинный атрибут 0 теперь связан с его вершинными данными.

Теперь, когда мы определили то, как OpenGL должен интерпретировать вершинные данные, мы также должны задействовать атрибут вершины вместе с функцией glEnableVertexAttribArray(), передавая расположение вершинного атрибута в качестве аргумента (вершинные атрибуты по умолчанию не задействованы). С этого момента у нас всё настроено: мы инициализировали данные вершин в буфере с помощью объекта вершинного буфера, настроили вершинный и фрагментный шейдеры и объяснили OpenGL, как связать данные вершин с вершинными атрибутами вершинного шейдера. Рисование объекта в OpenGL теперь будет выглядеть примерно следующим образом:

Мы должны повторять этот процесс каждый раз, когда хотим нарисовать объект. На первый взгляд может показаться, что данный код выглядит довольно компактно, но представьте себе, что у нас есть более 5 вершинных атрибутов и, возможно, 100 различных объектов (что является не редкостью). Привязка соответствующих буферных объектов и настройка всех вершинных атрибутов для каждого из этих объектов быстро превратится в очень громоздкий процесс. Что, если бы существовал бы какой-нибудь способ сохранить все эти конфигурации состояний в объекте и просто связать этот объект, чтобы восстановить его состояние?

Объект вершинного массива (VAO)

Объект вершинного массива (VAO от англ. «Vertex Array Object») может быть привязан точно так же, как и объект вершинного буфера, и все последующие вызовы вершинных атрибутов с этого момента будут сохранены внутри VAO. Преимущество данного приёма заключается в том, что при настройке указателей вершинных атрибутов вам нужно сделать эти вызовы только один раз, и всякий раз, когда мы хотим нарисовать объект, мы можем просто привязать соответствующий VAO. Благодаря этому переключение между различными данными вершин и конфигурациями атрибутов является таким же простым, как и привязка другого VАО. Всё то состояние, которое мы только что задали, — сохранено внутри VАО.

Примечание: Ядро OpenGL требует, чтобы мы использовали VAO для того, чтобы он знал, что делать с нашими входными данными вершин. Если нам не удастся связать VАО, то OpenGL, скорее всего, откажется что-либо рисовать.

Объект вершинного массива хранит следующую информацию:

   Вызовы функций aglEnableVertexAttribArray() или glDisableVertexAttribArray().

   Конфигурации вершинных атрибутов через функцию glVertexAttribPointer().

   Объекты вершинного буфера, связанные с вершинными атрибутами вызовами функции glVertexAttribPointer().

Процесс создания VAO аналогичен процессу создания VBO:

Чтобы использовать VАО, вам нужно его связать с помощью функции glBindVertexArray(). С этого момента мы должны привязать/настроить соответствующие VBO и указатель(и) атрибутов, а затем отменить привязку VAO для последующего использования. Как только мы хотим нарисовать объект, мы просто связываем VАО с предпочтительными настройками, прежде чем нарисовать объект, и всё.

Например:

И это всё! Всё, что мы сделали за последние несколько миллионов страниц, привело к этому моменту — VАО, которое хранит нашу конфигурацию вершинных атрибутов и выбор, какой VBO при этом использовать. Обычно, когда у вас есть несколько объектов, которые вы хотите нарисовать, вы сначала создаёте/настраиваете все VAO (и, следовательно, необходимые VBO и указатели атрибутов) и сохраняете их для последующего использования. В тот момент, когда мы хотим нарисовать один из наших объектов, мы берём соответствующий VАО, связываем его, затем рисуем объект и отменяем связывание VАО.

Треугольник, которого мы все так долго ждали

Чтобы нарисовать наши объекты, OpenGL предоставляет нам функцию glDrawArrays(), которая рисует примитивы, используя текущий активный шейдер, ранее определённую конфигурацию вершинных атрибутов и данные вершин из VBO (косвенно связанные через VAO):

Рассмотрим аргументы функции glDrawArrays() детальнее:

   Аргумент №1: Тип примитива OpenGL, который мы хотели бы нарисовать. Поскольку в самом начале я уже упоминал, что мы хотим нарисовать треугольник, то в качестве первого аргумента мы указываем GL_TRIANGLES.

   Аргумент №2: Начальный индекс вершинного массива, который мы хотели бы отобразить (мы просто оставляем его равным 0).

   Аргумент №3: Количество вершин, которые мы хотим нарисовать, а мы хотим нарисовать 3 вершины (мы визуализируем только 1 треугольник из наших данных, который имеет ровно 3 вершины).

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

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

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

Объекты элементного буфера (EBO)

Есть ещё одна последняя вещь, которую нам нужно обсудить при рендеринге вершин — объекты элементного буфера (EBO от англ. «Element Buffer Objects»). Чтобы объяснить, как работают объекты элементного буфера, лучше всего привести пример: предположим, что мы хотим нарисовать прямоугольник вместо треугольника. Мы можем нарисовать прямоугольник, используя два треугольника (OpenGL в основном работает с треугольниками). Это приведёт к созданию следующего набора вершин:

Как вы можете видеть, некоторые вершины перекрывают друг друга. Мы указываем нижнюю правую и верхнюю левую вершины дважды! Это создаёт нам проблему в виде накладных расходов в размере 50%, так как один и тот же прямоугольник может быть задан с 4 вершинами, а не с 6. А это может привести к довольно ощутимым проблемам, как только мы получим более сложные модели, которые имеют более 1000 треугольников, где будут большие перекрывающие друг друга куски. Лучшим решением было бы хранить только уникальные вершины, а затем указать порядок, в котором мы хотим нарисовать эти вершины. В таком случае нам нужно будет сохранить для прямоугольника только 4 вершины, а затем просто указать, в каком порядке мы хотели бы их нарисовать. Разве не было бы здорово, если бы OpenGL предоставил нам такую возможность?

К счастью, объекты элементного буфера работают именно так. EBO — это буфер, очень похожий на объект вершинного буфера, который хранит индексы, используемые OpenGL для определения того, какие вершины нужно нарисовать. Этот, так называемый, индексированный чертёж и будет тем решением нашей проблемы, которое мы искали. Для начала нам необходимо указать (уникальные) вершины и индексы, чтобы нарисовать их в виде прямоугольника:

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

Подобно VBO, мы связываем EBO и копируем индексы в буфер с помощью функции glBufferData(). Кроме того, точно так же, как и с VBO, мы вставляем эти вызовы между вызовами связывания и отвязывания, хотя на этот раз в качестве типа буфера мы указываем GL_ELEMENT_ARRAY_BUFFER:

Обратите внимание, что теперь мы передаём GL_ELEMENT_ARRAY_BUFFER в качестве целевого буфера. Последнее, что нам осталось сделать, — это заменить вызов функции glDrawArrays() на вызов функции glDrawElements(), чтобы указать, что мы хотим визуализировать треугольники из индексного буфера. При использовании glDrawElements() мы будем рисовать с использованием индексов, предоставленных в объекте элементного буфера EBO, связанного в данный момент:

Рассмотрим детальнее вызов функции glDrawElements():

   Аргумент №1: Определяем режим, в котором мы хотим рисовать (аналогично с функцией glDrawArrays());

   Аргумент №2: Счётчик или количество элементов, которые мы хотели бы нарисовать. Мы указали 6 индексов, значит необходимо нарисовать всего 6 вершин.

   Аргумент №3: Тип индексов, в нашем случае, GL_UNSIGNED_INT;

   Аргумент №4: Смещение в EBO (или индексный массив, но это в том случае, если вы не используете объекты элементных буферов), которое мы оставим равным 0.

Функция glDrawElements() берёт свои индексы из EBO, который в данный момент привязан к целевому объекту GL_ELEMENT_ARRAY_BUFFER. Это означает, что мы должны привязывать соответствующий EBO каждый раз, когда хотим визуализировать объект с индексами, что опять же немного неудобно. Так получилось, что объект вершинного массива также отслеживает привязки объектов элементного буфера. Последний объект элементного буфера, который привязывается во время привязки VАО, хранится как объект элементного буфера в VАО. Привязка к VАО затем также автоматически связывает и этот EBO.

Примечание: VАО хранит вызовы функции glBindBuffer(), когда целью является GL_ELEMENT_ARRAY_BUFFER. Это также означает, что он хранит свои вызовы отключения связей, поэтому убедитесь, что вы отвяжите буфер элементного массива перед тем, как отвязать ваш VAO, иначе он не будет иметь сконфигурированного EBO.

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

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

Примечание о каркасном режиме: С помощью вызова функции glPolygonMode(GL_FRONT_AND_BACK, GL_LINE) можно сконфигурировать OpenGL на отрисовку треугольников в режиме каркаса. Первый аргумент указывает на то, что мы хотим применить данный режим к передней и задней частям всех треугольников, а второй аргумент — нарисовать их в виде линий. Любые последующие вызовы функций отрисовки будут отображать треугольники в режиме каркаса до тех пор, пока мы не вернём первоначальные настройки с помощью функции glPolygonMode(GL_FRONT_AND_BACK, GL_FILL).

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

  Google Drive / Исходный код — Урок №4. Рисуем наш первый треугольник в OpenGL

  GitHub / Исходный код — Урок №4. Рисуем наш первый треугольник в OpenGL

Упражнения

Чтобы действительно получить хорошее представление об обсуждаемых концепциях, было создано несколько упражнений. Рекомендуется проработать их, прежде чем переходить к следующему уроку, дабы убедиться, что вы всё хорошо переварили.

Задание №1

Попробуйте нарисовать 2 треугольника рядом друг с другом с помощью функции glDrawArrays(), добавив больше вершин к вашим данным.

Ответ №1

Задание №2

Теперь создайте те же 2 треугольника, используя два разных VAO и VBO для своих данных.

Ответ №2

Задание №3

Создайте две шейдерные программы, где вторая программа использует другой фрагментный шейдер, который выводит жёлтый цвет; нарисуйте оба треугольника снова, где один из них будет иметь жёлтый цвет.

Ответ №3

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (13 оценок, среднее: 5,00 из 5)
Загрузка...

Комментариев: 25

  1. Аватар Евгений:

    Здравствуйте. Вот очень интересует такой вопрос — в таких фрагментах кода, как "int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);" — правильно ли я понимаю, что в данном случае выполняется приведение типов, как указатель к int? Поясню ход мыслей: чисто технически, обычный указатель является переменной типа unsigned int, и его значение — это порядковый номер ячейки оперативной памяти объёмом 4 байта( в 32-разрядных системах так точно). Если в данном примере glCreateShader() возвращает int именно в контексте "использовать как указатель", то не правильнее ли было бы применять "unsigned fragmentShader", дабы избежать ошибок обращения по отрицательным индексам памяти( помним, что инт знаковый тип)? Или glCreateShader() всегда возвращает беззнаковый тип? У меня успешно компилируется с "uint32_t fragmentShader". Заранее спасибо)

    1. Аватар VADIM:

      Значение, возвращаемое glCreateProgram() и glCreateShader() не является указателем ни в каком смысле, это просто номер объекта, его имя, идентификатор. Оно должно быть беззнаковым, так что тут просто опечатка.

  2. Аватар Роман:

    Здравствуйте.
    Я писал длинный комментарий минут 20, но потом закрыл браузер и не отправил. Он стёрся. Буду краток. Хочу написать класс Triangle, позицию и цвет каждой из вершин которого можно будет менять программно. Когда я использую glDrawArrays через uniform не выйдет, ибо он всё отправляет одним потоком. Как это сделать?
    Т.е. возможный код таков:

    ?

    1. Аватар Роман:

      Вот накидал С-шный код, проверьте пожалуйста:

      Будет ли он работать так, как я хочу?

  3. Аватар Кетчуп:

    Добрый день!
    Спасибо огромное за уроки!
    Единственное что меня мучает, это то как разбить этот один огромный файл на несколько меньших? Буду благодарен если посоветуете как так сделать.

    1. Дмитрий Бушуев Дмитрий Бушуев:

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

      1. Аватар Кетчуп:

        Окей, спасибо!

  4. Аватар Andrey:

    Добрый день! Спасибо огромное за ваш труд! Есть такой вопрос.

    1. Могу ли я привязать несколько VBO к одному VertexArrayID?
    2. Если да, а вроде как могу, ну по крайне мере технически, то как их потом отрисовать?

    Например:

  5. Аватар noname:

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

    Vertex shader failed to compile with the following errors:
    ERROR: 0:? : error(#76) Syntax error: unexpected tokens following #version
    ERROR: 0:? : error(#364) Invalid: unexpected token in symbol.
    ERROR: error(#273) 2 compilation errors. No code generated

    Код шейдера абсолютно тот же, что в уроке. Не могу понять в чём проблема.

    1. Дмитрий Бушуев Дмитрий Бушуев:

      В каком редакторе набирался код шейдеров?

      1. Аватар noname:

        Еще раз приветствую. Проблема была в том, что я неправильно загружал код шейдеров из файла (плохо знаю C++).

        1. Дмитрий Бушуев Дмитрий Бушуев:

          Хорошо что всё разрешилось 🙂

  6. Аватар Andrey:

    Добрый день! А зачем нужно вызывать glBindVertexArray(VAO); 2 раза. А потом и с нулем glBindVertexArray(0);?

    1. Дмитрий Бушуев Дмитрий Бушуев:

      Здравствуйте.
      Начну с последнего — glBindVertexArray(0);
      Это сделано чтобы каждый раз не отвязываться от VAO. Привязавшись к 0 мы гарантируем, что не произойдет никаких изменений, затрагивающих наш VAO (OpenGL никогда не создаст VAO со значением 0).

      Насчет вызова glBindVertexArray(VAO) дважды.
      Это вы про конкретный участок в статье или в архиве с исходными кодами? Потому, как в статье, в местах вставок фрагментов кода, некоторые маловажные в данный момент строчки кода заменены символом пропуска "[…]". А если посмотреть в исходниках, то там (если я не ошибаюсь), каждому вызову glBindVertexArray(VAO) соответствует вызов glBindVertexArray(0) (за исключением цикла рендеренга). Ну а вообще, несколько вызовов glBindVertexArray(VAO) — это своеобразная страховка, что вы действительно привязали VAO пере использованием 🙂

      1. Аватар Andrey:

        Спасибо большое за ответы и проделанную работу! Один момент еще возник.

        Вот есть код для генерации и привязки данных для вершин

        Есть код для привязки объекта вершинного массива (VAO)

        А где код, который связывает VAO с VBO.

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

        То, где то место в коде которое объединяет и как-то связывает обе этих величины — VAO с VBO?

        Или это где-то внутни под капотом. Типа
        тут мы открываем окно

        Все что тут положилось само внутри привязалось и потом мы закрыли это окно

        Как они узнают о существовании друг друга эти величины?

        1. Дмитрий Бушуев Дмитрий Бушуев:

          Добрый день.
          Посмотрите еще раз внимательно абзац "Объект вершинного массива (VAO)" и картинку к нему.

          Когда мы вызываем glVertexAttribPointer(), OpenGL автоматически устанавливает текущий связанный VBO в качестве буфера памяти атрибута вершины, поэтому все последующие вызовы отрисовки будут использовать данные вершины из памяти VBO, используемой в соответствии с glvertexattribpointer().

  7. Аватар Джек:

    Привет! Спасибо за уроки.
    Раньше писал под OpenGL 2.3. В новых версиях концепция сильно поменялась, давно хотел разобраться в шейдерах и программах отрисовки. Ваш материал хорошо разжеван. Надеюсь, дальше будет интереснее 😉

    1. Аватар Джек:

      И сразу разрешите вопрос. Вы смогли собрать проект на маке?
      У меня macOS Mojave 10.14. Компиляция прошла успешно, но с ворнингами об устаревшей версии.


      warning: 'glCreateShader' is deprecated: first deprecated in macOS 10.14 - OpenGL API deprecated. (Define GL_SILENCE_DEPRECATION to silence these warnings) [-Wdeprecated-declarations]
      int vertexShader = glCreateShader(GL_VERTEX_SHADER);

      Связанные ошибки получил в runtime:


      ERROR::SHADER::FRAGMENT::COMPILATION_FAILED:
      ERROR: 0:1: '' : version '330' is not supported
      ERROR: 0:1: '' : syntax error: #version
      ERROR: 0:2: 'layout' : syntax error: syntax error

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

      Возможно, что-то можете посоветовать?

      1. Дмитрий Бушуев Дмитрий Бушуев:

        >>У меня macOS Mojave 10.14
        В соответствии с этим новостями…:
        https://developer.apple.com/macos/whats-new/
        https://developer.apple.com/ios/whats-new/
        https://developer.apple.com/library/archive/documentation/GraphicsImaging/Conceptual/OpenGL-MacProgGuide/opengl_intro/opengl_intro.html

        …Apple объявила OpenGL — устаревшей технологией, начиная с macOS 10.14. Они хотят, чтобы вы переходили на "металл":
        https://developer.apple.com/metal

        >>Возможно, что-то можете посоветовать?
        Нуу, вариантов выхода из сложившейся ситуации не так много:
        1. Использовать "метал", как советует Apple;
        2. Не использовать MacOS 🙂

  8. Дмитрий Бушуев Дмитрий Бушуев:

    Добрый день.
    Попробуйте посмотреть в сторону этих книг:
    Вольф Д. — OpenGL 4. Язык шейдеров. Книга рецептов ( ДМК Пресс, 2015 )
    Гинсбург Д. — OpenGL ES 3.0. Руководство разработчика

    1. Аватар Джек:

      Разрешите свои 5 копеек вставить.
      У меня стаж в OpenGL 2 чуть больше 5 лет, работаю с визуализацией процессов в КИПиА и УФС. И Гинсбург у меня не зашел, показалось слишком сложно.

      Возможно, что-то посоветуете из свежей литературы?

      Еще айн вопрос. Какую версию OpenGL стоить выбрать для изучения, чтобы не сильно отставать от современности, но и поддерживать некоторые породы динозавров?

  9. Аватар Арбузик❤❤❤:

    Один из лучших уроков! Спасибо, что делаете для нас это 🙂

    1. Дмитрий Бушуев Дмитрий Бушуев:

      Пожалуйста 🙂

  10. Аватар nullptr:

    Здравствуйте! Не могли бы Вы подсказать литературу по OpenGL.

    1. Дмитрий Бушуев Дмитрий Бушуев:

      Добрый день.
      Видимо я промахнулся с ответом на ваш комментарий. Выше я привел несколько книг по OpenGL 🙂

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

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