Windows и C++

Рендеринг в настольном приложении с помощью Direct2D

Кенни Керр

 

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

 

 

Рис. 1. Окно мазохиста

int __stdcall wWinMain(HINSTANCE module, HINSTANCE, PWSTR, int)
{
  WNDCLASS wc = {};
  wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
  wc.hInstance = module;
  wc.lpszClassName = L"window";
  wc.lpfnWndProc = [] (HWND window, UINT message, WPARAM
    wparam, LPARAM lparam) -> LRESULT
  {
    if (WM_DESTROY == message)
    {
      PostQuitMessage(0);
      return 0;
    }
    return DefWindowProc(window, message, wparam, lparam);
  };
  RegisterClass(&wc);
  CreateWindow(wc.lpszClassName, L"Awesome?!",
    WS_OVERLAPPEDWINDOW | WS_VISIBLE, CW_USEDEFAULT, CW_USEDEFAULT,
    CW_USEDEFAULT, CW_USEDEFAULT, nullptr, nullptr, module, nullptr);
  MSG message;
  BOOL result;
  while (result = GetMessage(&message, 0, 0, 0))
  {
    if (-1 != result) DispatchMessage(&message);
  }
}

Я также показал, что Active Template Library (ATL) предоставляет хорошую абстракцию в C++ для скрытия большей части всей этой механики и что в Windows Template Library (WTL) эта абстракция была развита еще больше и подходит в основном для приложений, интенсивно использующих USER и GDI (см. мою статью по ссылке msdn.microsoft.com/magazine/jj891018).

Будущее рендеринга в Windows-приложениях — использование аппаратно-ускоряемого Direct3D, но работать с ним напрямую непрактично, если вам нужно лишь визуализировать двухмерное приложение или игру. И здесь на сцену выходит Direct2D. Я давал краткий обзор Direct2D, когда он был впервые объявлен несколько лет назад, но намерен посвятить следующие несколько статей более подробному рассмотрению разработки с применением Direct2D. Введение в архитектуру и фундаментальные концепции Direct2D см. в моей статье «Introducing Direct2D» за июнь 2009 г. (msdn.microsoft.com/magazine/dd861344).

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

Окно рабочего стола

В примере с ATL в прошлой статье я продемонстрировал оконный класс, производный от ATL-шаблона класса CWindowImpl. Все необходимое содержалось внутри класса окна приложения. Однако в конечном счете получилось, что большая часть инфраструктурного кода окна и его рендеринга оказалась перемешанной с обработкой событий и рендеринга окна, специфичной для данного приложения. Чтобы решить эту проблему, я старался помещать как можно больше этого стереотипного кода в базовый класс, используя полиморфизм на этапе компиляции для доступа к классу окна приложения, когда этот базовый класс требовал к себе внимания. Такой подход часто применяется в ATL и WTL, так почему бы не расширить его на собственные классы?

Разделение стереотипного кода демонстрируется на рис. 2. Базовым классом является шаблон класса DesktopWindow. Параметр template позволяет базовому классу вызывать конкретный класс без использования виртуальных функций. В данном случае он применяет эту методику для скрытия множества операций пред- и постобработки, специфичной для рендеринга, в то же время вызывая окно приложения для выполнения реальных операций рисования. Я расширю шаблон класса DesktopWindow, но сначала нужно немного поработать над регистрацией этого оконного класса.

Рис. 2. Окно рабочего стола

template <typename T>
class DesktopWindow :
  public CWindowImpl<DesktopWindow<T>, CWindow,
    CWinTraits<WS_OVERLAPPEDWINDOW | WS_VISIBLE>>
{
  BEGIN_MSG_MAP(DesktopWindow)
    MESSAGE_HANDLER(WM_PAINT, PaintHandler)
    MESSAGE_HANDLER(WM_DESTROY, DestroyHandler)
  END_MSG_MAP()
  LRESULT DestroyHandler(UINT, WPARAM, LPARAM, BOOL &)
  {
    PostQuitMessage(0);
    return 0;
  }
  LRESULT PaintHandler(UINT, WPARAM, LPARAM, BOOL &)
  {
    PAINTSTRUCT ps;
    VERIFY(BeginPaint(&ps));
    Render();
    EndPaint(&ps);
    return 0;
  }
  void Render()
  {
    ...
    static_cast<T *>(this)->Draw();
    ...
  }
    ...
};
struct SampleWindow : DesktopWindow<SampleWindow>
{
  void Draw()
  {
    ...
  }
};

Оптимизация оконного класса

Следует понимать, что Windows API для настольных приложений проектировался в расчете на упрощение рендеринга с использованием традиционных ресурсов USER и GDI. Некоторые из этих «удобств» нужно отключить, чтобы передать управление Direct2D и избежать лишней перерисовки, вызывающей неприятное мерцание. Прочие значения по умолчанию тоже нужно изменить так, чтобы рендеринг через Direct2D работал эффективнее. Многое из этого можно реализовать изменением информации об оконном классе до его регистрации, но, вероятно, вы заметили, что ATL скрывает ее от программиста. К счастью, ее все же можно получить.

В предыдущей статье я показал, что Windows API ожидает регистрации структуры оконного класса перед созданием окна на основе его спецификации. Один из атрибутов оконного класса — его фоновая кисть. Windows использует эту GDI-кисть для очистки клиентской области окна до того, как в окне начинается рисование его элементов. Это было удобно во времена USER и GDI, но в Direct2D-приложениях это излишне и является причиной мерцания окна. Простой способ избежать этого — присвоить описателю фоновой кисти nullptr в структуре оконного класса. Если вы ведете разработку на сравнительно быстром компьютере под управлением Windows 7 или Windows 8, то, возможно, решили, что это не обязательно, так как никакого мерцания вы не заметили. Это вызвано тем, что современный рабочий стол Windows формируется графическим процессором настолько эффективно, что уловить такое мерцание весьма трудно. Однако на слабых машинах конвейер рендеринга легко перегрузить, и мерцание станет заметным. Попробуйте на своем быстром компьютере сделать следующее. Если фоновая кисть окна белая, а вы закрашиваете клиентскую область окна контрастной черной кистью, то, добавив небольшую задержку (где-то в интервале от 10 до 100 мс), вы без проблем увидите это бьющее по глазам мерцание. Так как же избежать его?

Как я упоминал, если в регистрационных данных вашего оконного класса не указана фоновая кисть, у Windows не окажется никакой кисти, используемой при очистке соответствующего окна. Однако вы, вероятно, заметили в примерах с ATL, что регистрация оконного класса полностью скрывается от программиста. Распространенное решение — манипуляции с сообщением WM_ERASEBKGND, которое обрабатывается оконной процедурой по умолчанию (благодаря функции DefWindowProc); при этом клиентская область окна закрашивается фоновой кистью оконного класса. Если вы обрабатываете это сообщение, возвращая true, тогда никакого закрашивания не осуществляется. Это решение вполне разумное, так как данное сообщение посылается окну независимо от того, есть ли у оконного класса допустимая фоновая кисть. Другое решение — просто избегать запуска этого обработчика по умолчанию и первым делом удалять из оконного класса фоновую кисть. К счастью, ATL позволяет сравнительно легко переопределить эту часть создания окна. В процессе создания ATL вызывает метод GetWndClassInfo окна, чтобы получить ту самую информацию об оконном классе. Вы можете предоставить свою реализацию этого метода, но в ATL есть удобный макрос, который реализует это за вас:

DECLARE_WND_CLASS_EX(nullptr, CS_HREDRAW | CS_VREDRAW, -1);

Последний аргумент данного макроса является константой кисти, но значение –1 приводит к удалению этого атрибута из структуры оконного класса. Надежный способ определить, стерт ли фон окна, — проверить PAINTSTRUCT, заполняемую функцией BeginPaint в вашем обработчике WM_PAINT. Если член fErase этой структуры равен false, значит, Windows очистила фон вашего окна или, по крайней мере, что некий код отреагировал на сообщение WM_ERASEBKGND и выполнил очистку. Если обработчик WM_ERASEBKGND не очищает фон или не может этого сделать, тогда это задача обработчика сообщения WM_PAINT. Однако здесь мы можем задействовать Direct2D, чтобы полностью перехватить рендеринг клиентской области окна и избежать двойного закрашивания. Для уверенности вызовите функцию EndPaint, которая сообщит Windows, что вы действительно закрасили свое окно; иначе Windows продолжит заваливать вас бесполезным потоком сообщений WM_PAINT. А это, безусловно, повредило бы производительности вашего приложения и увеличило бы общее энергопотребление.

Другой аспект информации об оконном классе, заслуживающий внимания, — стили этого класса. По сути, это то, для чего предназначен второй аргумент предыдущего макроса. Стили CS_HREDRAW и CS_VREDRAW заставляют объявлять окно недействительным всякий раз, когда оно изменяется в размере как по вертикали, так и по горизонтали. Это определенно не является необходимостью. Вы могли бы, например, обрабатывать сообщение WM_SIZE и объявлять окно недействительным в этом обработчике, но я всегда стремлюсь к тому, чтобы Windows избавляла меня от написания лишних строк кода. В любом случае, если вы не объявляете окно недействительным, Windows не станет посылать ему никакие сообщения WM_PAINT, когда размер окна уменьшается. Это могло бы подойти в том случае, если вас устраивает, что содержимое окна просто обрезается, но в наши дни принято рисовать различные элементы окна относительно размера этого окна. Что бы вы ни предпочитали, это решение нужно принимать явным образом.

