Продолжаем изучение OpenGL: управление камерой при помощи мыши и клавиатуры

23 сентября 2015

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

Изменения в проекте

Сначала расскажу о паре изменений в проекте. Во-первых, я добавил в него еще одну библиотечку, deferxx. Она предоставляет макрос defer, который работает аналогично одноименной конструкции из мира языка Go. Пример использования:

if(glfwInit() == GL_FALSE) {
  std::cerr << "Failed to initialize GLFW" << std::endl;
  return -1;
}
defer(std::cout << "glfwTerminate()" << std::endl; glfwTerminate());

Процедура glfwTerminate будет вызвана обязательно при выходе из скоупа. Неважно, сделаем ли мы return или будет брошено исключение. Благодаря такому подходу мы (1) заметно сокращаем количество кода и (2) существенно понижаем шанс забыть закрыть что-то, когда это стало ненужным. Работает defer, как несложно догадаться, на деструкторах. Здесь можно найти ссылки на статьи, объясняющие, как пишутся такие вещи, информацию о возможном включении подобной фичи в стандарт, и так далее. К сожалению, на момент написания этих строк, CLion подсвечивал последнюю строчку в приведенном коде красным цветом. Судя по тикету, фикс появится в CLion версии 1.2.

Еще одно очень важное изменение заключается в том, что код, отвечающий за компиляцию шейдеров и линковку их в программу, был перенесен в файл utils.cpp. Код же самих шейдеров теперь подгружается из .glsl файлов, а не хардкодится в коде на C++. Это намного удобнее. Плюс, как оказалось, в CLion даже имеется подсветка синтаксиса для GLSL.

Важно! Чтобы наша программа могла найти шейдеры, в свойствах проекта нужно изменить working directory. Run → Edit Configurations… В списке слева находим программу, меняем ей Working directory на ту, в которой лежит каталог shaders. То есть, на корень репозитория.

Куб с разноцветными гранями

Рисование куба эквивалентно рисованию шести квадратов. Рисование же квадрата эквивалентно рисованию двух треугольников. Таким образом, чтобы нарисовать куб с цветными гранями, нужно всего лишь нарисовать в пространстве 12 разноцветных треугольников. Рисовать треугольники мы уже умеем. Вопрос заключается в том, как нарисовать их все разным цветом?

Для этого мы создадим еще один VBO. Только на этот раз вместо координат вершин будем хранить их цвета:

GLuint colorVBO;
glGenBuffers(1, &colorVBO);
glBindBuffer(GL_ARRAY_BUFFER, colorVBO);
glBufferData(
  GL_ARRAY_BUFFER, sizeof(globColorBufferData),
  globColorBufferData, GL_STATIC_DRAW
);

Сошлемся на этот VBO из VAO, взяв свободный массив атрибутов под номером один:

glBindBuffer(GL_ARRAY_BUFFER, colorVBO);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
glBindBuffer(GL_ARRAY_BUFFER, 0); // unbind VBO

В vertex shader считываем цвет вершин аналогично тому, как это делается для координат:

#version 330 core

layout(location = 0) in vec3 vertexPos;
layout(location = 1) in vec3 vertexColor;

out vec3 fragmentColor;

void main() {
  fragmentColor = vertexColor;
  gl_Position = vec4(vertexPos, 1);
}

Заметьте, что цвет присваивается выходному (out) значению fragmentColor. Это значение идет дальше по rendering pipeline, и потому может быть подхвачено и использовано во fragment shader:

#version 330 core

in vec3 fragmentColor;

out vec3 color;

void main() {
  color = fragmentColor;
}

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

glEnable(GL_CULL_FACE)

В коде к этому посту есть неприметная, но очень важная строчка:

glEnable(GL_CULL_FACE);

Этой строчкой мы активируем так называемый face culling. По умолчанию OpenGL рисует полигоны с обеих сторон. Другими словами, если мы развернем треугольник относительно камеры на 180 градусов, он все так же будет виден. Но если вы посмотрите на окружающие вас объекты, то обнаружите, что все они являются объемными, и видите вы только их внешнюю сторону. Возьмем, к примеру, ваш монитор. Вы не можете просунуть голову сквозь экран монитора и посмотреть на монитор изнутри. На нашей сцене пока нет таких сложных объектов, как мониторы, но есть куб. И OpenGL отрисовывает каждый из его полигонов с двух сторон, хотя внутреннюю их сторону в нормальном приложении мы никогда не увидим. Так вот, face culling как раз предназначен для того, чтобы не рисовать полигоны, которые в настоящее время повернуты к камере не той стороной.

Но как определить, какая из сторон является правильной? Оказывается, это можно сделать по порядку, в котором мы задаем вершины полигонов. Возьмем для примера один из полигонов нашего куба, который задается координатами (1,1,1), (-1,1,1) и (1,-1,1). По умолчанию камера смотрит на плоскость XY, «вниз» вдоль оси Z. Таким образом, данный полигон «закручен» против часовой стрелки относительно текущего положения камеры. По умолчанию OpenGL считает именно такую «закрученность» признаком передней стороны. При отрисовке сцены полигоны, смотрящие на камеру задней стороной, отбрасываются.

Зачем такие сложности? Для нашей простой сцены это, пожалуй, не очень-то и нужно. Но для более сложных face culling существенно сокращает время рендеринга. И поэтому лучше сразу учиться делать правильно. Следует однако быть постоянно начеку. Если по ошибке закрутить полигон не в ту сторону, он будет отброшен. В результате на его месте вы увидите дырку.

Изменить то, какую закрученность должна иметь передняя часть полигонов, можно при помощи процедуры glFrontFace. Какую часть отбрасывать, переднюю или заднюю, можно изменить вызовом glCullFace.

MVP

Оказывается, что мы можем контролировать положение камеры, перспективу, а также размещение всех объектов на сцене, просто умножая координаты вершин всех объектов на матрицы. Матрицы эти удобно представлять в виде произведения трех матриц — Model (M), View (V) и Projection (P), отсюда и название этого произведения, MVP. Когда-то давно мы уже разбирались, как задается положение камеры, и что такое перспектива, поэтому далее я буду предполагать, что вы это более-менее себе представляете. Еще более подробное объяснение можно найти здесь: на английском, на русском.

Пример создания матрицы MVP при помощи уже знакомой нам библиотеки GLM:

// поле зрения (Field of View, FOV) в 80 градусов
// отношение w:h = 4:3
// видим между 1 и 100 единицами перед собой
glm::mat4 P = glm::perspective(80.0f, 4.0f / 3.0f, 1.0f, 100.0f);

// камера находится в точке (0,0,5)
// она смотрит на точку (0,0,0)
// вектор, идущий из центра камеры к ее верху, равен (0, 1, 0)
// то есть, камера расположена горизонтально
glm::mat4 V = glm::lookAt(glm::vec3(0, 0, 5), glm::vec3(0, 0, 0),
                          glm::vec3(0, 1, 0));

// модель повернута относительно оси OY на 30 градусов
// по часовой стрелке, если смотреть вдоль этой оси
glm::mat4 M = glm::rotate(30.0f, 0.0f, 1.0f, 0.0f);

glm::mat4 MVP = P * V * M;

Здесь можно найти пояснения касательно выбора правильного FOV и почему у людей в реальной жизни он 180 градусов, а в OpenGL обычно 70-80. Дело в том, что экран попадает в существенно более узкое поле зрения, чем видят наши глаза, и FOV в OpenGL должен соответствовать именно этому более узкому полю. Попробуйте ради интереса увеличить FOV и посмотрите, что произойдет :) Вообще, поскольку экраны у всех разные и находятся на разном расстоянии от глаз, параметры перспективы рекомендуется выносить в настройки программы. Таким образом, каждый пользователь сможет подстроить их под себя.

Объяснение, почему матрица называется MVP, а не PVM, можно найти здесь. Умножение вектора на матрицу MVP эквивалентно умножению вектора на матрицу M, затем на матрицу V, и затем на P. Этот порядок и используют в названии.

Теперь мы хотим умножить координаты рисуемого нами объекта на матрицу MVP. Как это сделать? Вы можете помнить, что для манипулирования координатами вершин предназначен vertex shader. Но нам нужно как-то пробросить нашу матрицу MVP в шейдер. Сказано — сделано:

