Все статьи / Рисуем куб

В этой статье мы научимся рисовать простой 3D объект — куб. Также мы запрограммируем анимацию куба и виртуальную камеру, позволяющую взглянуть на куб глазами зрителя.


Вершины куба

В чём OpenGL измеряет расстояния в трёхмерном мире? В 2D графике всё понятно: у нас есть пиксели. На трёхмерной проекции объекты одинакового размера могут дать разные проекции из-за перспективного искажения: чем дальше поверхность или линия, тем она меньше. Поэтому OpenGL измеряет расстояния в единицах измерения, не уточняя, в каких именно. Вы можете измерять в метрах, попугаях или в футах — на картинке это не имеет разницы, потому что исходные размеры не совпадают с проекциями.

Кроме того, трёхмерные тела поддаются преобразованиям. Поэтому мы будем рисовать только единичный куб и не станем заботиться о поддержке масштаба, вращения и положения центра куба. Единичный куб лежит в координатах от -1 до +1, т.е. каждое ребро имеет длину 2.

struct Vertex
{
    glm::vec3 pos;
    glm::vec3 color;
};

const glm::vec3 DARK_GREEN = {0.05f, 0.45f, 0.1f};
const glm::vec3 LIGHT_GREEN = {0.1f, 0.8f, 0.15f};

// Вершины куба служат материалом для формирования треугольников,
// составляющих грани куба.
const Vertex CUBE_VERTICIES[] = {
    { {-1, +1, -1}, DARK_GREEN},
    { {+1, +1, -1}, DARK_GREEN},
    { {+1, -1, -1}, DARK_GREEN},
    { {-1, -1, -1}, DARK_GREEN},
    { {-1, +1, +1}, LIGHT_GREEN},
    { {+1, +1, +1}, LIGHT_GREEN},
    { {+1, -1, +1}, LIGHT_GREEN},
    { {-1, -1, +1}, LIGHT_GREEN},
};

Триангуляция куба

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

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

Иллюстрация

Теперь, глядя на иллюстрацию, можно перечислить все 12 треугольников, составляющих 6 граней.

// Привыкаем использовать 16-битный unsigned short,
// чтобы экономить память на фигурах с тысячами вершин.
const uint16_t CUBE_INDICIES[] = {
    0, 1, 2,
    0, 2, 3,
    2, 1, 5,
    2, 5, 6,
    3, 2, 6,
    3, 6, 7,
    0, 3, 7,
    0, 7, 4,
    1, 0, 4,
    1, 4, 5,
    6, 5, 4,
    6, 4, 7,
};

Рисование в Immediate Mode

Для непосредственного рисования граней куба мы применим не самый производительный метод, который к тому же устарел в современных версиях OpenGL: рисование в блоке glBegin/glEnd, также известное как OpenGL Immediate Mode.

void CIdentityCube::Draw()
{
    // менее оптимальный способ рисования: прямая отправка данных
    // могла бы работать быстрее, чем множество вызовов glColor/glVertex.
    glBegin(GL_TRIANGLES);
    for (uint16_t i : CUBE_INDICIES)
    {
        const Vertex &v = CUBE_VERTICIES[i];
        glColor3f(v.color.x, v.color.y, v.color.z);
        glVertex3f(v.pos.x, v.pos.y, v.pos.z);
    }
    glEnd();
}

Настройка фиксированной камеры

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

  • GL_PROJECTION моделирует ортографическое или перспективное преобразование от трёхмерной усечённой пирамиды (т.е. от области видимости камеры) к трёхмерному кубу с длиной ребра, равной 2 (т.е. к нормализованному пространству).
  • GL_MODELVIEW сочетает в себе два преобразования: от локальных координат объекта к мировым координатам, а также от мировых координат к координатам камеры.

За рамками фиксированного конвейера можно использовать столько матриц, сколько захочется — но сейчас мы ограничены. Кроме того, нам приходится смешивать матрицы:

  • поведение камеры описывается как ортографическим или перспективным преобразованием, так и положением камеры в мировом пространстве, то есть для моделирования камеры нужны GL_PROJECTION и GL_MODELVIEW одновременно
  • c другой стороны, для трансформаций над телами — например, вращения куба с помощью умножения координат на матрицу — нам нужна матрица GL_MODELVIEW.

