Создаём компьютерную игру
eng   рус

Основы OpenGL и рендеринг треугольника

Предыдущий урок: Первая программа на OpenGL: GLFW, SDL2, GLEW

В прошлом уроке мы научились создавать окна с привязанным контекстом OpenGL. Контекст - это всё текущее состояние OpenGL. При создании, контекст привязывается к определённому окну. На самом деле, OpenGL ничего не знает об окне. Вывод изображения происходит в буфер кадров. А уже SDL/GLFW связывает буфер кадров и окно.

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

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

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

В дальнейших уроках я буду использовать термины видеокарта, GPU (graphics processor unit - графический процессор) и сервер как взаимозаменяемые.

После создания контекста мы можем изменять состояние OpenGL. В данном уроке мы настроим OpenGL контекст для вывода треугольника в буфер кадров, и, соответственно, в окно.

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

Координаты треугольника

Для начала мы зададим координаты вершин, которые образуют треугольник. Координаты будут заданы в нормализованном виде:

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

Как видите, отдельные компоненты вершин являются типом float. Каждая вершина имеет четыре компоненты. Четыре компоненты (x, y, z, w) используются для более простой математики. Это однородные координаты. Использование однородных координат в дальнейшем позволит "закодировать" перенос и вращение объекта в одной матрице. У нас будет несколько уроков, в которых мы будем рассматривать всю математику, касающуюся местоположения объектов. Пока мы просто будем задавать четвёртой компоненте единицу. И нас пока не интересует z координата, поэтому мы задаём ноль для z для всех вершин, т.е. наш треугольник лежит в плоскости xy.

Объекты в OpenGL

При написании урока у меня были муки выбора перевода терминологии. Я стараюсь исходить из грамматики английского языка и из смысла термина. Например, Vertex Array Object - это всё же объект массива вершин, но никак не массив вершинных объектов (что можно встретить в других переводах данного термина). Или, Buffer Object - здесь я допускаю использования вариантов: буферный объект или объект буфера, хотя с точки зрения правил английского правильнее будет говорить буферный объект.

В OpenGL существует несколько типов объектов: объекты массива вершин, буферные, шейдерные и программные, текстурные объекты и объекты буфера рендеринга (renderbuffer objects), а также ряд других.

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

У каждого типа объектов есть своё пространство имён. Имена - это просто числа. Обычно, имена запрашиваются через функцию начинающуюся с Gen. "Сгенерированные" имена отмечаются OpenGL как занятые и при следующем вызове функции Gen, будут возвращены новые значения. После генерации объект нужно привязать к контексту.

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

О некоторых объектах OpenGL, нам необходимо поговорить подробней.

Буферный объект (buffer object) используется для хранения данных непосредственно в памяти видеокарты. Особенно нас интересует один тип буферных объектов: объекты буферов вершин - vertex buffer object (VBO), который хранит вершины.

Объекты массива вершин (vertex array object, в дальнейшем VAO) - контейнеры, которые содержат ссылки на буферные объекты. Помимо этого, объект массива вершин определяет формат атрибутов вершин. Атрибуты текущего объекта массива вершин используются как входящие данные в вершинный буфер при вызове команд рисования.

Важно понимать связь между VBO и VAO. VBO хранит данные, VAO - хранит состояние и указатели на данные из VBO. VAO описывает то (формат), что хранится в VBO. Любой буферный объект - это просто массив, хранящийся на сервере. В дальнейших уроках наши VAO объекты будут становиться сложнее и станет понятно их предназначение.

Шейдерные объекты (shader object) - скомпилированная программа, которая выполняется GPU.

Программный объект (program object) - набор разных шейдерных объектов.

Объекты буфера кадров (framebuffer object) содержат состояние буфера кадров а также набор буферов для цвета, глубины и трафарета. Каждый из этих буферов является объектом буфера рендеринга или текстурным объектом. Т.е. объект буфера кадров - это контейнер для объектов буфера рендернига.

