Математика в Gamedev по-простому. Матрицы и аффинные преобразования

Всем привет! Меня зовут Гриша, и я основатель CGDevs. Сегодня хочется продолжить тему математики в геймдеве. В предыдущей статье были показаны базовые примеры использования векторов и интегралов в Unity проектах, а сейчас поговорим о матрицах и аффинных преобразованиях. Если вы хорошо разбираетесь в матричной арифметике; знаете, что такое TRS и как с ним работать; что такое преобразование Хаусхолдера — то вы возможно не найдёте для себя ничего нового. Говорить мы будем в контексте 3D графики. Если же вам интересна эта тема — добро пожаловать под кат.

uvqkz8zsqguqq3sl4c_clkwknx8.jpeg
Начнём с одного из самых главных понятий в контексте статьи — аффинные преобразования. Аффинные преобразования — это, по сути, преобразование системы координат (или пространства) с помощью умножения вектора на специальную матрицу. К примеру, такие преобразования, как перемещение, поворот, масштабирование, отражение и др. Основным свойствами аффинных преобразований является то, что вы остаётесь в том же пространстве (невозможно сделать из трёх мерного вектора двумерный) и то, что если прямые пересекались/были параллельны/скрещивались до преобразования, то это свойство после преобразования сохранится. Помимо этого, у них очень много математических свойств, которые требуют знания теории групп, множеств и линейной алгебры, что позволяет работать с ними проще.

TRS матрица

Вторым важным понятием в компьютерной графике является TRS матрица. С помощью неё можно описать самые частые операции, используемые при работе с компьютерной графикой. TRS матрица — это композиция трёх матриц преобразования. Матрицы перемещения (Translation), поворота по каждой оси (Rotation) и масштабирования (Scale).
Выглядит она так.

9pcvlphst8r4db6bwww36jdwtuo.png

Где:
Перемещение — это t = new Vector3(d, h, l).
Масштабирование — s = new Vectro3(new Vector3(a, e, i).magnitude, new Vector3(b, f, j).magnitude, new Vector3(c, g, k).magnitude);
Поворот — это матрица вида:

in4c_nqanxcmlg4lah2colkjioo.png

А теперь перейдём чуть глубже к контексту Unity. Начнём с того, что TRS матрица — это очень удобная вещь, но ей не стоит пользоваться везде. Так как простое указание позиции или сложение векторов в юнити будет работать быстрее, но во многих математических алгоритмов матрицы в разы удобнее векторов. Функционал TRS в Unity во многом реализован и в классе Matrix4×4, но он не удобен с точки зрения применения. Так как помимо применения матрицы через умножение она может в целом хранить в себе информацию об ориентации объекта, а также для некоторых преобразований хочется иметь возможность рассчитывать не только позицию, а изменять ориентацию объекта в целом (к примеру отражение, которое в Unity не реализовано)

Все примеры ниже приведены для локальной системы координат (началом координат считается позиция GameObject«а, внутри которого находится объект. Если объект является корнем иерархии в юнити, то начало координат — это мировые (0,0,0)).

Так как с помощью TRS матрицы можно в принципе описать положения объекта в пространстве, то нам нужна декомпозиция из TRS в конкретные значения position, rotation и scale для Unity. Для этого можно написать методы-расширения для класса Matrix4×4

Получение позиции, поворота и скейла
public static Vector3 ExtractPosition(this Matrix4x4 matrix)
{
        Vector3 position;
        position.x = matrix.m03;
        position.y = matrix.m13;
        position.z = matrix.m23;
        return position;
}

public static Quaternion ExtractRotation(this Matrix4x4 matrix)
{
        Vector3 forward;
        forward.x = matrix.m02;
        forward.y = matrix.m12;
        forward.z = matrix.m22;

        Vector3 upwards;
        upwards.x = matrix.m01;
        upwards.y = matrix.m11;
        upwards.z = matrix.m21;

        return Quaternion.LookRotation(forward, upwards);
}

public static Vector3 ExtractScale(this Matrix4x4 matrix)
{
        Vector3 scale;
        scale.x = new Vector4(matrix.m00, matrix.m10, matrix.m20, matrix.m30).magnitude;
        scale.y = new Vector4(matrix.m01, matrix.m11, matrix.m21, matrix.m31).magnitude;
        scale.z = new Vector4(matrix.m02, matrix.m12, matrix.m22, matrix.m32).magnitude;
        return scale;
}