Обычно при программировании действуют так:

  • матрицу GL_PROJECTION обычно настраивают один раз для перспективного преобразования
  • матрицу GL_MODELVIEW постоянно модифицируют, когда локальная система координат очередного объекта не совпадает с мировой системой координат

Начнём настройку камеры с GL_MODELVIEW: зададим матрицу так, как будто бы камера смотрит с позиции eye на точку center, при этом направление “вверх” камеры задаёт вектор up:

void CWindow::SetupView(const glm::ivec2 &size)
{
    glViewport(0, 0, size.x, size.y);

    const glm::vec3 eye = {4, -4, 2};
    const glm::vec3 center = {0, 0, 0};
    const glm::vec3 up = {0, 0, 1};
    // Матрица моделирования-вида вычисляется функцией glm::lookAt.
    const glm::mat4 mv = glm::lookAt(eye, center, up);
    glLoadMatrixf(glm::value_ptr(mv));
    // ... настройка матрицы GL_PROJECTION
}

Для перспективного преобразования достаточно создать матрицу с помощью функции glm::perspective. Она принимает на вход несколько удобных для программиста параметров преобразования: горизонтальный угол обзора камеры (англ. field of view), соотношение ширины и высоты (англ. aspect), а также две граничных координаты для отсечения слишком близких к камере и слишком далёких от камеры объектов. Для лучшего понимания взгляните на иллюстрацию:

иллюстрация

void CWindow::SetupView(const glm::ivec2 &size)
{
    glViewport(0, 0, size.x, size.y);

    // ... настройка матрицы GL_MODELVIEW

    // Матрица перспективного преобразования вычисляется функцией
    // glm::perspective, принимающей угол обзора, соотношение ширины
    // и высоты окна, расстояния до ближней и дальней плоскостей отсечения.
    const float fieldOfView = glm::radians(70.f);
    const float aspect = float(size.x) / float(size.y);
    const float zNear = 0.01f;
    const float zFar = 100.f;
    const glm::mat4 proj = glm::perspective(fieldOfView, aspect, zNear, zFar);
    glMatrixMode(GL_PROJECTION);
    glLoadMatrixf(glm::value_ptr(proj));
    glMatrixMode(GL_MODELVIEW);
}

Решение проблем невидимых поверхностей

При программном рисовании трёхмерного мира, состоящего из объемных тел с непрерывной поверхностью (англ. solid bodies), возникает вопрос: как не нарисовать невидимые поверхности? Например, на рисунке горы, покрытой лесом, мы не должны увидеть ни деревьев на противоположном к нам склоне, ни поверхности самого склона.

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

Иллюстрация

Этот алгоритм так и называется — “алгоритм художника” (англ. painter’s algorithm). В компьютерной графике он иногда применим, но с модификацией: вместо деления объектов на три группы (задний фон, средний фон и передний фон) придётся отсортировать все объекты и вывести их в порядке приближения. К сожалению, не всегда объекты воможно отсортировать: это известно как “проблема художника” (англ. painter’s problem).

Иллюстрация

Для решения проблемы художника в OpenGL сделано следующее:

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

Тест буфера глубины

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

Фрагмент — это атомарная, то есть неделимая, часть фигуры. Если видеокарта не совершает сглаживание, то один фрагмент станет одним пикселем фигуры.

Тест глубины устроен следующим образом: если фрагмент в нормализованном пространтсве стал ближе, чем последнее значение буфера глубины, то мы выбираем его и обновляем значение в буфере глубины, иначе мы отбрасываем фрагмент. В псевдокоде способ выглядит так:

for fragment in triangle.rasterize():
    index = (round(fragment.x), round(fragment.y))
    if depthBuffer[index] > fragment.depth:
        depthBuffer[index] = fragment.depth
    else
        discard fragment

Как и любой другой буфер OpenGL, буфер глубины следует очищать. Для этого вызов glClear должен получать ещё один флаг GL_DEPTH_BUFFER_BIT:

void Clear()const
{
    // Заливка кадра цветом фона средствами OpenGL
    glClearColor(m_clearColor.x, m_clearColor.y, m_clearColor.z, m_clearColor.w);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
}

Также нужно после создания контекста включить режим теста глубины командой glEnable:

