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



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

TRS матрица


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



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

Поворот – это матрица вида:



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

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

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

Получение позиции, поворота и скейла
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 в нашем случае – это Matrix4x4.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);
}


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



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

Комментарии (18)


  1. EndUser
    09.12.2018 23:41

    Есть ли способ передвинуть объект на 17 и более километров от центра так, чтобы графика не взорвалась?
    Или таки придётся делать floating origin?


    1. Leopotam
      10.12.2018 00:37

      Перейти с float на double, либо да, двигать привязку координат.


      1. EndUser
        10.12.2018 01:24

        Это я знаю, но.
        Да, я могу отказаться от PhysX и использовать свою кинематику, основанную на double. Этого хватит на 17 миллионов (или миллиардов? не помню) километров, что для «наземной» симуляции весьма достаточно. Хватит и на миллиметровую точность или ещё точнее.
        Но сам-то мир Unity3D, вроде бы, построен на float, и, помимо кинематики в вакууме, есть:
        * коллизии. Сможет ли Unity безглючно рассчитать контакт автомобиля или лучше самолёта с террейном далеко от центра, если расстояние в сантиметрах будет выше ёмкости float? А мешей объектов друг с другом?
        * графическое отображение. Сможет ли Unity безглючно визуализировать террейн и окружающие объекты так же далеко от центра?


        1. Leopotam
          10.12.2018 01:48

          В вопросе не было ни слова про юнити. Нет, штатная физика в юнити не умеет большие дистанции от начала координат как раз из-за ограничения точности float-ов. Нет, визуализация так же использует float-ы.


          1. EndUser
            10.12.2018 07:41

            Спасибо!
            Хотя вопрос включал графику к физике, что могло подразумевать фреймворк/либу, предположительно Unity ;-)
            А что, есть графика на double?


            1. Leopotam
              10.12.2018 10:52

              А почему бы и нет? Софтовый рендер никто не отменял :)


        1. DyadichenkoGA Автор
          10.12.2018 08:58

          Ну с 17 км будет сантиметровая точность. Но вообще в Unity да — floating origin. Можно придумать какой угодно маппинг, но по своей сути — это floating origin. И сохранить значения PhysX физики не так сложно (ну проскочит один кадр или можно сделать ручную компенсацию)

          В целом тут две основные причины почему рендер на double особого смысла не имеет. Если камера движется вместе с симуляцией, то с помощью тех же матриц компенсировать позиции чисто для рендера — не самая страшная задача. Дабл работает медленнее, так как просто процессору нужно больше регистров складывать. И скажем PhysX сам по себе работает с флотом. С точки зрения рендера такая точность не имеет смысла, так как объект на расстоянии 17 км от камеры или 18 км от камеры это в большинстве случаев и так, и так пару пикселей. Если нужна точность для расчёта самой симуляции и без PhysX (какая-нить жидкостная симуляция или просто математическая, а юнити для визуализации) то проще считать всё в дабле в своих методах, где нужна точность, а потом делать отображение модели симуляции во флот со сдвигом.


          1. DyadichenkoGA Автор
            10.12.2018 09:19

            В общем дело тут не только в Unity. Сам PhysX на такое не рассчитан, а с точки зрения рендера такой диапазон чисел просто смысла не имеет. Так как если дистанция между объектом и камерой будет 17км, то объект тупо не будет видно в большинстве случаев с перспективной камерой. А дабл жрёт больше памяти, работает медленнее и т.п. Конечно было бы удобное апи, чтобы люди стреляли себе в ногу и не понимали, почему фпс в разы медленнее, чем на флоте, и почему PhysX всё равно глючит, если в него прокидывается дабл. А так, учитывая что всё то, что находится в фруструме камеры не требует такой точности, достаточно построить правильное преобразование всех позиций из одного трёхмерного пространства в другое. Да, применить ко всем объектам. Исходя из «абстрактной позиции мира» и «позиции камеры». Самое простое (но правда для реалтайма вряд ли подходит при сложной симуляции) — считать что камера в (0,0,0) вообще всегда для рендера, и к ней подтягивается мир. В этом случае у нас ломается только OcclusionCulling, но зато самая высокая точность с точки зрения рендера будет по идее)


    1. freeExec
      11.12.2018 09:03

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


      1. EndUser
        11.12.2018 10:14

        Да, согласен.
        Но если самолёт летит над танком? :-)


        1. freeExec
          11.12.2018 10:16

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


          1. DyadichenkoGA Автор
            11.12.2018 10:19

            Один юнит равный 100м в любом случае даст такую же ошибку флота, так как коичество значащих символов не изменится, а на вход PhysX получает флот, и там он округлится


  1. Brightori
    10.12.2018 08:37

    Блин, рейтинга не хватает проголосовать ) нормальные статьи продолжай в том же духе ).


    1. DyadichenkoGA Автор
      10.12.2018 08:38

      Спасибо) Но в целом видимо математику не любят. По сравнению с другими статьями статистика прям печальная)


      1. EndUser
        10.12.2018 11:00

        а с точки зрения рендера такой диапазон чисел просто смысла не имеет

        Смысл есть — если оба, и камера, и объект на 20км от центра, при этом рядом друг с другом, то неточность float, как я предполагаю, можно будет наблюдать вблизи. Хотя бы потому, что графический mesh точно так же поплывёт.

        О математике: зависит от математики. Иногда авторы дают сущие трюизмы. Иногда авторы дают темы, настолько нерелевантные хабру, что я не знаю, что и говорить.


        1. DyadichenkoGA Автор
          10.12.2018 11:26

          Позиции в сцене юнити для рендера и позиции в симуляции не обязаны совпадать. Абстрагировать симуляцию так, чтобы у тебя камера всегда находилась в (0,0,0) в юнити сцене, а остальное двигалось с поправкой на это — не супер большая проблема. Смысла нет, так как действительно важно расстояние между камерой и объектами. OpenGL если мне не изменяет память работает с интом и флотом (GLSL), а PhysX с флотом, то есть дабл округлится в этой точке, и мы получим тоже самое. Задача симуляции — не так часто встречается и легко обыгрывается, но на уровне движка её обыгрывать нет смысла, так как это приводит к не очевидному поведению.

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

          TRS — это вообще отдельная песня. Так как, как и с методом Хаусхолдера найти адекватное структурированное описание что это и из чего складывается — надо постараться. Сама матрица простая, если это написано не языком вышмата, а русским. Так как если мы перейдём к определению, в котором вообще это всё работает в гиперплоскости строго говоря, то думаю большинство тупо не сможет прочесть этот текст со справочником (матрица 4х4, а работаем мы с 3д пространством)

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

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


          1. DyadichenkoGA Автор
            10.12.2018 11:31

            По этой причине это сказано в самом начале до ката, что тут нет ничего супер сложного, и если вы разбираетесь в теме, то в статье не найдёте ничего нового. Но я работаю с огромным числом самых разных проектов, так как не являюсь штатным сотрудником, и чаще дорабатываю чужие решения. И вот костыли связанные с незнанием математики встречаются слишком часто. При этом достаточно простых вещей, а не того, что допустим при физическом моделировании, при использовании метода конечных элементов, ошибка зависит от самого острого угла треугольника, и поэтому EarClipping — не лучший алгоритм, чтобы триангулировать что-либо в подобных задачах. И надо использовать хотя бы Делоне.


  1. Closius
    10.12.2018 20:15

    Про кватернионы хоть что-нить сказали ради приличия.