Фактор DirectX
Геометрические элементы Direct2D и манипуляции над ними
Исходный код можно скачать по ссылке
Геометрия в высшей школе преподносится в двух видах: эвклидова геометрия ориентирована на черчение, теоремы и доказательства, тогда как аналитическая геометрия описывает геометрические фигуры в числовом виде с использованием точек в системе координат, часто называемой декартовой в честь пионера аналитической геометрии — Рене Декарта (René Descartes).
Именно аналитическая геометрия образует основу всей векторной компьютерной графики, поэтому совершенно правильно, что интерфейс ID2D1Geometry (и шесть производных от него интерфейсов) фактически находится в центре векторной графики Direct2D.
В предыдущей статье (msdn.microsoft.com/magazine/dn342879) я рассмотрел, как использовать ID2D1PathGeometry для рендеринга линий в приложении для рисования пальцем, выполняемом в Windows 8. Теперь я хотел бы сделать шаг назад, чтобы обсудить геометрические элементы (geometries) в более широком плане и, в частности, исследовать некоторые весьма интригующие методы, определенные в ID2D1Geometry для создания различных геометрических элементов.
Даже если вы знакомы с использованием геометрических элементов в Windows Runtime (WinRT), ID2D1Geometry предоставляет средства, недоступные в WinRT-классе Geometry.
Обзор
Геометрические элементы — это в основном наборы координат точек. Эти точки не привязаны ни к каким устройствам, поэтому геометрические элементы являются аппаратно-независимыми объектами. По этой причине вы создаете геометрические элементы различных видов, вызывая методы объекта ID2D1Factory, который представляет собой аппаратно-независимую Direct2D-фабрику. Шесть методов создания геометрических элементов наряду с типом интерфейса создаваемых ими объектов показаны в табл. 1.
Табл. 1. Шесть методов и интерфейсов создания геометрических элементов
Метод ID2D1Factory | Создаваемый объект (производный от ID2D1Geometry) |
CreateRectangleGeometry | ID2D1RectangleGeometry |
CreateRoundedRectangleGeometry | ID2D1RoundedRectangleGeometry |
CreateEllipseGeometry | ID2D1EllipseGeometry |
CreatePathGeometry | ID2D1PathGeometry |
CreateTransformedGeometry | ID2D1TransformedGeometry |
CreateGeometryGroup | ID2D1GeometryGroup |
За исключением CreatePathGeometry эти методы создают неизменяемые объекты: вся информация, необходимая для создания объекта, передается методу создания, и вы не можете ничего изменить после того, как геометрический элемент создан.
Единственная причина, по которой объект ID2D1PathGeometry отличается от остальных, — CreatePathGeometry возвращает фактически пустой объект. Вскоре я опишу, как его заполнить.
CreateTransformedGeometry принимает существующий объект ID2D1Geometry и матрицу аффинного преобразования (affine transform matrix). Полученный геометрический элемент транслируется, масштабируется, поворачивается или наклоняется (skew) на основе этой матрицы. Метод CreateGeometryGroup принимает массив объектов ID2D1Geometry и создает геометрический элемент, состоящий изо всех индивидуальных геометрических элементов.
Если вы используете шаблон проекта Visual Studio Direct2D (XAML) для создания приложения Windows Store, которое обращается к DirectX, то обычно вы создаете геометрические объекты в переопределенной версии CreateDeviceIndependentResources класса рендеринга и применяете их на протяжении жизненного цикла программы, в частности в переопределенной версии Render.
Интерфейс ID2D1RenderTarget (от которого наследуют интерфейсы вроде ID2D1DeviceContext) определяет два метода для рендеринга геометрических элементов на поверхности рисования: DrawGeometry и FillGeometry.
Метод DrawGeometry рисует линии и кривые указанной кистью, толщиной мазков и стилем, поддерживая сплошные, точечные, штрих-пунктирные или оформленные по пользовательскому шаблону линии. Метод FillGeometry заполняет замкнутые области геометрического элемента, используя кисть и необязательную маску прозрачности. Кроме того, можно использовать геометрические элементы для отсечения (clipping), что требует вызова PushLayer мишени рендеринга (render target) с передачей структуры D2D1_LAYER_PARAMETERS, включающей геометрию.
Иногда геометрический элемент нужно анимировать. Самый эффективный подход — применение матрицы преобразования к мишени рендеринга до визуализации этого элемента. Однако, если такой подход не годится, вам понадобится заново создавать геометрический элемент в новых координатах, скорее всего в методе Update класса рендеринга. (Или, возможно, вы предпочтете изначально создать набор геометрических элементов и сохранить их.) Хотя повторное создание геометрических элементов увеличивает издержки рендеринга, оно действительно иногда необходимо. Но, если без него можно обойтись, не используйте этот метод.
Приемник геометрических элементов
Объект ID2D1PathGeometry является набором прямых линий и кривых, а именно кривых Безье третьего и четвертого порядка и дуг, которые являются кривыми на контуре эллипса. Вы можете контролировать, должны ли эти линии кривые соединяться и должны ли они определять замкнутые области.
Заполнение геометрических элементов траектории (path geometry) (далее для краткости — геометрии траектории) линиями и кривыми включает использование объекта типа ID2D1GeometrySink. Рассматривайте этот приемник как рецептор — место назначения для линий и кривых, входящих в геометрию траектории.
Вот как формируется геометрия траектории.
- Создаем объект ID2D1PathGeometry вызовом CreatePathGeometry в объекте ID2D1Factory.
- Вызываем Open в ID2D1PathGeometry, чтобы получить новый объект ID2D1GeometrySink.
- Вызываем методы ID2D1GeometrySink для добавления линий и кривых в геометрию траектории.
- Вызываем Close в ID2D1GeometrySink.
ID2D1PathGeometry нельзя использовать, пока не вызван Close объекта ID2D1GeometrySink. После вызова Close объект ID2D1PathGeometry становится неизменяемым: его содержимое нельзя никак изменить, и вы больше не можете вызвать Open. Необходимость в приемнике геометрических элементов после этого отпадает, и вы можете избавиться от него.
Третий элемент в списке обычно включает самый обширный код. Геометрия траектории — это набор фигур; каждая фигура является серией соединенных линий и кривых, называемых сегментами. При вызове функций в ID2D1GeometrySink вы начинаете с дополнительного вызова SetFillMode, чтобы задать алгоритм заполнения замкнутых областей. Затем для каждой серии соединенных линий и кривых в геометрии траектории вы вызываете:
- BeginFigure, указывающий первую точку и нужно ли заполнять замкнутые области;
- методы, имена которых начинаются со слова «Add», для добавления соединенных линий, кривых Безье и дуг в фигуру;
- EndFigure, указывающий, следует ли автоматически соединять прямой линией последнюю точку с первой.
Изучая документацию на ID2D1GeometrySink, отметьте, что этот интерфейс наследует от ID2D1SimplifiedGeometrySink, который и определяет наиболее важные методы. Вскоре я подробнее рассмотрю эту дифференциацию.
Содержимое ID2D1PathGeometry неизменяемо после определения траектории, и, если вам понадобится изменить ID2D1PathGeometry, вам придется заново создать его и вновь пройти весь процесс определения траектории. Однако, если вы просто добавляете дополнительные фигуры в начало или конец геометрии существующей траектории, то можете воспользоваться сокращением: создать новую геометрию траектории, добавить некоторые фигуры, а затем переместить содержимое геометрии существующей траектории в геометрию новой траектории, вызвав функцию Stream геометрии существующей траектории с новым ID2D1GeometrySink. После этого можно добавить дополнительные фигуры до замыкания.
Рисование и заполнение
Теперь я готов показать вам кое-какой код. Сопутствующий этой статье проект GeometryExperimentation был создан в Visual Studio 2012 с помощью шаблона Windows Store Direct2D (XAML). Я переименовал класс SimpleTextRenderer в GeometryVarietiesRenderer и удалил весь код и разметку, связанные с рендерингом образца текста.
В XAML-файле я определил набор кнопок-переключателей для разных способов рендеринга геометрических элементов. Каждая такая кнопка связана с членом определенного мной перечисления RenderingOption. Большое выражение switch/case в переопределении Render использует члены этого перечисления для управления тем, какой код будет выполняться.
Вся процедура создания геометрии происходит в переопределении CreateDeviceIndependentResources. Один из геометрических рисунков, создаваемых программой, — пятиконечная звезда. В какой-то мере обобщенный метод, создающий этот геометрический рисунок, показан на рис. 1. Он состоит из одной фигуры с четырьмя сегментами линий, но последняя точка автоматически соединяется с первой.
Рис. 1. Метод для построения пятиконечной звезды
HRESULT GeometryVarietiesRenderer::CreateFivePointedStar(
float radius, ID2D1PathGeometry** ppPathGeometry)
{
if (ppPathGeometry == nullptr)
return E_POINTER;
HRESULT hr = m_d2dFactory->CreatePathGeometry(ppPathGeometry);
ComPtr<ID2D1GeometrySink> geometrySink;
if (SUCCEEDED(hr))
{
hr = (*ppPathGeometry)->Open(&geometrySink);
}
if (SUCCEEDED(hr))
{
geometrySink->BeginFigure(Point2F(0, -radius),
D2D1_FIGURE_BEGIN_FILLED);
for (float angle = 2 * XM_2PI / 5;
angle < 2 * XM_2PI; angle += 2 * XM_2PI / 5)
{
float sin, cos;
D2D1SinCos(angle, &sin, &cos);
geometrySink->AddLine(Point2F(radius * sin, -radius * cos));
}
geometrySink->EndFigure(D2D1_FIGURE_END_CLOSED);
hr = geometrySink->Close();
}
return hr;
}
На рис. 2 показана большая часть метода CreateDeviceIndependentResources. (Чтобы упростить листинг, я убрал обработку HRESULT-значений, указывающих на ошибку.) Этот метод начинается с создания геометрического элемента, напоминающего прямоугольную волну, вызова для создания пятиконечной звезды и вызова другого метода для создания символа бесконечности. Два из этих геометрических рисунков преобразуются, и все три комбинируются в методе CreateGeometryGroup (вызываемом внизу на рис. 2) в член с именем m_geometryGroup.
Рис. 2. Большая часть переопределения CreateDeviceIndependentResources
void GeometryVarietiesRenderer::CreateDeviceIndependentResources()
{
DirectXBase::CreateDeviceIndependentResources();
// Создаем геометрическую фигуру в виде прямоугольной волны
HRESULT hr = m_d2dFactory->CreatePathGeometry(&m_squareWaveGeometry);
ComPtr<ID2D1GeometrySink> geometrySink;
hr = m_squareWaveGeometry->Open(&geometrySink);
geometrySink->BeginFigure(Point2F(-250, 50),
D2D1_FIGURE_BEGIN_HOLLOW);
geometrySink->AddLine(Point2F(-250, -50));
geometrySink->AddLine(Point2F(-150, -50));
geometrySink->AddLine(Point2F(-150, 50));
geometrySink->AddLine(Point2F(-50, 50));
geometrySink->AddLine(Point2F(-50, -50));
geometrySink->AddLine(Point2F( 50, -50));
geometrySink->AddLine(Point2F( 50, 50));
geometrySink->AddLine(Point2F(150, 50));
geometrySink->AddLine(Point2F(150, -50));
geometrySink->AddLine(Point2F(250, -50));
geometrySink->AddLine(Point2F(250, 50));
geometrySink->EndFigure(D2D1_FIGURE_END_OPEN);
hr = geometrySink->Close();
// Создаем геометрическую фигуру
// в виде звезды и преобразуем ее
ComPtr<ID2D1PathGeometry> starGeometry;
hr = CreateFivePointedStar(150, &starGeometry);
hr = m_d2dFactory->CreateTransformedGeometry(starGeometry.Get(),
Matrix3x2F::Translation(0, -200),
&m_starGeometry);
// Создаем геометрическую фигуру в виде
// символа бесконечности и преобразуем ее
ComPtr<ID2D1PathGeometry> infinityGeometry;
hr = CreateInfinitySign(100, &infinityGeometry);
hr = m_d2dFactory->CreateTransformedGeometry(infinityGeometry.Get(),
Matrix3x2F::Translation(0, 200),
&m_infinityGeometry);
// Создаем группу
CreateGeometryGroup();
...
}
Метод CreateDeviceIndependentResources создает два стиля штрихов (мазков) со скругленными краями и соединениями (joins). Один из них сплошной, другой — точечный.
CreateDeviceDependentResources создает две кисти: черную для рисования и красную для заливки.
При запуске программы выбирается первая кнопка-переключатель, и вызывается DrawGeometry:
m_d2dContext->DrawGeometry(m_geometryGroup.Get(),
m_blackBrush.Get());
Результат показан на рис. 3 и выглядит довольно странно, как эмблема некоего культа.
Рис. 3. Начальный экран GeometryExperimentation
Поскольку программа скомбинировала три разных геометрических рисунка для упрощения рендеринга, все они визуализируются одной и той же кистью. В реальной программе вы, по-видимому, предпочли бы поддерживать набор индивидуальных геометрических фигур и окрашивать их по-разному.
Первые несколько вариантов демонстрируют, как можно рисовать геометрический элемент более толстыми штрихами и линией со стилем, в том числе точечную линию. (В рамках этой программы используется толщина мазков, равная 1, 10 и 20 пикселей, поэтому она очень наглядно различается.)
При демонстрации геометрии траектории и анимации в XAML мне нравится применять анимацию на основе XAML к смещению пунктиров в стиле мазков, создавая иллюзию перемещения точек по контуру геометрической фигуры. Вы можете сделать то же самое в DirectX (как демонстрирует вариант Animated Dot Offset), но в этом случае при каждом обновлении экрана вам придется явным образом заново создавать объект ID2D1StrokeStyle. Это делается в методе Update.
Замкнутые области геометрического рисунка можно закрасить кистью:
m_d2dContext->FillGeometry(m_geometryGroup.Get(),
m_redBrush.Get());
Результат показан на рис. 4. В прямоугольной волне замкнутых областей нет. Внутренний пятиугольник пятиконечной звезды не заполняется потому, что данный режим заливки определяется алгоритмом, известным под названием Alternate. Чтобы закрасить пятиугольник, выберите Winding Fill Mode с помощью пары кнопок-переключателей слева внизу на рис. 4. Режим заливки нужно указывать при создании ID2D1GeometryGroup, поэтому объект m_geometryGroup приходится создавать заново при щелчке любой из этих двух кнопок-переключателей. Если геометрические элементы в геометрической группе перекрываются, то области пересечения тоже закрашиваются на основе выбранного режима заливки.
Рис. 4. Закраска в геометрических фигурах
Зачастую требуется рисовать и заполнять геометрические элементы. В таком случае сначала вызывайте FillGeometry, а потом DrawGeometry, чтобы сохранить полную видимость мазков.
Упрощенная геометрия
Допустим, ваше приложение динамически создает ID2D1PathGeometry, возможно, на основе пользовательского ввода, и вы хотели бы «опрашивать» («interrogate») геометрию траектории для извлечения всех фигур и сегментов. Скорее всего вы предпочли бы сохранять эту информацию в формате XAML как серию стандартных тегов PathGeometry, PathFigure, LineSegment и BezierSegment.
Поначалу кажется, что это невозможно. В ID2D1PathGeometry есть методы GetFigureCount и GetSegmentCount, но нет никаких методов для извлечения этих фигур и сегментов.
Но вспомните метод Stream. Этот метод принимает ID2D1GeometrySink и копирует содержимое геометрии траектории в этот приемник (sink). Здесь главное в том, что вы можете написать свой класс, реализующий интерфейс ID2D1GeometrySink, и передать его экземпляр методу Stream. В рамках своего класса вы можете обрабатывать все вызовы BeginFigure, AddLine и т. д. и делать с их помощью что угодно.
Конечно, такой класс нетривиален. Он потребует реализаций всех методов в ID2D1GeometrySink, а также в ID2D1SimplifiedGeometrySink и IUnknown.
Однако есть способ несколько упростить эту задачу: интерфейс ID2D1Geometry определяет метод Simplify, который преобразует любой геометрический объект в «упрощенный», содержащий только прямые линии и кривые Безье третьего порядка (cubic Bezier splines). Этот трюк возможен благодаря тому, что кривые Безье четвертого порядка (quadratic Bezier splines) и дуги можно аппроксимировать до кривых Безье третьего порядка. Тогда ваш класс должен реализовать только методы в ID2D1SimplifiedGeometrySink и IUnknown. Просто передайте экземпляр этого класса в метод Simplify любого геометрического объекта.
Кроме того, вы можете использовать Simplify для копирования упрощенного контента в геометрию новой траектории. Вот код в GeometryExperimentation, который делает именно это (проверка HRESULT удалена):
m_d2dFactory->CreatePathGeometry(&m_simplifiedGeometry);
ComPtr<ID2D1GeometrySink> geometrySink;
m_simplifiedGeometry->Open(&geometrySink);
m_geometryGroup->Simplify(D2D1_GEOMETRY_SIMPLIFICATION_OPTION_CUBICS_AND_LINES,
IdentityMatrix(), geometrySink.Get());
geometrySink->Close();
Заметьте, что первый аргумент метода Simplify указывает, что вам нужны кривые Безье третьего порядка и прямые линии в упрощенной геометрии траектории. Вы можете ограничиться только линиями, и тогда кривые Безье будут аппроксимированы серией прямых линий. Этот процесс называется линеаризацией (flattening). Если линеаризация выполняется с высокой точностью, то визуально вы не заметите разницы, но вы также можете указать погрешность линеаризации, и тогда кривая Безье будет аппроксимирована с меньшей точностью. Вот фрагмент кода из GeometryExperimentation, который создает «сильно упрощенную» геометрию:
m_d2dFactory->CreatePathGeometry(&m_grosslySimplifiedGeometry);
m_grosslySimplifiedGeometry->Open(&geometrySink);
m_geometryGroup->Simplify(D2D1_GEOMETRY_SIMPLIFICATION_OPTION_LINES,
IdentityMatrix(), 20, geometrySink.Get());
geometrySink->Close();
Звезда и прямоугольная волна будут выглядеть так же, но контуры символа бесконечности больше не будут плавными, как показано на рис. 5. По умолчанию погрешность линеаризации составляет 0.25.
Рис. 5. Сильно упрощенная геометрия символа бесконечности
Другие манипуляции над геометрическими элементами
Интерфейс ID2D1Geometry определяет три дополнительных метода, аналогичных Simplify в том смысле, что они вычисляют новую геометрию и записывают ее в ID2D1SimplifiedGeometrySink. Здесь я расскажу о методах Outline и Widen, но не стану говорить о CombineWithGeometry (потому что он интересен лишь при наличии множества перекрывающихся геометрических фигур, которых в моей программе нет).
Как вы видели, в геометрии траектории могут быть пересекающиеся сегменты. В символе бесконечности пересекающийся сегмент находится прямо в центре, а в пятиконечной звезде таких сегментов несколько. Метод Outline, определенный в ID2D1Geometry, создает геометрию новой траектории на основе существующей, что исключает эти пересечения, но сохраняет те же замкнутые области.
Вот код из GeometryExperimentation, который преобразует группу геометрических элементов в оконтуренную (outlined) траекторию:
m_d2dFactory->CreatePathGeometry(&m_outlinedGeometry);
m_outlinedGeometry->Open(&geometrySink);
m_geometryGroup->Outline(IdentityMatrix(), geometrySink.Get());
geometrySink->Close();
Поскольку этот объект m_outlinedGeometry определяет те же заполняемые области, что и m_geometryGroup, результат будет зависеть от того, какой режим использовался при создании m_geometryGroup — Alternate Fill Mode или Winding Fill Mode.
В исходной группе всего три фигуры: звезда, прямоугольная волна и символ бесконечности. Если эта группа создана в Alternate Fill Mode, оконтуренная геометрия содержит восемь фигур: пять из них относятся к пяти вершинам звезды, одна — к прямоугольной волне и две — к символу бесконечности. Но визуально они кажутся одинаковыми. Однако, если группа создавалась в Winding Fill Mode, в оконтуренной геометрии всего четыре фигуры: символ бесконечности состоит из двух фигур, как и в случае Alternate Fill Mode, а звезда из одной, потому что вся ее внутренняя область закрашена, как показано на рис. 6.
Рис. 6. Геометрия оконтуренной траектории
Определение такой геометрии с нуля было бы очень трудным в плане математики, но метод Outline здорово упрощает задачу. Поскольку геометрия траектории, определенная Outline, не содержит пересекающихся сегментов, режим заливки не оказывает никакого влияния..
Самым интересным мне кажется метод Widen. Чтобы понять, что он делает, рассмотрим геометрию траектории, которая содержит всего одну прямую линию между двумя точками. Когда эта геометрия рисуется, ее элементы выводятся линиями определенной толщины, поэтому на самом деле она визуализируется как заполненный прямоугольник. Если стиль линии включает скругленные концы, этот прямоугольник дополняется двумя закрашенными полуокружностями.
Метод Widen вычисляет геометрию траектории, которая описывает контур этого визуализируемого объекта. Для этого Widen требуются аргументы, указывающие нужную толщину штрихов и их стиль, как в DrawGeometry. Вот код из проекта GeometryExperimentation:
m_d2dFactory->CreatePathGeometry(&m_widenedGeometry);
m_widenedGeometry->Open(&geometrySink);
m_geometryGroup->Widen(20, m_roundedStrokeStyle.Get(),
IdentityMatrix(), geometrySink.Get());
geometrySink->Close();
Обратите внимание на толщину штрихов в 20 пикселей. Потом программа рисует это расширенную геометрию, используя штрихи толщиной в один пиксель:
m_d2dContext->DrawGeometry(m_widenedGeometry.Get(),
m_blackBrush.Get(), 1);
Результат показан на рис. 7. Я нахожу артефакты, создаваемые этим процессом, крайне интересными. Создается впечатление, будто я каким-то образом заглянул во внутренности алгоритма рисования линий.
Рис. 7. Расширенная геометрия траектории
Программа GeometryExperimentation также позволяет закрашивать расширенную геометрию траектории:
m_d2dContext->FillGeometry(m_widenedGeometry.Get(),
m_redBrush.Get());
Закрашивание геометрии траектории, которая была расширена конкретной толщиной и стилем штриха, визуально идентична исходной траектории, сразу нарисованной с той же толщиной и стилем. Единственное визуальное различие между вариантами Draw Rounded Wide Stroke и Fill Widened в GeometryExperimentation — цвет, поскольку для рисования я использую черный, а для закрашивания — красный.
Возможно вы предпочтете закрашивать и рисовать расширенную геометрию траектории, но избавиться от внутренних артефактов. Сделать это исключительно легко. Просто примените метод Outline к расширенной геометрии траектории:
m_d2dFactory->CreatePathGeometry(&m_outlinedWidenedGeometry);
m_outlinedWidenedGeometry->Open(&geometrySink);
m_widenedGeometry->Outline(IdentityMatrix(), geometrySink.Get());
geometrySink->Close();
Теперь, когда вы заполняете и рисуете оконтуренную расширенную геометрию траектории, вы получаете изображение, как на рис. 8. Оно свободно от артефактов и визуально довольно заметно отличается от всего остального, что выводит эта программа, а также от всего того, для чего я набрался смелости написать код с нуля.
Рис. 8. Оконтуренная расширенная геометрия траектории, выведенная штрихами и закрашенная
А есть ли другие методы?
Записывать геометрические данные в ID2D1SimplifiedGeometrySink можно другим методом, но вам придется основательно поискать его. Вы не найдете его среди Direct2D-интерфейсов. Он находится в DirectWrite, и это метод GetGlyphRunOutline в IDWriteFontFace. Это мощный метод, который генерирует геометрические элементы траектории из контуров текстовых символов и очень интересен, чтобы его игнорировать. Ждите новостей в следующем выпуске моей рубрики.
Чарльз Петцольд (Charles Petzold) — давний «пишущий» редактор MSDN Magazine и автор книги «Programming Windows, 6th edition» (O’Reilly Media, 2012) о написании приложений для Windows 8. Его веб-сайт находится по адресу charlespetzold.com.
Выражаю благодарность за рецензирование статьи экспертам Вессаму Банасси (Wessam Bahnassi) из In|Framez Technology, Ворачаи Чаовеерапраситу (Worachai Chaoweeraprasit) из Microsoft, Энтони Ходсдону (Anthony Hodsdon) из Microsoft и Майклу Б. Маклафлину (Michael B. McLaughlin) из Bob Taco Industries.