Опыт изучения OpenGL — Часть 5 — Рисуем треугольник

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

Литература

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

  • Jason McKesson — Learning Modern 3D Graphics Programming.
    — Introduction.
    — Chapter 1. Hello, Triangle!
  • David Wolf — OpenGL 4 Shading Language Cookbook (2nd Edition).
    — Chapter 1. Getting Started with GLSL.
  • Dave Shreiner et al. — OpenGL Programming Guide (8th Edition).
    — Chapter 1. Introduction to OpenGL.
  • Graham Sellers — OpenGL SuperBible (7th Edition).
    — Chapter 2. Our First OpenGL Program.

Про архитектуру видеопроцессоров (для любопытствующих) — то, что я нашел в Интернете:

  • Erik Lindholm, John Nickolls, Stuart Oberman, John Montrym — NVIDIA Tesla: A Unified Graphics and Computing Architecture — 2008 IEEE.
  • NVIDIA Whitepaper — NVIDIA’s Next Generation CUDA Compute Architecture: Fermi.

Теория

Конечная цель работы графической программы — формирование изображения на экране. Изображение как правило изменяется во времени и должно обновляться с достаточно высокой частотой, чтобы глазу было комфортно его воспринимать. На сегодняшний день технология формирования изображения такова, что оно представляет собой матрицу маленьких элементов, каждый из которых имеет определенный цвет. Эти элементы называются пикселями. Пиксел (pixel — picture element) — наименьший элемент изображения, характеризуется координатами на экране X и Y, и цветом.

Так что в конечном счете сформировать изображение — значит для каждого пикселя задать определенный цвет. Что-то вроде собирания мозаики. Собирание мозаики — процесс долгий и кропотливый, нам же надо формировать картинку, меняющуюся во времени с частотой не менее 24 кадров в секунду. Так что приходится автоматизировать процесс, и выполняет эту задачу видеопроцессор (GPU — graphics processing unit).

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

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

Ясно, что видеопроцессор должен принимать от прикладной программы некие команды рисования. Мы не хотим, чтобы эти команды звучали «задать пикселю с координатами X и Y цвет COLOR», иначе о какой автоматизации может идти речь? Нет, команды рисования должны быть более высокоуровневыми, например, «нарисуй линию», «нарисуй треугольник» (команда «нарисовать точку» тоже существует). Итак, в OpenGL существует три примитива рисования: точка, линия и треугольник — это кирпичики, из которых программист строит изображение (по-видимому, самым ходовым примитивом является треугольник, так как из треугольников можно строить поверхности). Таким образом, программист должен передать видеокарте информацию об изображении в виде набора примитивов. Что это за информация? Во-первых, тип примитива (точка/линия/треугольник). Во-вторых — вершины (по-английски «вершина» — vertex, мн. ч. — vertices). Примитив — это геометрическая фигура. А у геометрической фигуры есть вершины (у точки — одна вершина, у линии — две, у треугольника — три). Каждая вершина несет кусочек информации (так называемые атрибуты вершины), прежде всего это — координаты вершины в трехмерном пространстве. Помимо координат с каждой вершиной могут быть связаны разнообразные данные, самые распространенные из которых: цвет вершины, координаты текстуры и вектор нормали к поверхности в точке, где расположена вершина (но вообще это могут быть произвольные данные — все это целиком во власти программиста). Со всем этим мы будем разбираться в последующих заметках.