Пока мы обсуждаем вопросы, связанные с фоном окна, замечу, что зачастую желательно явным образом объявлять окно недействительным. Это позволяет сосредоточить рендеринг окна в обработчике сообщения WM_PAINT, а не разбрасывать обработку рисования по разным местам и по разным путям кода в приложении. Возможно, вам требуется что-то рисовать в ответ на щелчок мышью. Конечно, вы могли бы выполнять рендеринг прямо там, в обработчике сообщения о щелчке. В качестве альтернативы можно было бы просто объявлять окно недействительным и позволить обработчику WM_PAINT визуализировать текущее состояние приложения. Это роль функции InvalidateRect. В ATL есть метод Invalidate, который просто обертывает эту функцию. В этой функции разработчиков нередко сбивает с толку то, как нужно обращаться с параметром erase. Здравый смысл вроде бы говорит, что нужно сказать «да» в этом параметре, чтобы вызвать немедленную перерисовку окна, или «нет», чтобы как-то отложить эту операцию. Увы, это не так, и в документации написано то же самое. Перерисовка окна вызывается объявлением окна недействительным. Параметр erase является заменителем функции DefWindowProc, которая обычно очищает фон окна. Если erase равен true, то последующий вызов BeginPaint очистит фон окна. И здесь появляется еще одна причина на то, чтобы полностью избегать применения фоновой кисти оконного класса, не полагаясь на обработчик сообщения WM_ERASEBKGND. Без фоновой кисти у BeginPaint вновь нечем рисовать, поэтому параметр erase не даст никакого эффекта. Если вы позволите ATL задавать фоновую кисть для своего оконного класса, вам придется соблюдать осторожность при объявлении окна недействительным, так как это опять приведет к мерцанию. С этой целью я добавил в шаблон класса DesktopWindow защищенный член:

void Invalidate()
{
  VERIFY(InvalidateRect(nullptr, false));
}

Кроме того, для объявления окна недействительным было бы неплохо обрабатывать сообщение WM_DISPLAYCHANGE. Это гарантирует, что окно будет должным образом перерисовываться, если какое-то изменение повлияет на внешний вид окна.

Запуск приложения

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

int __stdcall wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
{
  SampleWindow window;
  return window.Run();
}

Рис. 3. Метод Run в DesktopWindow

int Run()
{
  D2D1_FACTORY_OPTIONS fo = {};
  #ifdef DEBUG
  fo.debugLevel = D2D1_DEBUG_LEVEL_INFORMATION;
  #endif
  HR(D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED,
                       fo,
                       m_factory.GetAddressOf()));
  static_cast<T *>(this)->CreateDeviceIndependentResources();
  VERIFY(__super::Create(nullptr, nullptr, L"Direct2D"));
  MSG message;
  BOOL result;
  while (result = GetMessage(&message, 0, 0, 0))
  {
    if (-1 != result)
    {
      DispatchMessage(&message);
    }
  }
  return static_cast<int>(message.wParam);
}

Прежде чем создавать окно, я подготавливаю параметры для фабрики Direct2D, включая отладочный уровень для сборок в режиме отладки. Я настоятельно советую вам делать то же самое, так как это позволяет Direct2D выдавать вам все виды полезной диагностической информации в процессе разработки приложения. Функция D2D1CreateFactory возвращает указатель на интерфейс фабрики, который я передаю смарт-указателю превосходного ComPtr в Windows Runtime Library, защищенному члену класса DesktopWindow. Затем я вызываю метод CreateDeviceIndependentResources для создания любых аппаратно-независимых ресурсов — таких вещей, как геометрические данные и стили штрихов (stroke styles), которые можно многократно использовать на протяжении всей жизни приложения. Хотя я переопределяю этот метод в производных классах, я предоставляю пустую заглушку в шаблоне класса DesktopWindow, если необходимости в нем нет. Наконец, метод Run блокируется в простом цикле обработки сообщений. Все пояснения по этому циклу вы найдете в прошлой статье.

Мишень прорисовки

Мишень прорисовки (render target) в Direct2D должна создаваться по запросу в методе Render, вызываемом в обработчике сообщения WM_PAINT. В отличие от некоторых других мишеней прорисовки в Direct2D вполне возможно, что устройство (в большинстве случаев — графический процессор [GPU]), которое обеспечивает аппаратно-ускоряемый рендеринг окна рабочего стола, может исчезнуть или каким-то образом измениться, что сделает любые ресурсы, выделенные этой мишенью прорисовки, недействительными. Из-за природы рендеринга прямого режима (immediate mode) в Direct2D приложение отвечает за отслеживание того, какие ресурсы являются аппаратно-специфическими, и время от времени может потребоваться повторное создание этих ресурсов. К счастью, управлять этим достаточно легко. На рис. 4 приведен полный исходный код метода Render класса DesktopWindow.