glEnable(GL_DEPTH_TEST);

Отсечение задних граней

OpenGL рассчитан на дополнительное отсечение невидимых поверхностей, построенное по принципу отсечения задних граней. По умолчанию включён режим, аналогичный вызову glFrontFace(GL_CCW), и OpenGL делит примитивы на две группы:

  • те, вершины которых перечисляются против часовой стрелки (GL_CCW), становятся передними гранями (GL_FRONT)
  • те, вершины которых перечисляются по часовой стрелке (GL_CW), становятся задним гранями (GL_BACK)

Вызов glFrontFace(GL_CW) изменит классификацию на обратную: перечисление по часовой даст переднюю грань, перечисление против часовой даст заднюю.

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

Режим отсечения граней можно включить командой glEnable(GL_CULL_FACE), после чего можно выбрать способ отсечения: убирать задние грани (GL_BACK), передние грани (GL_FRONT) или оба вида граней (GL_FRONT_AND_BACK).

Соберём всю инициализацию состояния OpenGL в метод OnWindowInit, который будет вызываться один раз поле инициализации окна

void CWindow::OnWindowInit(const glm::ivec2 &size)
{
    (void)size;
    glEnable(GL_DEPTH_TEST);
    glEnable(GL_CULL_FACE);
    glFrontFace(GL_CCW);
    glCullFace(GL_BACK);
}

Чтобы метод OnWindowInit был вызван своевременно, его можно объявить виртуальным в классе CAbstractWindow и вызывать в методе Show:

void CAbstractWindow::Show(const glm::ivec2 &size)
{
    m_pImpl->Show(size);
    OnWindowInit(size);
}

Диагностика проблем

При выводе трёхмерных тел встречается ряд типовых ошибок. Если что-то не работает, пройдитесь по следующему чеклисту:

  • вы не забыли вызвать glBegin/glEnd до и после вызова glColor/glVertex?
  • тело не выпадает из порта просмотра из-за матрицы GL_PROJECTION?
  • тело не обрезается дальней и ближней плоскостями отсечения из-за матрицы GL_PROJECTION?
  • в массиве индексов нумерация вершин начинается с нуля?
  • включён тест глубины и режим отсечения задних граней?
  • в массиве индексов вершины примитивов (треугольников и четырёхугольников) перечислены по часовой стрелке для внешнего наблюдателя?

Если всё нормально, то вы получите статичное изображение куба без возможности поменять положение камеры:

Иллюстрация

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

Трёхмерная система координат

В OpenGL используется правосторонняя система координат, в которых пользователь может задавать вершины примитивов, из которых состоят трехмерные объекты. Правосторонней система координат называется потому, что ее можно образовать при помощи большого, указательного и среднего пальцев правой руки, задающих направления координатных осей X, Y и Z соответственно.

иллюстрация

Система координат задаётся точкой отсчёта и координатными осями, которые, в свою очередь, задают направления и длины трёх единичных векторов (1, 0, 0), (0, 1, 0) и (0, 0, 1). Как точка отсчёта, так и координатные оси могут меняться при переходе из одной системы координат в другую.

Например, представьте себе систему координат комнаты, где в качестве центра взята точка в геометрическом центре пола, а ось z указывает вверх, и расстояния измеряются в метрах. Тогда точки головы человека в комнате всегда будут иметь координату z, большую нуля, обычно в диапазоне (1.6; 1.8). Если же перейти в другую систему отсчёта, где центром служит точка в геометрическом центре потолка, то голова человека в комнате будет иметь отрицательную координату z.

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

Иллюстрация

О локальных системах координат можно сказать следующее:

  • для перехода к мировой системе координат всегда есть аффинное преобразование, состоящее из некоторого числа перемещений, вращений и масштабирований
  • если точка отсчёта в разных координатах разная, это можно представить перемещением (англ. translate)
  • если координатные оси направлены в другие стороны, это можно представить вращением (англ. rotate)
  • если единицы измерения разные, например, метры в одной системе и километры в другой, это можно представить масштабированием (англ. scale)

Самый удивительный факт: любое элементарное трёхмерное преобразование, а также их комбинацию можно представить в виде матрицы 4x4! Чтобы понять, как это происходит, разберёмся с однородным представлением точек и векторов.

