Фактор DirectX

Двухмерный портал в трехмерный мир

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

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

Charles PetzoldЕсли вы хорошо осведомлены в двухмерной графике, то можете предположить, что трехмерная графика аналогична за исключением дополнительного измерения. Не совсем так! Любой, кто хотя бы поверхностно занимался программированием трехмерной графики, знает, насколько это трудно. Программирование трехмерной графики требует освоение новых и экзотических концепций, выходящих за рамки всего, с чем вы сталкивались в мире обычной двухмерной графики. Чтобы получить на экране самое элементарное трехмерное изображение, нужно выполнить массу подготовительной работы, и даже в этом случае малейший просчет может сделать его невидимым. Следовательно, визуальная обратная связь, столь важная в изучении программирования графики, откладывается до тех пор, пока не будут собраны воедино все программные части и пока они не начнут работать в полной гармонии.

В DirectX признается глубокое различие в программировании двух- и трехмерной графики, по каковой причине он и разделен на Direct2D и Direct3D. Хотя вы можете смешивать двух- и трехмерный контент на одном и том же устройстве вывода, программные интерфейсы совершенно разные, и ничего среднего между ними нет. DirectX не позволяет играть немного кантри и немного рок-н-ролла.

Или все же позволяет?

Интересно, что Direct2D включает некоторые концепции и механизмы, заимствованные из программирования трехмерной графики. Через такие механизмы, как тесселяция геометрических элементов (разложение сложных геометрических элементов на треугольники) и двухмерные эффекты с применением шейдеров (которые состоят из специального кода, выполняемого графическим процессором, или GPU), можно задействовать некоторые мощные концепции трехмерной графики, не выходя за рамки Direct2D.

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

Полагаю, ничего удивительного в том, что Direct2D включает некоторые механизмы трехмерной графики, быть не должно. С архитектурной точки зрения, Direct2D построен поверх Direct3D, благодаря чему Direct2D тоже может использовать преимущества аппаратного ускорения средствами GPU. Эта связь между Direct2D и Direct3D становится более очевидной, когда вы начинаете исследовать более низкие уровни Direct2D.

Я начну это исследование с обзора трехмерных координат и систем координат.

Обзор

Если вы следили за моими статьями в этой рубрике за последние месяцы, то знаете, что можно вызвать метод GetGlyphRunOutline какого-либо объекта, реализующего интерфейс IDWriteFontFace, и получить экземпляр ID2D1PathGeometry, который описывает контуры текстовых символов в виде прямых линий и кривых Безье. После этого можно манипулировать координатами этих линий и кривых для искажения текстовых символов самыми разными способами.

Также можно преобразовывать двухмерные координаты геометрических элементов траектории (path geometry) (далее для краткости геометрии траектории) в трехмерные, а затем выполнять операции с этими трехмерными координатами перед их обратным преобразованием в двухмерные, чтобы нормально отображать геометрию траектории. Правда, забавно?

Координаты в двухмерном пространстве выражаются как числовые пары (X, Y), которые соответствуют позиции на экране, а трехмерные координаты — в форме (X, Y, Z), с концептуальной точки зрения, ось Z прямо перпендикулярна поверхности экрана. Если только вы не имеете дело с голографическим экраном или трехмерным принтером, эти координаты по оси Z далеко не столь реальны, как координаты X и Y.

Есть и другие различия между двух- и трехмерными системами координат. Обычно начало координат в двухмерной системе — точка (0, 0) — находится в верхнем левом углу экрана. Координаты по оси X возрастают слева направо, а координаты по оси Y — сверху вниз. В трехмерных системах очень часто начало координат находится в центре экрана, и это больше похоже на стандартную декартову систему координат: значения по оси X по-прежнему увеличиваются слева направо, а значения по оси Y — от центра вверх, но принимают и отрицательные значения (с увеличением от центра вниз). (Конечно, начало координат, масштаб и ориентация этих осей может изменяться с помощью матричных преобразований, и обычно так и происходит.)

С концептуальной точки зрения, положительная Z-ось может быть направлена либо из экрана, либо в экран. Эти два соглашения известны как правосторонняя (right-hand) и левосторонняя (left-hand) системы координат, что подразумевает способ их различения: в правосторонней системе координат, если вы направляете указательный палец правой руки в направлении положительной X-оси, средний палец располагаете в направлении положительной Y-оси, то ваш большой палец указывает на положительную Z-ось. Кроме того, если вы сгибаете пальцы правой руки от положительной X-оси к положительной Y-оси, ваш большой палец указывает на положительную Z-ось. В случае левосторонней системы координат все то же самое, кроме использования левой руки.

