Введение в OpenGL. Полигональные 3D-модели. Графический конвейер

Это пока черновой вариант. Материал будет дорабатываться в течение семестра.

Введение в OpenGL

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

OpenGL - кроссплатформенный интерфейс. Он работает под Windows, Linux, OS X и другими операционными системами. Версия OpenGL ES предназначена для работы на мобильных устройствах (Android, iOS). WebGL - вариант OpenGL для запуска в браузере.

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

OpenGL, как стандарт, был предложен в 1992м году компанией Silicon Graphics Inc. За основу был взят их графический интерфейс Iris GL для суперкомпьютеров IRIS. Развитие стандарта было поручено консорциуму ARB (Architecture Review Board), председателем которого стала Silicon Graphics Inc.

В конце 1990х годов появились первые видеокарты с графическими процессорами, что позволило значительно ускорить выполнение графических программ.

Конкуренция между производителями видеокарт привела к быстрому их развитию и добавлению новых возможностей. Но противоречия внутри консорциума ARB задерживали стандартизацию этих возможностей. Лишь в 2004м с трудом вышла версия OpenGL 2.0, в которую был включен язык шейдеров GLSL.

В 2006м году консорциум ARB передал развитие OpenGL консорциуму Khronos Group. Вскоре после этого, в 2008м вышла новая версия OpenGL 3.0, в которой были стандартизированы новые возможности, а множество старых функций были объявлены устаревшими. Это разделило историю OpenGL на 2 периода: старый и современный OpenGL (Modern OpenGL).

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

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

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

Каркас графического приложения

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

void main()
{
    Инициализация графического контекста

    Инициализация функций OpenGL

    Создание буферов, текстур, шейдеров и т.д.

    while (окно не закрыто)
    {
        Обработка событий ввода (клавиатура, мышь)

        Обновление 3D-сцены

        Рендеринг кадра

        Переключение буферов
    }

    Удаление буферов, текстур, шейдеров и т.д.
}

Термины:

  • Графический контекст - вспомогательный объект для взаимодействия графического приложения, операционной системы и видеокарты
  • Рендеринг - процесс синтеза двумерного растрового изображения из исходных 3D-моделей
  • Текстура - обычно двумерное растровое изображение
  • Шейдер - программа на специальном языке программирования, которая выполняется на графическом процессоре

Для работы графическому приложению необходимо использовать ресурсы видеокарты и операционной системы. При этом ресурсы должны быть изолированы от других приложений. А результат рендеринга должен быть показан в окне операционной системы. Для этого и используется вспомогательный объект - графический контекст. При создании контекста происходит создание окна и буферов для рендеринга.

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

//Создаем окно (графический контекст)
GLFWwindow* window = glfwCreateWindow(800, 600, "MIPT OpenGL demos", NULL, NULL);

//Делаем этот контекст текущим
glfwMakeContextCurrent(window);

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

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

glewExperimental = GL_TRUE;
glewInit();

Для обработки событий ввода необходимо зарегистрировать в GLFW несколько функций обратного вызова:

glfwSetKeyCallback(window, keyCallback);

Функции обратного вызова вызываются при проверке новых событий каждую итерацию цикла:

while (!glfwWindowShouldClose(window))
{
    glfwPollEvents();

Графическое приложение обычно имеет 2 буфера: передний (front) и задний (back). Содержимое переднего буфера выводится на экран монитора, а рендеринг осуществляется в невидимый задний буфер. После завершения рендеринга кадра необходимо поменять буферы местами:

    glfwSwapBuffers(window);
}

Далее рассмотрим загрузку 3D-моделей и рендеринг.

Полигональные 3D-модели

Термин:

  • Меш - полигональная 3D-модель

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

Треугольник состоит из 3х вершин. Каждая вершина имеет набор атрибутов. Широко используемые атрибуты:

  • координаты вершины
  • цвет вершины
  • текстурные координаты вершины (нужны для привязки вершины к текстуре)
  • нормаль (перпендикуляр к поверхности 3D-модели в вершине)
  • тангенциальный вектор (касательный к поверхности 3D-модели в вершине)

OpenGL позволяет универсально работать с почти любыми атрибутами. Можно задавать вершинам, например, веса, напряжения, силы и любые другие параметры по желанию.

Важно: 3D-модель описывается через атрибуты вершин. Нельзя задавать атрибуты ребрам или полигонам.

