Этот урок будет немного сложнее предыдущего, но зато интересней, в нем будет рассмотрен следующий материал:
- Системы координат OpenGL
- Работа с матрицами, подготовка и передача их в шейдерную программу
- Создание простой текстуры и передача ее в шейдер
- Создание вершинного и индексного буфера для геометрии куба
- Вывод вращающегося куба на экран
Координаты объекта, в режиме FFP (Fixed Function Pipeline) в предыдущих версиях OpenGL, проходили следующие преобразования системы координат:
- Локальные координаты преобразуются матрицей
ModelView
в видовые (eye space) - Видовые преобразуются матрицей
Projection
в однородные (clip space) - Однородные
(X,Y,Z,W)
преобразуются в нормализованные(X/W, Y/W, Z/W, 1)
- Нормализованные преобразуются параметрами
glViewport
иglDepthRange
в экранные (screen space
)
Разработчикам также была облегчена работа с матрицами преобразований. В частности разработчик мог использовать стек матриц в различных режимах работы с матрицами. Режим работы задавался с помощью функции glMatrixMode
, например можно было задать режим GL_MODELVIEW
или GL_PROJECTION
, который давал доступ к модельно-видовой (ModelView
) и проекционной (Projection
) матрицам, соответственно. Для работы со стеком матриц предназначались функции glPushMatrix
и glPopMatrix
. Однако в OpenGL версии 3 и выше все эти функции были объявлены устаревшими и исключены из API.
В OpenGL 3.3 за первые два пункта преобразований координат теперь отвечает разработчик: используя шейдерную программу он должен перевести локальные координаты объекта в однородные. Каким образом он это сделает - неважно, он может эмулировать старую схему или придумать что-то свое, главное получить однородные координаты.
Однородные координаты называются так неспроста, они переводят все имеющиеся координаты в единое пространство, ограниченное по всем осям системы координат параметром W
. В итоге, после перевода однородных координат в нормализованные, любые координаты вершин, которые необходимо отобразить, находятся в пределах [-1, 1]
по всем трем осям нормализованной системы координат. Если координаты вершины не попадают в этот интервал - вершина отбрасывается.
Зачастую разработчики графических приложений разбивают первый этап преобразования из локальных координат в видовые на два:
- Трансформация координат матрицей объекта (
ModelMatrix
) - Трансформация координат матрицей наблюдателя (
ViewMatrix
)
Такое разбиение очень удобно - у каждого объекта есть матрица преобразований, которая переводит локальную систему координат в мировую и есть наблюдатель, положение которого задано в мировых координатах. Таким образом мы можем оперировать неким дополнительным пространством - мировым (world
), это пространство служит для описания сцены и расположения на ней объектов и наблюдателей.
Терминология обозначения матриц и систем координат одна из многих причин путаниц при работе с разными графическими API, например в DirectX матрица перевода локальной системы координат объекта в мировые называется не ModelMatrix
, а WorldMatrix
.
Кстати, в OpenGL правая система координат, т.е. построенная по правилу правой руки. В начальном положении ось Z
направлена на нас, ось X
направлена вправо и ось Y
направлена вверх, относительно экрана монитора.
В этом и последующих уроках будет использоваться подход к матрицам описанный выше, для описания объекта на сцене необходимо будет задать три матрицы: ModelMatrix
, ViewMatrix
и ProjectionMatrix
.
Матрицы ViewMatrix
и ProjectionMatrix
обычно привязаны к специальному объекту - камере. Матрица ViewMatrix
меняется если наблюдатель изменил свое положение или направление взгляда. Матрица ProjectionMatrix
меняется гораздо реже, например при переключении из меню приложения к сцене и т.п.
Матрица ModelMatrix
закреплена за объектом, она меняется при движении объекта или его вращении.
Для того, чтобы передать матрицу в шейдерную программу надо сделать несколько действий:
- Указать в шейдере тип принимаемой матрицы -
uniform matN matrixName
- После сборки шейдерной программ (
link
) получить индекс юниформа (location
) - Передать матрицу в шейдерную программу используя одну из функций
glUniformMatrix
Передавать все три матрицы в шейдерную программу весьма расточительно - необходимо будет вычислять матрицу преобразования локальных координат в видовые на каждую вершину объекта, поэтому обычно итоговую матрицу преобразований вычисляют в самом приложении, отдельно для каждого объекта, и передают ее в шейдерную программу перед выводом этого объекта на экран.
В этом уроке мы будем выводить на экран вращающийся куб, для этого нам потребуется рассчитать матрицу вращения ModelMatrix
:
// построение матрицы вращения
void Matrix4Rotation(Matrix4 M, float x, float y, float z)
{
const float A = cosf(x), B = sinf(x), C = cosf(y),
D = sinf(y), E = cosf(z), F = sinf(z);
const float AD = A * D, BD = B * D;
M[ 0] = C * E; M[ 1] = -C * F; M[ 2] = D; M[ 3] = 0;
M[ 4] = BD * E + A * F; M[ 5] = -BD * F + A * E; M[ 6] = -B * C; M[ 7] = 0;
M[ 8] = -AD * E + B * F; M[ 9] = AD * F + B * E; M[10] = A * C; M[11] = 0;
M[12] = 0; M[13] = 0; M[14] = 0; M[15] = 1;
}
Функция Matrix4Rotation
строит матрицу вращения для углов поворота по трем осям координат (x, y, z)
. Если вас интересует подробное описание построение матрицы поворота, то рекомендую ознакомиться с детальным The Matrix and Quaternions FAQ. Отдельно на этой теме мы в уроке останавливаться не будем.
Помимо этого мы отодвинем точку наблюдения от куба, чтобы видеть его полностью, для этого нам понадобиться построить матрицу переноса ViewMatrix
:
// построение матрицы переноса
void Matrix4Translation(Matrix4 M, float x, float y, float z)
{
M[ 0] = 1; M[ 1] = 0; M[ 2] = 0; M[ 3] = x;
M[ 4] = 0; M[ 5] = 1; M[ 6] = 0; M[ 7] = y;
M[ 8] = 0; M[ 9] = 0; M[10] = 1; M[11] = z;
M[12] = 0; M[13] = 0; M[14] = 0; M[15] = 1;
}
Функция Matrix4Translation
строит матрицу переноса по трем координатным осям (x, y, z)
. Опять же детальную информацию вы можете получить в The Matrix and Quaternions FAQ.
Также нам понадобиться матрица проекции ProjectionMatrix
, о которой было рассказано в предыдущем уроке. Также как и там мы будем использовать перспективную матрицу проекции, построенную при помощи функции Matrix4Perspective
.
Однако это еще не все, как уже было сказано выше, нам необходимо рассчитать итоговую матрицу преобразований координат прежде чем передавать ее в шейдерную программу, для композиции трансформаций используется матричное умножение:
// перемножение двух матриц
void Matrix4Mul(Matrix4 M, Matrix4 A, Matrix4 B)
{
M[ 0] = A[ 0] * B[ 0] + A[ 1] * B[ 4] + A[ 2] * B[ 8] + A[ 3] * B[12];
M[ 1] = A[ 0] * B[ 1] + A[ 1] * B[ 5] + A[ 2] * B[ 9] + A[ 3] * B[13];
M[ 2] = A[ 0] * B[ 2] + A[ 1] * B[ 6] + A[ 2] * B[10] + A[ 3] * B[14];
M[ 3] = A[ 0] * B[ 3] + A[ 1] * B[ 7] + A[ 2] * B[11] + A[ 3] * B[15];
M[ 4] = A[ 4] * B[ 0] + A[ 5] * B[ 4] + A[ 6] * B[ 8] + A[ 7] * B[12];
M[ 5] = A[ 4] * B[ 1] + A[ 5] * B[ 5] + A[ 6] * B[ 9] + A[ 7] * B[13];
M[ 6] = A[ 4] * B[ 2] + A[ 5] * B[ 6] + A[ 6] * B[10] + A[ 7] * B[14];
M[ 7] = A[ 4] * B[ 3] + A[ 5] * B[ 7] + A[ 6] * B[11] + A[ 7] * B[15];
M[ 8] = A[ 8] * B[ 0] + A[ 9] * B[ 4] + A[10] * B[ 8] + A[11] * B[12];
M[ 9] = A[ 8] * B[ 1] + A[ 9] * B[ 5] + A[10] * B[ 9] + A[11] * B[13];
M[10] = A[ 8] * B[ 2] + A[ 9] * B[ 6] + A[10] * B[10] + A[11] * B[14];
M[11] = A[ 8] * B[ 3] + A[ 9] * B[ 7] + A[10] * B[11] + A[11] * B[15];
M[12] = A[12] * B[ 0] + A[13] * B[ 4] + A[14] * B[ 8] + A[15] * B[12];
M[13] = A[12] * B[ 1] + A[13] * B[ 5] + A[14] * B[ 9] + A[15] * B[13];
M[14] = A[12] * B[ 2] + A[13] * B[ 6] + A[14] * B[10] + A[15] * B[14];
M[15] = A[12] * B[ 3] + A[13] * B[ 7] + A[14] * B[11] + A[15] * B[15];
}
Функция Matrix4Mul
перемножает матрицы A
и B
и помещает результат в матрицу M
. Надеюсь как выполняется матричное умножение знает каждый из вас и подробно объяснить смысл производимых в функции действий вам не надо :)
Стоит также вспомнить, что умножение матриц операция ассоциативная, т.е. A*(B*C) = (A*B)*C
, поэтому мы можем вычислить часть выражения заранее, а часть в случае необходимости.
В данном уроке мы не будем менять матрицу проекции и матрицу наблюдателя, поэтому их мы можем объединить заранее и не вычислять постоянно:
// переменные для хранения матриц
Matrix4 viewMatrix = {0.0f}, projectionMatrix = {0.0f}, viewProjectionMatrix = {0.0f};
// создадим перспективную матрицу
const float aspectRatio = (float)window->width / (float)window->height;
Matrix4Perspective(projectionMatrix, 45.0f, aspectRatio, 1.0f, 10.0f);
// с помощью матрицы наблюдателя отодвинем сцену назад
Matrix4Translation(viewMatrix, 0.0f, 0.0f, -4.0f);
// совместим матрицу проекции и матрицу наблюдателя
Matrix4Mul(viewProjectionMatrix, projectionMatrix, viewMatrix);
Для вращения куба нам необходимо построить матрицу вращения:
// матрица вращения куба и итоговая матрица трансформации
Matrix4 modelMatrix = {0.0f}, modelViewProjectionMatrix = {0.0f};
// зададим углы поворота куба с учетом времени
// просто произвольные значения
if ((cubeRotation[0] += 3.0f * (float)deltaTime) > 360.0f)
cubeRotation[0] -= 360.0f;
if ((cubeRotation[1] += 15.0f * (float)deltaTime) > 360.0f)
cubeRotation[1] -= 360.0f;
if ((cubeRotation[2] += 7.0f * (float)deltaTime) > 360.0f)
cubeRotation[2] -= 360.0f;
// рассчитаем матрицу преобразования координат вершин куба
Matrix4Rotation(modelMatrix, cubeRotation[0], cubeRotation[1], cubeRotation[2]);
Matrix4Mul(modelViewProjectionMatrix, viewProjectionMatrix, modelMatrix);
Для передачи матрицы в шейдерную программу нам необходим узнать ее индекс:
// переменная для хранения индекса матрицы в шейдерной программе
GLint matrixLocation;
// получим индекс матрицы
matrixLocation = glGetUniformLocation(shaderProgram, "modelViewProjectionMatrix");
После получения итоговой матрицы и ее индекса мы можем передать ее в шейдерную программу:
// предаем матрицу трансформации в формате row-major
glUniformMatrix4fv(matrixLocation, 1, GL_TRUE, modelViewProjectionMatrix);
Очень частая проблема, которая приводит к путанице в среде разработчиков графических приложений - формат самих матриц и формат расположения их в памяти.
Формат матрицы можно определить по формату векторов, это либо row-vector
матрица (используется вектор-строка), либо column-vector
матрица (используется вектор-столбец). Соответственно при использовании row-vector
необходимо вектор умножать на матрицу, а при использовании column-vector
умножать матрицу на вектор.
По формату расположения в памяти матрицы также делятся на два типа: row-major
матрица (матрица в памяти записана по строкам) и column-major
матрица (матрица в памяти записана по столбцам). Стоит отметить, что переход между форматами осуществляется путем транспонирования матрицы.
По историческим причинам OpenGL использует column-major
матрицы, соответственно ожидая на входе в свои функции именно этот формат, однако такой формат неудобен, поэтому в этих уроках используется row-major
формат расположения в памяти, а при передачи в шейдерную программу функцией glUniformMatrix
устанавливается флаг transpose
, которые переводит матрицу в column-major
формат.
Тема текстур в OpenGL очень объемная, существуют различные типа текстур для разных целей. В этом уроке мы создадим самую простую и наиболее часто используемую текстуру - двумерную текстуру, в OpenGL такая текстура обозначается как GL_TEXTURE_2D
.
Изображение для текстуры в этом уроке хранится в формате TGA. Этот очень простой формат, местами даже проще чем BMP. Для загрузки изображения из этого формата и создания текстуры используется функция TextureCreateFromTGA
:
// формат заголовка TGA
struct TGAHeader
{
uint8_t idlength;
uint8_t colormap;
uint8_t datatype;
uint8_t colormapinfo[5];
uint16_t xorigin;
uint16_t yorigin;
uint16_t width;
uint16_t height;
uint8_t bitperpel;
uint8_t description;
};
// функция загрузки изображения из файла TGA и сздания текстуры
GLuint TextureCreateFromTGA(const char *fileName)
{
ASSERT(fileName);
TGAHeader *header;
uint8_t *buffer;
uint32_t size;
GLint format, internalFormat;
GLuint texture;
// попытаемся загрузить изображение из файла
if (!LoadFile(fileName, true, &buffer, &size))
return 0;
// если размер файла заведомо меньше заголовка TGA
if (size <= sizeof(TGAHeader))
{
LOG_ERROR("Too small file '%s'\n", fileName);
delete[] buffer;
return 0;
}
header = (TGAHeader*)buffer;
// проверим формат TGA-файла - несжатое RGB или RGBA изображение
if (header->datatype != 2 || (header->bitperpel != 24 && header->bitperpel != 32))
{
LOG_ERROR("Wrong TGA format '%s'\n", fileName);
delete[] buffer;
return 0;
}
// получим формат текстуры
format = (header->bitperpel == 24 ? GL_BGR : GL_BGRA);
internalFormat = (format == GL_BGR ? GL_RGB8 : GL_RGBA8);
// запросим у OpenGL свободный индекс текстуры
glGenTextures(1, &texture);
// сделаем текстуру активной
glBindTexture(GL_TEXTURE_2D, texture);
// установим параметры фильтрации текстуры - линейная фильтрация
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// установим параметры "оборачивания" текстуры - отсутствие оборачивания
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// загрузим данные о цвете в текущую автивную текстуру
glTexImage2D(GL_TEXTURE_2D, 0, internalFormat, header->width, header->height, 0, format,
GL_UNSIGNED_BYTE, (const GLvoid*)(buffer + sizeof(TGAHeader) + header->idlength));
// после загрузки в текстуру данные о цвете в памяти нам больше не нужны
delete[] buffer;
// проверим на наличие ошибок
OPENGL_CHECK_FOR_ERRORS();
return texture;
}
Добавить к кооментариям исходного кода приведенного выше практически нечего, используя функцию TextureCreateFromTGA
полчаем готовую для исопльзования текстуру:
// переменная для хранения индекса текстуры
GLuint colorTexture = 0;
// создадим и загрузим текстуру
colorTexture = TextureCreateFromTGA("data/texture.tga");
// если не получилось загрузить текстуру
if (!colorTexture)
return false;
// делаем активным текстурный юнит 0
glActiveTexture(GL_TEXTURE0);
// назначаем текстуру на активный текстурный юнит
glBindTexture(GL_TEXTURE_2D, colorTexture);
Функция glActiveTexture
задает активный текстурный юнит видеокарты, который мы будем использовать. Привязка текстуры к активному текстурному юниту осуществляется функцией glBindTexture
.
Теперь необходимо сообщить шейдерной программе в каком текстурном юните распологается наша текстура:
// переменная для хранения индекса текстуры
GLint textureLocation = -1;
// получим индекс текстуры из шейдерной программы
textureLocation = glGetUniformLocation(shaderProgram, "colorTexture");
// укажем, что текстура привязана к текстурному юниту 0
if (textureLocation != -1)
glUniform1i(textureLocation , 0);
Теперь шейдерная программа знает какой текстурный юнит использовать. Максимальное количество доступных текстурных юнитов можно узнать используя функцию glGetIntegerv
с параметром GL_MAX_TEXTURE_IMAGE_UNITS
.
Также ккак и в прошлом уроке для тругольника мы вручную зададим геометрию куба, однако в этот раз не будем задавать цвет вершин, а назначим им текстурные координаты:
// количество вершин куба
static const uint32_t cubeVerticesCount = 24;
// описание геометрии куба для всех его сторон
// координаты вершин куба
const float s = 1.0f; // половина размера куба
const float cubePositions[cubeVerticesCount][3] = {
{-s, s, s}, { s, s, s}, { s,-s, s}, {-s,-s, s}, // front
{ s, s,-s}, {-s, s,-s}, {-s,-s,-s}, { s,-s,-s}, // back
{-s, s,-s}, { s, s,-s}, { s, s, s}, {-s, s, s}, // top
{ s,-s,-s}, {-s,-s,-s}, {-s,-s, s}, { s,-s, s}, // bottom
{-s, s,-s}, {-s, s, s}, {-s,-s, s}, {-s,-s,-s}, // left
{ s, s, s}, { s, s,-s}, { s,-s,-s}, { s,-s, s} // right
};
// текстурные координаты куба
const float cubeTexcoords[cubeVerticesCount][2] = {
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f}, // front
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f}, // back
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f}, // top
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f}, // bottom
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f}, // left
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f} // right
};
Конечно мы можем рисовать куб используя прямоугольники, однак обычно геометрия моделей представлена в виде граней (face
), которые представляют собой треугольники. Для того чтобы нарисовать куб треугольниками не дублируя вершин на понадобиться индексный буфер, в котором последовательно идут по три индекса для каждого из треугольников, котоыре мы будем рисовать:
// количество индексов куба
const uint32_t cubeIndicesCount = 36;
// индексы вершин куба в порядке поротив часовой стрелки
const uint32_t cubeIndices[cubeIndicesCount] = {
0, 3, 1, 1, 3, 2, // front
4, 7, 5, 5, 7, 6, // back
8,11, 9, 9,11,10, // top
12,15,13, 13,15,14, // bottom
16,19,17, 17,19,18, // left
20,23,21, 21,23,22 // right
};
Индексы определяют номера вершин в вершинном буфере, которые будет использоваться при выводе объекта на экран.
Теперь необходимо создать VBO для хранения вершин куба и индексного буфера, также не забываем про VAO, в котором будут хранится все созданные связи между VBO и вершинными атрибутами в шейдерной программе:
// переменные для хранения VAO и VBO связанных с кубом
GLuint cubeVBO[3], cubeVAO;
// переменные для хранения индексов вершинных атрибутов
GLint positionLocation = -1, texcoordLocation = -1;
// создаем VAO
glGenVertexArrays(1, &cubeVAO);
// сделаем VAO активным
glBindVertexArray(cubeVAO);
// создадим 3 VBO для данных куба - координаты вершин, текстурные координат и индексный буфер
glGenBuffers(3, cubeVBO);
// получим индекс вершинного атрибута 'position' из шейдерной программы
positionLocation = glGetAttribLocation(shaderProgram, "position");
if (positionLocation != -1)
{
// начинаем работу с буфером для координат вершин куба
glBindBuffer(GL_ARRAY_BUFFER, cubeVBO[0]);
// поместим в буфер координаты вершин куба
glBufferData(GL_ARRAY_BUFFER, cubeVerticesCount * (3 * sizeof(float)),
cubePositions, GL_STATIC_DRAW);
// укажем параметры вершинного атрибута для текущего активного VBO
glVertexAttribPointer(positionLocation, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), 0);
// разрешим использование вершинного атрибута
glEnableVertexAttribArray(positionLocation);
}
// получим индекс вершинного атрибута 'texcoord' из шейдерной программы
texcoordLocation = glGetAttribLocation(shaderProgram, "texcoord");
if (texcoordLocation != -1)
{
// начинаем работу с буфером для текстурных координат куба
glBindBuffer(GL_ARRAY_BUFFER, cubeVBO[1]);
// поместим в буфер текстурные координаты куба
glBufferData(GL_ARRAY_BUFFER, cubeVerticesCount * (2 * sizeof(float)),
cubeTexcoords, GL_STATIC_DRAW);
// укажем параметры вершинного атрибута для текущего активного VBO
glVertexAttribPointer(texcoordLocation, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), 0);
// разрешим использование вершинного атрибута
glEnableVertexAttribArray(texcoordLocation);
}
// начинаем работу с индексным буфером
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, cubeVBO[2]);
// поместим в буфер индексы вершин куба
glBufferData(GL_ELEMENT_ARRAY_BUFFER, cubeIndicesCount * sizeof(uint32_t),
cubeIndices, GL_STATIC_DRAW);
Обратите внмание, что вершинный буфер задается параметром GL_ARRAY_BUFFER
, а индексный буфер задается параметром GL_ELEMENT_ARRAY_BUFFER
.
С VAO может быть связан только один индексный буфер, в отличии от вершинных буферов, которых может быть несколько. Таким образом предопагается, что VAO будет использоваться для вывода на экран одной модели, в данном случае - куба.
Совместив все предыдущие действия мы можем вывести на экран куб:
// делаем шейдерную программу активной
glUseProgram(shaderProgram);
// передаем в шейдер матрицу преобразования координат вершин куба
glUniformMatrix4fv(matrixLocation, 1, GL_TRUE, modelViewProjectionMatrix);
// сделаем VAO куба активным
glBindVertexArray(cubeVAO);
// вывдоим куб на экран
glDrawElements(GL_TRIANGLES, cubeIndicesCount, GL_UNSIGNED_INT, NULL);
Код вершинного шейдера:
#version 330 core
// матрица преобразования координат, получаемая из программы
uniform mat4 modelViewProjectionMatrix;
// входные вершинные атрибуты
in vec3 position;
in vec2 texcoord;
// исходящие параметры, которые будут переданы в фрагментный шейдер
out vec2 fragTexcoord;
void main(void)
{
// перевод позиции вершины из локальных координат в однородные
gl_Position = modelViewProjectionMatrix * vec4(position, 1.0);
// передадим текстурные координаты в фрагментный шейдер
fragTexcoord = texcoord;
}
Код фрагментного шедера:
#version 330 core
// текстура
uniform sampler2D colorTexture;
// параметры, полученные из вершинного шейдера
in vec2 fragTexcoord;
// результирующий цвет пикселя на экране
out vec4 color;
void main(void)
{
// получим цвет пикселя из текстуры по текстурным координатам
color = texture(colorTexture, fragTexcoord);
}
Доступ к исходному коду уроку с проектом для MSVC можно получить двумя способами:
- Используя SVN lesson03
- Скачав архив lesson03.zip lesson03.tar.gz