Опыт изучения OpenGL — Часть 8 — Vector maths API

В прошлой заметке я закончил теоретическую часть рассказа про пространственные преобразования в компьютерной графике. Теперь хочу сказать два слова про программирование всего этого на C++.

Vector & Matrix

Было довольно очевидно, из чего должен состоять API для векторной алгебры в моем проекте. Это должны были быть два класса: Vector и Matrix. Элементами вектора могут быть числа различных типов (скажем, int, float, double и пр.), поэтому очевидно, что классы Vector и Matrix должны были быть шаблонными (тип элемента матрицы/вектора должен быть параметром шаблона). И вектор, и матрица могут иметь разные размерности, например вектор может быть двумерный или трехмерный, матрица может быть 2×2 или 3×3 или 4×4 и т. д. Причем двумерный вектор и трехмерный вектор — это векторы разных типов! Так же как матрица 2×2 и матрица 3×3 — это матрицы разных типов. Поэтому размерности вектора и/или матрицы тоже должны быть параметрами шаблона. Ну и наконец, для векторов и матриц надо определить операции сложения, умножения (вектора на вектор, вектора на скаляр, матрицы на вектор, матрицы на скаляр, матрицы на матрицу), скалярного и векторного произведения и другие.

Классов векторов я написал несколько. Один базовый класс VectorBase может иметь произвольную размерность. VectorBase хранит свои элементы в массиве std::array. Получить доступ к i-ому элементу ветора можно при помощи оператора «квадратные скобки» либо функции at(). Кроме того, класс VectorBase я сделал итерабельным. Три производных класса Vector наследуют классу VectorBase и имеют размерности от 2 до 4. Сделал я их для того, чтобы определить в них методы X(), Y(), Z(), W() — для удобства обращения к элементам таких векторов. Действительно, код становится более читабельным, если например вместо vec.at(0) написать vec.X(), или вместо vec[1] написать vec.Y() и т. д. Ниже привожу только объявления классов, полный код можно увидеть в репозитории.

// Vector.h

namespace vmath
{
    /*
    A vector
    Template parameters:
    T - type of vector coordinates
    Dimension - vector space dimension (number of coordinates in vector)
    */

    template<typename T, int Dimension>
    class VectorBase { ... };

    /*
    A vector
    Template parameters:
    T - type of vector coordinates
    Dimension - vector space dimension (number of coordinates in vector)
    */

    template<typename T, int Dimension>
    class Vector : public VectorBase<T, Dimension>

    /* 2-dimensional vector */
    template<typename T>
    class Vector<T, 2> : public VectorBase<T, 2> { ... };

    /* 3-dimensional vector */
    template<typename T>
    class Vector<T, 3> : public VectorBase<T, 3> { ... };

    /* 4-dimensional vector */
    template<typename T>
    class Vector<T, 4> : public VectorBase<T, 4> { ... };
}

Класс матрицы я решил реализовать в виде массива столбцов. При этом каждый столбец — это вектор. Соответственно, способ хранения элементов матрицы в памяти компьютера у меня — column-major. Ниже приведу только объявление класса матрицы.

// Matrix.h

namespace vmath
{
    /*
    A matrix. Elements are stored in column-major format.
    Template parameters:
    T - type of matrix elements
    Rows - number of rows
    Cols - number of columns
    */

    template <typename T, int Rows, int Cols>
    class Matrix { ... }
}

SSE

Ниде показан код операции сложения двух векторов:

template<typename T, typename V, int Dimension>
const Vector<decltype(std::declval<T>() + std::declval<V>()), Dimension> operator+(
    const Vector<T, Dimension>& lhs,
    const Vector<V, Dimension>& rhs) noexcept
{
    Vector<decltype(std::declval<T>() + std::declval<V>()), Dimension> vec;
    for (int i = 0; i < Dimension; i++)
        vec[i] = lhs[i] + rhs[i];
    return vec;
}