Единый тип данных для точек и векторов

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

Если использовать процедурную парадигму программирования, получится нечто такое:

struct SPointOrVector
{
    float x = 0;
    float y = 0;
    float z = 0;
    bool isVector = false;
};

void RotateZ(const SPointOrVector &x, float angle);
void Translate(const SPointOrVector &x, float x, float y, float z);
void Scale(const SPointOrVector &x, float scale);

Объектно-ориентированный подход предлагает такой вариант:

class IPrimitive3D
{
public:
    void RotateZ(float angle);
    void Translate(float x, float y, float z);
    void Scale(float scale);
};

class CVector3D : IPrimitive3D
{
    // реализация операций для вектора
};

class CPoint3D : IPrimitive3D
{
    // реализация операций для точки
};

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

Проблема решается с помощью математического приёма — однородного представления точек и векторов.

Однородное представление точек и векторов.

Давайте будем считать, что трёхмерная точка (x, y, z) хранится как четырёхкомпонентный вектор (x, y, z, 1). А вектор хранится как (x, y, z, 0). Всего лишь одно флаговое значение в конце позволяет избежать любых ветвлений в алгоритмах трансформации векторов и точек. Это происходит благодаря свойствам алгебры матриц.

Как известно, можно умножить матрицу на матрицу при условии, что ширина одной матрицы равна высоте другой (иначе операция просто недопустима). Для получения элемента с позицией i,j в новой матрице достаточно взять i-ю строку левой матрицы и j-й столбец правой матрицы. Вот пример:

| 1 0 0 |   | x 1 |   | 1*x+0*y+0*z 1*1+1*0+0*0 |
| 1 1 1 | * | y 1 | = | 1*x+1*y+1*z 1*1+1*1+1*0 |
| 2 1 1 |   | z 0 |   | 2*x+1*y+1*z 2*1+1*1+1*0 |

Как ни странно, умножение 4-х компонентного вектора на матрицу 4x4 тоже возможно! Для этого достаточно считать 4-х компонентный вектор матрицей 4x1. После умножения получится новый 4-х компонентный вектор.

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

Класс CAnimatedCube

Давайте запомним несколько простых правил. Некоторые из них даже будут доказаны чуть ниже.

  • умножение матрицы преобразования на вектор или точку в однородном представлении даёт преобразованный вектор или точку
  • можно легко составить базовую матрицу, представляющих одно элементарное аффинное преобразование
  • умножение матрицы A на матрицу B даёт новую матрицу, которая описывает новую трансформацию, созданную путём применения трансформации B, а затем A (именно в таком порядке)
  • умножение матриц не коммутативно: вы не можете заменить A*B на B*A
  • инвертирование матрицы (например, с помощью детерминанта матрицы) даёт матрицу обратной трансформации, которая вернёт точку или вектор в исходное состояние — в идеальном мире. В дискретном мире компьютеров обратное преобразование может быть чуть-чуть неточным из-за особенностей представления типов float и double.

Для демонстрации этих правил расширим класс CIdentityCube из предыдущего урока. Новый класс будет называться CAnimatedCube, и он будет демонстрировать работу трёх базовых аффинных трансформаций. Смена и продвижение анимации будет выполняться в методе Update, вызываемом периодически снаружи.

class CAnimatedCube : public CIdentityCube
{
public:
    void Update(float deltaTime);
    void Draw()const;

private:
    enum Animation
    {
        Rotating,
        Pulse,
        Bounce,
    };

    glm::mat4 GetAnimationTransform()const;

    static const float ANIMATION_STEP_SECONDS;
    Animation m_animation = Rotating;
    float m_animationPhase = 0;
};

С учётом перечисленных выше правил, мы можем написать метод Draw для анимированного куба. При этом вместо glLoadMatixf следует применить glMultMatrixf, чтобы вместо замены уже существующей трансформации всего лишь модифицировать её. Если мы заменим матрицу GL_MODELVIEW, камера будет работать некорректно.

void CAnimatedCube::Draw() const
{
    // метод GetAnimationTransform вычисляет матрицу трансформации.
    const glm::mat4 matrix = GetAnimationTransform();
    glPushMatrix();
    glMultMatrixf(glm::value_ptr(matrix));
    CIdentityCube::Draw();
    glPopMatrix();
}