Кроме того, для удобной работы можно реализовать пару расширений класса Transform, чтобы работать в нём с TRS.

Расширение трансформа
public static void ApplyLocalTRS(this Transform tr, Matrix4x4 trs)
{
        tr.localPosition = trs.ExtractPosition();
        tr.localRotation = trs.ExtractRotation();
        tr.localScale = trs.ExtractScale();
}

public static Matrix4x4 ExtractLocalTRS(this Transform tr)
{
        return Matrix4x4.TRS(tr.localPosition, tr.localRotation, tr.localScale);
}


На этом плюсы юнити заканчиваются, так как матрицы в Unity очень бедны на операции. Для многих алгоритмов необходима матричная арифметика, которая в юнити не реализована даже в совершенно базовых операциях, таких как сложение матриц и умножения матриц на скаляр. Кроме того, из-за особенности реализации векторов в Unity3d, так же есть, ряд неудобств, связанных с тем, что вы можете сделать вектор 4×1, но не можете сделать 1×4 из коробки. Так как дальше пойдёт речь про преобразование Хаусхолдера для отражений, то сначала реализуем необходимые для этого операции.

По сложению/вычитанию и умножению на скаляр — всё просто. Выглядит достаточно громоздко, но ничего сложного тут нет, так как арифметика простая.

Базовые матричные операции
public static Matrix4x4 MutiplyByNumber(this Matrix4x4 matrix, float number)
{
        return new Matrix4x4(
                new Vector4(matrix.m00 * number, matrix.m10 * number, matrix.m20 * number, matrix.m30 * number),
                new Vector4(matrix.m01 * number, matrix.m11 * number, matrix.m21 * number, matrix.m31 * number),
                new Vector4(matrix.m02 * number, matrix.m12 * number, matrix.m22 * number, matrix.m32 * number),
                new Vector4(matrix.m03 * number, matrix.m13 * number, matrix.m23 * number, matrix.m33 * number)
        );
}

public static Matrix4x4 DivideByNumber(this Matrix4x4 matrix, float number)
{
        return new Matrix4x4(
                new Vector4(matrix.m00 / number, matrix.m10 / number, matrix.m20 / number, matrix.m30 / number),
                new Vector4(matrix.m01 / number, matrix.m11 / number, matrix.m21 / number, matrix.m31 / number),
                new Vector4(matrix.m02 / number, matrix.m12 / number, matrix.m22 / number, matrix.m32 / number),
                new Vector4(matrix.m03 / number, matrix.m13 / number, matrix.m23 / number, matrix.m33 / number)
        );
}

public static Matrix4x4 Plus(this Matrix4x4 matrix, Matrix4x4 matrixToAdding)
{
        return new Matrix4x4(
                new Vector4(matrix.m00 + matrixToAdding.m00, matrix.m10 + matrixToAdding.m10,
                        matrix.m20 + matrixToAdding.m20, matrix.m30 + matrix.m30),
                new Vector4(matrix.m01 + matrixToAdding.m01, matrix.m11 + matrixToAdding.m11,
                        matrix.m21 + matrixToAdding.m21, matrix.m31 + matrix.m31),
                new Vector4(matrix.m02 + matrixToAdding.m02, matrix.m12 + matrixToAdding.m12,
                        matrix.m22 + matrixToAdding.m22, matrix.m32 + matrix.m32),
                new Vector4(matrix.m03 + matrixToAdding.m03, matrix.m13 + matrixToAdding.m13,
                        matrix.m23 + matrixToAdding.m23, matrix.m33 + matrix.m33)
        );
}

public static Matrix4x4 Minus(this Matrix4x4 matrix, Matrix4x4 matrixToMinus)
{
        return new Matrix4x4(
                new Vector4(matrix.m00 - matrixToMinus.m00, matrix.m10 - matrixToMinus.m10,
                        matrix.m20 - matrixToMinus.m20, matrix.m30 - matrixToMinus.m30),
                new Vector4(matrix.m01 - matrixToMinus.m01, matrix.m11 - matrixToMinus.m11,
                        matrix.m21 - matrixToMinus.m21, matrix.m31 - matrixToMinus.m31),
                new Vector4(matrix.m02 - matrixToMinus.m02, matrix.m12 - matrixToMinus.m12,
                        matrix.m22 - matrixToMinus.m22, matrix.m32 - matrixToMinus.m32),
                new Vector4(matrix.m03 - matrixToMinus.m03, matrix.m13 - matrixToMinus.m13,
                        matrix.m23 - matrixToMinus.m23, matrix.m33 - matrixToMinus.m33)
        );
}


