Pull to refresh

Использование Direct3D с высокоуровневыми библиотеками компонентов VCL/LCL

Reading time13 min
Views10K
Данная публикация адресуется новичкам в области программирования компьютерной графики, желающим использовать графическую библиотеку Microsoft DirectX. Сразу оговорюсь:
— затронутая тема, наверняка, относится и к OpenGL, но я это не проверял опытным путём (созданием приложений под OpenGL), поэтому в заголовке упоминаю только Direct3D;
— приводимые здесь примеры кода относятся к языкам Delphi/FreePascal, но перечисленные «рецепты» по большому счету универсальны в пределах целевой ОС (Windows) — их можно применять к любому языку программирования и, с высокой вероятностью — к любой высокоуровневой библиотеке компонентов, помимо VCL (Delphi) и LCL (Lazarus);
— данная публикация не затрагивает тему создания каркасного приложения Direct3D и методов работы с графическими библиотеками DirectX и OpenGL; все эти вещи хорошо освещены в других источниках, и мне практически нечего к этому добавлять.

Итак, ближе к теме. При разработке приложений с трёхмерной графикой для построения каркаса учебного (а тем более — рабочего) приложения обычно рекомендуется использовать чистый Win32 API… Но если очень хочется использовать в своих приложениях ещё и преимущества высокоуровневых библиотек компонентов, тогда добро пожаловать под кат.

Введение в проблему


При использовании чистого Win32 API цикл обработки поступающих оконных сообщений приходится прописывать «вручную», и обычно это выглядит примерно так:

repeat
  if ( PeekMessage(msg, 0, 0, 0, PM_REMOVE) ) then
  // если есть какое-то сообщение в очереди - получаем его и обрабатываем
  begin
    TranslateMessage(msg);
    DispatchMessage(msg);
  end
  // иначе немедленно выполняем очередную отрисовку 3D-сцены
  else
    RenderScene();

  // и вот так повторять до завершения работы приложения
until ( msg.message = WM_QUIT );

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

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

Отступление по поводу маскировки исключений


Я был удивлен, когда не смог нормально запустить откомпилированный в Lazarus проект с использованием Direct3D, стабильно получая при запуске программы исключения, «кивающие» на вычисления с плавающей запятой. Потратив некоторое время на изучение проблемы, так и не нашел в интернете прямых сведений об этой проблеме, но обратил внимание, что если компилировать проект в Delphi для 64-разрядной архитектуры, то при выполнении получаю весьма похожую по сути ошибку. Изучение содержимого окон Debug-режима в Delphi показало, что для FPU-расширения процессора регистр маскировки исключений MXCSR имеет различные значения во всех рассмотренных случаях. Даже после этого нагуглить ничего стоящего тоже не удалось, кроме упоминания о том, что модуль OpenGL из стандартной поставки Delphi содержит в секции «initialization» строку, которая устанавливает маскировку исключений на все возможные случаи.

Маскировка исключений FPU не относится к теме этой публикации, поэтому не буду сильно заострять на ней внимание. Приведу только самый простой пример: когда умножение очень больших чисел с плавающей запятой приводит к переполнению, то в этом случае происходит одно из двух: результат умножения становится равным INFINITY (или -INFINITY), если включена маскировка соответствующего исключения; либо процессор генерирует исключительную ситуацию «floating point overflow» (которая должна быть обработана программой в блоке «try except»), если маскировка соответствующего исключения отключена.

В итоге, попробовав установить в своих проектах маскировку исключений так, как это сделано в стандартном модуле OpenGL, я добился того, чтобы мои Direct3D-приложения работали как в Lazarus, так и в Delphi (включая 64-битную платформу) без проблем.

К сожалению, мне не удалось найти в MSDN или других источниках (может, плохо искал?) указаний на то, что нужно делать именно так и никак иначе, но тем не менее, рекомендую читателям в своих Direct3D-проектах прописывать следующий код:

uses Math;
...
INITIALIZATION
  Math.SetExceptionMask([exInvalidOp..exPrecision]);
END.

При этом должен заметить, что маскировка исключений будет иметь определённые побочные эффекты, которые обязательно следует учитывать. Например, становится «возможным» деление на ноль (с такой проблемой люди столкнулись, например, здесь), поэтому при выполнении вычислений с плавающей запятой нужно обязательно проверять промежуточные результаты.

Впрочем, если вы хотите получать исключительные ситуации при вычислениях с плавающей запятой, как привыкли ранее, то ничто не мешает в нужных местах использовать конструкцию примерно такого вида:

var
  mask: TArithmeticExceptionMask;
begin
  mask := SetExceptionMask([]);  // отключаем всю (или как Вам нужно) маскировку исключений
  try
    // все необходимые вычисления с плавающей запятой
  finally
    SetExceptionMask(mask);  // возвращаем обратно предыдущие флаги маскировки исключений
  end;
end;

На этом я закругляюсь с вопросом маскировки исключений.

Ещё одно отступление — а зачем нам это нужно?


Целей для создания Direct3D-приложений с использованием высокоуровневых библиотек компонентов может быть много. Например, отладка каких-то моментов, таких как шейдеры и эффекты. А может быть, вы создаёте собственный 3D-движок и нуждаетесь в редакторе файлов определений, на основе которых движок будет выполнять загрузку ресурсов и отрисовку сцен? В таких случаях хотелось бы иметь возможность видеть сразу результат, а при необходимости — редактировать что-то и «на лету» при помощи «вменяемого» пользовательского интерфейса со строками меню, модальными диалогами и т.д. и т.п.

К данной публикации я подготовил относительно примитивную программу, которая выводит на отрисовку в главном окне один-единственный треугольник (используется API DirectX 11), и при этом позволяет во время выполнения редактировать и применять вершинный и пиксельный шейдеры, используемые при отрисовке сцены. Для этого понадобилось поместить на главную форму приложения необходимый набор компонентов — многострочное поле ввода и кнопку. Сразу предупреждаю — программа исключительно демонстрационная (для данной публикации), поэтому не следует от неё ожидать чего-то особенного. Ссылка на исходные коды приводится в конце текста данной публикации.

На этом отступления заканчиваются, и я перехожу к основной теме.

Способ тривиальный — событие TForm.OnPaint, функция Windows.InvalidateRect()


Программисты, знакомые не только с высокоуровневыми библиотеками компонентов, но и с чистым Win32 API, наверняка уже сложили в голове простую схему: «надо отрисовывать Direct3D-сцену в обработчике события формы (или другого компонента), именуемом OnPaint, и там же, по окончанию отрисовки, вызывать функцию InvalidateRect() из Win32 API, чтобы спровоцировать систему на отправку нового сообщения WM_PAINT, которое приведёт к повторному вызову обработчика OnPaint, и так мы далее пойдём по кругу в бесконечном цикле отрисовки, не забывая по ходу дела реагировать и на остальные оконные сообщения».

В общем-то, всё верно.

Вот примерный план кода для обработчика OnPaint:

procedure TFormMain.FormPaint(Sender: TObject);
begin
  // отрисовка Direct3D-сцены
  // ...

  // вывод результатов на экран с помощью интерфейса IDXGISwapChain
  pSwapChain.Present ( 0, 0 );

  // генерация следующего события WM_PAINT для бесконечного цикла отрисовки
  InvalidateRect ( Self.Handle, nil, FALSE );
end;

Но, как говорится, «гладко было на бумаге».

Давайте посмотрим, что получится (напоминаю, что в конце текста будет ссылка на исходные коды — скачав их, читатель может найти подкаталог «01 — OnPaint + InvalidateRect», скомпилировать и запустить программы и убедиться в не очень корректной работе примера).

Проблема 1: при компиляции приложения в Delphi и последующем запуске, Direct3D-сцена отрисовывается как ожидается, но контролы пользовательского интерфейса нормально отображаться не хотят. Пока не изменишь расположение или размер окна программы, не хотят нормально отображаться ни надписи, ни содержимое многострочного поля редактирования, ни статус-бар, ни кнопка… Ну, положим, многострочное поле редактирования более-менее нормально перерисовывается, когда мы начинаем его прокручивать и редактировать содержимое, но в целом результат неудовлетворительный. А если программа в процессе работы открывает диалоговые окна (или хотя бы примитивный MessageBox), то они либо закрываться нормально не хотят, либо отображаться на экране (MessageBox можно закрыть и вслепую кнопкой «пробел», но диалоговое окно, унаследованное от TForm, закрыть у меня уже никак не получается). Для наглядной демонстрации этой проблемы я добавил в главное меню программы-примера пункты «Дополнительно -> О программе (MessageBox)» и «Дополнительно -> О программе (TForm)».

