Уроки по OpenGL с сайта OGLDev


Урок 15 - Управление камерой - часть 2

В этом уроке мы завершим реализацию камеры добавив управление направлением обзора. Есть несколько различных степеней свободы, которые связаны с разработкой камеры. Мы собираемся дать тот же тип управления, что и используется в шутерах (или других играх от первого лица). Это значит, что у нас будет возможность поворачивать камеру на 360 градусов (вокруг положительной части оси Y), что соответствует повороту головы влево или вправо, а полный круг завершается поворотом всего тела. К тому же мы добавим возможность наклонять камеру вверх и вниз для лучшего обзора. Мы будем не состоянии повернуть камеру вверх больше чем на 90 градусов, что бы увидеть события за спиной потребуется совершить вращение вдоль Y. Такие степени свободы не подойдут для симулятора полетов, но в данной серии уроков это нас не волнует. В любом случае мы будем иметь камеру, которой удобно исследовать наш 3D мир, который мы будем расширять в ближайших уроках.

Следующее противовоздушное оружие второй мировой войны продемонстрирует нам тип камеры, которую мы собираемся разработать:

У пушки 2 управляющих оси:

  1. Она может поворачиваться на 360 градусов вокруг вектора (0,1,0). Этот угол называется 'горизонтальным углом', а вектор 'вертикальной осью'.
  2. Она может наклоняться вверх и вниз вокруг вектора, параллельного земле. Это движение ограничено, пушка не может провернуться на полный круг. Этот угол называется 'вертикальным углом', а вектор 'горизонтальной осью'. Заметим, что вертикальная ось постоянна (0,1,0), а вот горизонтальная меняется и всегда перпендикулярна стволу пушки. Это ключевой момент для понимании того, как именно будут проводиться математические подсчеты.

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

Вращение вектора направления на горизонтальный угол довольно просто. Используя простые правила тригонометрии мы можем увидеть, что Z координата это синус горизонтального угла, а X - косинус (на данный момент камера стоит ровно, так что Y равен 0). В уроке 7 приведена диаграмма, которая поможет разобраться.

Поворот вектора направления на вертикальный угол более сложный, так как вращается не только камера, но и ее горизонтальная ось. Горизонтальная ось может быть найдена векторным произведением вертикальной оси и вектором направления, после того, как он будет повернут на горизонтальный угол, но вращение вокруг произвольного вектора (подъем пушки вверх и вниз) может быть довольно сложно.

К счастью, у нас есть простое математическое решение для этой проблемы - кватернионы. Кватернионы были открыты в 1843 году Гамильтоном, ирландским математиком, и они основаны на комплексных числах. Кватернион 'Q' определяется как:

Где i, j и k комплексные числа, и для них справедливо равенство:

На практике мы указываем кватернион как 4-вектор (x, y, z, w). Сопряжение кватерниону 'Q' выглядит так:

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

В целом функция для подсчета кватерниона 'W', который представляет повернутый вектор 'V' на угол 'a', такова:

Где Q - это кватернион вращения, определенный так:

После подсчета 'W' повернутый вектор выглядит довольно просто (W.x,W.y,W.z). Важно заметить, что первое действие в подсчете 'W' это умножение 'Q' на 'V', что является умножением кватерниона на вектор, и результат это кватернион, и после нам требуется умножение кватерниона на кватернион (результат Q*V умножается на кватернион, обратный 'Q'). Эти виды умножения не одинаковые. Файл math_3d.cpp содержит представления этих операции.

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

Наш вектор (x,z), и мы хотим найти способ получить горизонтальный угол, представленный альфой (координата Y участвует только в вертикальном углу). Так как длина радиуса окружности равна 1, то очень легко заметить, что синус альфы - это z. Согласно этому, нахождение арксинуса z и будет наш угол альфа. Все готово? - еще нет. Так как z в промежутке [-1,1], то результат арксинуса в пределах от -90 градусов до 90 градусов. Но горизонтальный угол должен меняться по всей окружности, то есть 360 градусов. К тому же, наш кватернион не поворачивает по часовой стрелке. Это значит, что когда мы вращаем на 90 градусов кватернионом, то мы попадем в точку с z равным -1, а это противоположная точка. ИМХО, самый простой путь получить значение арксинуса используя положительное значение Z, и комбинируя результат с указанным кватернионом круга, в котором расположен вектор. Для примера, когда вектор направления (0,1) мы вычисляем арксинус 1, который равен 90, и вычитаем 360. Итого 270. Арксинус от [0,1] принимает значения от 0 до 90 градусов. Комбинируя это с указанным кватернионом для круга получим итоговое значение горизонтального угла.

Вычисление вертикального угла немного проще. Мы собираемся ограничить наклон от -90 градусов (смотрим вверх) до +90 градусов (направлена вниз). Это значит, что нам требуется только отрицательное значение арксинуса от Y координаты вектора направления. Когда Y равен 1 (смотрим вверх) арксинус равен 90, то есть нужно изменить знак. Когда Y равен -1 (смотрим вниз) арксинус равен -90, и если изменить знак, получим 90 градусов. Если у вас затруднения посмотрите на диаграмму еще раз и замените Z Y'ком, и X Z'том.

Прямиком к коду!

camera.cpp:38

Camera::Camera(int WindowWidth, int WindowHeight, const Vector3f& Pos, const Vector3f& Target, const Vector3f& Up)
{
    m_windowWidth  = WindowWidth;
    m_windowHeight = WindowHeight;
    m_pos = Pos;

    m_target = Target;
    m_target.Normalize();

    m_up = Up;
    m_up.Normalize();

    Init();
}

Конструктор камеры теперь принимает размеры окна. Нам это потребуется для перемещения курсора в центр экрана. Заметим, что мы вызываем функцию Init(), которая установит внутренние параметры камеры.

camera.cpp:54

void Camera::Init()
{
    Vector3f HTarget(m_target.x, 0.0, m_target.z);
    HTarget.Normalize();

    if (HTarget.z >= 0.0f){
        if (HTarget.x >= 0.0f){
            m_AngleH = 360.0f - ToDegree(asin(HTarget.z));
        }
        else{
            m_AngleH = 180.0f + ToDegree(asin(HTarget.z));
        }
    }
    else{
        if (HTarget.x >= 0.0f){
            m_AngleH = ToDegree(asin(-HTarget.z));
        }
        else{
            m_AngleH = 90.0f + ToDegree(asin(-HTarget.z));
        }
    }

    m_AngleV = -ToDegree(asin(m_target.y));

    m_OnUpperEdge = false;
    m_OnLowerEdge = false;
    m_OnLeftEdge  = false;
    m_OnRightEdge = false;
    m_mousePos.x  = m_windowWidth / 2;
    m_mousePos.y  = m_windowHeight / 2;

    glutWarpPointer(m_mousePos.x, m_mousePos.y);
}

В функции Init() мы начинаем с вычисления горизонтального угла. Мы создаем новый вектор, названый HTarget (направление по горизонтали), который является проекцией исходного вектора направления на плоскость XZ. Затем мы его нормируем (так как для выводов выше требуется единичный вектор на плоскости XZ). Затем мы проверяем какой кватернион соответствует вектору для конечного подсчета значения координаты Z. Далее мы подсчитываем вертикальный угол; сделать это гораздо проще.

У камеры появилось 4 новых параметра для проверки не касается ли курсор границ экрана. Мы собираемся добавить автоматическое вращение в этом случае, это позволит нам поворачиваться на все 360 градусов. Мы назначаем все флаги в false, так как курсор в начале в центре экрана. Следующие 2 строки кода вычисляют центр экрана (основывается на разрешении экрана), и новая функция glutWarpPointer перемещает курсор. Старт с курсором в центре экрана упростит нам жизнь.

camera.cpp:140

void Camera::OnMouse(int x, int y)
{
    const int DeltaX = x - m_mousePos.x;
    const int DeltaY = y - m_mousePos.y;

    m_mousePos.x = x;
    m_mousePos.y = y;

    m_AngleH += (float)DeltaX / 20.0f;
    m_AngleV += (float)DeltaY / 20.0f;

    if (DeltaX == 0){
        if (x <= MARGIN){
            m_OnLeftEdge = true;
        }
        else if (x >= (m_windowWidth - MARGIN)){
            m_OnRightEdge = true;
        }
    }
    else{
        m_OnLeftEdge = false;
        m_OnRightEdge = false;
    }

    if (DeltaY == 0){
        if (y <= MARGIN){
            m_OnUpperEdge = true;
        }
        else if (y >= (m_windowHeight - MARGIN)){
            m_OnLowerEdge = true;
        }
    }
    else {
        m_OnUpperEdge = false;
        m_OnLowerEdge = false;
    }

    Update();
}

Эта функция используется что бы сообщить камере, что положение мыши изменилось. Входящие параметры - это новые координаты. Мы начинаем с подсчета разницы между новыми координатами и предыдущими по осям и X и Y. Дальше мы записываем новые значения для следующих вызовов функции. Мы обновляем текущие горизонтальные и вертикальные углы на эту разность в значениях. Я использую уменьшение (в 20 раз) для получения удобного мне движения, но для разных компьютеров могут потребоваться различные значения. Мы собираемся улучшить этот момент, когда введем частоту кадров в секунду.

После проверки мы обновляем значения 'm_OnEdge' согласно положению курсора. Граница по умолчанию равна 10 пикселям, и триггер сработает, если мышь будет достаточна близка к границе. Наконец, мы вызываем *Update() для перерасчета векторов направления и вектора вверх, основанных на новых горизонтальном и вертикальном углах.

camera.cpp:183

void Camera::OnRender()
{
    bool ShouldUpdate = false;

    if (m_OnLeftEdge){
        m_AngleH -= 0.1f;
        ShouldUpdate = true;
    }
    else if (m_OnRightEdge){
        m_AngleH += 0.1f;
        ShouldUpdate = true;
    }

    if (m_OnUpperEdge){
        if (m_AngleV > -90.0f){
            m_AngleV -= 0.1f;
            ShouldUpdate = true;
        }
    }
    else if (m_OnLowerEdge){
        if (m_AngleV < 90.0f){
            m_AngleV += 0.1f;
            ShouldUpdate = true;
        }
    }

    if (ShouldUpdate){
        Update();
    }
}