Моя цель здесь — получить двухмерную геометрию траектории короткой строки текста, а затем изогнуть ее вокруг начала координат в трехмерное кольцо так, чтобы соединить начало строки с ее концом аналогично иллюстрации на рис. 1. Поскольку я буду преобразовывать двухмерные координаты в трехмерные, а затем обратно, я предпочел использовать трехмерную систему координат с Y-координатами, возрастающими в направлении вниз, как в двухмерной системе координат. Положительная Z-ось выходит из экрана, но на самом деле это левосторонняя система координат.

Система координат, используемая для программ в этой статье
Рис. 1. Система координат, используемая для программ в этой статье

Чтобы максимально упростить всю задачу, я работал с файлом шрифтов, хранимым как ресурс программы, и создавал объект IDWriteFontFile для получения объекта IDWriteFontFace. В качестве альтернативы вы могли бы получать IDWriteFontFace более окольным методом из набора системных шрифтов.

Объект ID2D1PathGeometry, генерируемый в методе GetGlyphRunOutline, потом передается методом Simplify с аргументом D2D1_GEOMETRY_SIMPLIFICATION_OPTION_LINES для превращения всех кривых Безье в последовательности коротких линий. Эта упрощенная геометрия передается в собственную реализацию ID2D1GeometrySink с именем FlattenedGeometrySink для дальнейшего разложения всех прямых линий на гораздо более короткие отрезки. Результат — полностью деформируемая геометрия, состоящая только из линий.

Чтобы упростить манипуляции этими координатами, FlattenedGeometrySink генерирует набор объектов Polygon. На рис. 2 показано определение структуры Polygon. В основном это просто набор соединенных двухмерных точек. Каждый объект Polygon соответствует замкнутой фигуре в геометрии траектории. Не все фигуры в геометриях траекторий замкнуты, но те, что относятся к глифам текста, замкнуты всегда, поэтому такая структура отлично подходит для данной цели. Одни символы (вроде «C», «E» и «X») описываются всего одним Polygon, другие («A», «D» и «O») состоят из двух объектов Polygon для внутренней и внешней частей, третьи (например, «B») — из трех, а некоторые символы могут содержать гораздо больше таких объектов.

Рис. 2. Класс Polygon для хранения замкнутых фигур траектории

struct Polygon
{
  // Конструкторы
  Polygon()
  {
  }
  Polygon(size_t pointCount)
  {
    Points = std::vector<D2D1_POINT_2F>(pointCount);
  }
  // Конструктор перемещения
  Polygon(Polygon && other) : Points(std::move(other.Points))
  {
  }
  std::vector<D2D1_POINT_2F> Points;
  static HRESULT CreateGeometry(ID2D1Factory* factory,
                                const std::vector<Polygon>& polygons,
                                ID2D1PathGeometry** pathGeometry);
};
HRESULT Polygon::CreateGeometry(ID2D1Factory* factory,
                                const std::vector<Polygon>& polygons,
                                ID2D1PathGeometry** pathGeometry)
{
  HRESULT hr;
  if (FAILED(hr = factory->CreatePathGeometry(pathGeometry)))
    return hr;
  Microsoft::WRL::ComPtr<ID2D1GeometrySink> geometrySink;
  if (FAILED(hr = (*pathGeometry)->Open(&geometrySink)))
    return hr;
  for (const Polygon& polygon : polygons)
  {
    if (polygon.Points.size() > 0)
    {
      geometrySink->BeginFigure(polygon.Points[0],
                                D2D1_FIGURE_BEGIN_FILLED);
      if (polygon.Points.size() > 1)
      {
        geometrySink->AddLines(polygon.Points.data() + 1,
                               polygon.Points.size() - 1);
      }
      geometrySink->EndFigure(D2D1_FIGURE_END_CLOSED);
    }
  }
  return geometrySink->Close();
}

