OpenGL communityСтатьи

Урок 2 - Рисуем первый треугольник

Автор:

Рисуем первый треугольник с использованием OpenGL 3.3

Введение

Если вы уже работали с версиями OpenGL младше 3.0, то наверняка вам приходилось использовать конструкции вид glBegin() / glEnd():

glBegin(GL_TRIANGLES);
  glColor3f(1.0f, 0.0f, 0.0f);
  glVertex3f(-1.0f, -1.0f, -2.0f);

  glColor3f(0.0f, 1.0f, 0.0f);
  glVertex3f( 0.0f,  1.0f, -2.0f);

  glColor3f(0.0f, 0.0f, 1.0f);
  glVertex3f( 1.0f, -1.0f, -2.0f);
glEnd();

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

Изображение

Расширения

Из первого урока вы узнали как создать контекст с поддержкой OpenGL 3.3, однако одного контекста мало, необходимо еще получить доступ к функционалу OpenGL 3.3 Для этого предусмотрен специальный механизм получения указателей на функции из драйвера видеокарты, который был мельком упомянут в предыдущем уроке, для Windows это функция wglGetProcAddress.

Стоит также оговориться почему нам необходима функция wglGetProcAddress, дело в том что при сборке приложения в Windows нам доступна только библиотека с поддержкой функционала OpenGL 1.1, а все функции из старших версий мы должны загружать уже в процессе работы приложения, динамически. Не во всех ОС это так, например в Linux производители драйверов для видеокарт распространяют библиотеку с поддержкой именно того функционал, который есть в драйвере, его можно подключать уже в момент сборки приложения, статически. Но даже там есть возможность загружать расширения в процессе работы приложения, сделано это для того, чтобы бинарные файлы собранные на одном компьютере могли быть запущены на другом, возможно с совершенно другим драйвером.

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

Динамическая загрузка функция достаточно проста, вы уже видели как используется функция wglGetProcAddress в прошлом уроке - надо создать контекст OpenGL нужной версии и можно приступать к загрузке функций. Для динамической загрузки функций нам понадобится файл с прототипами этих функций glext.h. Этот свободно доступен с сайта opengl.org и с выходом новых версий стандарта OpenGL он обновляется. В архиве с исходниками к этому уроку этот файл уже есть.

Используя прототипы мы определим указатели на функции и после создания контекста с поддержкой OpenGL 3.3 загрузим их динамически. Выглядит это так:

// используя прототип из glext.h определяем указатель на функцию
PFNGLCREATEPROGRAMPROC glCreateProgram = NULL;

// после создания контекста получаем указатель на функцию
glCreateProgram = (PFNGLCREATEPROGRAMPROC)wglGetProcAddress("glCreateProgram");

// запишем в лог ошибку, если не удалось получить указатель на функцию из драйвера
if (glCreateProgram == NULL)
{
  LOG_ERROR("Loading extension 'glCreateProgram' fail (%d)\n", GetLastError());

  return false;
}

В исходниках приложенных к этому уроку процесс получения указателя и проверки на успех немного автоматизирован с использованием макроса OPENGL_GET_PROC:

// макрос для получения указателя на функцию и проверка его на валидность
#define OPENGL_GET_PROC(p,n) \
  n = (p)wglGetProcAddress(#n); \
  if (NULL == n) \
  { \
    LOG_ERROR("Loading extension '%s' fail (%d)\n", #n, GetLastError()); \
    return false; \
  }

// используется макрос следующим образом
OPENGL_GET_PROC(PFNGLCREATEPROGRAMPROC, glCreateProgram);

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

Шейдеры

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

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

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

Шейдеры для OpenGL пишутся с использованием специального языка GLSL, на который имеется спецификация (доступна с сайта opengl.org). Мы будем использовать GLSL 3.30, эта версия GLSL вышла одновременно с выходом стандарта OpenGL 3.3.

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

#version 330 core

uniform mat4 projectionMatrix;

in vec3 position;
in vec3 color;

out vec3 fragmentColor;

void main(void)
{
  // перевод вершинных координат в однородные
  gl_Position   = projectionMatrix * vec4(position, 1.0);
  // передаем цвет вершины в фрагментный шейдер
  fragmentColor = color;
}

Входные атрибуты обозначены как in, а выходные атрибуты как out, юниформы обозначаются как uniform. Выходные атрибуты предназначены для передачи данных из шейдера на следующий этап обработки данных, в нашем случае следующий этап это фрагментный шейдер:

#version 330 core

in vec3 fragmentColor;

out vec4 color;

void main(void)
{
  // зададим цвет пикселя
  color = vec4(fragmentColor, 1.0);
}

Здесь имеется выходной атрибут color, он определяет какого цвета будет пиксель на экране. Стоит также отметить что фрагментный шейдер получает интерполированные данные из предыдущего вершинного шейдера, каким образом данные будут интерполироваться можно указать отдельно для каждого атрибута, по-умолчанию это обычная линейная интерполяция. Таким образом, в случае с атрибутом fragmentColor, цвет меняется "линейно" от вершины к вершине.

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

Полностью создание шейдерной программы происходит следующим образом:

// переменные для хранения индекса шейдерной программы и шейдеров
GLuint shaderProgram, vertexShader, fragmentShader;

// переменные для хранения загруженных файлов
uint8_t  *shaderSource;
uint32_t sourceLength;

// создадим шейдерную программу и шейдеры для нее
shaderProgram  = glCreateProgram();
vertexShader   = glCreateShader(GL_VERTEX_SHADER);
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);


// загрузим вершинный шейдер
if (!LoadFile("data/lesson.vs", true, &shaderSource, &sourceLength))
  return false;

// зададим шейдеру исходный код и скомпилируем его
glShaderSource(vertexShader, 1, (const GLchar**)&shaderSource, (const GLint*)&sourceLength);
glCompileShader(vertexShader);

// исходный код шейдера нам больше не нужен
delete[] shaderSource;

// проверим статус компиляции шейдера
if (ShaderStatus(vertexShader, GL_COMPILE_STATUS) != GL_TRUE)
  return false;


// загрузим фрагментный шейдер
if (!LoadFile("data/lesson.fs", true, &shaderSource, &sourceLength))
  return false;

// зададим шейдеру исходный код и скомпилируем его
glShaderSource(fragmentShader, 1, (const GLchar**)&shaderSource, (const GLint*)&sourceLength);
glCompileShader(fragmentShader);

// исходный код шейдера нам больше не нужен
delete[] shaderSource;

// проверим статус компиляции шейдера
if (ShaderStatus(fragmentShader, GL_COMPILE_STATUS) != GL_TRUE)
  return false;


// присоединим загруженные шейдеры к шейдерной программе
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);

// сборка шейдерной программы из прикрепленных шейдеров
glLinkProgram(shaderProgram);

// проверим успешна ли сборка шейдерной программы
if (ShaderProgramStatus(shaderProgram, GL_LINK_STATUS) != GL_TRUE)
  return false;


// сделаем шейдерную программу активной
glUseProgram(shaderProgram);

// проверка корректности шейдерной программы
// этот шаг не обязателен, но желательно его делать
glValidateProgram(shaderProgram);
if (ShaderProgramStatus(shaderProgram, GL_VALIDATE_STATUS) != GL_TRUE)
  return false;

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

// проверка статуса param шейдера shader
GLint ShaderStatus(GLuint shader, GLenum param)
{
  GLint status, length;
  GLchar buffer[1024];

  // получим статус шейдера
  glGetShaderiv(shader, param, &status);

  // в случае ошибки запишем ее в лог-файл
  if (status != GL_TRUE)
  {
    glGetShaderInfoLog(shader, 1024, &length, buffer);
    LOG_ERROR("Shader: %s\n", (const char*)buffer);
  }

  // проверим не было ли ошибок OpenGL
  OPENGL_CHECK_FOR_ERRORS();

  // вернем статус
  return status;
}