Итак, пользовательская программа отправляет видеокарте поток примитивов рисования (вероятнее всего, этот поток сохраняется в памяти видеокарты). Видеопроцессор превращает каждый примитив в набор пикселей. Как уже было сказано, этот процесс распараллелен и конвейеризован благодаря архитектуре видеопроцессора. Стадии конвейера перечислены в [Guide8th, OpenGL’s Rendering Pipeline]. Здесь приведу только те стадии, которые важны для нас в начале изучения OpenGL:

  1. Vertex shader. На вход этой стадии поступает отдельная вершина. Как правило эта стадия производит преобразование ее координат в 3-хмерном пространстве. Что это за преобразование? Об этом — в следующих заметках, в сегодняшнем примере с рисованием треугольника никаких преобразований не будет. Но, забегая вперед, скажу, что говоря о преобразованиях, мы имеем в виду переход из одной системы координат в другую (в компьютерной графике выделяют три системы координат: связанная с моделью (model), связанная с миром (world) и связанная с наблюдателем (view)) и из трехмерного пространства в плоский экран (это преобразование проекции).
  2. Primitive setup — сборка примитивов из нескольких вершин.
  3. Culling and Clipping — отбрасывание невидимых поверхностей и обрезка примитивов, которые частично выходят за границы экрана.
  4. Rasterization (растеризация) — генерация массива пикселей, которые соответствуют данному примитиву рисования. Про растеризацию хорошо написано в [McKesson, Introduction. Graphics and Rendering].
  5. Fragment shader — закрашивание пикселей, т. е. определение цвета каждого пикселя.

Определенные стадии конвейера являются программируемыми, т. е. прикладной программист может контролировать работу видеопроцессора на этих стадиях. Для этого он пишет программы, которые называются шейдерами (shaders). Шейдеры пишут на специальном языке. В OpenGL этот язык называется GLSL (OpenGL Shading Language), в DirectX — HLSL (High Level Shading Language). Оба языка похожи на язык Си.

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

  • Описание трехмерных объектов. В компьютерной графике все объемные трехмерные объекты полые внутри — они будто сделаны из бумаги. То есть мы имеем дело с поверхностями. А поверхности состоят из треугольников. У треугольника есть три вершины. Так что описание трехмерного объекта суть массив координат вершин в пространстве. Такие массивы называют полигональными сетками (mesh). Полигональная сетка помещается в память видеокарты, с тем, чтобы последняя нарисовала объект на экране.
  • Программа, которую должен выполнять видеопроцессор. Формирование изображения состоит из ряда программируемых стадий. Каждой стадии соответствует свой шейдер. Шейдеры пишет программист. Набор шейдеров компилируется в программу и отправляется видеокарте. Подробнее о шейдерах читайте в [Wolf]. Чтобы не путать с обычной прикладной программой, далее я буду называть программу, которую выполняет видеопроцессор, шейдерной программой.

Практика

А теперь напишем программу, которая будет рисовать на экране треугольник. Сначала мы напишем ее так, как это делают в учебниках, т. е. при помощи голого OpenGL, без создания какого-либо фреймворка (Кое-какой фреймворк все-таки присутствует, но он служит не для рисования, а для создания окна и обработки сообщений. В книгах чаще всего для этих целей применяется библиотека FreeGLUT, у меня же для этого используется класс engine::ProgramBase). Программу, подобную представленной ниже, с пояснениями, вы найдете в каждой книге из приведенного выше списка литературы. Мы подробно обсудим, что происходит в этой программе, а также увидим, что программа эта трудночитаема и трудноизменяема. Вот эти недостатки мы и попытаемся устранить путем создания своего собственного фреймворка в виде набора типов и функций. Итак…

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

Рисунок 1 — Разноцветный треугольник, нарисованный при помощи OpenGL
// Program.h

#include "ProgramBase.h"
#include "GLEWHeaders.h"
#include "File.h"

class Program final : public engine::ProgramBase
{
    private:
        GLuint m_Program;            // shader program handle
        GLuint m_VertexBufferObject; // VBO handle
        GLuint m_VAO;                // Vertex Array Object hadnle