Функции для работы с аффинными трансформациями (и не только!) можно найти в GML:

#include <glm/gtc/matrix_transform.hpp>

// Документация по функциям для модификации матриц:
// http://glm.g-truc.net/0.9.2/api/a00245.html

Единичная матрица

Единичная матрица (матрица идентичности, англ. identity matrix) задает преобразование, при котором точки и векторы остаются без изменений, отображаясь сами в себя. Посмотрите сами на перемножение этой матрицы и точки/вектора:

    точка (x, y, z)
| 1 0 0 0 |   | x |   | x |
| 0 1 0 0 |   | y |   | y |
| 0 0 1 0 | * | z | = | z |
| 0 0 0 1 |   | 1 |   | 1 |
    вектор (x, y, z)
| 1 0 0 0 |   | x |   | x |
| 0 1 0 0 |   | y |   | y |
| 0 0 1 0 | * | z | = | z |
| 0 0 0 1 |   | 0 |   | 0 |

Если мы не хотим возвращать из метода GetAnimationTransform() какую-либо преобразующую трансформацию, мы можем просто вернуть единичную матрицу. Именно такую матрицу создаёт конструктор по умолчанию класса glm::mat4. Теперь мы можем заложить каркас метода GetAnimationTransform:

glm::mat4 CAnimatedCube::GetAnimationTransform() const
{
    switch (m_animation)
    {
    case Rotating:
        return GetRotateZTransfrom(m_animationPhase);
    case Pulse:
        return GetScalingPulseTransform(m_animationPhase);
    case Bounce:
        return GetBounceTransform(m_animationPhase);
    }
    // Недостижимый код - вернём единичную матрицу.
    return glm::mat4();
}

Матрица перемещения

Матрица перемещения воздействует на точку, но вектор сохраняет неизменным. Действует она так:

    точка (x, y, z)
| 1 0 0 dx |   | x |   | x + dx |
| 0 1 0 dy |   | y |   | y + dy |
| 0 0 1 dz | * | z | = | z + dz |
| 0 0 0 1  |   | 1 |   | 1      |

    вектор (x, y, z)
| 1 0 0 dx |   | x |   | x |
| 0 1 0 dy |   | y |   | y |
| 0 0 1 dz | * | z | = | z |
| 0 0 0 1  |   | 0 |   | 0 |

В GLM есть функция glm::translate, умножающая переданную матрицу на матрицу перемещения. Чтобы анимировать куб, будем вычислять смещение по оси Ox в каждый момент времени. После этого получение матрицы перемещения будет очень простым:

return glm::translate(glm::mat4(), {offset, 0.f, 0.f});

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

Иллюстрация

В процессе анимации от 0% до 100% куб должен один или несколько раз прыгнуть в сторону и затем вернуться обратно. Для этого воспользуемся делением с остатком, а также формулой расчёта расстояния на основе начальной скорости и противоположно направленного ускорения. Можно написать так:

/// @param phase - Фаза анимации на отрезке [0..1]
glm::mat4 GetBounceTransform(float phase)
{
    // начальная скорость и число отскоков - произвольные константы.
    const int bounceCount = 4;
    const float startSpeed = 15.f;
    // "время" пробегает bounceCount раз по отрезку [0...1/bounceCount].
    const float time = fmodf(phase, 1.f / float(bounceCount));
    // ускорение подбирается так, чтобы на 0.25с скорость стала
    // противоположна начальной.
    const float acceleration = - (startSpeed * 2.f * float(bounceCount));
    // расстояние - результат интегрирования функции скорости:
    //  speed = startSpeed + acceleration * time;
    float offset = time * (startSpeed + 0.5f * acceleration * time);

    // для отскоков с нечётным номером меняем знак.
    const int bounceNo = int(phase * bounceCount);
    if (bounceNo % 2)
    {
        offset = -offset;
    }

    return glm::translate(glm::mat4(), {offset, 0.f, 0.f});
}

Матрица масштабирования

Матрица масштабирования воздействует как на точку, так и на вектор, изменяя соответственно удалённость точки от начала координат и длину вектора. Действует так:

    точка (x, y, z)