В сопутствующем этой статье исходном коде есть программа Windows Store под названием CircularText, которая создает набор объектов Polygon из текста «Text in an Infinite Circle of», где конец намеренно соединяется с началом в круг. На самом деле в программе текстовая строка указана как «ext in an Infinite Circle of T», чтобы избежать пробела в начале или конце, который исчезал бы при генерации геометрии траектории из глифов.

Класс CircularTextRenderer в проекте CircularText содержит два объекта std::vector типа Polygon с именами m_srcPolygons (исходные объекты Polygon, сгенерированные из геометрии траектории) и m_dstPolygons (объекты Polygon, используемые для генерации визуализируемой геометрии траектории). На рис. 3 показан метод CreateWindowSizeDependentResources, который преобразует исходные полигоны в конечные с учетом размера экрана.

Рис. 3. Программа CircularText: из 2D в 3D и обратно

void CircularTextRenderer::CreateWindowSizeDependentResources()
{
  // Получаем размер окна и размер геометрии
  Windows::Foundation::Size logicalSize = m_deviceResources->GetLogicalSize();
  float geometryWidth = m_geometryBounds.right - m_geometryBounds.left;
  float geometryHeight = m_geometryBounds.bottom - m_geometryBounds.top;
  // Вычисляем несколько коэффициентов
  // для преобразования 2D в 3D
  float radius = logicalSize.Width / 2 - 50;
  float circumference = 2 * 3.14159f * radius;
  float scale = circumference / geometryWidth;
  float height = scale * geometryHeight;
  for (size_t polygonIndex = 0; polygonIndex < m_srcPolygons.size(); polygonIndex++)
  {
    const Polygon& srcPolygon = m_srcPolygons.at(polygonIndex);
    Polygon& dstPolygon = m_dstPolygons.at(polygonIndex);
    for (size_t pointIndex = 0; pointIndex < srcPolygon.Points.size(); pointIndex++)
    {
      const D2D1_POINT_2F pt = srcPolygon.Points.at(pointIndex);
      float radians = 2 * 3.14159f * (pt.x - m_geometryBounds.left) / geometryWidth;
      float x = radius * sin(radians);
      float z = radius * cos(radians);
      float y = height * ((pt.y - m_geometryBounds.top) / geometryHeight - 0.5f);
      dstPolygon.Points.at(pointIndex) = Point2F(x, y);
    }
  }
  // Создаем геометрию траектории из набора Polygon
DX::ThrowIfFailed(
    Polygon::CreateGeometry(m_deviceResources->GetD2DFactory(),
                            m_dstPolygons,
                            &m_pathGeometry)
    );
}

Во вложенном цикле вычисляются значения x, y и z. Это трехмерные координаты, но они даже не сохраняются. Вместо этого они немедленно преобразуются обратно в двухмерные простым отбрасыванием значения z. Чтобы вычислить эти трехмерные координаты, код сначала преобразует горизонтальную позицию исходной геометрии траектории в угловую в радианах от 0 до 2π. Функции sin и cos вычисляют позицию на единичной окружности (unit circle), которая находится на плоскости XZ. Значение y получается более прямым преобразованием из вертикальных координат исходной геометрии траектории.

Метод CreateWindowSizeDependentResources завершается получением нового объекта ID2D1PathGeometry из конечного набора объектов Polygon. Затем метод Render задает матричное преобразование, чтобы поместить начало координат в центр экрана. Далее он заполняет и формирует контуры этой геометрии траектории. Результат показана на рис. 4.

Отображение CircularText
Рис. 4. Отображение CircularText

Работает ли программа? Трудно сказать! Приглядитесь повнимательнее и вы заметите широкие символы в центре и более узкие слева и справа. Но более серьезная проблема в том, что я начал с геометрии траектории без пересекающихся линий и теперь геометрические элементы отображаются с просветами; перекрывающиеся области не заполнены. Этот характерный эффект для геометрических элементов, и он наблюдается независимо от того, в каком режиме заполнения создавалась геометрия траектории с помощью структуры Polygon — с чередованием (alternate) или с намоткой (winding).

Создание некоторой перспективы

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

Чтобы получить какую-то перспективу для трехмерного текста, координаты надо повернуть в пространстве. Как вы знаете, Direct2D поддерживает структуру матричного преобразования D2D1_MATRIX_3x2_F, с помощью которой можно определить двухмерные преобразования. Для ее применения к вашему выводу двухмерной графики вы должны сначала вызвать метод SetTransform из ID2D1RenderTarget.