    public:
        Program()
        {
            // --- Compile shaders and link program ---
            GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
            std::string vertexShaderText = util::file::ReadAllText("../../shaders/passthrough.vert");
            const GLchar* text = vertexShaderText.c_str();
            glShaderSource(vertexShader, 1, &text, nullptr);
            glCompileShader(vertexShader);
            // here compilation error check must go which I skipped for the sake of simplicity

            GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
            std::string fragmentShaderText = util::file::ReadAllText("../../shaders/passthrough.frag");
            text = fragmentShaderText.c_str();
            glShaderSource(fragmentShader, 1, &text, nullptr);
            glCompileShader(fragmentShader);
            // here compilation error check must go which I skipped for the sake of simplicity

            m_Program = glCreateProgram();
            glAttachShader(m_Program, vertexShader);
            glAttachShader(m_Program, fragmentShader);
            glLinkProgram(m_Program);
            // here link error check must go which I skipped for the sake of simplicity

            glDetachShader(m_Program, vertexShader);
            glDetachShader(m_Program, fragmentShader);
            glDeleteShader(vertexShader);
            glDeleteShader(fragmentShader);

            // --- Create buffer and populate it with vertex data ---
            GLfloat vertexData[]{
                -0.5f, 0.5f, 0.5f,      // vertex 1 coordinates
                1.0f, 0.0f, 0.0f, 1.0f, // vertex 1 color = red

                0.0f, -0.5f, 0.5f,      // vertex 2 coordinates
                0.0f, 1.0f, 0.0f, 1.0f, // vertex 1 color = green

                0.5f, 0.5f, 0.5f,       // vertex 3 coordinates
                0.0f, 0.0f, 1.0f, 1.0f  // vertex 1 color = blue
            };

            glGenBuffers(1, &m_VertexBufferObject);
            glBindBuffer(GL_ARRAY_BUFFER, m_VertexBufferObject);
            glBufferData(GL_ARRAY_BUFFER, sizeof(vertexData), vertexData, GL_STATIC_DRAW);

            // --- Create Vertex Array Object (VAO) and store vertex data format in it ---
            glGenVertexArrays(1, &m_VAO);
            glBindVertexArray(m_VAO);

            GLuint positionAttribLocation = glGetAttribLocation(m_Program, "v3_position");
            glVertexAttribPointer(
                /*index*/      positionAttribLocation,
                /*size*/       3,
                /*type*/       GL_FLOAT,
                /*normalized*/ GL_FALSE,
                /*stride*/     sizeof(GLfloat) * 7,
                /*pointer*/    nullptr
            );
            glEnableVertexAttribArray(positionAttribLocation);

            GLuint colorAttribLocation = glGetAttribLocation(m_Program, "v4_color");
            glVertexAttribPointer(
                /*index*/      colorAttribLocation,
                /*size*/       4,
                /*type*/       GL_FLOAT,
                /*normalized*/ GL_FALSE,
                /*stride*/     sizeof(GLfloat) * 7,
                /*pointer*/    (void*)(sizeof(GLfloat)*3)
            );
            glEnableVertexAttribArray(colorAttribLocation);
        }

    protected:
        void UpdateTime(double currentTime, double timeDelta) override { }
        void setupCamera(const engine::Camera& cam) override { }

        void OnRender(opengl::OpenGLWindow* sender) override
        {
            // use the shader program
            glUseProgram(m_Program);

            // bind the VAO, that stores vertex atrributes' format
            glBindVertexArray(m_VAO);

            // draw the triangle
            glDrawArrays(
                /*mode*/  GL_TRIANGLES, // draw triangles (not lines and not points)
                /*first*/ 0,            // start drawing triangle from the 1st vertex
                /*count*/ 3);           // triangle has 3 vertices
        }
};

В конструкторе класса Program подготавливается все, что нужно для рисования. Само рисование происходит в методе Program::OnRender. Подготовка к рисованию, как уже было сказано, включает 1) загрузку и компиляцию шейдеров и 2) описание вершин объекта, который мы будем рисовать, т. е. треугольника. В программе каждый из этих этапов отмечен комментарием.

Вершины