Каждый атрибут может иметь 1, 2, 3 или 4 компоненты. Например, координаты имеют 3 компоненты: x, y, z. Текстурные координаты обычно имеют 2 компоненты: s, t. Цвет имеет 3 компоненты: r, g, b, но часто дополняется 4й компонентой a (alpha - прозрачность).

Все компоненты одного атрибута должны быть одного типа. Типами могут быть: GL_BYTE, GL_UNSIGNED_BYTE, GL_SHORT, GL_UNSIGNED_SHORT, GL_INT, GL_UNSIGNED_INT, GL_HALF_FLOAT, GL_FLOAT, GL_DOUBLE, GL_FIXED и другие.

Важно: в рамках одной 3D-модели количество атрибутов, количество компонент каждого атрибута и их типы должны быть одинаковыми для всех вершин.

Значения атрибутов вершин хранятся в буферах в видеопамяти. Можно выделить 3 варианта хранения атрибутов.

  • Под каждый атрибут выделяется свой буфер
  • Имеется один буфер. В начале располагаются все значения одного атрибута, потом все значения другого
  • Имеется один буфер. Сначала идут все атрибуты первой вершины, потом все атрибуты второй вершины и т.д.

Но как видеокарта узнает, какие вершины образуют треугольники? Есть 2 варианта:

  1. Неявный. Треугольники задаются через порядок вершин в буфере. Каждые 3 вершины образуют треугольник: (0, 1, 2) - треугольник, (3, 4, 5) - треугольник и т.д.
  2. Индексный. Вершины в буфере располагаются в произвольном порядке, но добавляется индексный буфер, в котором хранятся номера вершин, которые образуют треугольник. Например, индексный буфер { 0, 3, 4, 3, 1, 5 }. Здесь 2 треугольника, которые образованы вершинами с номерами (0, 3, 4) и (3, 1, 5).

Практика

Чтобы создать буфер, используется команда glGenBuffers. Она создает на видеокарте 1 или несколько объектов buffer object и возвращает их целочисленные идентификаторы.

GLuint vbo;
glGenBuffers(1, &vbo); //В переменную vbo будет записан идентификатор буфера

OpenGL требует, чтобы перед использованием буфер был «привязан», т.е. сделан активным буфером.

glBindBuffer(GL_ARRAY_BUFFER, vbo);

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

float points[] =
{
    0.0f, 0.5f, 0.0f,
    0.5f, -0.5f, 0.0f,
    -0.5f, -0.5f, 0.0f
};

//Цель буфера, размер, указатель, предполагаемый способ использования буфера
glBufferData(GL_ARRAY_BUFFER, 9 * sizeof(float), points, GL_STATIC_DRAW);

Эту процедуру нужно повторить для всех буферов 3D-модели, если разные атрибуты хранятся в разных буферах.

Всё аналогично для индексного буфера, только цель буфера будет GL_ELEMENT_ARRAY_BUFFER.

unsigned short indices[] =
{
    0, 1, 2,
    0, 2, 3,
    4, 0, 3,
    4, 3, 5,
    5, 6, 2,
    5, 2, 3
};

//Создаем ещё один буфер для хранения индексов
GLuint ibo;
glGenBuffers(1, &ibo);

//Делаем этот буфер текущим
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);

//Копируем содержимое массива индексов в буфер на видеокарте
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

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

Для хранения настроек меша используется специальный объект vertex array object:

//Создаем объект vertex array object
GLuint vao;
glGenVertexArrays(1, &vao);

//Активируем объект
glBindVertexArray(vao);

//Активируем буфер с вершинными атрибутами
glBindBuffer(GL_ARRAY_BUFFER, vbo);

//Включаем 0й вершинный атрибут - координаты
glEnableVertexAttribArray(0);

//Включаем 1й вершинный атрибут - цвета
glEnableVertexAttribArray(1);

//Устанавливаем настройки: 0й атрибут, 3 компоненты типа GL_FLOAT, не нужно нормализовать, 0 - значения расположены в массиве впритык, 0 - сдвиг от начала
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);

//Устанавливаем настройки: 1й атрибут, 4 компоненты типа GL_FLOAT, не нужно нормализовать, 0 - значения расположены в массиве впритык, 84 - сдвиг от начала массива
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 0, (void*)84);

//Подключаем буфер с индексами
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);

//Отключаем vertex array object
glBindVertexArray(0);

В этом примере используется один буфер, в котором хранятся 2 вершинных атрибута: сначала все координаты, затем все цвета.