Чаще всего для этой цели вы будете использовать класс Matrix3x2F из пространства имен D2D1. Этот класс наследует от D2D1_MATRIX_3x2F_F и предоставляет методы для определения различных типов стандартов для трансляции, масштабирования, поворота и наклона.

В классе Matrix3x2F также определен метод TransformPoint, позволяющий применять преобразование «вручную» к индивидуальным объектам D2D1_POINT_2F. Это полезно для операций над точками перед их рендерингом.

Возможно, вы считаете, что мне нужна матрица поворота в трехмерном пространстве для наклона отображаемого текста. Преобразования трехмерных матриц я обязательно буду рассматривать в следующих статьях, а пока обойдусь поворотом в двухмерном пространстве. Вообразите, что вы находитесь где-то на отрицательной оси X с рис. 1, глядя в направлении начала координат. Положительные оси Z и Y располагаются точно так же, как оси X и Y в обычной двухмерной системе координат, поэтому вполне возможно повернуть все координаты вокруг трехмерной оси X, применив двухмерную матрицу поворота к значениям по осям Z и Y.

Вы можете поэкспериментировать с этим в программе CircularText. Создайте двухмерную матрицу поворота в методе CreateWindowSizeDependentResources и применяйте ее до того, как будут осуществляться манипуляции с координатами Polygon:

Matrix3x2F tiltMatrix = Matrix3x2F::Rotation(-8);

Это поворот на –8 градусов, и знак «минус» указывает на вращение против часовой стрелки. Во вложенном цикле после вычисления x, y и z примените это преобразование к значениям z и y так, будто это значения x и y:

 

D2D1_POINT_2F tiltedPoint =
  tiltMatrix.TransformPoint(Point2F(z, y));
z = tiltedPoint.x;
y = tiltedPoint.y;

Вы увидите то, что показано на рис. 5.

Отображение CircularText под наклоном
Рис. 5. Отображение CircularText под наклоном

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

Возможность применения трехмерных преобразований к этому объекту предполагает, что его можно столь же легко поворачивать вокруг оси Y, — и это действительно так. Если вообразить, что смотришь на начало координат с положительной оси Y, станет понятно, что оси X и Z ориентированы так же, как оси X и Y в двухмерной системе координат.

Проект SpinningCircularText реализует два преобразования поворота для вращения текста и его наклона. Вся вычислительная логика, которая раньше находилась в CreateWindowSizeDependentResources, перемещена в метод Update. Трехмерные точки поворачиваются дважды: один раз вокруг оси X на основе прошедшего времени, а затем вокруг оси Y в зависимости от жестов пользователя на экране — движения пальца вверх и вниз. Этот метод Update показан на рис. 6.

Рис. 6. Метод Update в SpinningCircularText

void SpinningCircularTextRenderer::Update(DX::StepTimer const& timer)
{
  // Получаем размеры окна и геометрии
  Windows::Foundation::Size logicalSize = m_deviceResources->GetLogicalSize();
  float geometryWidth = m_geometryBounds.right - m_geometryBounds.left;
  float geometryHeight = m_geometryBounds.bottom - m_geometryBounds.top;
  // Вычисляем несколько коэффициентов
  // для преобразования 2D в 3D
  float radius = logicalSize.Width / 2 - 50;
  float circumference = 2 * 3.14159f * radius;
  float scale = circumference / geometryWidth;
  float height = scale * geometryHeight;
  // Вычисляем матрицу поворота
  float rotateAngle = -360 * float(fmod(timer.GetTotalSeconds(), 10)) / 10;
  Matrix3x2F rotateMatrix = Matrix3x2F::Rotation(rotateAngle);
  // Вычисляем матрицу наклона
  Matrix3x2F tiltMatrix = Matrix3x2F::Rotation(m_tiltAngle);
  for (size_t polygonIndex = 0; polygonIndex <m_srcPolygons.size(); polygonIndex++)
  {
    const Polygon& srcPolygon = m_srcPolygons.at(polygonIndex);
    Polygon& dstPolygon = m_dstPolygons.at(polygonIndex);
    for (size_t pointIndex = 0; pointIndex <srcPolygon.Points.size(); pointIndex++)
    {
      const D2D1_POINT_2F pt = srcPolygon.Points.at(pointIndex);
      float radians = 2 * 3.14159f * (pt.x - m_geometryBounds.left) / geometryWidth;
      float x = radius * sin(radians);
      float z = radius * cos(radians);
      float y = height * ((pt.y - m_geometryBounds.top) / geometryHeight - 0.5f);
      // Применяем поворот к X и Z
      D2D1_POINT_2F rotatedPoint = rotateMatrix.TransformPoint(Point2F(x, z));
      x = rotatedPoint.x;
      z = rotatedPoint.y;
      // Применяем наклон к Y и Z
      D2D1_POINT_2F tiltedPoint = tiltMatrix.TransformPoint(Point2F(y, z));
      y = tiltedPoint.x;
      z = tiltedPoint.y;
      dstPolygon.Points.at(pointIndex) = Point2F(x, y);
    }
  }
  // Создаем геометрию траектории из набора объектов Polygon
  DX::ThrowIfFailed(
     Polygon::CreateGeometry(m_deviceResources->GetD2DFactory(),
     m_dstPolygons, 
     &m_pathGeometry)
     );
  // Обновляем отображаемый текст - значение FPS
  uint32 fps = timer.GetFramesPerSecond();
  m_text = (fps > 0) ? std::to_wstring(fps) + L" FPS" : L" - FPS";
}

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

