Системы координат OpenGL
Координаты объекта, в режиме FFP (Fixed Function Pipeline) в предыдущих версиях OpenGL, проходили следующие преобразования системы координат:
- Локальные координаты преобразуются матрицей ModelView в видовые (eye space)
- Видовые преобразуются матрицей Projection в однородные (clip space)
- Однородные (X,Y,Z,W) преобразуются в нормализованные (X/W, Y/W, Z/W, 1)
- Нормализованные преобразуются параметрами Viewport в экранные (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");
После получения итоговой матрицы и ее индекса мы можем передать ее в шейдерную программу:
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:
Добавить к кооментариям исходного кода приведенного выше практически нечего, используя функцию TextureCreateFromTGA полчаем готовую для исопльзования текстуру:
GLuint colorTexture = 0;
colorTexture = TextureCreateFromTGA("data/texture.tga");
if (!colorTexture)
return false;
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, colorTexture);
Функция glActiveTexture задает активный текстурный юнит видеокарты, который мы будем использовать. Привязка текстуры к активному текстурному юниту осуществляется функцией glBindTexture.
Теперь необходимо сообщить шейдерной программе в каком текстурном юните распологается наша текстура:
GLint textureLocation = -1;
textureLocation = glGetUniformLocation(shaderProgram, "colorTexture");
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},
{ s, s,-s}, {-s, s,-s}, {-s,-s,-s}, { s,-s,-s},
{-s, s,-s}, { s, s,-s}, { s, s, s}, {-s, s, s},
{ s,-s,-s}, {-s,-s,-s}, {-s,-s, s}, { s,-s, s},
{-s, s,-s}, {-s, s, s}, {-s,-s, s}, {-s,-s,-s},
{ s, s, s}, { s, s,-s}, { s,-s,-s}, { s,-s, s}
};
const float cubeTexcoords[cubeVerticesCount][2] = {
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f},
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f},
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f},
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f},
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f},
{0.0f,1.0f}, {1.0f,1.0f}, {1.0f,0.0f}, {0.0f,0.0f}
};
Конечно мы можем рисовать куб используя прямоугольники, однак обычно геометрия моделей представлена в виде граней (face), которые представляют собой треугольники. Для того чтобы нарисовать куб треугольниками не дублируя вершин на понадобиться индексный буфер, в котором последовательно идут по три индекса для каждого из треугольников, котоыре мы будем рисовать:
const uint32_t cubeIndicesCount = 36;
const uint32_t cubeIndices[cubeIndicesCount] = {
0, 3, 1, 1, 3, 2,
4, 7, 5, 5, 7, 6,
8,11, 9, 9,11,10,
12,15,13, 13,15,14,
16,19,17, 17,19,18,
20,23,21, 21,23,22
};
Индексы определяют номера вершин в вершинном буфере, которые будет использоваться при выводе объекта на экран.
Теперь необходимо создать VBO для хранения вершин куба и индексного буфера, также не забываем про VAO, в котором будут хранится все созданные связи между VBO и вершинными атрибутами в шейдерной программе: