Гайд по матрицам в OpenTK
Вольный перевод поста NogginBops "The OpenTK matrix guide"
Пост не является авторским и/или официальным.
Более того - он так же не является профессиональным.
Автор оригинала: NogginBops
Ссылка на оригинал: The OpenTK matrix guide
TL;DR: Как добиться согласованности при использовании OpenTK
Вокруг матриц в OpenTK и того, как они соотносятся с GLSL, существует много путаницы. В этом посте я постараюсь её развеять и показать, что всё не так сложно, как многие думают.
У матриц есть два независимых свойства: порядок хранения элементов и соглашение об умножении. Их часто смешивают, хотя на удивление мало что их связывает.
Порядок хранения элементов матрицы
Матрица — это двумерная сетка чисел, и у неё нет “очевидного” способа хранения в памяти, которая линейна. Превратить двумерную сетку в одномерный массив можно множеством способов, но для матриц популярны два: построчный порядок (row-major order) и постолбцовый порядок (column-major order).
При построчном порядке строки матрицы хранятся одна за другой, вот так:
В памяти это будет выглядеть так:
В коде это можно записать так:
1
2
3
4
5
6
7
public struct Matrix4
{
public Vector4 Row0;
public Vector4 Row1;
public Vector4 Row2;
public Vector4 Row3;
}
Аналогично, при постолбцовом порядке столбцы хранятся один за другим, вот так:
В памяти это будет выглядеть так:
В коде это можно записать так:
1
2
3
4
5
6
7
public struct Matrix4
{
public Vector4 Column0;
public Vector4 Column1;
public Vector4 Column2;
public Vector4 Column3;
}
Знать порядок хранения матрицы важно при передаче матриц между библиотеками, так как обе стороны должны договориться, какое число в какую позицию матрицы попадает.
Особенно это важно при взаимодействии между языками программирования, где компилятор не может проверить типы на стыке языков. Представьте, что вы получили float[16], представляющий матрицу: ничто не подскажет нам, какой позиции в матрице соответствует каждый элемент. Мы должны сами выбрать порядок хранения и придерживаться его. Если кто-то другой использует другой порядок, нам нужно преобразовать нашу матрицу в его порядок, прежде чем передавать ему массив.
Что произойдёт, если прочитать матрицу с построчным порядком так, будто у неё постолбцовый порядок? Другими словами, что если передать матрицу с постолбцовым порядком в функцию, которая читает матрицу построчно? Взгляните ещё раз на иллюстрации выше и попробуйте догадаться сами.
Ответ: строки матрицы с построчным порядком будут прочитаны как столбцы матрицы с постолбцовым порядком. Это значит, что столбец 0 матрицы теперь будет считаться строкой 0. В математике замена строк матрицы на столбцы называется “транспонированием” матрицы. Таким образом, передача матрицы с постолбцовым порядком в функцию, ожидающую матрицу с построчным порядком, означает, что функция прочитает транспонированную версию матрицы. А значит, чтобы корректно вызвать функцию, нам нужно преобразовать нашу матрицу с построчным порядком в матрицу с постолбцовым порядком, что можно сделать, транспонировав её. В обратную сторону это тоже работает.
Соглашение об умножении
Второе важное свойство матриц — соглашение об умножении. Упрощённо, оно говорит нам, с какой стороны — слева или справа — нужно умножать вектор на матрицу, чтобы получить правильный результат. В качестве примера рассмотрим простую матрицу переноса 4x4. Есть два способа задать матрицу, которая при умножении на вектор даст перенос. Первый — создать матрицу переноса, в которой смещение находится в последнем столбце:
Чтобы перенести вектор с помощью этой матрицы, нужно умножить её на вектор справа:
Как видим, в результате получается вектор, смещённый на значения из матрицы. Но на самом деле матрицу переноса можно было построить и по-другому. Мы могли бы поместить смещение в последнюю строку матрицы и умножать на неё вектор-строку слева, вот так:
Эти два варианта дают одинаковый результат, но сами матрицы не равны. Поэтому, чтобы знать, как умножать векторы на матрицы, нам нужно знать, умножать ли вектор справа или слева. В математической нотации всегда явно видно, умножается ли вектор как столбец или как строка, но в языках программирования библиотеки зачастую используют один и тот же тип для всех векторов, и форма вектора (столбец или строка) не записывается явно, как в математической нотации. Например, если в OpenTK у нас есть переменная Vector4 v, OpenTK позволит написать как v * M (умножение слева, как вектор-строка), так и M * v (умножение справа, как вектор-столбец), что может сбивать с толку.
Важный факт об умножении матриц: vM = Mᵀvᵀ. То есть если матрица ожидает умножения на вектор-строку слева, мы можем транспонировать её и умножить на вектор-столбец справа. В коде это выглядело бы примерно так:
1
2
3
4
5
Matrix4 M = ...;
Vector4 v = ...;
// Это всегда будет верно (с поправкой на точность float).
Debug.Assert(v * M == Matrix4.Transpose(M) * v);
Почему их путают?
На Discord-сервер OpenTK приходит много людей с вопросами, почему ничего не рендерится или почему все их трансформации ведут себя странно. Ответ почти всегда один: пользователь ошибся либо с порядком хранения, либо с соглашением об умножении — то ли потому что не знает, что это такое, то ли потому что смешивает одно с другим.
Причин, по которым эти два свойства матриц смешивают и путают, много, но я подозреваю, что главная из них в том, что оба соглашения можно поменять транспонированием матрицы — случайно или намеренно.
Транспонировать матрицу могут, потому что она будет передана в функцию, ожидающую матрицу с противоположным порядком хранения, а могут — чтобы сменить соглашение об умножении со слева-направо на справа-налево или наоборот. Кроме того, транспонирование — полезная математическая операция, которая встречается в некоторых формулах безо всякой прямой связи с порядком хранения или соглашением об умножении.
Ещё одна путаница связана с GLSL и OpenGL. Исторически OpenGL использовал матрицы с постолбцовым порядком хранения и соглашением об умножении справа-налево, однако в современных версиях OpenGL это уже не так. Современный OpenGL почти полностью безразличен к порядку хранения1 и соглашению об умножении2, как мы увидим в следующем разделе.
Как добиться согласованности при использовании OpenTK
OpenTK использует построчный порядок хранения и порядок умножения слева-направо. Это значит, что в коде на C# векторы умножаются на матрицы слева. В следующих разделах мы разберём, как добиться согласованного порядка умножения матриц и в C#, и в GLSL.
C#
В C# OpenTK использует матрицы с построчным порядком хранения и порядком умножения слева-направо, то есть операции применяются слева направо. Самая левая матрица будет применена первой. В этом примере у нас простая настройка модель-вид-проекция, где мы передаём матрицу в шейдер как uniform-переменную:
1
2
3
4
5
6
7
8
Matrix4 M = Matrix4.CreateScale(2); // модель
Matrix4 V = Matrix4.CreateTranslation(0, 0, -2).Inverted(); // вид
Matrix4 P = Matrix4.CreatePerspectiveFieldOfView(90f/180f * MathF.PI, Width / Height, 0.1f, 100f); // проекция
Matrix4 MVP = M * V * P; // умножение слева направо
// Передаём transpose=true, чтобы сообщить OpenGL, что мы отправляем матрицу с построчным порядком хранения
GL.UniformMatrix4(0, true, ref MVP);
Здесь интересен вызов GL.UniformMatrix4, в котором мы передаём true в аргументе transpose. Это нужно потому, что по умолчанию OpenGL читает матрицы в постолбцовом порядке, а транспонирование превращает нашу матрицу с построчным порядком в матрицу с постолбцовым. Лично я мысленно переименовал этот аргумент во что-то вроде is_row_major, потому что транспонирование для меня — операция, относящаяся к соглашению об умножении и не имеющая ничего общего с порядком хранения.
Uniform-переменные в GLSL
Если передавать матрицы в GLSL так, как показано выше, там действует то же соглашение об умножении слева-направо. То есть векторы стоят слева от матриц, и операции применяются слева направо.
1
2
3
4
5
6
7
8
9
10
11
// Небольшой шейдер, преобразующий позиции вершин с помощью матриц модели, вида и проекции.
in vec3 v;
uniform mat4 M; // модель
uniform mat4 VP; // вид * проекция
void main() {
// Используем принятое в OpenTK соглашение об умножении матриц слева-направо.
gl_Position = vec4(v, 1.0) * M * VP;
}
UBO/SSBO в GLSL
При использовании Uniform Buffer Objects (UBO) или Shader Storage Buffer Objects (SSBO) можно указать квалификатор раскладки row_major, чтобы сообщить OpenGL, что наши матрицы хранятся построчно.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Небольшой шейдер для инстансинга, использующий матрицы модели для каждого экземпляра, загруженные через SSBO.
in vec3 v;
// Сообщаем OpenGL, что буфер содержит матрицы с построчным порядком хранения.
layout(row_major, binding = 0) readonly buffer InstanceTransforms {
mat4 instance_M[];
}
uniform mat4 VP; // вид * проекция
void main() {
// Используем принятое в OpenTK соглашение об умножении матриц слева-направо.
gl_Position = vec4(v, 1.0) * instance_M[gl_InstanceID] * VP;
}
Вершинные атрибуты в GLSL
Это единственный API в OpenGL, который не безразличен к порядку хранения. Дело в том, что матричные вершинные атрибуты задаются четырьмя отдельными вершинными атрибутами типа vec4, где каждый vec4 становится столбцом матрицы. Невозможно задать атрибут vec4, отдельные числа которого идут с шагом (stride), а значит, невозможно подать на вход вершинного шейдера матрицу с построчным порядком хранения. Это не значит, что их нельзя использовать в OpenTK, — просто матрица, которую вы получите в GLSL, будет транспонирована.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Небольшой шейдер для инстансинга, использующий матрицы модели в вершинных атрибутах
// и glVertexAttribDivisor для инстансинга
in vec3 v;
in mat4 instance_M_T; // транспонированная матрица модели из-за ограничения вершинных атрибутов
uniform mat4 VP; // вид * проекция
void main()
{
mat4 instance_M = transpose(instance_M_T); // Транспонируем транспонированную матрицу
gl_Position = vec4(v, 1.0) * instance_M * VP;
}
Лично я для инстансинга предпочитаю подход с SSBO, так как он проще, намного гибче и в целом не должен быть медленнее вершинных атрибутов3.
TBN-матрица для normal mapping в GLSL
При реализации normal mapping обычно строят матрицу TBN (Tangent Bitangent Normal — касательная, бикасательная, нормаль). Конструкторы матриц — единственная часть GLSL, которая не безразлична к соглашению об умножении. Соглашение об умножении определяется тем, где расположены элементы матрицы, а конструкторы матриц в GLSL принимают на вход векторы-столбцы. Поэтому если построить TBN-матрицу с помощью конструктора вот так: mat3(fTangent, fBitangent, fNormal), получится матрица с соглашением об умножении справа-налево. Простое решение — транспонировать построенную матрицу: transpose(mat3(fTangent, fBitangent, fNormal)), что вернёт соглашение об умножении слева-направо.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Часть фрагментного шейдера, реализующего normal mapping,
// показывающая, как конструкторы матриц строят матрицы из векторов-столбцов.
in vec3 fNormal;
in vec4 fTangentW; // xyz: касательная, w: знак бикасательной
in vec2 fUV;
uniform sampler2D texNormal;
out vec3 oNormal;
void main()
{
vec3 fTangent = fTangentW.xyz;
vec3 fBitangent = cross(fNormal, fTangent) * fTangentW.w;
// Входные векторы рассматриваются как столбцы,
// что даёт матрицу с соглашением об умножении справа-налево.
mat3 TBN = transpose(mat3(fTangent, fBitangent, fNormal));
vec3 tangentSpaceNormal = texture(texNormal, fUV) * 2.0 - 1.0;
oNormal = tangentSpaceNormal * TBN;
}
А если вы переживаете, что лишнее транспонирование снизит производительность, могу вас заверить: компилятор разберётся и уберёт транспонирование при оптимизации. Так что не беспокойтесь: согласованность соглашения об умножении можно получить без потерь в производительности.
Что делают другие библиотеки?
У других библиотек, помимо OpenTK, свои соглашения. Вот неполный список библиотек с их порядком хранения и соглашением об умножении:
| Библиотека | Порядок хранения | Соглашение об умножении |
|---|---|---|
OpenTK | Построчный | v * M |
System.Numerics | Построчный | v * M |
| GLM | Постолбцовый | M * v |
| DirectXMath | Построчный | v * M |
| Unity | Постолбцовый | M * v |
| Unreal | Построчный | v * M |
| Godot | Постолбцовый | M * v |
Как видим, порядок хранения и соглашение об умножении сильно коррелируют. Почему так? Оказывается, когда матрица хранится построчно, умножать на неё вектор слева действительно быстрее, поскольку все компоненты результирующего вектора можно вычислять параллельно. Поэтому библиотеку, в которой порядок хранения и соглашение об умножении не коррелируют, встретить довольно сложно, но это не значит, что таких не может быть. Создавая собственные матрицы в OpenTK, вы вполне можете построить матрицы с соглашением об умножении справа-налево, используя структуру Matrix4 из OpenTK.
Матрицы в вершинных атрибутах, к сожалению, можно задавать только в постолбцовом порядке. ↩︎
Конструкторы матричных типов принимают в качестве аргументов векторы-столбцы; конструктора, строящего матрицу из строк, не существует. ↩︎
В этой статье сравниваются SSBO и вершинные атрибуты для повершинных данных. Разумно ожидать похожих результатов и для данных экземпляров, но с меньшей разницей в производительности. ↩︎
Пост не является авторским и/или официальным.
Более того - он так же не является профессиональным.
Автор оригинала: NogginBops
Ссылка на оригинал: The OpenTK matrix guide