// проверка статуса param шейдерной программы program
GLint ShaderProgramStatus(GLuint program, GLenum param)
{
  GLint status, length;
  GLchar buffer[1024];

  // получим статус шейдерной программы
  glGetProgramiv(program, param, &status);

  // в случае ошибки запишем ее в лог-файл
  if (status != GL_TRUE)
  {
    glGetProgramInfoLog(program, 1024, &length, buffer);
    LOG_ERROR("Shader program: %s\n", (const char*)buffer);
  }

  // проверим не было ли ошибок OpenGL
  OPENGL_CHECK_FOR_ERRORS();

  // вернем статус
  return status;
}

Вершинный буфер и его связь с вершинными атрибутами

Для хранения данных о вершинах геометрии в OpenGL существует специальный объект называемый Vertex Buffer Object, коротко VBO. VBO позволяет создать буфер в памяти видеокарты и поместить туда необходимые нам данные. Вершинный буфер создается с подсказкой, как часто мы будем менять данные в этом буфере.

Однако создания одного VBO недостаточно, необходимо создать еще один специальный объект Vertex Array Object, коротко VAO. VAO хранит связи между параметрами вершинных атрибутов и источниками данных в VBO. В VAO хранится из какого VBO какие атрибуты берутся, тип и размер этих атрибутов, смещение в буфере VBO до начала данных для этих атрибутов. Есть одно исключение - VBO для хранения индексов, к VAO можно присоединить только один такой VBO, но про индексный буфер будет рассказано в отдельном уроке.

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

В этом уроке мы используем VBO для хранения всего трех вершин для нашего треугольника, менять мы их не собираемся, поэтому данные поместим в VBO всего один раз, при инициализации OpenGL, там же создадим VAO:

// количество вершин в нашей геометрии, у нас простой треугольник
const int vertexCount = 3;

// размер одной вершины меша в байтах - 6 float на позицию и на цвет вершины
const int vertexSize = 6 * sizeof(float);

// подготовим данные для вывода треугольника, всего 3 вершины по 6 float на каждую
static const float triangleMesh[vertexCount * 6] = {
  /* 1 вершина, позиция: */ -1.0f, -1.0f, -2.0f, /* цвет: */ 1.0f, 0.0f, 0.0f,
  /* 2 вершина, позиция: */  0.0f,  1.0f, -2.0f, /* цвет: */ 0.0f, 1.0f, 0.0f,
  /* 3 вершина, позиция: */  1.0f, -1.0f, -2.0f, /* цвет: */ 0.0f, 0.0f, 1.0f
};

// переменные для хранения индексов VAO и VBO
GLuint meshVAO, meshVBO;


// создадим Vertex Array Object (VAO)
glGenVertexArrays(1, &meshVAO);

// установим созданный VAO как текущий
glBindVertexArray(meshVAO);

// создадим Vertex Buffer Object (VBO)
glGenBuffers(1, &meshVBO)

// устанавливаем созданный VBO как текущий
glBindBuffer(GL_ARRAY_BUFFER, meshVBO);

// заполним VBO данными треугольника
glBufferData(GL_ARRAY_BUFFER, vertexCount * vertexSize, triangleMesh, GL_STATIC_DRAW);

Передача атрибутов и юниформов в шейдер

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

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

У атрибутов есть одна особенность - мы можем использовать функцию glBindAttribLocation для назначения своих индексов атрибутам прежде чем вызвать функцию glLinkProgram. Можно и не устанавливать свои индексы, шейдер это сделает за нас, именно так и сделано в исходных кодах к этому уроку.

Когда шейдерная программа собрана, индексы интересующих нас атрибутов и юниформов получены и в VBO скопированы данные геометрии - можно настроить параметры вершинных атрибутов:

// переменные для хранения индексов атрибутов
GLint positionLocation, colorLocation;

// смещения данных внутри вершинного буфера
const int vertexOffsetPosition = 0;
const int vertexOffsetColor    = 3 * sizeof(float);


// получим индекс атрибута 'position' из шейдера
positionLocation = glGetAttribLocation(shaderProgram, "position");
if (positionLocation != -1)
{
  // укажем параметры доступа вершинного атрибута к VBO
  glVertexAttribPointer(positionLocation, 3, GL_FLOAT, GL_FALSE,
    vertexSize, (const GLvoid*)vertexOffsetPosition);
  // разрешим использование атрибута
  glEnableVertexAttribArray(positionLocation);
}