Сперва обсудим вершины, т. е. полигональную сетку. Для начала надо решить, какие данные будут связаны с каждой вершиной. В нашем примере у каждой вершины будут координаты в трехмерном пространстве XYZ (для данного примера можно было бы обойтись и двумя координатами XY, но пусть будет три, ведь впоследствии мы будем иметь дело с трехмерным объектами) и цвет. Вершин у нашего объекта три, следовательно, имеем три набора по три координаты и три цвета. Все эти данные в OpenGL должны помещаться в так называемые буферы (Буферы, в которые помещаются вершины, в литературе часто называют vertex buffer objects — VBO. Данные из буфера VBO являются входными данными для шейдера vertex shader.) Вообще в буферы помещаются как правило любые данные большого размера, используемые для рисования, это могут быть массивы атрибутов вершин, массивы индексов вершин, текстуры, карты shadow maps, переменные (uniform block) и пр. Буфер надо 1) создать и 2) загрузить в него данные. Ссылаемся на буфер мы, как и на все объекты в OpenGL, по его дескриптору. В нашей программе дескриптор VBO хранится в поле GLuint m_VertexBufferObject. Есть разные варианты хранения вершин. Например, можно создать два буфера, в один из которых поместить координаты всех трех вершин, а в другой — их цвета. Но мне кажется наиболее логичным хранить все атрибуты вершин в одном буфере.

В OpenGL постоянно встречаются понятия bind, binding point и т. п. Так или иначе, чтобы поработать («поработать» может означать модифицировать состояние объекта или использовать его для рисования) с некоторым объектом OpenGL (например с VBO), его надо привязать к так называемой точке привязки в контексте OpenGL. Например, чтобы загрузить в VBO массив атрибутов вершин, надо привязать VBO к точке привязки (в нашем примере точка привязки называется GL_ARRAY_BUFFER). Когда работу с объектом заканчивают, то его как правило «отвязывают» от точки привязки. О привязках хорошо написано в [McKesson, Introduction. What is OpenGL].

Шейдеры

Теперь поговорим о шейдерах. Их два: vertex shader и fragment shader. Это минимальное необходимое число шейдеров в шейдерной программе (меньше их быть не может). Ниже приведен их исходный код на языке GLSL.

// Vertex shader
// passthrough.vert

#version 400 core

layout (location = 0) in vec3 v3_position; // vertex position
layout (location = 1) in vec4 v4_color;    // vertex color

out vec4 vs_color;

void main(void)
{
    // gl_Position has the type vec4. It consists of four components: X, Y, Z and W.
    // W accepts the value 1.0f in this example. I'll give you more details in the future posts.
    gl_Position = vec4(v3_position, 1.0f);

    vs_color = v4_color;
}

Vertex shader — это программа, которая выполняется по одному разу для каждой вершины нашей полигональной сетки. Поскольку мы рисуем треугольник, то vertex shader за время отрисовки выполнится три раза. У vertex shader’а есть так называемые входные переменные или атрибуты (vertex input variables/attributes). Это те данные, которые этот шейдер получает на входе. Набор значений атрибутов соответствует ровно одной вершине. Атрибуты вершины — это позиция в виде вектора из трех координат XYZ vec3 v3_position и цвет в виде вектора из четырех каналов RGBA vec4 v4_color. Результатом работы шейдера является присваивание значения глобальной переменной gl_Position и выходной переменной vs_color. Последняя является входной переменной для fragment shader’а, исходный код которого приведен ниже. В нашем примере со входными переменными не производится никаких преобразований, они передаются на выход как есть (поэтому файлы шейдеров и названы словом «passthrough»).

// Fragment shader
// passthrough.frag

#version 400 core

in vec4 vs_color; // color produced by the vertex shader

out vec4 color;

void main()
{
    color = vs_color;
}

Fragment shader выполняется после растеризации нашего треугольника. Растеризация — это разбиение треугольника на отдельные пиксели (они называются фрагментами). Для каждого фрагмента выполняется fragment shader. Поскольку фрагментов в треугольнике гораздо больше, чем вершин, то и fragment shader выполняется большее число раз, чем vertex shader. При этом значение входной переменной vs_color линейно интерполируется (подробнее об этом читайте в [Wolf]). Результатом работы этого шейдера является цвет пикселя, который помещается в выходную переменную vec4 color. В нашем примере с цветом, который fragment shader получает на входе тоже не производится никаких преобразований, он передается на выход как есть.