| sx 0  0  0 |   | x |   | sx*x |
| 0  sy 0  0 |   | y |   | sy*y |
| 0  0  sz 0 | * | z | = | sz*z |
| 0  0  0  1 |   | 1 |   | 1    |

    вектор (x, y, z)
| sx 0  0  0 |   | x |   | sx*x |
| 0  sy 0  0 |   | y |   | sy*y |
| 0  0  sz 0 | * | z | = | sz*z |
| 0  0  0  1 |   | 0 |   | 0    |

В GLM есть функция glm::scale, умножающая переданную матрицу на матрицу масштабирования, имеющую потенциально разные коэффициенты масштабирования для трёх разных компонентов вектора.

Давайте используем эту функцию, чтобы реализовать пульсирование куба — сжатие от нормальных размеров к нулевым и обратно:

/// @param phase - Фаза анимации на отрезке [0..1]
glm::mat4 GetScalingPulseTransform(float phase)
{
    // число пульсаций размера - произвольная константа.
    const int pulseCount = 4;
    float scale = fabsf(cosf(float(pulseCount * M_PI) * phase));

    return glm::scale(glm::mat4(), {scale, scale, scale});
}

Матрица поворота

Матрица поворота воздействует и на точку, и на вектор, при этом удалённость точки от начала координат и длина вектора не меняются. Существует Теорема Эйлера, согласно которой любой поворот в трёхмерном пространстве вокруг произвольной оси можно представить как последовательность трёх поворотов вокруг трёх базовых осей системы координат в определённом порядке и на определённые углы:

Иллюстрация

Перечислим три матрицы поворота на угол “a” вокруг трёх осей системы координат (посмотреть, что получается при перемножении, вы можете самостоятельно):

 поворот вокруг Ox
| 1 0      0       0 |
| 0 cos(a) -sin(a) 0 |
| 0 sin(a) cos(a)  0 |
| 0 0      0       1 |

 поворот вокруг Oy
| cos(a)  0 sin(a) 0 |
| 0       1 0      0 |
| -sin(a) 0 cos(a) 0 |
| 0       0 0      1 |

 поворот вокруг Oz
| cos(a) -sin(a) 0 0 |
| sin(a) cos(a)  0 0 |
| 0      0       1 0 |
| 0      0       0 1 |

В GLM есть функция glm::rotate, умножающая переданную матрицу на матрицу поворота вокруг переданного произвольного вектора оси на переданный угол. Как уже было сказано ранее, следует настроить GLM так, чтобы углы выдавались в радианах — иначе вы будете получать предупреждения об использовании устаревшего API. Проверьте, что в stdafx.h перед включением GLM объявлен макрос GLM_FORCE_RADIANS:

#define GLM_FORCE_RADIANS
#include <glm/vec2.hpp>
#include <glm/vec3.hpp>
#include <glm/vec4.hpp>
#include <glm/mat4x4.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <glm/gtc/matrix_transform.hpp>

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

/// @param phase - Фаза анимации на отрезке [0..1]
glm::mat4 GetRotateZTransfrom(float phase)
{
    // угол вращения вокруг оси Z лежит в отрезке [0...2*pi].
    const float angle = float(2 * M_PI) * phase;

    return glm::rotate(glm::mat4(), angle, {0, 0, 1});
}

Вот так это будет выглядеть к концу урока:

Иллюстрация

Как создать камеру

В OpenGL в режиме версии 1.x есть две трансформирующих вершины матрицы: GL_MODELVIEW и GL_PROJECTION. Матрица GL_MODELVIEW объединяет к себе как переход от локальной системы координат к мировой (Model), так и переход от мировых координат к системе коодинат камеры (View). Класс CCamera будет возвращать только одну компоненту GL_MODELVIEW: матрицу вида, созданную функцией glm::lookAt.

Правила движения камеры будут следующими:

  • камера всегда смотрит на точку (0, 0, 0), вращается вокруг неё, приближается к ней или отдаляется
  • для вращения камеры служат клавиши “Влево” и “Вправо” либо “A” и “D” на клавиатуре
  • для приближения и отдаления служат клавиши “Вперёд” и “Назад” либо “W” и “S” на клавиатуре
  • камера не может приближаться ближе чем на 1.5f и не может отдаляться дальше чем на 30.f
  • камера не должна двигаться рывками, и даже при неравных интервалах перерисовки кадра движение должно оставаться плавным, т.е. зависит от deltaTime между кадрами