Но для отражения нам понадобится операция умножения матриц в конкретном частном случае. Умножение вектора размерности 4×1 на 1×4 (транспонированный) Если вы знакомы с матричной математикой, то знаете, что при таком умножении надо смотреть на крайние цифры размерности, и вы получите размерность матрицы на выходе, то есть в данном случае 4×4. Информации по тому, как перемножаются матрицы достаточно, поэтому это расписывать не будем. Вот для примера реализованный конкретный случай, который нам пригодится в будущем

Перемножение вектора на транспонированный
public static Matrix4x4 MultiplyVectorsTransposed(Vector4 vector, Vector4 transposeVector)
{

        float[] vectorPoints = new[] {vector.x, vector.y, vector.z, vector.w},
                transposedVectorPoints = new[]
                        {transposeVector.x, transposeVector.y, transposeVector.z, transposeVector.w};
        int matrixDimension = vectorPoints.Length;
        float[] values = new float[matrixDimension * matrixDimension];

        for (int i = 0; i < matrixDimension; i++)
        {
                for (int j = 0; j < matrixDimension; j++)
                {
                        values[i + j * matrixDimension] = vectorPoints[i] * transposedVectorPoints[j];
                }

        }

        return new Matrix4x4(
                new Vector4(values[0], values[1], values[2], values[3]),
                new Vector4(values[4], values[5], values[6], values[7]),
                new Vector4(values[8], values[9], values[10], values[11]),
                new Vector4(values[12], values[13], values[14], values[15])
        );
}


Преобразование Хаусхолдера

В поисках того, как отразить объект относительно какой-либо оси, я часто встречаю совет поставить отрицательный scale по необходимому направлению. Это очень плохой совет в контексте Unity, так как он ломает очень много систем в движке (батчинг, коллизии и др.) В некоторых алгоритмах это превращается в достаточно нетривиальные вычисления, если вам надо отразить не банально относительно Vector3.up или Vector3.forward, а по произвольному направлению. Сам метод отражения в юнити из коробки не реализован, поэтому я реализовал метод Хаусхолдера.
Преобразование Хаусхолдера, используется не только в компьютерной графике, но в этом контексте — это линейное преобразование, которое отражает объект относительно плоскости, которая проходит через «начало координат» и определяется нормалью к плоскости. Во многих источниках оно описано достаточно сложно, и непонятно, хотя его формула — элементарна.

H=I-2*n* (n^T)

Где H — матрица преобразования, I в нашем случае — это Matrix4×4.identity, а n = new Vector4(planeNormal.x, planeNormal.y, planeNormal.z, 0). Символ T означает транспонирование, то есть после умножения n* (n^T) мы получим матрицу 4×4.

Тут пригодятся реализованные методы и запись получится очень компактной.

Преобразование Хаусхолдера
public static Matrix4x4 HouseholderReflection(this Matrix4x4 matrix4X4, Vector3 planeNormal)
{
        planeNormal.Normalize();
        Vector4 planeNormal4 = new Vector4(planeNormal.x, planeNormal.y, planeNormal.z, 0);
        Matrix4x4 householderMatrix = Matrix4x4.identity.Minus(
                MultiplyVectorsTransposed(planeNormal4, planeNormal4).MutiplyByNumber(2));
        return householderMatrix * matrix4X4;
}


Важно: planeNormal должна быть нормализована (что логично), а также последней координатой n стоит 0, чтобы не было эффекта растяжения по направлению, так как оно зависит от длинны вектора n.

Теперь для удобства работы в Unity реализуем метод расширение для трансформа

Отражение трансформа в локальной системе координат
public static void LocalReflect(this Transform tr, Vector3 planeNormal)
{
        var trs = tr.ExtractLocalTRS();
        var reflected = trs.HouseholderReflection(planeNormal);
        tr.ApplyLocalTRS(reflected);
}


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

ln0uivbr1o7yxfbiwyyumcbbkc8.png

Спасибо за внимание!

© Habrahabr.ru