Эта функция вызывается из главного цикла рендера. Она нам необходима для случаев, когда мышь не движется, но находится около одной из границ экрана. В этом случае мышь не будет передавать событий, но мы все еще хотим, что бы камера вращалась (до тех пор, пока курсор не отведут от края экрана). Мы проверяем не установлен ли хоть один из флагов, и если таковой найден, то изменится один из углов. В этом случае будет вызвана функция Update() для обновления векторов камеры. Если мышь не находится в окне, то нам передается это событие, и все флаги снимаются. Заметим, что вертикальный угол находится в пределах от -90 градусов и до +90 градусов. Это необходимо для запрета полного круга, когда мы наклоняемся вверх или вниз.

camera.cpp:214

void Camera::Update()
{
    const Vector3f Vaxis(0.0f, 1.0f, 0.0f);

    // Rotate the view vector by the horizontal angle around the vertical axis
    Vector3f View(1.0f, 0.0f, 0.0f);
    View.Rotate(m_AngleH, Vaxis);
    View.Normalize();

    // Rotate the view vector by the vertical angle around the horizontal axis
    Vector3f Haxis = Vaxis.Cross(View);
    Haxis.Normalize();
    View.Rotate(m_AngleV, Haxis);
    View.Normalize();

    m_target = View;
    m_target.Normalize();

    m_up = m_target.Cross(Haxis);
    m_up.Normalize();
}

Эта функция обновляет значения векторов направления и вверх согласно горизонтальному и вертикальному углам. Мы начинаем с вектором обзора в "сброшенном" состоянии. Это значит, что он параллелен земле (вертикальный угол равен 0) и смотрит направо (горизонтальный угол равен 0 - смотри диаграмму выше). Мы устанавливаем вертикальную ось прямо вверх и вращаем вектор направления на горизонтальный угол относительно нее. В результате получаем вектор, который, в общем то, соответствует искомому, но имеет не правильную высоту (т.к. он принадлежит плоскости XZ). Совершив векторное произведение между этим вектором и вертикальной осью, мы получаем еще один вектор на плоскости XZ, но он будет перпендикулярен плоскости, образованной вертикальным вектором и вектором направления. Это наша новая горизонтальная ось, и настал момент вращать вектор вокруг нее на вертикальный угол. Результат - итоговый вектор направления, и мы записываем его в соответствующее место в классе. Теперь нам нужно исправить вектор вверх. Например, если камера смотрит поворачивается вверх, то вектор будет откланяться назад (он обязан быть под углом в 90 градусов с вектором направления). Это схоже с тем, как вы наклоняете голову, когда смотрите на небо. Новый вектор подсчитывается просто векторным произведением итоговым вектором направления и новым вектором вправо. Если вертикальный угол снова 0, тогда вектор направления возвращается на плоскость XZ, и вектор вверх обратно (0,1,0). Если вектор направления движется вверх или вниз, то вектор вверх наклоняется вперед / назад соответственно.

main.cpp:15

glutGameModeString("1920x1200@32");
glutEnterGameMode();

Эта функция glut'а разрешает вашему приложению запускаться в полноэкранном режиме, называемом как 'игровой режим'. Это упростит поворот на 360 градусов, так как все что вам требуется сделать, это передвинуть курсор к одному из краев экрана. Заметим, что разрешение и количество цветов задаются через строку в функции. 32 бита для пикселя обеспечивают наибольшее количество цветов для рендера.

main.cpp:227

pGameCamera = new Camera(WINDOW_WIDTH, WINDOW_HEIGHT);

Камера теперь автоматически установится в нужное положение, так как в конструкторе она установит положение курсора в центре экрана (используя функцию glutWarpPointer). Этот вызов вернет код ошибки если glut еще не был инициализирован.

main.cpp:122

glutPassiveMotionFunc(PassiveMouseCB);
glutKeyboardFunc(KeyboardCB);

Мы регистрируем 2 новых функции обратного вызова. Одна для мыши и другая для нажатия специальных клавиш (это клавиши направление и функциональные клавиши). Пассивное движение означает, что мышь движется без нажатия каких-либо кнопок.

main.cpp:104

static void KeyboardCB(unsigned char Key, int x, int y)
{
     switch (Key) {
        case 'q':
        exit(0);
     }
}

static void PassiveMouseCB(int x, int y)
{
     pGameCamera->OnMouse(x, y);
}

Теперь мы используем полноэкранный режим, поэтому может возникнуть проблема с выходом из приложения. При нажатии 'q' мы выходим. Обратный вызов для мыши просто передает ее координаты в камеру.

main.cpp:67

static void RenderSceneCB()
{
    pGameCamera->OnRender();

Где-то в функции рендера мы должны вызвать камеру. Это дает ей шанс для действий, если мышь не двигалась, но находится около границы экрана.

powered byDisqus