Объекты буфера рендеринга (renderbuffer object) содержат одиночное изображение. Мы можем изменять содержимое объектов буферов рендеринга отдельными командами.

Например, буферный объект создаётся вызовом функции BindBuffer, в которую передаётся имя. При создании происходит выделение ресурсов для буферного объекта и его состояния, а имя привязывается к самому объекту.

Но вернёмся к нашим вершинам.

Vertex Array Object (VAO) и Vertex Buffer Object (VBO)

Вершины могут иметь различные атрибуты. В нашем примере вершины имеют только один атрибут - координаты. В следующих уроках мы будем задавать другие атрибуты, например, цвет, нормали. Важно хорошо различать атрибуты и компоненты. Атрибут - у вершины, компонент - у атрибута вершины. В нашем примере вершина имеет один атрибут - координаты. У этого атрибута четыре компоненты - x, y, z, w.

На стороне OpenGL массив вершин представлен объектом массива вершин (vertex array object). Этот объект содержит различные данные: сколько компонентов у атрибутов, тип отдельных компонентов, как представлять примитивы. Объект массива вершин имеет имя. Имя любого объекта OpenGL - это просто целое число больше нуля. Мы можем получить свободные имена с помощью функции GenVertexArrays (сгенерировать вершинный массив). Имя функции вводит в заблуждение, так как данная функция возвращает свободные имена (числа) и ничего не генерирует.

Процесс определения атрибутов вершин и их передача в шейдер называется переносом вершины в OpenGL. Взглянем на код, который передаёт заданные в массиве vertices вершины в видеокарту:

GLuint vao; glGenVertexArrays(1, &vao); glBindVertexArray(vao); GLuint vbo; glGenBuffers(1, &vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo); glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

Сначала мы генерируем имя для объекта массива вершин. Мы запрашиваем у OpenGL одно имя (первый аргумент glGenVertexArrays) и сохраняем в переменной vao. Затем мы связываем VAO с контекстом OpenGL с помощью glBindVertexArray. Важно связать vao до того как мы зададим формат вершин. Порядок именно такой: сначала связываем vao с контекстом, потом задаём формат вершин.

Для объекта буфера вершин мы делаем то же самое - генерируем имя и связываем полученное имя с контекстом. Флаг GL_ARRAY_BUFFER говорит, что мы связываем с именем vbo массив вершин. В следующем уроке мы узнаем какие ещё буферы можно создавать.

glBufferData копирует vertices в память видеокарты. Первый аргумент указывает, что мы копируем вершины, второй - размер массива, третий- адрес откуда копировать, а вот четвёртый аргумент представляет интерес. Здесь мы указываем GL_STATIC_DRAW - мы говорим видеокарте, что эти данные не будут изменяться и будут использоваться в командах рисования: один раз загрузили и много раз использовали. Другие варианты этого аргумента рассмотрим в следующих уроках.

glBindBuffer делает vbo активным (привязывает к контексту).

Ещё раз обращаю ваше внимание, что значения vao и vbo - числа. Рассматривайте имена объектов OpenGL как указатели. Просто мы не имеем прямого доступа к памяти GPU.

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

glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0); glEnableVertexAttribArray(0);

Первый аргумент - индекс атрибута вершин, который мы хотим изменить. Должен соответствовать атрибуту шейдера. В нашей вершине определено только местоположение, соответственно и в шейдер мы передадим только местоположение. Т.е. мы связываем данные из массива вершин с шейдером. Так как у нас пока только один атрибут, то он всегда будет равен нулю. В более сложных примерах мы будем запрашивать атрибуты у шейдерной программы. Второй аргумент - количество значений у текущего атрибута. Мы задаём все четыре компоненты местоположения, поэтому и сюда мы передаём 4. Третий аргумент - тип значений. Четвёртый - нужно ли нормализовывать координаты, пока пропустим. Пятый определяет шаг (stride) между разными атрибутами в массиве. Будет актуально, когда у нас будет больше атрибутов. Последний аргумент - смещение, где начинается атрибут. В нашем массиве один атрибут (координаты) и его первый элемент совпадает с началом массива, поэтому - ноль.

