Рендеринг 3D графики с помощью OpenGL

в 11:30, , рубрики: c++, glsl, OpenGL, Работа с 3D-графикой

Введение

Рендеринг 3D графики — непростое занятие, но крайне интересное и захватывающее. Эта статья для тех, кто только начинает знакомство с OpenGL или для тех кому интересно, как работают графические конвейеры, и что они из себя представляют. В этой статье не будет точных инструкций, как создать OpenGL контекст и окно или как написать своё первое оконное приложение на OpenGL. Связанно это с особенностями каждого языка программирования и выбором библиотеки или фреймворка для работы с OpenGL (Я буду использовать C++ и GLFW) тем более в сети легко найти туториал под интересующий вас язык. Все примеры, приведённые в статье, будут работать и на других языках с немного изменённой семантикой команд, почему это так, расскажу чуть позже.

Что такое OpenGL?

OpenGL — cпецификация, определяющая платформонезависимый программный интерфейс для написания приложений, использующих двумерную и трёхмерную компьютерную графику. OpenGL не является реализацией, а только описывает те наборы инструкций, которые должны быть реализованы, т.е. является API.

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

Устройство OpenGL

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

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

Объекты в OpenGL

Библиотеки OpenGL написаны на C и имеют многочисленные API к ним для разных языков, но тем не менее это C библиотеки. Множество конструкций из языка С не транслируются в высокоуровневые языки, поэтому OpenGL был разработан с использованием большого количества абстракций, одной из этих абстракций являются объекты.

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

glGenObject(&objectId);
glBindObject(GL_TAGRGET, objectId);
glSetObjectOption(GL_TARGET, GL_CORRECT_OPTION, correct_option); //Ok
glSetObjectOption(GL_TARGET, GL_WRONG_OPTION, wrong_option); //вызов будет отброшен, т.к. устанавливается неправильный параметр

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

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

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

Базовые объекты: Шейдеры и шейдерные программы.=

Shader — это небольшая программа которая выполняется на графическом ускорителе(GPU) на определённом этапе графического конвейера. Если рассматривать шейдеры абстрактно, то можно сказать, что это этапы графического конвейера, которые:

  1. Знают откуда брать данные на обработку.
  2. Знают как обрабатывать входные данные.
  3. Знают куда записать данные для дальнейшей их обработки.

Но как же выглядит графический конвейер? Очень просто, вот так:

Рендеринг 3D графики с помощью OpenGL - 1
Пока в этой схеме нас интересует только главная вертикаль, которая начинается с Vertex Specification и заканчивается на Frame Buffer. Как уже говорилось ранее, каждый шейдер имеет свои входные и выходные параметры, которые отличаются по типу и количеству параметров.
Кратко опишем каждый этап конвейера, чтобы понимать, что он делает:

  1. Вершинный шейдер — нужен для обработки данных 3D координат и всех других входных параметров. Чаще всего в вершинном шейдере производятся вычисление положения вершины относительно экрана, расчёт нормалей (если это необходимо) и формирование входных данных в другие шейдеры.
  2. Шейдер тесселяции и шейдер контроля тесселяции — эти два шейдера отвечают за детализацию примитивов, поступающих из вершинного шейдера и подготавливают данные для обработки в геометрическом шейдере. Сложно описать в двух предложениях на что способны эти два шейдера, но чтобы у читателей было небольшое представление приведу пару изображений с низким и высоким уровнем теселяции:
    Рендеринг 3D графики с помощью OpenGL - 2 Рендеринг 3D графики с помощью OpenGL - 3

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

  3. Геометрический шейдер — отвечает за формирование геометрических примитивов из выходных данных шейдера тесселяции. С помощью геометрического шейдера можно формировать новые примитивы из базовых примитивов OpenGL (GL_LINES, GL_POINT, GL_TRIANGLES, e.t.c), например с помощью геометрического шейдера можно создать эфект частиц, описав частицу только цветом, центром скопления, радиусом и плотностью.
  4. Шейдер растеризации — один из не программируемых шейдеров. Если говорить понятным языком переводит все выходные графические примитивы в фрагменты (пиксели), т.е. определяет их положение на экране.
  5. Фрагментный шейдер — последний этап графического конвейера. В фрагментном шейдере вычисляется цвет фрагмента(пикселя) который будет установлен в текущем буфере кадра. Чаще всего во фрагментном шейдере проводят вычисление затенения и освещения фрагмента, мапинг текстур и карт нормалей — все эти техники позволяют достичь невероятно красивых результатов.

Шейдеры OpenGL пишутся на специальном С-подобном языке GLSL из которого они компилируются и линкуются в шейдерную программу. Уже на данном этапе кажется, что написание шейдерной программы это крайне трудоёмкое занятие, т.к. нужно определить 5 ступеней графического конвейера и связать их воедино. К большому счастью это не так: в графическом конвейере по умолчанию определены шейдеры тесселяции и геометрии, что позволяет нам определить всего два шейдера — вершинный и фрагментный (иногда его назвают пиксельным шейдером). Лучше всего рассмотреть эти два шейдера на классическом примере:

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

#version 450 
layout (location = 0) in vec3 vertexCords;
layout (location = 1) in vec3 color;

out vec3 Color;

void main(){
    gl_Position = vec4(vertexCords,1.0f) ;
    Color = color;
}

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

#version 450 
in vec3 Color;

out vec4 out_fragColor;

void main(){
    out_fragColor = Color;
}

Пример сборки шейдерной программы

unsigned int vShader = glCreateShader(GL_SHADER_VERTEX); // создание вершинного шейдера
glShaderSource(vShader,&vShaderSource); //загрузка кода 
glCompileShader(vShader); // компиляция
// то же самое проделываем для фрагментного шейдера

unsigned int shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vShader); // привязываем вершинный шейдер
glAttachShader(shaderProgram, fShader); // привязываем фрагментный шейдер
glLinkProgram(shaderProgram); // линкуем

Эти два простых шейдера ничего не вычисляют лишь передают данные дальше по конвейеру. Обратим внимение как связаны вершинный и фрагментный шейдеры: в вершинном шейдере объявлена out переменная Color в которую будет записан цвет после выполнения главной функции, в то время как в фрагментном шейдере объявлена точно такая же переменная с квалификатором in, т.е. как и описывалось раньше фрагментный шейдер получает данные из вершинного посредством нехитрого прокидывания данных дальше через конвейер (но на самом деле не всё так просто).

Замечание: Если в фрагментном шейдере не объявить и не проинициализировать out переменную типа vec4, то на экран ничего выводиться не будет.

Внимательные читатели уже заметили объявление входных переменных типа vec3 со странными квалификаторами layout в начале вершинного шейдера, логично предполагать что это входные данные, но откуда нам их взять?

Базовые объекты: Буферы и Вершинные массивы

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

float vertices[] = {
        //координаты      //цвет
	-0.8f, -0.8f, 0.0f,  1.0f, 0.0f, 0.0f,
	0.8f, -0.8f, 0.0f,   0.0f, 1.0f, 0.0f,
	0.0f, 0.8f, 0.0f,     0.0f, 0.0f, 1.0f };

unsigned int VBO; //vertex buffer object 
glGenBuffers(1,&VBO);
glBindBuffer(GL_SOME_BUFFER_TARGET,VBO);
glBufferData(GL_SOME_BUFFER_TARGET, sizeof(vertices), vertices, GL_STATIC_DRAW);

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

GL_STATIC_DRAW — данные в буфере изменяться не будут.
GL_DYNAMIC_DRAW — данныe в буфере будут изменяться, но не часто.
GL_STREAM_DRAW — данные в буфере будут изменяться при каждом вызове отрисовки.

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

image

Картинка позаимствована с этого туториала.

Как и с буферами вершинные массивы лучше рассмотреть на примере их конфигурации

	unsigned int VBO, VAO;
	glGenBuffers(1, &VBO);
	glGenBuffers(1, &EBO);

	glGenVertexArrays(1, &VAO);
	glBindVertexArray(VAO);

        //загрузка данных в память 
	glBindBuffer(GL_ARRAY_BUFFER, VBO);
	glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

        // конфигурируем первый вершинный атрибут (позиции)
	glEnableVertexAttribArray(0);
	glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), nullptr);

        // конфигурируем второй вершинный атрибут (цвета)
	glEnableVertexAttribArray(1);
	glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), reinterpret_cast<void*>
(sizeof(float) * 3));

	glBindBuffer(GL_ARRAY_BUFFER, 0);
	glBindVertexArray(0);

Создание вершинных массивов ничем не отличается от создания других OpenGL объектов, самое интересное начинается после строчки:

glBindVertexArray(VAO); 

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

	glBindBuffer(GL_ARRAY_BUFFER, VBO);
	glEnableVertexAttribArray(0);
	glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), nullptr);

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

  1. Номер атрибута, который мы хотим сконфигурировать.
  2. Количество элементов, которые мы хотим взять. (Т.к. входная переменная вершинного шейдера с layout = 0 имеет тип vec3, то мы берём 3 элемента типа float)
  3. Тип элементов.
  4. Нужно ли нормализовывать элеметы, если речь идёт о векторе.
  5. Смещение для следующей вершины (Т.к. у нас последовательно расположены координаты и цвета и каждый имеет тип vec3, то смещаемся на 6 * sizeof(float) = 24 байта).
  6. Последний аргумент показывает какое смещение брать для первой вершины. (для координат этот аргумент равен 0 байт, для цветов 12 байт)

Всё теперь мы готовы отрендерить наше первое изображение

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

{ // your render loop 
    glUseProgram(shaderProgram);
    glBindVertexArray(VAO);
    glDrawElements(GL_TRIANGLES,0,3); // указываем примитивы для отрисовки и количество вершин
}

Если вы всё сделали правильно, то вы должны получить вот такой результат:

Рендеринг 3D графики с помощью OpenGL - 5

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

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

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

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

Автор: Hirikava

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js