С учётом сказанного, спроектируем следующий интерфейс класса:

#pragma once

#include <glm/fwd.hpp>
#include <SDL2/SDL_events.h>
#include <boost/noncopyable.hpp>
#include <set>

class CCamera : private boost::noncopyable
{
public:
    explicit CCamera(float rotationRadians, float distance);

    void Update(float deltaSec);
    bool OnKeyDown(const SDL_KeyboardEvent &event);
    bool OnKeyUp(const SDL_KeyboardEvent &event);

    glm::mat4 GetViewTransform() const;

private:
    float m_rotationRadians = 0;
    float m_distance = 1;
    std::set<unsigned> m_keysPressed;
};

Методы Update, OnKeyDown, OnKeyUp должны вызываться извне — например, из класса окна. При этом методы обработки событий возвращают true, если событие было обработано, чтобы класс окна мог не рассылать это событие далее другим объектам.

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

  • пользователь нажал “Влево”, затем “Вправо”, потом отпустил “Влево”; после этого камера должна вращаться вправо
  • пользователь нажал “Влево” и “Вперёд”; после этого камера должна вращаться влево и при этом приближаться
  • пользователь нажал “Вперёд” и “Назад”; при этом камера может не двигаться или двигаться в одном приоритетном направлении — оба варианта хороши

Чтобы отслеживать нажатие только нужных клавиш, создадим функцию-предикат ShouldTrackKeyPressed:

bool ShouldTrackKeyPressed(const SDL_Keysym &key)
{
    switch (key.sym)
    {
    case SDLK_LEFT:
    case SDLK_RIGHT:
    case SDLK_UP:
    case SDLK_DOWN:
    case SDLK_w:
    case SDLK_a:
    case SDLK_s:
    case SDLK_d:
        return true;
    }
    return false;
}

Также подключим заголовок с функциями вращения вектора, введём вспомогательные константы и функции, позволяющие получить скорость поворота и скорость приближения (возможно, нулевые или отрицательные) на основе информации о нажатых клавишах:

#include <glm/gtx/rotate_vector.hpp>

namespace
{
const float ROTATION_SPEED_RADIANS = 1.f;
const float LINEAR_MOVE_SPEED = 5.f;
const float MIN_DISTANCE = 1.5f;
const float MAX_DISTANCE = 30.f;

float GetRotationSpeedRadians(std::set<unsigned> & keysPressed)
{
    if (keysPressed.count(SDLK_RIGHT) || keysPressed.count(SDLK_d))
    {
        return ROTATION_SPEED_RADIANS;
    }
    if (keysPressed.count(SDLK_LEFT) || keysPressed.count(SDLK_a))
    {
        return -ROTATION_SPEED_RADIANS;
    }
    return 0;
}

float GetLinearMoveSpeed(std::set<unsigned> & keysPressed)
{
    if (keysPressed.count(SDLK_UP) || keysPressed.count(SDLK_w))
    {
        return -LINEAR_MOVE_SPEED;
    }
    if (keysPressed.count(SDLK_DOWN) || keysPressed.count(SDLK_s))
    {
        return +LINEAR_MOVE_SPEED;
    }
    return 0;
}
} // anonymous namespace

После этого с небольшим применением линейной алгебры мы можем реализовать методы класса CCamera:

CCamera::CCamera(float rotationRadians, float distance)
    : m_rotationRadians(rotationRadians)
    , m_distance(distance)
{
}

void CCamera::Update(float deltaSec)
{
    m_rotationRadians += deltaSec * GetRotationSpeedRadians(m_keysPressed);
    m_distance += deltaSec * GetLinearMoveSpeed(m_keysPressed);
    m_distance = glm::clamp(m_distance, MIN_DISTANCE, MAX_DISTANCE);
}

bool CCamera::OnKeyDown(const SDL_KeyboardEvent &event)
{
    if (ShouldTrackKeyPressed(event.keysym))
    {
        m_keysPressed.insert(unsigned(event.keysym.sym));
        return true;
    }
    return false;
}