Код этот простой и понятный (хотя в нем присутствуют разные кракозябры из C++11) — в цикле последовательно складываются элементы вектора. Из книжки [Gregory, 4.7. Hardware-Accelerated SIMD Math] я узнал, что в процессорах Intel давно существуют средства для выполнения операций над векторами под названием SSE — Streaming SIMD Extensions. SIMD означает SIngle Instruction Multiple Data — одна операция выполняется одновременно над несколькими числами — ровно то, что происходит при сложении или покомпонентном умножении векторов. SSE добавляет в архитектуру процессора набор 128-разрядных регистров (регистры имеют имена xmm0, xmm1, xmm2 и т. д.) и набор машинных инструкций для выполнения SIMD-оперций (например, инструкция addps xmm0, xmm1 складывает регистры xmm0 и xmm1 и помещает результат в xmm0). В один 128-разрядный регистр можно поместить четыре 32-разрядных числа с плавающей точкой (float), т. е. вектор. Дальше одна операция (сложение, вычитание, умножение) выполняется над двумя такими регистрами. Результат тоже сохраняется в регистре, из которого его затем можно переместить в оперативную память (адрес в оперативной памяти обязательно должен быть выровнен по границе 16 байт). Операции, исользующие SSE, можно запрограммировать либо при помощи ассемблерных вставок, либо при помощи специальных ключевых слов компилятора (в книге [Gregory] объясняется, почему последний вариант предпочтителен). Ниже приведен пример реализации операции сложения двух векторов с использованием SSE:

#include <xmmintrin.h>

// Adds two vectors unsing SSE.
inline const Vector<float, 4> operator+(
    const Vector<float, 4>& lhs,
    const Vector<float, 4>& rhs) noexcept
{
    __m128 a = _mm_load_ps(lhs.data()); // load vector data into a __m128 variable (xmm register)
    __m128 b = _mm_load_ps(rhs.data()); // load vector data into a __m128 variable (xmm register)

    __m128 c = _mm_add_ps(a, b); // perform the addition operation (the result is stored in __m128 variable i. e. in an xmm register)

    __declspec(align(16)) Vector<float, 4> vec; // declare a variable of type Vector aligned to the 16-byte boundary in RAM
    _mm_store_ps(vec.data(), c); // store the result of the SSE operation in the variable in RAM

    return vec; // return the result stored in RAM
}

В [Gregory, 4.7.4 Vector-Matrix Multiplication with SSE] также объясняется как выполнить умножение матрицы и ветора при помощи SSE.

WorldPositionAndOrientation

В прошлой заметке я говорил, что позиция объекта в world space задается вектором, а ориентация объекта может быть задана двумя векторами, которые указывают направление осей координат в системе model space, связанной с объектом (направление третьей оси можно рассчитать как векторное произведение двух первых). На основании информации о позиции объекта и его ориентации в пространстве можно сформировать матрицу перехода из model space в world space. Информацию о позиции и ориентации объекта я решил поместить в класс engine::WorldPositionAndOrientation. Ниже привожу только поля этого класса:

class WorldPositionAndOrientation
{
    private:
        vec3 m_Position;
        vec3 m_ForwardDirection;
        vec3 m_UpDirection;

    ...
}

Объект может перемещаться и вращаться в пространстве. Поэтому удобно движение и вращение объекта поместить в соответствующие функции (ниже привожу только объявления некоторых функций):

/*
Rotates object around object's up axis (Y)
Parameters:
angle - angle in radians (+ CW, - CCW)
*/

WorldPositionAndOrientation& Yaw(GLfloat angle) noexcept;

/*
Rotates object around object's right axis (X)
Parameters:
[angle] - angle in radians (+ CW, - CCW)
*/

WorldPositionAndOrientation& Pitch(GLfloat angle) noexcept;

/*
Rotates object around object's forward axis (Z)
Parameters:
angle - angle in radians (+ CW, - CCW)
*/

WorldPositionAndOrientation& Roll(GLfloat angle) noexcept;

/*
Moves object position up or down (along object's Y-axis)
Parameters:
delta - translation along object's Y-axis (+ up, - down)
*/

WorldPositionAndOrientation& MoveUpDown(GLfloat delta) noexcept;

/*
Moves object position right or left (along object's X-axis)
Parameters:
[delta] - translation along object's X axis
*/

WorldPositionAndOrientation& MoveRightLeft(GLfloat delta) noexcept;

И наконец, функция формирования матрицы model-to-world:

inline auto make_ModelToWorldMatrix(const WorldPositionAndOrientation& wpo)
{
    return vmath::make_ModelToWorldMatrix(
        wpo.getPosition(),
        wpo.getForwardDirection(),
        wpo.getUpDirection());
}

… которая пользуется функцией make_ModelToWorldMatrix из файла Matrix.h:

/*
Creates a model-to-world matrix which transforms model coordinates into world coordinates.
Parameters:
[position] - position vector of the model in world space
[forwardDirection] - forward direction of the model (z-axis direction) in world space
[upDirection] - up direction of the model (y-axis direction) in world space
Assumptions:
[forwardDirection] is normalized (i. e. it is of unit length).
[upDirection] is normalized (i. e. it is of unit length).
[upDirection] is not necessarily the same as model's y-axis direction, but it shouldn't be the same as forwardDirection.
Handedness of the model space is the same as that of the world space.
*/

template<typename T>
const Matrix<T, 4, 4> make_ModelToWorldMatrix(
    const Vector<T, 3>& position,
    const Vector<T, 3>& forwardDirection,
    const Vector<T, 3>& upDirection) noexcept
{
    auto s = cross_product(upDirection, forwardDirection); // X = [Y x Z] = [Up x Forward]
    auto u = cross_product(forwardDirection, s); // Y = [Z x X] = [Forward x S]

    return Matrix<T, 4, 4>
    {
        { s.X(), s.Y(), s.Z(), T(0) }, // right
        { u.X(), u.Y(), u.Z(), T(0) }, // up
        { forwardDirection.X(), forwardDirection.Y(), forwardDirection.Z(), T(0) }, // forward
        { position.X(), position.Y(), position.Z(), T(1) } // position
    };
}

Сейчас хочу сказать про изменение в коде, которое произошло уже после того, как я дописал этот пост. Как было показано в прошлой заметке и как можно видеть в коде функции make_ModelToWorldMatrix, три первых столбца (напоминаю, что столбцы в коде записаны горизонтально, так как используется порядок column-major) матрицы model-to-world являются векторами, которые задают направления осей right, up и forward системы координат model space. Последний столбец задает позицию начала координат model space. Поэтому почему бы не хранить означенные четыре вектора в виде матрицы model-to-world? Тогда саму матрицу не придется конструировать каждый раз когда она понадобится. Поэтому я изменил класс engine::WorldPositionAndOrientation, чтобы он содержал не три отдельных вектора, а одну матрицу model-to-world размером 4×4. При этом векторы right, up, forward и position легко извлечь, так как они являются столбцами матрицы:

class WorldPositionAndOrientation
{
    private:
        mat4 m_ModelToWorldMatrix;

    public:
        const mat4& ModelToWorldMatrix() const noexcept { return m_ModelToWorldMatrix; }

        // Gets object's right direction in world space.
        const vec3& RightDirection() const noexcept { return m_ModelToWorldMatrix.at(0).vec3(); }

        // Gets object's up direction in world space.
        const vec3& UpDirection() const noexcept { return m_ModelToWorldMatrix.at(1).vec3(); }

        // Gets object's forward direction in world space.
        const vec3& ForwardDirection() const noexcept { return m_ModelToWorldMatrix.at(2).vec3(); }

        // Gets object's position in world space.
        vec3& Position() noexcept { return m_ModelToWorldMatrix.at(3).vec3(); }
    ...
}

Camera

Кроме различных объектов-моделей в игре присутствует камера, которая тоже обладает позицией и ориентацией в world space. На основании позиции и ориентации камеры можно сформировать матрицу перехода из world space в camera space. Но кроме этого объект «камера» также может содержать информацию о параметрах перспективной проекции. Мой класс engine::Camera именно такой (ниже приведены фрагменты кода этого класса):

class Camera
{
    #pragma region Fields

    private:
        WorldPositionAndOrientation m_WorldPositionAndOrientation;

        GLfloat m_ViewAngle;
        GLfloat m_AspectRatio;
        GLfloat m_NearPlane;
        GLfloat m_FarPlane;

        mat4 m_PerspectiveProjectionMatrix; // cached perspective projection matrix

    ...

    public:
        const mat4& getPerspectiveProjectionMatrix() const { return m_PerspectiveProjectionMatrix; }

        mat4 getWorldToCameraMatrix() const
        {
            return vmath::make_WorldToCameraMatrix<GLfloat>(
                m_WorldPositionAndOrientation.getPosition(),
                m_WorldPositionAndOrientation.getForwardDirection(),
                m_WorldPositionAndOrientation.getUpDirection());
        }

    private:
        void CalcPerspectiveProjectionMatrix()
        {
            m_PerspectiveProjectionMatrix = vmath::make_PerspectiveProjectionMatrix(
                m_ViewAngle,
                m_AspectRatio,
                m_NearPlane,
                m_FarPlane);
        }
}

На этом все. В следующей заметке рассмотрим вопрос о том, как матрицы передаются в шейдеры, которые и выполняют всякие преобразования над моделями 3-хмерных объектов.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *