Фактор DirectX

Трехмерные преобразования над двухмерными битовыми картами

Чарльз Петцольд

Исходный код можно скачать по ссылке

Charles PetzoldПрограммирование трехмерной графики — это в основном создание оптических иллюзий. Изображения визуализируются на плоском экране, состоящем из двухмерного массива пикселей, но эти объекты должны выглядеть так, будто у них его третье измерение с глубиной.

По-видимому, самый большой вклад в иллюзию трехмерности вносит заливка (shading) — искусство и наука окраски пикселей таким образом, чтобы поверхности напоминали реальные текстуры с освещением и тенями.

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

Недавно я исследовал несколько способов поддержки трехмерной графики в Direct2D — компоненте DirectX. Изучение трехмерной графики в относительной знакомом и комфортном Direct2D позволяет постепенно освоить концепции трехмерной графики до погружения в очень пугающие глубины Direct3D.

Битовые карты в трехмерной графике?

Один из нескольких способов, которыми трехмерная графика поддерживается в Direct2D, скрыт в последнем аргументе методов DrawBitmap, определенных в ID2D1DeviceContext. Этот аргумент позволяет применять трехмерное преобразование к двухмерной битовой карте. (Эта функциональность специфична для ID2D1DeviceContext. Она не поддерживается методами DrawBitmap, определенными в ID2D1RenderTarget или в других интерфейсах, производных от него.)

У методов DrawBitmap, определенных в ID2D1DeviceContext, последний аргумент имеет тип D2D1_MATRIX_4X4_F, который представляет матрицу преобразования 4×4, выполняющую трехмерное преобразование битовой карты при ее визуализации на экране:

void DrawBitmap(ID2D1Bitmap *bitmap,
                D2D1_RECT_F *destinationRectangle,
                FLOAT opacity,
                D2D1_INTERPOLATION_MODE interpolationMode,
                const D2D1_RECT_F *sourceRectangle,
                const D2D1_MATRIX_4X4_F *perspectiveTransform)

Вроде бы это единственное предназначение D2D1_MATRIX_4X4_F. Больше нигде в DirectX он не используется. В Direct3D-программировании для представления трехмерных преобразований вместо него применяется библиотека DirectX Math.

DD2D1_MATRIX_4X4_F — это typedef для D2D_MATRIX_4X4_F, который определен на рис. 1. Фактически это набор из 16 значений с плавающей точкой, упорядоченных в четыре строки с четырьмя столбцами. Вы можете сослаться на значение в третьей строки и втором столбце, используя поле _32, или получить то же значение как элемент массива с отсчетом индексов от нуля — m[2][1].

Рис. 1. Матрица трехмерного преобразования, применяемая к битовым картам

typedef struct D2D_MATRIX_4X4_F
{
  union
  {
    struct
    {
      FLOAT _11, _12, _13, _14;
      FLOAT _21, _22, _23, _24;
      FLOAT _31, _32, _33, _34;
      FLOAT _41, _42, _43, _44;
    } ;
    FLOAT m[4][4];
  };
} D2D_MATRIX_4X4_F;

Однако, показывая вам математику преобразований, я буду ссылаться на элемент в третьей строке и втором столбце матрицы как m32. Всю матрицу можно представить в традиционной матричной нотации:

Трехмерное преобразование битовой карты в Direct2D лежит в основе аналогичного механизма в Windows Runtime, где оно предоставляется как структура Matrix3D и свойство Projection, определенные в UIElement. Эти два механизма столь похожи, что вы можете переносить свои знания одной среды в другую.

Линейное преобразование

Многие, впервые встречая трехмерные преобразования, спрашивают: почему матрица именно 4×4? Разве матрица 3×3 не была бы более подходящей для трехмерной графики?

Чтобы ответить на этот вопрос, исследуя трехмерное преобразование в DrawBitmap, я создал программу BitmapTransformExperiment, которую включил в код, сопутствующий этой статье. Данная программа содержит собственные элементы управления «наборные счетчики» (spinner controls), позволяющие выбирать значения для 16 элементов матрицы преобразования и наблюдать, как эта матрица влияет на отображение битовой карты (растрового изображения). Пример показан на рис. 2.

Программа BitmapTransformExperiment
Рис. 2. Программа BitmapTransformExperiment

Для начальных экспериментов ограничьте свое внимание верхними тремя строками и тремя столбцами с левой стороны матрицы. Это дает матрицу 3×3, которая выполняет следующее преобразование:

Матрица 1×3 слева представляет трехмерную координату. Для битовой карты значение x меняется от 0 до ширины битовой карты, значение y — от 0 до высоты битовой карты и значение z равно 0.

Когда матрица 1×3 умножается на матрицу преобразования 3×3, стандартное перемножение матриц приводит к трансформации координат:

Матрица тождественности, или единичная матрица (identity matrix) (в ней диагональные элементы m11, m22 и m33 равны 1, а все остальные — 0), не дает никакого преобразования.

Поскольку я начинаю с плоской битовой карты, координата z равна 0, а значит, m31, m32 и m33 не влияют на результат. Когда преобразованная битовая карта визуализируется на экране, результат (x', y', z') проецируется на плоскую двухмерную координатную систему, игнорируя координату z', т. е. и в этом случае элементы m13, m23 и m33 не дают никакого эффекта. Вот почему третья строка и третий столбец затеняются в программе BitmapTransformExperiment. Вы можете задать значения для этих элементов матрицы, но они никак не скажутся на визуализации битовой карты.

Вы обнаружите, что m11 — это масштабный множитель по горизонтали со значением по умолчанию 1. Сделайте его больше или меньше, чтобы увеличить или уменьшить ширину битовой карты, или присвойте ему отрицательное значение, чтобы перевернуть битовую карту вокруг вертикальной оси. Аналогично m22 — масштабный множитель по вертикали.

Значение m21 является коэффициентом скоса по вертикали (vertical skewing factor): значения, отличные от 0, превращают прямоугольную битовую карту в параллелограмм, так как правый край смещается вверх или вниз. Аналогично m12 — коэффициент скоса по горизонтали.

Комбинация скоса по горизонтали и вертикали может привести к повороту. Чтобы повернуть битовую карту по часовой стрелке на конкретный угол, присвойте m11 и m22 косинус этого угла, m21 — его синус, а m12 — отрицательный синус. На рис. 2 показан поворот на 30 градусов.

{Для верстки: далее в тексте попадаются греческие буквы, не потеряйте их}

В контексте трехмерной графики поворот, иллюстрируемый рис. 2, на самом деле является поротом вокруг оси Z, которая концептуально исходит из экрана. Для угла α матрица преобразования выглядит так:

Также можно повернуть битовую карту вокруг оси Y или X. Вращение вокруг оси Y не влияет на координату y, поэтому матрица преобразования является следующей:

Если вы поэкспериментируете с этим в программе BitmapTransformExperiment, то заметите, что эффект дает лишь значение m11. Присваивая его косинусу угла поворота, вы просто уменьшаете ширину визуализируемой битовой карты. Это уменьшение по ширине согласуется с поворотом вокруг оси Y.

Аналогично осуществляется поворот вокруг оси X:

В программе BitmapTransformExperiment это приводит к уменьшению высоты битовой карты.

Знаки двух коэффициентов наклона (синусов) в матрицах преобразования управляют направлением поворота. С концептуальной точки зрения, положительная ось Z считается исходящей из экрана, и вращение следует правилу левой руки: выровняйте большой палец левой руки по оси поворота и указывайте им в сторону положительных значений; кривая, описываемая остальными пальцами, задает направление поворота для положительных углов.

Тип трехмерного преобразования, представленный этой матрицей преобразования 3×3 известен как линейное преобразование. Это преобразование включает только константы, умножаемые на координаты x, y и z. Какие бы значения вы ни вводили в первые три строки и столбца матрицы преобразования, битовая карта никогда не превратится в нечто более экзотичное, чем параллелограмм; в трехмерных пространствах куб всегда преобразуется в параллелепипед.

Вы видели, как битовую карту можно масштабировать, наклонять и поворачивать, но на протяжении этих упражнений верхний левый угол битовой карты оставался зафиксированным в одной позиции. (Эта позиция определяется набором двухмерных преобразований в методе Render до вызова DrawBitmap.) Невозможность смещения верхнего левого угла битовой карты вытекает из математики линейного преобразования. В формуле этого преобразования нет ничего, что могло бы сдвинуть точку (0, 0, 0) в другую позицию. Тип преобразования, который делает это возможным, называется трансляцией (translation).

Трансляция

Чтобы понять, как добиться трансляции в трехмерном пространстве, давайте подумаем о двухмерных преобразованиях.

В двухмерном пространстве линейное преобразование осуществляется матрицей 2×2, и оно поддерживает масштабирование, наклон и вращение. Чтобы получить еще и трансляцию, предполагают, что двухмерная графика существует в трехмерном пространстве, но на двухмерной плоскости, где координата z всегда равна 1. Для подстройки под дополнительное измерение матрица двухмерного линейного преобразования расширяется в матрицу 3×3, но обычно последняя строка фиксирована:

Перемножение матриц дает эти формулы преобразования:

Элементы m31 и m32 являются множителями трансляции (translation factors). Секрет этого процесса в том, что трансляция в двух измерениях эквивалентна наклону в трех измерениях.

Аналогичный процесс используется в трехмерной графике: трехмерные координаты считаются расположенными в четырехмерном пространстве, где координата четвертого измерения равна 1. Но, пытаясь представить точку в системе четырехмерных координат, вы сталкиваетесь с небольшой и довольно смехотворной проблемой: трехмерная точка описывается как (x, y, z), и за z никаких букв нет; какую же букву использовать для четвертого измерения? Ближайшая доступная буква — w, поэтому координаты четырехмерной точки — (x, y, z, w).

Для включения дополнительного измерения матрица трехмерного линейного преобразования расширяется до 4×4:

Теперь формулы преобразования выглядят так:

Теперь у вас есть трансляция по осям X, Y и Z с помощью элементов m41, m42 и m43. Расчеты вроде бы происходят в четырехмерном пространстве, но на самом деле ограничены трехмерным сечением четырехмерного пространства, где координата w всегда равна 1.

Гомогенные координаты

Если вы поиграете со значениями m41 и m42 в BitmapTransformExperiment, то заметите, что они действительно приводят к горизонтальной и вертикальной трансляции.

А как насчет последней строки матрицы? Что будет, если не ограничивать эту строку нулями и единицами? Вот полная матрица преобразования 4×4, примененная к трехмерной точке:

Формулы для x', y' и z' остаются теми же, а w' теперь вычисляется так:

И это настоящая проблема. Ранее применялся небольшой трюк, чтобы использовать трехмерное сечение четырехмерного пространства, где координата w всегда равна 1. Но теперь координата по оси W больше не равна 1, и вы вышли за пределы трехмерного сечения. Вы заблудились в четырехмерном пространстве и должны вернуться к тому трехмерному сечению, где w = 1.

Однако нужды строить машину для путешествий по измерениям нет. К счастью, вы можете совершить скачок чисто математически, поделив все преобразованные координаты на w':

Теперь w' равно 1, и вы снова дома!

Но какой ценой? Теперь у вас есть деление в формулах преобразования, и легко увидеть, что в некоторых обстоятельствах делитель может оказаться равным 0. И вы получите бесконечные координаты.

Ну, может быть, это и к лучшему.

Когда немецкий математик Август Фердинанд Мёбиус (1790–1868) изобрел систему, которую я только что описал (назвав ее гомогенными, или проективными, координатами), одной из его целей было представить бесконечные координаты, используя конечные числа.

Матрицу с нулями и единицами в последней строке называют аффинным преобразованием (affine transform), подразумевая, что оно не дает бесконечные координаты, поэтому преобразование, способное привести к получению бесконечных координат, называют неаффинным (non-affine transform).

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

Опробуйте это в BitmapTransformExperiment: если вы сделаете m14 небольшим положительным числом (а интересные результаты дают только малые значения), то значения x' и y' будут пропорционально уменьшаться по мере увеличения x. Затем сделайте m14 небольшим отрицательным числом, и более высокие значения x' и y' будут увеличиваться. Этот эффект в сочетании с ненулевым значением m12 показан на рис. 3. Визуализируемая битовая карта больше не будет параллелограммом, и перспектива предполагает, что правый край приближается к вашим глазам.

Перспектива в BitmapTransformExperiment
Рис. 3. Перспектива в BitmapTransformExperiment

Аналогично ненулевые значения m24 позволяют делать так, чтобы верх или низ битовой карты казался приближающимся к вам или, наоборот, отдаляющимся от вас. В программировании настоящей трехмерной графики обычно используется значение m34, так как оно дает возможность увеличивать или уменьшать размер объектов на основе их координат z — расстояния от глаз наблюдателя.

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

Любой выпуклый четырехугольник

Применяя эту матрицу преобразования 4×4 к плоской битовой карте, вы используете лишь половину элементов матрицы. Но даже в таком случае на рис. 3 видно то, что нельзя сделать с помощью обычной двухмерной матрицы преобразования в Direct2D, в частности превратить прямоугольник в нечто иное, чем параллелограмм. И действительно, объекты преобразования, используемые в большей части Direct2D, называются D2D1_MATRIX_3X2_F и Matrix3x2F, подчеркивая недоступность третьей строки матрицы и невозможность выполнения неаффинных преобразований.

С помощью D2D1_MATRIX_4X4_F можно получить преобразование, которое превращает битовую карту в любой выпуклый четырехугольник, т. е. в произвольную четырехстороннюю фигуру с непересекающимися сторонами и внутренними углами в вершинах менее 180 градусов.

Если вы мне не верите, попробуйте поиграть с программой NonAffineStretch. Заметьте, что эта программа — модифицированная версия одноименной программы под Windows Runtime из главы 10 моей книги «Programming Windows, 6th Edition» (Microsoft Press, 2013).

Эта программа в действии показана на рис. 4. Вы можете использовать мышь и сенсорный ввод, чтобы перетаскивать зеленые точки в любые места на экране. Пока вы сохраняете фигуру выпуклым четырехугольником, преобразование 4×4 может быть получено на основе позиций точек. Это преобразование применяется при рисовании битовой карты (кроме того, в нижнем правом углу показывается текущая матрица). Задействовано только восемь значений; элементы в третьей строке и третьем столбце всегда содержат значения по умолчанию, а m44 всегда равен 1.

Программа NonAffineStretch
Рис. 4. Программа NonAffineStretch

Выполняемые при этом математическое операции довольно сложны, но алгоритм вы найдете в главе 10 моей книги.

Класс Matrix4x4F

Чтобы немного упростить работу со структурой D2D1_MATRIX_4X4_F, на ее основе в пространстве имен D2D1 создан класс Matrix4x4F. Этот класс определяет конструктор и оператор перемножения (который я использовал в алгоритме NonAffineStretch), а также несколько полезных статических методов для создания распространенных матриц преобразования. Например, метод Matrix4x4F::RotationZ принимает аргумент, который является углом в градусах, и возвращает матрицу, представляющую преобразование для поворота на этот угол вокруг оси Z:

Другие функции Matrix4x4F создают матрицы для поворота вокруг осей X и Y, а также для поворота вокруг произвольной оси, матрица которого гораздо сложнее.

У функции Matrix4x4F::PerspectiveProjection есть аргумент depth. Она возвращает такую матрицу:

Это означает, что формула преобразования для x' следующая:

И аналогично для y' и z', т. е. всякий раз, когда координата z равна depth, делитель равен 0, и все координаты становятся бесконечными.

С концептуальной точки зрения, это означает, что вы смотрите на экран компьютера с расстояния в depth единиц, где единицы такие же, как и на самом экране, т. е. пиксели или аппаратно-независимые единицы. Если координата z какого-то графического объекта равна depth, он находится в depth единицах перед экраном — прямо в вашем глазу! Этот объект должен показаться вам очень большим — математически бесконечным.

Но постойте-ка: единственная цель структуры D2D1_MATRIX_4X4_F и класса Matrix4x4F — использование в вызовах DrawBitmap, а битовые карты всегда имеют нулевые координаты z. Тогда, как же m34 со значением –1/depth вообще на что-то влияет?

Если в вызове DrawBitmap используется сама матрица PerspectiveProjection, эффекта действительно никакого не будет. Но она предназначена для применения в сочетании с другими матричными преобразованиями. Матричные преобразования можно компоновать их перемножением. Хотя у исходной битовой карты нет координат z, и они игнорируются при рендеринге, координаты z определенно могут играть роль в компоновке преобразований.

Давайте рассмотрим пример. Программа RotatingText создает битовую карту с текстом «ROTATE» и шириной приблизительно в половину ширины экрана. Большая часть методов Update и Render приведена на рис. 5.

Рис. 5. Код из RotatingTextRenderer.cpp

void RotatingTextRenderer::Update(DX::StepTimer const& timer)
{
  ...
  // Начинаем с матрицы тождественности
  m_matrix = Matrix4x4F();
   // Поворачиваем вокруг оси Y
  double seconds = timer.GetTotalSeconds();
  float angle = 360 * float(fmod(seconds, 7) / 7);
  m_matrix = m_matrix * Matrix4x4F::RotationY(angle);
  // Применяем перспективу на основе ширины битовой карты
  D2D1_SIZE_F bitmapSize = m_bitmap->GetSize();
  m_matrix = m_matrix * 
    Matrix4x4F::PerspectiveProjection(bitmapSize.width);
}
void RotatingTextRenderer::Render()
{
  ...
  ID2D1DeviceContext* context = 
    m_deviceResources->GetD2DDeviceContext();
  Windows::Foundation::Size logicalSize = 
    m_deviceResources->GetLogicalSize();
  context->SaveDrawingState(m_stateBlock.Get());
  context->BeginDraw();
  context->Clear(ColorF(ColorF::DarkMagenta));
  // Смещаем начало координат вверх по центру экрана
  Matrix3x2F centerTranslation =
    Matrix3x2F::Translation(logicalSize.Width / 2, 0);
  context->SetTransform(centerTranslation *
    m_deviceResources->GetOrientationTransform2D());
  // Рисуем битовую карту
  context->DrawBitmap(m_bitmap.Get(),
                      nullptr,
                      1.0f,
                      D2D1_INTERPOLATION_MODE_LINEAR,
                      nullptr,
                      &m_matrix);
  ...
}

В методе Update метод Matrix4x4F::RotationY создает следующее преобразование:

Умножаем эту матрицу на показанную ранее (она была возвращена из метода Matrix4x4F::PerspectiveProjection) и получим:

Формулы преобразования выглядят так:

Здесь определенно включена перспектива, и результат можно посмотреть на рис. 6.

Вывод программы RotatingText
Рис. 6. Вывод программы RotatingText

Берегитесь: аргументу depth в Matrix4x4F::PerspectiveProjection присваивается ширина битовой карты, поэтому по мере того, как вращающаяся битовая карта разворачивается, она может задеть ваш нос.


Чарльз Петцольд (Charles Petzold) — давний «пишущий» редактор MSDN Magazine и автор книги «Programming Windows, 6th edition» (O’Reilly Media, 2012) о написании приложений для Windows 8. Его веб-сайт находится по адресу charlespetzold.com.

Выражаю благодарность за рецензирование статьи экспертам Microsoft Джиму Галасину (Jim Galasyn) и Майку Ричизу (Mike Riches).