В начале программы мы загружаем из текстовых файлов шейдеры, компилируем их (glCompileShader) и компонуем программу (glLinkProgram), после чего сами шейдеры перестают быть нам нужны, поэтому мы их удаляем (glDeleteShader). В приведенной программе я убрал проверку наличия ошибок компиляции и компоновки, чтобы сократить длину кода — об этих вещах читайте [Wolf, Chapter 1. Getting started with GLSL. Compiling a shader].

Vertex Array Object

И наконец, последний, третий этап подготовки к рисованию — создание VAO — Vertex Array Object (VAO). VAO — это штука, которая связывает полигональную сетку и шейдерную программу. У программы есть входные данные — это атрибуты вершин — входные переменные шейдера vertex shader. Таких входных переменных может быть много. Значения их поступают из буфера (VBO) или из нескольких подобных буферов. Как правило, в буфере хранится массив элементов одинакового формата (по одному на каждую вершину). Каждый элемент содержит значения входных переменных vertex shader’а. Но программа «не знает» формата тех данных, которые хранятся в буфере VBO. А форматы могут быть самые разные. Например, можно хранить две, а можно — три координаты вершин объекта. Каждую координату можно хранить в виде целого числа или числа с плавающей точкой, двойной или одинарной точности. Вариантов на самом деле значительно больше. То же касается и формата, в котором хранится цвет. Эти форматы мы указываем при помощи функции glVertexAttribPointer. VAO всю эту информацию о формате данных запоминает. VAO также запоминает ссылку на сам буфер (или буферы, если их несколько) с данными. Ниже я как мог изобразил как данные из буфера VBO должны соответствовать входным переменным шейдера vertex shader:

    |    Vertex Array Object    |               Vertex Shader                  |
    |---------------------------+----------------------------------------------|
1st |-0.5f, 0.5f, 0.5f,       --|-> layout (location = 0) in  vec3 v3_position |
    | 1.0f, 0.0f, 0.0f, 1.0f, --|-> layout (location = 1) vec4 v4_color        |
    |---------------------------+----------------------------------------------|
2nd | 0.0f, -0.5f, 0.5f,      --|-> layout (location = 0) in  vec3 v3_position |
    | 0.0f, 1.0f, 0.0f, 1.0f, --|-> layout (location = 1) vec4 v4_color        |
    |---------------------------+----------------------------------------------|
3rd | 0.5f, 0.5f, 0.5f,       --|-> layout (location = 0) in  vec3 v3_position |
    | 0.0f, 0.0f, 1.0f, 1.0f  --|-> layout (location = 1) vec4 v4_color        |

Что мы должны сообщить OpenGL, чтобы всё было именно так? А вот что:

  • Между адресами двух соседних элементов массива вершин имеется расстояние в 7*sizeof(GLfloat) = 28 байт. По-английски это расстояние называется stride. OpenGL должен знать его для того, чтобы определить где в буфере начинается очередная вершина. Если i-я вершина начинается с адреса Addr, то (i+1)-я вершина начинается с адреса Addr+stride.
  • В шейдере за координаты вершины отвечает атрибут vec3 v3_position.
  • Координаты каждой вершины находятся в самом начале элемента массива вершин (т. е. смещение относительно начала каждого элемента массива равно 0 байт).
  • Координат три: XYZ.
  • Каждая координата имеет тип GLfloat.
  • В шейдере за цвет вершины отвечает атрибут vec4 v4_color.
  • Цвет вершины расположен по смещению 3*sizeof(GLfloat) = 12 байт относительно начала каждого элемента массива вершин.
  • Цвет состоит из четырех компонент: RGBA.
  • Каждая компонента цвета имеет тип GLfloat.