bool CCamera::OnKeyUp(const SDL_KeyboardEvent &event)
{
    if (ShouldTrackKeyPressed(event.keysym))
    {
        m_keysPressed.erase(unsigned(event.keysym.sym));
        return true;
    }
    return false;
}

glm::mat4 CCamera::GetViewTransform() const
{
    glm::vec3 direction = {0.f, 0.5f, 1.f};
    // Нормализуем вектор (приводим к единичной длине),
    // затем поворачиваем вокруг оси Y.
    // см. http://glm.g-truc.net/0.9.3/api/a00199.html
    direction = glm::rotateY(glm::normalize(direction), m_rotationRadians);

    const glm::vec3 eye = direction * m_distance;
    const glm::vec3 center = {0, 0, 0};
    const glm::vec3 up = {0, 1, 0};

    // Матрица моделирования-вида вычисляется функцией glm::lookAt.
    // Она даёт матрицу, действующую так, как будто камера смотрит
    // с позиции eye на точку center, а направление "вверх" камеры равно up.
    return glm::lookAt(eye, center, up);
}

Изменения в CWindow

Теперь класс CWindow должен хранить три объекта:

CAnimatedCube m_dynamicCube;
CIdentityCube m_staticCube;
CCamera m_camera;

Конструктор CCamera требует два аргумента, их можно задать следующим образом:

CWindow::CWindow() : m_camera(CAMERA_INITIAL_ROTATION, CAMERA_INITIAL_DISTANCE) { SetBackgroundColor(QUIET_GREEN); }

В методе OnUpdateWindow мы должны вызывать метод Update у всех трёх объектов системы:

void CWindow::OnUpdateWindow(float deltaSeconds)
{
    m_camera.Update(deltaSeconds);
    m_dynamicCube.Update(deltaSeconds);
    m_staticCube.Update(deltaSeconds);
}

В методе Draw немного схитрим: применим вызов glTranslate (вместо нормальной работы с функциями GLM), чтобы развести два куба в стороны:

void CWindow::OnDrawWindow(const glm::ivec2 &size)
{
    SetupView(size);

    // Смещаем анимированный единичный куб в другую сторону
    glPushMatrix();
    glTranslatef(0, -1.5f, 0);
    m_dynamicCube.Draw();
    glPopMatrix();

    // Смещаем статический единичный куб в другую сторону
    glPushMatrix();
    glTranslatef(0, 1.5f, 0);
    m_staticCube.Draw();
    glPopMatrix();
}

Метод SetupView станет проще, потому что мы можем не вычислять матрицу GL_MODELVIEW, а получить её начальное (для кадра) значение у камеры.

void CWindow::SetupView(const glm::ivec2 &size)
{
    glViewport(0, 0, size.x, size.y);

    // Матрица вида возвращается камерой и составляет
    // начальное значение матрицы GL_MODELVIEW.
    glLoadMatrixf(glm::value_ptr(m_camera.GetViewTransform()));

    // Матрица перспективного преобразования вычисляется функцией
    // glm::perspective, принимающей угол обзора, соотношение ширины
    // и высоты окна, расстояния до ближней и дальней плоскостей отсечения.
    const float fieldOfView = glm::radians(70.f);
    const float aspect = float(size.x) / float(size.y);
    const float zNear = 0.01f;
    const float zFar = 100.f;
    const glm::mat4 proj = glm::perspective(fieldOfView, aspect, zNear, zFar);
    glMatrixMode(GL_PROJECTION);
    glLoadMatrixf(glm::value_ptr(proj));
    glMatrixMode(GL_MODELVIEW);
}

Наконец, следует перегрузить методы OnKeyDown/OnKeyUp класса CAbstractInputControlWindow в классе CWindow:

void CWindow::OnKeyDown(const SDL_KeyboardEvent &event)
{
    m_camera.OnKeyDown(event);
}

void CWindow::OnKeyUp(const SDL_KeyboardEvent &event)
{
    m_camera.OnKeyUp(event);
}

Конец!

Теперь вы можете взять полный пример (github.com) или посмотреть, каким будет результат запуска (в виде статичного скриншота):

Иллюстрация

Чтобы сделать результат более наглядным, была сделана серия скриншотов, которые затем были объединены в gif с помощью GIMP:

Скриншот

Ссылки