Информация, которую мы указали в glVertexAttribPointer сохраняется в vao (в VAO объект, который привязан к контексту в данный момент).

Функция glEnableVertexAttribArray активирует массив атрибутов вершин. Аргумент указывает, какой атрибут мы активируем. Совпадает с первым аргументом функции glVertexAttribPointer

Итак, мы создали массив вершин и скопировали его в OpenGL. Настало время узнать, что с вершинами произойдёт дальше.

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

Рендеринг происходит в несколько стадий. Каждая стадия является программируемой (её можно изменять) и её можно настраивать. Обратите внимание, что всё действие происходит в процессорах видеокарты.

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

Для нас пока важны только две стадии графического конвейера: вершинный и фрагментный шейдеры.

Вершинный шейдер - Vertex Shader

Вершинный шейдер (vertex shader). На этой стадии координаты вершин преобразуются в конечную форму. В игре может быть огромное количество объектов. При этом у каждого треугольника должны быть свои координаты. Помимо этого, игрок может находиться в разных точках трёхмерного пространства, т.е. у игровой камеры тоже могут быть разные координаты. Ситуация непростая. Но здесь нам на помощь приходят преобразования и матрицы. В вершинном шейдере мы и делаем все преобразования. Каждая вершина попадает в вершинный шейдер со своими изначальными координатами. В шейдере происходит какая-то магия. На выходе из шейдера мы получаем координаты вершины в виде нормализованных координат устройства. Видимая область будет находится в диапазоне от -1 до 1. Центр нормализованных координат соответствует центру экрана. Ось y проходит снизу вверх.

Преобразования координат мы будем рассматривать в отдельном уроке. Как мы видели выше, координаты нашего треугольника заданы в нормализованном виде.

Вершины будут подаваться в вершинный буфер по одной.

Где-то потом нормализованные координаты устройства будут преобразованы в фактические координаты окна приложения. OpenGL делает это автоматически.

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

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

Существуют другие стадии графического конвейера, но мы пока их опустим.

Теперь можно посмотреть на код. Нам нужно два дополнительных файла для шейдеров. Поместите файлы в папку с проектом. Сначала вершинный шейдер:

Код шейдеров, GLSL

// vs.glsl #version 460 core layout (location = 0) in vec4 position; void main() { gl_Position = position; }

Код шейдеров пишется на языке GLSL. Он сильно похож на C.

Строка layout (location = 0) in vec4 aPos; берёт атрибут вершины с индексом ноль (соответствует тому же атрибуту функции glVertexAttribPointer) и помещает его в переменную position с типом vec4. В GLSL определено много типов для векторов и матриц. veс4 по сути это массив из четырёх float - это и есть отдельные компоненты координат вершины.

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

Теперь фрагментный шейдер:

#version 460 core layout(location = 0) out vec4 color; void main() { color = vec4(0.0f, 0.0f, 0.0f, 1.0f); }

В предыдущих версиях OpenGL во фрагментном шейдере был аналог переменной gl_Position вершинного шейдера. Эта переменная определяла конечный цвет фрагмента (пикселя) и называлась gl_FragColor. В новых версиях мы должны связывать выходящие переменные с атрибутами. У нас пока только один атрибут - цвет, поэтому мы указываем значение 0. Более того, так как у нас только один атрибут мы могли бы опустить часть layout(location = 0), OpenGL сам бы связал переменную color с атрибутом 0. Внутри main мы присваиваем всем фрагментам черный цвет (любой пиксель внутри треугольника будет черным). Первые три канала (красный, зелёный, синий) равны нулю, а последний (прозрачность, но в этом примере она работать не будет) - единице.

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

Для начала нам нужно прочитать файлы в строку char*:

std::ifstream ivs("vs.glsl"); std::string vs((std::istreambuf_iterator(ivs)), (std::istreambuf_iterator())); const char* vsc = vs.c_str(); std::ifstream ifs("fs.glsl"); std::string fs((std::istreambuf_iterator(ifs)), (std::istreambuf_iterator())); const char* fsc = fs.c_str();

Мы открываем ifstream, затем читаем файл в строку, а затем сохраняем текст в char* для каждого шейдера. Файлы vs.glsl и fs.glsl находятся в одной папке с основным кодом.

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

GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER); glShaderSource(vertexShader, 1, &vsc, NULL); glCompileShader(vertexShader); GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(fragmentShader, 1, &fsc, NULL); glCompileShader(fragmentShader);

glCreateShader создаёт объект шейдера. Функция возвращает имя объекта. В аргументе мы указываем тип шейдера, который мы хотим создать.

glShaderSource задаёт исходный код шейдера. Мы привязываем строки, содержащие исходный код шейдеров. Первый аргумент - имя шейдера, второй - количество строк в исходном коде, третий - адрес строки с исходным кодом, четвёртый - массив, содержащий количество символов в каждой строке исходного кода. Если указываем NULL, то предполагается что строка заканчивается нулём.

glCompileShader компилирует шейдер. Мы передаём в неё имя шейдера, который нужно скомпилировать.

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

GLuint shaderProgram = glCreateProgram(); glAttachShader(shaderProgram, vertexShader); glAttachShader(shaderProgram, fragmentShader); glLinkProgram(shaderProgram); glUseProgram(shaderProgram);

В первой строке создаётся объект программы. shaderProgram - имя объекта. Затем мы привязываем оба шейдера к программе.

Далее мы связываем программу с помощью функции glLinkProgram. На этом этапе шейдерный объект типа GL_VERTEX_SHADER будет использован, чтобы создать исполняемый код, который будет запущен вершинным процессором на GPU, а объект типа GL_FRAGMENT SHADER - фрагментным процессором.

Теперь можно привязать программу к контексту OpenGL с помощью glUseProgram.

Перед основным циклом мы зададим фоновый цвет:

glClearColor(1.0f, 1.0f, 1.0f, 1.0f);

glClearColor задаёт цвет, которым будет очищаться буфер.В данном случае - белый. Значения каналов должны находиться в диапазоне от нуля до единицы.

В основном цикле мы каждый кадр будем перерисовывать треугольник:

glClear(GL_COLOR_BUFFER_BIT); glDrawArrays(GL_TRIANGLES, 0, 3); // SDL_GL_SwapWindow(window); // смена кадров в SDL2 или // glfwSwapBuffers(window); // смена кадров в GLFW

glClear очищает буферы рендеринга. В данном случае мы указываем очистить только буфер цвета. glDrawArrays рисует примитивы - посылает определённое количество вершин в вершинный шейдер. Первый аргумент - примитивы, которые мы хотим нарисовать. Можно указывать точки, линии, треугольники и линии/треугольники заданные в определённой последовательности. Второй аргумент - индекс вершины, с которой начать рисование. Третий аргумент - количество индексов, которые будут использованы данной командой.

Заключение - Первый треугольник в OpenGL

При запуске программы вы должны увидеть чёрный (именно этот цвет мы использовали в фрагментном shader) треугольник на белом фоне в центре экрана. Код исходников можно скачать в прикреплённых файлах (вверху справа под меню). В архиве лежит решение для Visual Studio 2019 с двумя проектами: на SDL2 и GLFW.

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

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

26 августа 2020 г. 8:27
1 Guest
Здравствуйте, Роман. Пытаюсь изучить (хотя бы поверхностно) OpenGL. Идет с трудом, т.к. материала хоть и много, но он очень отрывочен или разрозненный. До сих пор не пойму, почему в большинстве материалов нужно: glMatrixMode(GL_PROJECTION); glLoadIdentity(); glViewport(0, 0, opengl_cntx->width(), opengl_cntx->height()); glOrtho... В вашем примере - ничего из этого. Когда следующая партия уроков?