В команде glVertexAttribPointer указывается:

  • номер атрибута, для которого задаются настройки
  • количество компонентов (1, 2, 3, 4)
  • тип данных компонентов (GL_FLOAT и другие)
  • нужно ли нормализовать значения, т.е. приводить к диапазону [0; 1]
  • расстояние в байтах между атрибутами соседних вершин - нужно, если атрибуты чередуются
  • сдвиг от начала буфера (особенность этой функции - нужно приводить к void*)

Замечание:

Нужно осторожно относиться к дублированию вершин при работе с индексными мешами. 2 вершины считаются идентичными, если у них совпадают ВСЕ вершинные атрибуты. Обычно это справедливо для гладких мешей.

Но возьмем, например, куб. На первый взгляд, он содержит 8 вершин. Однако, каждая из этих вершин принадлежит 3м граням с разными нормалями. Поэтому на самом деле 3D-модель куба будет содержать 24 неповторяющихся вершины (4 вершины на каждую из 6ти граней).

Для запуска отрисовки меша нужно подключить соответствующий ему vertex array object и вызвать команду отрисовки. Семейство команд glDrawArrays предназначено для безиндексных мешей, семейство glDrawElements - для индексных мешей.

Вариант без индексов:

//Подключаем vertex array object с настойками полигональной модели
glBindVertexArray(_vao);

//Геометрический примитив, сдвиг от начала буфера с вершинными атрибутами, количество вершин
glDrawArrays(GL_TRIANGLES, 0, 36);

Вариант с индексами:

//Подключаем vertex array object с настойками полигональной модели
glBindVertexArray(_vao);

//Геометрический примитив, количество индексов в буфере с индексами, тип данных в буфере с индексами, сдвиг от начала буфера с индексами
glDrawElements(GL_TRIANGLES, 24, GL_UNSIGNED_SHORT, 0);

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

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

В упрощенном виде конвейер представлен на рисунке:

Некоторые шаги конвейера жестко зашиты в видеокарте. На их выполнение можно влиять лишь косвенно, изменяя отдельные параметры.

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

Всего имеется 5 шагов конвейера, где можно использовать шейдеры. Из них только 2 являются обязательными: вершинный и фрагментный.

В основной программе рендеринг одной 3D-модели выглядит следующим образом:

Изменение состояния OpenGL (установка параметров, необходимых для данной 3D-модели)
Запуск графического конвейера (glDrawArrays, glDrawElements и другие)

Графический конвейер начинает свою работу с вызовов glDrawArrays, glDrawElements и их вариантов.

Преобразование вершин

Изначально координаты вершин меша заданы в произвольной локальной системе координат. Чтобы отобразить 3D-модель на экране монитора, необходимо преобразовать все вершины модели в экранную систему координат (ось X направлена вправо, ось Y - вверх, координаты измеряются в пикселях).

Это преобразование зависит от нескольких факторов: расположения 3D-модели относительно других 3D-моделей в сцене, расположения и ориентации виртуальной камеры, угла обзора, размеров экрана и т.д. Преобразование разбивается на несколько подэтапов, каждый из которых можно представить в виде матрицы, на которую умножается вектор с координатами.

На входе: 3 локальные координаты вершины. На выходе: экранные координаты XY в пикселях экрана и координата Z - глубина. Глубина пропорциональна расстоянию от экрана до вершины.

Преобразование вершин разбивается на 2 части: 1я часть выполняется в вершинном шейдере и полностью определяется нами, 2я часть жестко зашита в видеокарте. После завершения вершинного шейдера видеокарта проверяет, попадает ли вершина в объем видимости: будет ли она видна на экране. Если нет - вершина отбрасывается.

Важное следствие: вершинный шейдер выполняется для всех вершин меша.

Помимо преобразования координат вершин вершинный шейдер может также обрабатывать другие вершинные атрибуты и передавать их на выход.

Растеризация

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

Каждый фрагмент соответствует одному пикселю экрана.

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

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

Обработка фрагментов

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

Задача фрагментного шейдера - рассчитать цвет фрагмента. Также фрагментный шейдер может отбросить (уничтожить) фрагмент или изменить его глубину.

Тесты и смешивание

Для каждого пикселя экрана хранится текущий цвет в буфере цвета и текущая глубина в буфере глубины.

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

  • цвет пикселя в буфере цвета будет заменен на цвет фрагмента
  • цвет пикселя в буфере цвета будет получен путем смешивания старого цвета пикселя и цвета фрагмента,

В обоих случаях глубина пикселя будет заменена на глубину фрагмента.

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