Проблема 2: при компиляции приложения в Lazarus и последующем запуске, впридачу к описанным выше проблемам (как будто их недостаточно), добавляется невозможность завершить работу программы — она не реагирует ни на стандартную кнопку закрытия в заголовке (“X”), ни на пункт меню «Выход»… Чтобы программа завершилась сама, без «помощи» диспетчера задач или комбинации «Ctrl+F2» в IDE, необходимо свернуть программу в таскбар (интересно, почему так?) после нажатия на кнопку закрытия окна.

Избавиться от последней проблемы на самом деле очень просто, нужно всего лишь дописать дополнительное условие перед вызовом функции InvalidateRect(), примерно так:

  if ( not ( Application.Terminated ) ) then
    InvalidateRect(Self.Handle, nil, FALSE);

Но вот решить первую проблему так легко, увы, не получится.

Вывод: описанный в этом подзаголовке способ нарушает нормальную работу очереди оконных сообщений Windows, мешая ряду оконных сообщений быть обработанными вовремя, и особенно это видно в случае использования высокоуровневой библиотеки компонентов (по крайней мере, это относится к VCL и LCL в их версиях на момент написания публикации).

Примечание: в MSDN можно найти описание функции GetMessage, где упоминается, что сообщение WM_PAINT имеет низкий приоритет по сравнению с другими оконными сообщениями (кроме WM_TIMER — его приоритет ещё ниже), и обрабатывается после всех остальных оконных сообщений.

Итого: факт, как говорится, налицо. Если и не во всех версия ОС, то как минимум в популярной ныне операционной системе Windows 7 (в которой я запускал все приложенные к публикации программы-примеры), ситуация с приоритетом в обработке сообщения WM_PAINT будет несколько посложнее, чем хотелось бы, особенно если приложение использует высокоуровневую библиотеку компонентов, и поэтому полагаться на указанный в MSDN приоритет нельзя.

На этом можно было бы перейти к следующему способу организации бесконечного цикла отрисовки, но я сделаю ещё одно короткое отступление, на один маленький абзац.

Библиотеки VCL и LCL предлагают программисту в классах, унаследованных от TWinControl, метод Invalidate(). В библиотеке VCL его вызов сводится к вызову вышеозначенной функции InvalidateRect() чистого Win32 API, но в общем случае поведение этого метода зависит от реализации в конкретной библиотеке. Так, в LCL этот метод приводит к вызову другой функции Win32 API, имеющей имя RedrawWindow() — эта функция даёт примерно тот же результат (будет выполнена новая отрисовка окна), но кое-какие нюансы отличаются. Поэтому, чтобы не акцентировать внимание на нюансах, я сразу предложил обратиться к функции InvalidateRect() из Win32 API.

Способ более удачный — задействуем событие Application.OnIdle


Раз предыдущий способ неудачен, поскольку нарушает нормальную работу очереди оконных сообщений Windows, то логично попытаться сделать так, чтобы отрисовка окна приложения выполнялась строго после обработки всех остальных оконных приложений. На первый взгляд (по крайней мере, если детально не вглядываться во внутренности библиотек) эта задача может показаться невозможной без модификации цикла обработки оконных сообщений, скрытого в недрах библиотек VCL и LCL, но на самом деле это не так.

У объекта Application есть событие OnIdle, которое вызывается каждый раз, когда обнаруживается факт отсутствия новых оконных сообщений, и более того — обработчик этого события может сообщить, что он хочет обрабатывать это событие повторно (в цикле) до тех пор, пока не появятся наконец новые сообщения. После того, как будут обработаны новые сообщения, будет снова вызван обработчик события Application.OnIdle… И так далее до завершения работы приложения. В общем, событие Application.OnIdle вполне подходит для организации бесконечного цикла отрисовки, хотя и со своими нюансами (для получения более подробной информации по этому событию, советую обращаться к справке в используемой Вами среде разработки).

Теперь мы можем убрать из обработчика OnPaint вызов API-функции InvalidateRect() и перенести его в обработчик события Application.OnIdle.

В итоге получается код по примерно такой схеме:

procedure TFormMain.FormCreate(Sender: TObject);
begin
  Application.OnIdle := OnApplicationIdle;
  // прочий код инициализации
  // ...
end;

procedure TFormMain.FormPaint(Sender: TObject);
begin
  // отрисовка Direct3D-сцены
  // ...

  // вывод результатов на экран с помощью интерфейса IDXGISwapChain
  pSwapChain.Present ( 0, 0 );

  // генерации следующего события WM_PAINT здесь больше нет — она перенесена в OnApplicationIdle()
end;

procedure TFormMain.OnApplicationIdle(Sender: TObject; var Done: Boolean);
begin
  if ( Application.Terminated )  // выполняется завершение работы приложения
     or ( { другие условия, при которых не нужно продолжать бесконечный цикл отрисовки } ) then
  begin
    // перерисовка не нужна, завершаем цикл обработки OnIdle()
    Done := TRUE;
    Exit;
  end;

  // будем обрабатывать OnIdle() повторно для обеспечения бесконечного цикла отрисовки
  Done := FALSE;

  // обеспечить сообщение WM_PAINT для последующей отрисовки
  InvalidateRect ( Self.Handle, nil, FALSE );
end;

В приложенных к публикации исходниках можно найти подкаталог «02 — OnPaint + OnApplicationIdle» и убедиться, что программа работает намного лучше, обновляя содержимое всех контролов своевременно и корректно отображая все модальные диалоговые окна.

К вышесказанному хочу добавить ещё вот что: если свернуть окно программы в таскбар и открыть диспетчер задач, то можно увидеть, что программа «кушает» как минимум одно ядро процессора полностью, и это несмотря на то, что рисовать программе по большому счёту нечего и незачем. Если вы хотите, чтобы ваша программа уступала ресурсы CPU другим приложениям в подобных случаях, а также не вызывала глюков в открытых модальных окнах (я такое видел только в Lazarus), то можно модифицировать обработчик события Application.OnIdle следующим способом:

procedure TFormMain.OnApplicationIdle(Sender: TObject; var Done: Boolean);
begin
  if ( Application.Terminated )  // выполняется завершение работы приложения
     or ( Application.ModalLevel > 0 )  // открыты модальные окна
     or ( Self.WindowState = wsMinimized )  // окно программы свернуто
     or ( { другие условия, при которых не нужно продолжать бесконечный цикл отрисовки } ) then
  begin
    // перерисовка не нужна, завершаем цикл обработки OnIdle()
    Done := TRUE;
    Exit;
  end;

  // будем обрабатывать OnIdle() повторно
  Done := FALSE;

  // обеспечить сообщение WM_PAINT для последующей отрисовки
  InvalidateRect ( Self.Handle, nil, FALSE );
end;

Однако, даже в случае обработки события Application.OnIdle невозможно добиться идеального бесконечного цикла отрисовки. Например, когда открыто главное меню окна, то в процессе навигации по нему событие Application.OnIdle не будет вызываться и, соответственно, анимация Direct3D-сцены «остановится». То же самое произойдёт и в случае открытия программой модального диалога или окна MessageBox.

Конечно, с такими проблемками тоже можно побороться. Например, положить на форму объект TTimer, настроить его на срабатывание каждые 50 миллисекунд, и вызывать в его обработчике события всё ту же функцию InvalidateRect() — тогда можно будет надеяться, что и при навигации по главному меню, и при работе с модальными диалогами цикл отрисовки будет продолжать свою работу, но в эти моменты уже не будет возможности адекватно оценивать FPS и производительность отрисовки 3D-сцены в целом. Впрочем, это вряд ли будет интересовать пользователя в те моменты, когда он открывает главное меню и диалоговые окна, поэтому я не акцентирую внимание на непрерывности бесконечного цикла отрисовки — главное чтобы он был и работал в те моменты, когда внимание пользователя сосредоточено на окне с Direct3D-сценой, а остальное уже не столь важно и отдается на откуп читателю — желающие могут реализовать момент с TTimer самостоятельно и убедиться, что это работает вполне ожидаемым образом.

Отрисовка 3D-сцены в отдельный контрол


Когда часть окна программы отведена под отрисовку Direct3D-сцены, а другая — под контролы пользовательского интерфейса, то будет не совсем правильно выделять видеопамять под всё окно программы целиком.