Посмотрим, как вся эта информация записывается в объект VAO при помощи функции glVertexAttribPointer:

glBindVertexArray(m_VAO); // bind VAO
glBindBuffer(GL_ARRAY_BUFFER, m_VertexBufferObject); // store the reference to the vertex buffer object in the VAO

GLuint positionAttribLocation = glGetAttribLocation(m_Program, "v3_position");
glVertexAttribPointer(
    /*index*/      positionAttribLocation, // the location of vertex shader input variable v3_position is 0 (layout (location = 0) in vec3 v3_position)
    /*size*/       3,                      // the coordinates have 3 components: XYZ
    /*type*/       GL_FLOAT,               // the type of a single component is GLfloat
    /*normalized*/ GL_FALSE,               // this parameter is applicable to integer components only (which is not the case here)
    /*stride*/     sizeof(GLfloat) * 7,    // stride between two consequent vertices equals the size of one vertex because vertices are tightly packed
    /*pointer*/    nullptr                 // the offset of the coordinates from the beginning of the vertex is zero because the coordinates are located at the beggining of each vertex
);
glEnableVertexAttribArray(positionAttribLocation);

GLuint colorAttribLocation = glGetAttribLocation(m_Program, "v4_color");
glVertexAttribPointer(
    /*index*/      colorAttribLocation,       // the location of vertex shader input variable v3_position is 1 (layout (location = 1) in vec3 v4_color)
    /*size*/       4,                         // the color has 4 components: RGBA
    /*type*/       GL_FLOAT,                  // the type of a single component is GLfloat
    /*normalized*/ GL_FALSE,                  // this parameter is applicable to integer components only (which is not the case here)
    /*stride*/     sizeof(GLfloat) * 7,       // stride between two consequent vertices equals the size of one vertex because vertices are tightly packed
    /*pointer*/    (void*)(sizeof(GLfloat)*3) // the offset of color from the beginning of vertex equals the size of coordinates data because the color is located right after the coordinates
);
glEnableVertexAttribArray(colorAttribLocation);

Как видите, для того, чтобы сохранить в VAO информацию о форматах атрибутов вершин и ссылку на буфер, из которого вершины берутся, надо предварительно привязать VAO к контексту OpenGL при помощи функции glBindVertexArray. Это правило в OpenGL: хочешь работать с объектом — привяжи его к контексту. И наконец, рассмотрим функцию, в которой непосредственно происходит рисование — Program::OnRender:

void OnRender(opengl::OpenGLWindow* sender) override
{
    // use the shader program
    glUseProgram(m_Program);

    // bind the VAO, that stores vertex atrributes' format
    glBindVertexArray(m_VAO);

    // draw the triangle
    glDrawArrays(
        /*mode*/  GL_TRIANGLES, // draw triangles (not lines and not points)
        /*first*/ 0,            // start drawing triangle from the 1st vertex
        /*count*/ 3);           // triangle has 3 vertices
}

Тут всё более-менее понятно. Главная часть — вызов функции glDrawArrays (вообще, существует несколько подобных функций, о них мы еще поговорим). Перед этим вызовом мы говорим OpenGL использовать для рисования ту шейдерную программу, которую мы в ранее скомпилировали из шейдеров (функция glUseProgram), и привязываем VAO — объект, который хранит информацию о том, из какого буфера следует считывать вершины и в каком формате они хранятся (функция glBindVertexArray). Эти две операции в данном примере конечно необязательны, так как и программа уже используется, и VAO уже был привязан в конструкторе класса Program. Но, тем не менее, этот подход правильный, и к нему надо привыкать, поскольку в более сложных примерах у нас может оказаться и несколько программ, и несколько различных VAO, поэтому каждый раз перед рисованием их нужно привязывать к контексту.

В следующей заметке начнем переписывать приведенную программу рисования треугольника, чтобы сделать ее проще и понятней. Делать это мы будем путем создания некоего набора классов, т. е. своего фреймворка, движка, API… или как вам будет угодно это называть.

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

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