// получим индекс атрибута 'color' из шейдера
colorLocation = glGetAttribLocation(shaderProgram, "color");
if (colorLocation != -1)
{
  // укажем параметры доступа вершинного атрибута к VBO
  glVertexAttribPointer(colorLocation, 3, GL_FLOAT, GL_FALSE,
    vertexSize, (const GLvoid*)vertexOffsetColor);
  // разрешим использование атрибута
  glEnableVertexAttribArray(colorLocation);
}

Матрицы

В данном уроке нам понадобится только одна матрица - перспективная матрица проекции. Она нам необходима, чтобы переводить позиции вершин в однородные координаты (clip space). Раньше установка матрицы проекции делалась достаточно просто, сначала мы говорили, что хотим поменять матрицу проекции командой glMatrixMode(GL_PROJECTION), а потом указывали параметры матрицы проекции, например для создания перспективной матрицы проекции использовалась функция gluPerspective(fov, aspect, znear, zfar).

В OpenGL версии 3.0 и выше отменили работу со стеком матриц OpenGL, теперь необходимо вручную создавать матрицы и передавать их в шейдер для дальнейшего использования. Создать матрицу, подобную той что делает gluPerspective, просто:

// построение перспективной матрицы проекции
void Matrix4Perspective(float *M, float fovy, float aspect, float znear, float zfar)
{
  float f = 1 / tanf(fovy / 2),
        A = (zfar + znear) / (znear - zfar),
        B = (2 * zfar * znear) / (znear - zfar);

  M[ 0] = f / aspect; M[ 1] =  0; M[ 2] =  0; M[ 3] =  0;
  M[ 4] = 0;          M[ 5] =  f; M[ 6] =  0; M[ 7] =  0;
  M[ 8] = 0;          M[ 9] =  0; M[10] =  A; M[11] =  B;
  M[12] = 0;          M[13] =  0; M[14] = -1; M[15] =  0;
}

В итоге в массиве M у нас будет готовая перспективная матрица. Именно эту матрицу мы будем передавать в шейдер как юниформ projectionMatrix:

// массив для хранения перспективной матрици проекции
float projectionMatrix[16];

// переменная для хранения индекса юниформа
GLint projectionMatrixLocation;

// кожффициент отношения сторон окна OpenGL
const float aspectRatio = (float)window->width / (float)window->height;


// создадим перспективную матрицу проекции
Matrix4Perspective(projectionMatrix, 45.0f, aspectRatio, 0.5f, 5.0f);

// получим индекс юниформа projectionMatrix из шейдерной программы
projectionMatrixLocation = glGetUniformLocation(shaderProgram, "projectionMatrix");

// передадим матрицу в шейдер
if (projectionMatrixLocation != -1)
    glUniformMatrix4fv(projectionMatrixLocation, 1, GL_TRUE, projectionMatrix);

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

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

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

Наконец мы подошли к последнему шагу нашего урока - вывод треугольника на экран. Совместив все предыдущие занния мы можем создать шейдер, загрузить его, установить нужные параметры, скопировать данные геометрии в буфер видеокарты, установить матрицу и наконец приступать к рендерингу треугольника:

// очистим буфер цвета и глубины
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

// делаем шейдерную программу активной
glUseProgram(shaderProgram);

// для рендеринга исопльзуем VAO
glBindVertexArray(meshVAO);

// рендер треугольника из VBO привязанного к VAO
glDrawArrays(GL_TRIANGLES, 0, vertexCount);

Использование VAO

Согласно стандарту OpenGL версии 3.3 операции над вершинными атрибутами без активного VAO более не допускаются (секция E стандарта). Указано, что VAO по-умолчанию (индекс 0) считается устаревшим и при использовании функции glVertexAttribPointer без текущего активного VAO будет выдана ошибка INVALID_OPERATION. На момент написания урока в драйверах ATI именно так и сделано, однако nVidia все еще позволяет пользователю не создавать VAO.

Полезные ссылки

Исходный код

Доступ к исходному коду уроку с проектом для MSVC можно получить двумя способами:

#OpenGL, #уроки

26 октября 2010 (Обновление: 5 мая 2020)

Комментарии [64]