Работая над программой SpinningCircularText, я адаптировал класс SampleFpsTextRenderer, сгенерированный шаблоном Visual Studio, для создания класса SpinningCircularTextRenderer, но оставил в качестве отображаемого текста скорость рендеринга (частоту кадров). Это позволило мне увидеть, насколько плоха производительность. На моем Surface Pro частота кадров (FPS) колебалась в районе 25 в режиме Debug, а это говорит о том, что код выдает FPS ниже частоты обновления экрана.

Если вам не нравится такая производительность, то, боюсь, у меня для вас плохие новости: я намерен еще больше ухудшить ее.

Отделение переднего плана от заднего

Самая крупная проблема с подходом на основе геометрии траектории к трехмерной графике заключается в эффекте перекрывающихся областей. Можно ли избежать этого перекрытия? Изображение, рисуемое этой программой, не столь сложное. В любой момент мы имеем передний вид части текста и задний вид остального текста, при этом передний вид всегда должен показываться поверх заднего вида. Если бы геометрию траектории можно было разделить на две (одну для заднего плана, а другую для переднего), вы могли бы выполнять рендеринг этих геометрий траекторий отдельными вызовами FillGeometry, благодаря чему передний план рисовался бы поверх заднего. Эти две геометрии траектории можно было бы визуализировать даже разными кистями.

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

Вместо этого исходную геометрию траектории нужно разбивать пополам до получения объектов Polygon. Это разбиение зависит от угла поворота, а значит, в метод Update придется переместить гораздо больше логики.

Исходную геометрию можно разбить пополам двумя вызовами метода CombineWithGeometry. Этот метод комбинирует две геометрии различными способами для создания третьей геометрии. Две комбинируемые геометрии являются исходной геометрией траектории, которая описывает контуры текста, и геометрией прямоугольника, которая определяет подмножество геометрии траектории. Это подмножество появляется либо на переднем плане, либо на заднем — в зависимости от угла поворота.

Например, если угол поворота равен 0, геометрия прямоугольника должна охватывать центральную половину геометрии траектории контуров текста. Это часть исходной геометрии, которая отображается на переднем плане. Вызов CombineWithGeometry в режиме D2D1_COMBINE_MODE_INTERSECT возвращает геометрию траектории, состоящую только из этой центральной области, а вызов CombineWithGeometry в режиме D2D1_COMBINE_MODE_EXCLUDE дает геометрию траектории всего остального — частей слева и справа. Затем эти две геометрии можно раздельно преобразовать в объекты Polygon для манипуляций с координатами и последующего преобразования обратно, чтобы разделить геометрии для рендеринга.

Эта логика является частью проекта OccludedCircularText, который реализует метод Render по алгоритму заполнения двух геометрий разными кистями, как показано на рис. 7.

Отображение OccludedCircularText
Рис. 7. Отображение OccludedCircularText

Теперь гораздо очевиднее, что расположено на переднем плане, а что — на заднем. Тем не менее, в метод Update пришлось вынести столько вычислений, что его производительность стала очень низка.

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

Я говорю, конечно же, о скромном треугольнике.


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

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