Логичнее будет создать панельку (или иной контрол), которая при необходимости будет менять свои размеры вместе с окном программы (удобно использовать свойство Align для автоматической подгонки размеров контрола) и избавит от «шаманства» с матрицами преобразований при отрисовке Direct3D-сцены.

К сожалению, мне не удалось найти стандартных малофункциональных контролов типа TPanel, которые бы имели «публичный» обработчик события OnPaint, поэтому пришлось реализовать компонент-наследник от TCustomControl (можно и от других классов) и перегрузить его метод Paint().

Такая реализация предельно проста, и приложенные к публикации исходные коды содержат подобный пример в подкаталоге «03 — TCustomControl descendant».

Использование тем оформления Windows и двойная буферизация при отрисовке окон


В прилагаемых к публикации исходниках, проекты Delphi включают в своих настройках манифест для поддержки тем оформления Windows, и поэтому скомпилированные в Delphi программы имеют вполне современный вид во время выполнения.

Что же касается проектов Lazarus, то настройка такого манифеста для них отключена, и это, к сожалению, не случайность — я выставил её намеренно, и сейчас объясню почему.

Библиотеки компонентов VCL и LCL умеют использовать двойную буферизацию при отрисовке окон. В проектах-примерах можно увидеть строку в обработчике FormCreate(), отключающую двойную буферизацию.
Почему важно отключать двойную буферизацию, когда мы отрисовываем окно средствами Direct3D? Потому, что отрисовку окон и контролов эти библиотеки выполняют средствами GDI. А поскольку Direct3D в программах-примерах выполняет вывод в окно напрямую, минуя любую «пользовательскую» двойную буферизацию, то и выходит, что библиотека компонентов, при включенной двойной буферизации будет получать в своих заэкранных буферах просто чёрный прямоугольник — ведь в заэкранный буфер мы ничего не рисовали! Таким образом, при каждой отрисовке, при включенной двойной буферизации, будет происходить примерно следующий сценарий: библиотека создает заэкранный буфер, очищает его чёрным цветом, затем наш обработчик OnPaint() рисует сцену средствами Direct3D и выводит её на экран, минуя созданный библиотекой компонентов заэкранный буфер… а после выполнения обработчика OnPaint() библиотека компонентов свой пустой буфер (чёрный прямоугольник) отрисовывает поверх той картинки, которую мы получили средствами Direct3D. В итоге, включив двойную буферизацию, будем иметь весьма заметное (вплоть до чёрного окна с редкими «вспышками») мерцание окна программы. Это можно проверить на программах-примерах, поменяв соответствующую строчку в обработчике FormCreate() в любом из проектов.

Наверное, Вы уже задумались — если во всех программах-примерах двойная буферизация отключена, то какие могут быть проблемы?
Рассказываю — даже при выставлении свойству DoubleBuffered формы (или целевого контрола) значения FALSE, программы, созданные в Lazarus с использованием библиотеки компонентов LCL, всё равно будут использовать двойную буферизацию, когда программе доступны темы оформления для Windows (при помощи упомянутого выше манифест-файла).
Доказательство этого очень даже простое, в заголовочном файле win32callback.inc библиотеки LCL, в коде функции WindowProc(), имеется строка:
useDoubleBuffer := (ControlDC = 0) and (lWinControl.DoubleBuffered or ThemeServices.ThemesEnabled);

обратите внимание на последнюю часть условия — она достаточно красноречиво объясняет бесполезность отключения свойства DoubleBuffered при доступных темах оформления Windows.

Что же касается библиотеки VCL в Delphi, то она умеет обходиться без двойной буферизации при использовании тем оформления, вот аналогичная строчка из недр VCL:
if not FDoubleBuffered or (Message.DC <> 0) then


На этом у меня пока всё, благодарю за внимание.
Полезные дополнения к материалу и конструктивную критику — жду в комментариях.

Исходные коды


Программы-примеры к данной публикации доступны по следующей ссылке:
github.com/yizraor/PubDX_VCL_LCL
спойлер
(гитхабом раньше не пользовался, надеюсь, что получилось правильно)
Tags:
Hubs:
Total votes 11: ↑10 and ↓1+9
Comments21

Articles