Рис. 4. Метод Render в DesktopWindow

void Render()
{
  if (!m_target)
  {
    RECT rect;
    VERIFY(GetClientRect(&rect));
    auto size = SizeU(rect.right, rect.bottom);
    HR(m_factory->CreateHwndRenderTarget(RenderTargetProperties(),
      HwndRenderTargetProperties(m_hWnd, size),
      m_target.GetAddressOf()));
    static_cast<T *>(this)->CreateDeviceResources();
  }
  if (!(D2D1_WINDOW_STATE_OCCLUDED & m_target->CheckWindowState()))
  {
    m_target->BeginDraw();
    static_cast<T *>(this)->Draw();
    if (D2DERR_RECREATE_TARGET == m_target->EndDraw())
    {
      m_target.Reset();
    }
  }
}

Метод Render начинает с проверки допустимости ComPtr, управляющего COM-интерфейсом мишени прорисовки. Благодаря этому мишень прорисовки создается заново только при необходимости. Это бывает минимум один раз — при первой визуализации окна. Если с нижележащим устройством что-то происходит или возникает другая причина, по которой требуется заново создать мишень прорисовки, то метод EndDraw (см. ближе к концу метода Render на рис. 4) вернет константу D2DERR_RECREATE_TARGET. Затем мишень прорисовки просто освобождается с помощью ComPtr-метода Reset. Когда в следующий раз окну будет предложено прорисовать себя, метод Render пройдет все этапы создания новой мишени прорисовки для Direct2D.

Он начинает с получения клиентской области окна в физических пикселях. Direct2D по большей части использует логические пиксели, естественным образом помогая этому методу поддерживать дисплеи с высокими показателями DPI (точек на дюйм). Далее инициируется сопоставление между физическим дисплеем и его логической системой координат. Затем он вызывает фабрику Direct2D, чтобы создать объект мишени прорисовки. В этот момент метод обращается к производному оконному классу приложения для создания любых аппаратно-зависимых ресурсов, например кистей и битовых карт, специфичных для нижележащего устройства мишени прорисовки. И вновь классом DesktopWindow предоставляется пустая заглушка, если необходимости в этой операции нет.

Перед рисованием метод Render проверяет, действительно ли окно видимо и не перекрыто ли оно полностью другим окном. Это избавляет от лишней операции рендеринга. Как правило, это происходит, только когда нижележащая цепочка замен (swap chain) в DirectX невидима, скажем, когда пользователь блокирует рабочий стол или переключается между рабочими столами. Затем методы BeginDraw и EndDraw обрамляют вызов метода Draw окна приложения. Direct2D использует возможность пакетной передачи геометрических данных в вершинный буфер, объединения команд рисования и другие средства, чтобы обеспечить максимальную пропускную способность и производительность.

Последний критически важный этап в корректной интеграции Direct2D с окном рабочего стола — изменение размеров мишени прорисовки соответственно изменению размеров окна (если таковое происходит). Я уже говорил о том, как окно автоматически объявляется недействительным, чтобы обеспечить его своевременную перерисовку, но сама мишень прорисовки не имеет ни малейшего представления, что размеры окна изменились. К счастью, сделать это достаточно легко, что и иллюстрирует рис. 5.

Рис. 5. Изменение размеров мишени прорисовки

MESSAGE_HANDLER(WM_SIZE, SizeHandler)
LRESULT SizeHandler(UINT, WPARAM, LPARAM lparam, BOOL &)
{
  if (m_target)
  {
    if (S_OK != m_target->Resize(SizeU(LOWORD(lparam),
      HIWORD(lparam))))
    {
      m_target.Reset();
    }
  }
  return 0;
}

Если ComPtr в данный момент содержит допустимый указатель на COM-интерфейс мишени прорисовки, вызывается метод Resize этой мишени с передачей ему новых размеров — они предоставляются через LPARAM оконного сообщения. Если по какой-то причине мишень прорисовки не в состоянии изменить размеры всех своих внутренних ресурсов, то ComPtr просто сбрасывается, что приводит к повторному созданию мишени прорисовки при следующем запросе визуализации.

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


Кенни Керр (Kenny Kerr) — высококвалифицированный программист. Живет в Канаде. Автор учебных курсов для Pluralsight, обладатель звания Microsoft MVP. С ним можно связаться через блог kennykerr.ca. Кроме того, читайте его заметки в twitter.com/kennykerr.

Выражаю благодарность за рецензирование статьи эксперту Ворачаи Чаовеерапраситу (Worachai Chaoweeraprasit).