GLint matrixId = glGetUniformLocation(programId, "MVP");
glUniformMatrix4fv(matrixId, 1, GL_FALSE, &MVP[0][0]);

Процедуры glGetUniformLocation и glUniformMatrix4fv очень просты, поэтому не будем на них подробно останавливаться. С документацией по этим процедурам можно ознакомиться здесь и здесь.

Вот теперь переданную таким образом матрицу MVP можно использовать в vertex shader:

#version 330 core

layout(location = 0) in vec3 vertexPos;
uniform mat4 MVP;

void main() {
  vec4 temp = vec4(vertexPos, 1);
  gl_Position = MVP * temp;
}

Заметьте, что, в отличие от пробрасывания in/out переменных (так называемые attribute-переменные) из шейдера в шейдер, как мы это делали выше с цветом вершин, uniform-переменные неизменяемы и «глобальны», то есть, доступны во всех шейдерах и одинаковы для все вершин. Attribute-переменные можно указывать разные для разных вершин и менять при передаче от шейдера к шейдеру.

Если после внесения всех этих изменений запустить программу, окажется, что перспектива изменилась, и что на куб мы смотрим под другим углом. Либо, если куб изначально был неудачно расположен, его не окажется в поле зрения, и вы увидите черный экран. В этом случае попробуйте повращать камеру путем модификации матрицы V. Или…

Управление камерой с помощью мыши и клавиатуры

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

Курсор мыши находится посередине окна. Известно последнее положение камеры, а также углы ее наклона относительно горизонтали и вертикали:

glm::vec3 position;
float horizontalAngleRad;
float verticalAngleRad;

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

int windowWidth, windowHeight;
glfwGetWindowSize(window, &windowWidth, &windowHeight);

double mouseX, mouseY;
glfwGetCursorPos(window, &mouseX, &mouseY);

horizontalAngleRad += mouseSpeedRad * (windowWidth/2 - mouseX);
verticalAngleRad   += mouseSpeedRad * (windowHeight/2 - mouseY);

glfwSetCursorPos(window, windowWidth/2, windowHeight/2);

Немного тригонометрии позволяет нам определить, где у камеры верх, лево, право, перед и зад:

glm::vec3 direction(
  cos(verticalAngleRad) * sin(horizontalAngleRad),
  sin(verticalAngleRad),
  cos(verticalAngleRad) * cos(horizontalAngleRad)
);

glm::vec3 right = glm::vec3(
  sin(horizontalAngleRad - 3.14f/2.0f),
  0,
  cos(horizontalAngleRad - 3.14f/2.0f)
);

glm::vec3 up = glm::cross(right, direction);

Изменяем позицию камеры:

if(glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS) {
  position += direction * deltaTimeSec * speed;
}

if(glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS) {
  position -= direction * deltaTimeSec * speed;
}

if(glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS) {
  position -= right * deltaTimeSec * speed;
}

if(glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS) {
  position += right * deltaTimeSec * speed;
}

И получаем матрицу V:

*pOutViewMatrix = glm::lookAt(position, position + direction, up);

Полную версию класса Camera и его метода getViewMatrix вы найдете в файле utils/camera.cpp. Плюс к возможности двигать камеру я также реализовал постоянное вращение куба вокруг своей оси. За счет этого я мог быстрее проверять, что куб правильно отрисовывается со всех сторон.

Заключение

Полученная в итоге программка выглядит как-то так:

управление камерой при помощи мыши и клавиатуры

Движение камеры осуществляется при помощи мыши и кнопок WASD. Переключение в режим «все паутинкой» (отрисовка каркасов, wireframes) происходит нажатием Z, а обратно нажатием X. Выход из программы осуществляется нажатием Q.

Полную версию исходного кода к посту вы найдете в этом репозитории. Как обычно, он был проверен на трех машинах, использующих разные GPU и ОС (Linux, MacOS и Windows).

В качестве упражнения попробуйте отключить face culling, запустить программу и нажать Z. Запомните, как сейчас выглядит куб. Затем нажмите X и поместите камеру внутрь куба. Закройте программу, верните face culling и повторите эксперимент. Сравните результаты двух наблюдений и объясните различия.

Дополнение: Продолжаем изучение OpenGL: текстуры и skybox

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.