QtQuick-QML в качестве игрового UI

в 6:28, , рубрики: c++, Gamedev, qt, UI, Программирование, разработка игр

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

Выходом является использование готовых универсальных UI библиотек. Текущее их поколение представлено такими «монстрами» как Scaleform и Coherent UI, хотя если вам так хочется писать UI на HTML, то можно и просто взять Awesomium.

К сожалению, у этой троицы, при всех её преимуществах, есть один существенный недостаток — жуткие тормоза, особенно на мобильных устройствах (несколько лет назад, я лично наблюдал, как практически пустой экран на Scaleform потреблял 50% от времени кадра на iPhone4).

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

Впрочем, причина, по которой именно привычные старые Qt Widgets не используются в играх, лежит на поверхности: они не рассчитаны на использование совместно с OpenGL или DirectX рендером. Попытки их скрестить дают довольно плохую производительность даже на десктопе, а про мобилки и говорить нечего.

Однако, уже довольно давно в Qt есть гораздо более подходящая для этой задачи библиотека: QtQuick. Её контролы по умолчанию рендерятся ускоренно, а возможность задавать описание UI в текстовом формате отлично подходит для быстрой настройки и изменения внешнего вида игры.

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

Аргументы против:

Начну с вещи, самой отдалённой от технических вопросов, а именно с лицензирования. Qt использует двойную лицензию — LGPL3 и коммерческую. Это означает, что если вас интересуют, в том числе, платформы, где динамическая линковка невозможна (iOS), то придётся раскошелится на 79$ в месяц за каждого работника «использующего» Qt. «Использовать», это, как я понимаю, хотя бы просто собирать проект с библиотеками, то есть, платить придётся за каждого программиста на проекте.

Деньги не очень большие, но всё равно не бесплатно. И есть ещё один очень интересный момент: коммерческую лицензию Qt желательно получить как только вы начнёте использовать Qt в вашем проекте. В противном случае, при попытке получить лицензию вам предложат «связаться с нашими специалистами для обсуждения условий». Они и понятно: не только в нашей стране умные граждане догадались бы для всей разработки пять лет использовать бесплатную версию, и только для сборки финального билда купить лицензию на 1 месяц!

Пожалуй, самым важным техническим аргументом против Qt является её вес. Практически пустое десктопное приложение, использующее QML, занимает более 40Mb (при динамической линковке DLL). На Андроиде размеры будут несколько меньше, порядка 25Mb (в разжатом виде — APK будет заметно легче), но для мобильной платформы это просто ОЧЕНЬ много! Qt предлагают костыль, который позволяет установить библиотеки на телефон пользователя один раз, а использовать их из разных приложений (Ministro), но этот костыль, очевидно, доступен только на Андроиде, а нам бы хотелось ещё как-то решить вопрос с размерами на iOS и Windows Phone…

Впрочем, сокрушаясь по поводу разжиревших библиотек, не стоит забывать, что конкуренты — упомянутые выше Scaleform и Coherent — в этом плане не сильно лучше, оба выдают пустые приложения размерами в десятки мегабайт. Unity — немного легче, но всё равно, около 10Mb. Поэтому, здесь Qt сильно проигрывает только собственным, оптимизированным под задачу разработкам.

В заключение, упомяну ещё один потенциальный недостаток — Qt не готов к использованию под Web (Emscripten). Большей части разработчиков это не очень важно, но вот мы, например, занимаемся этим направлением, и тут использовать Qt пока нельзя, хотя работы в этом направлении ведутся.

Аргументы за:

Главным аргументом за использование QtQuick/QML является удобный формат описания UI, а также визуальный редактор для него. Плюс, большой готовый набор контролов.

Стоит упомянуть и возможность писать некоторую часть кода UI на JavaScript внутри QML, например, всякую простую арифметику, связывающую состояние полей разных объектов — возможность, очень редко доступная в самодельных UI библиотеках (и при этом часто необходимая).

Однако, стоит заметить, что Qt Designer — это не конструктор форм Visual Studio. Даже для базовых контролов, идущих в поставке Qt, он не даёт редактировать все возможные их свойства (например потому, что их можно добавлять динамически). В частности, вы не сможете через редактор назначить кнопке картинки для нажатого и отпущенного положения. И это только начало проблем. С другой стороны, совмещая использование визуального и текстового редактора, все эти проблемы можно преодолеть. Просто не надо рассчитывать, что можно будет отдать Qt Designer художнику, и он вам всё настроит мышкой, не залезая в текстовое представление.

Производительность, по моим ощущениям, у QtQuick допустимая. В свежем релизе Qt 5.7 её обещали ещё заметно улучшить с новыми QtQuick Controls 2.0, заточенными под мобильные платформы.

Технические особенности

Теперь перейдём к самому интересному — техническим особенностям использования Qt в игре.

Главный цикл

Первое, с чем предстоит столкнуться — Qt предпочитает быть хозяином главного цикла. В то же время, многие игровые движки так же претендуют на это. Кому-то придётся уступить. В моём случае, Nya engine, который мы используем на работе, без проблем расстаётся с main loop'ом, и, после минимальной инициализации, легко использует OpenGL контекст, созданный Qt. Но даже если ваш движок отказывается выпускать главный цикл из цепких лапок, то это не конец мира. Достаточно в вашем цикле вызывать у класса Qt приложения метод processEvents. Пример реализации приведён на StackOverflow, вместе с критикой.

Если же вы пошли путём передачи главного цикла в руки Qt, то возникает вопрос — а когда же рендерить нашу игру? Объект QQuickView, в который грузится UI для отображения, предоставляет сигналы beforeRendering и afterRendering, на которые можно подписаться. Первый сработает до отрисовки UI — тут самое время отрендерить большую часть игровой сцены. Второй — после того, как UI нарисован, и тут можно нарисовать ещё какие-нибудь красивые партиклы, ну, или вдруг какие-то модельки, которым положено быть поверх UI (скажем, 3д-куклу персонажа в окне экипировки). ВАЖНО! При соединении сигналов, укажите тип соединения Qt::ConnectionType::DirectConnection, иначе вас ждёт ошибка из-за попытки доступа к контексту OpenGL из другого потока.

При этом, надо не забыть запретить Qt очищать экран перед рисованием UI — а то все наши труды будут затёрты (setClearBeforeRendering( false )).

Ещё, в afterRendering имеет смысл позвать у QQuickView функцию update. Дело в том, что обычно Qt экономит наше время и деньги, и пока в нём самом ничего не изменилось, перерисовывать UI не будет, и как следствие — не вызовет эти самые before/afterRendering, и мы тоже ничего нарисовать не сможем. Вызов update заставит на следующем же кадре всё нарисовать ещё раз. Если вам хочется ограничить количество кадров в секунду, то тут же можно и поспать.

Ещё кое-что об отрисовке

Нужно помнить про то, что у нас с Qt общий OpenGL контекст. Это значит, что обращаться с ним нужно осторожно. Во-первых, Qt будет с ним сам делать, что хочет. Поэтому когда нам надо будет отрисовать что-то самим (в before или в afterRendering), то во-первых, надо будет этот контекст сделать текущим (m_qt_wnd->openglContext()->makeCurrent( m_qt_wnd )), а во-вторых, установить ему все нужные нам настройки. В Nya engine это делается одним вызовом apply_state(true), но у вас в движке это может быть и сложнее.

Во-вторых, после того, как мы нарисовали своё, надо вернуть контекст в угодное Qt состояние, позвав m_qt_wnd->resetOpenGLState();

Кстати, стоит учесть, что поскольку OpenGL контекст создаёт Qt, а не ваш движок, то надо сделать так, чтобы ваш движок не делал ничего лишнего раньше, чем контекст будет создан. Для этого, можно подписаться на сигнал openglContextCreated, ну, или делать инициализацию в первом вызове beforeRendering.

Взаимодействий с QML

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

Если вы пишите свой код в QtCreator, либо же в другой IDE, к которой каким-то чудом прикручен вызов Qt-шного кодогенератора (MOC), то жизнь ваша будет проста. Достаточно связать между собой слоты и сигналы по именам, и QML будет получать вызовы от C++, и наоборот.

Однако, вы можете захотеть жить без MOCа. Это возможно! Но придётся достать из загашника некоторое количество костылей.

Сюда (QML -> C++)

Qt нынче поддерживает два способа связывать сигналы и слоты — старый, по именам, и новый, по указателям. Так вот, с QML можно связываться только по именам. Это значит, во-первых, что нельзя на сигнал от QML повесить лямбду (хнык-хнык, а я так хотел C++11!), а во-вторых — что придётся иметь объект, в котором объявлен слот, и объект этот должен быть наследником QObject, и внутри себя иметь макрос Q_OBJECT, для кодогенерации. А у нас кодогенерации нету. Что делать? Правильно, брать объекты, у которых все слоты уже объявлены, и поэтому кодогенерация им не нужна.

На самом деле, это вообще очень полезный подход, который, с некоторой вероятностью, вам и так понадобится. Мы будем использовать вспомогательный класс QSignalMapper. У этого класса есть ровно один слот — map(). К нему можно привязать сколько угодно сигналов от сколько угодно объектов. В ответ, QSignalMapper для каждого принятого сигнала породит другой сигнал — mapped(), добавив к нему заранее зарегистрированный ID объекта, породившего сигнал, или даже указатель на него. Как это использовать? Очень просто.

Создаём отдельный QSignalMapper на каждый тип сигналов, которые могут исходить от QML (clicked — для кнопок, и т.п.). Далее, когда нам в C++ надо подписаться на сигнал от объекта в QML, мы связываем этот сигнал с нужным QSignalMapper'ом, а уже его сигнал mapped() связываем со своим классом, или даже лямбдой (на этом уровне C++11 уже работает, ура-ура). На вход нам придёт ID объекта, и по нему-то мы и поймём, что нам с ним делать:

QObject *b1 = m_qt_wnd->rootObject()->findChild<QObject*>( "b1" );
QObject::connect( b1, SIGNAL( clicked() ), &m_clickMapper, SLOT( map() ) );
QObject *b2 = m_qt_wnd->rootObject()->findChild<QObject*>( "b2" );
QObject::connect( b2, SIGNAL( clicked() ), &m_clickMapper, SLOT( map() ) );

m_clickMapper.setMapping( b1, "b1" );
m_clickMapper.setMapping( b2, "b2" );

QObject::connect( &m_clickMapper, static_cast<void(QSignalMapper::*)(const QString&)>(&QSignalMapper::mapped), [this]( const QString &sender ) {
    if ( sender == "b1" )
        m_speed *= 2.0f;
    else if ( sender == "b2" )
        m_speed /= 2.0f;
} );

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

Туда (C++ -> QML)

Тут нас без кодогенерации ждёт засада — связать сигнал из C++ со слотом в QML не получится (точнее, способы есть, но на мой вкус, они слишком сложны). С другой стороны, а зачем?

На деле, у нас есть аж два (ну ОК, полтора) пути. Во-первых, можно напрямую менять свойства QML объектов из C++ кода, вызываю у них setProperty( «propName», value ). То есть, если вам просто нужно проставить новый текст какому-нибудь полю, то можно так. Очевидно, что этот метод взаимодействия достаточно ограничен во всех смыслах, но на самом деле вы себе даже не представляете, на сколько. Дело в том, что попытка потрогать свойства QML объектов из render-треда приведёт к ошибке. То есть, вот из этих самых before/afterRendering ничего трогать нельзя. А вы там уже, небось, игровую логику написали? :) Я — да.

Чего делать? Во-первых, можно завести в основном треде таймер, который будет срабатывать раз в N секунд и обрабатывать игровую логику. А рендер пусть рендерится отдельно. Придётся их как-то синхронизировать, но это решаемый вопрос.

Но если так делать не хочется, то выход есть! Сигналы QML мы посылать не можем, property писать не можем, а вот функции, внезапно, вызывать очень даже можем. Поэтому, если вам нужно повоздействовать на UI, то достаточно в нём объявить функцию, которая ваше воздействие будет осуществлять (скажем, setNewText), а потом позвать её из C++ через invokeMethod:

QVariant a1 = "NEW TEXT";
m_fps_label->metaObject()->invokeMethod( m_fps_label, "setText", Q_ARG(QVariant, a1) );

Важный момент: аргументы при таком вызове могут быть только типа QVariant, и надо использовать этот вот макрос, Q_ARG. Ещё, если метод чего-то может вернуть, то надо будет указать Q_RETURN_ARG( QVariant, referenceToReturnVariable ).

Ресурсы

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

Возникает желание все ресурсы, связанные с UI запихнуть туда же, где лежат остальные ресурсы игры. Тем более, что их не всегда можно чётко разделить — порой одна и та же текстура может использоваться и в 3D сцене, и в UI. При этом, очень хочется, чтобы в QML-файле у нас по прежнему было написано «source: images/button_up.png», чтобы во время разработки, пока ресурсы у нас не упакованы, мы могли бы редактировать UI в Qt Designer, не занимаясь написанием плагинов к нему.

И вот в этот момент нас ждёт жесточайший, и очень обидный облом. Фактически, нам нужно подсунуть Qt свою ресурсную систему под видом файловой. Но поддержку виртуальных файловых систем в виде QAbstractFileEngine в версии 5.x благополучно выпилили «в связи с проблемами с производительностью» (обсуждение). Я не знаю, что и какой пяткой там было написано. Все наши игры прекрасно работают с VFS, сочетающий в себе несколько источников ресурсов, и на производительность не жалуются. Самое обидное, что замены авторы Qt не предложили.

Впрочем, пока что этот класс не выпилили до конца, а лишь «приватизировали», так что если вы любите жить рискованно, то можно использовать его, подключив приватную библиотеку и хидера.

Один костыль авторы оставили — в QMLEngine можно зарегистрировать QQuickImageProvider. С его помощью, вы сможете хотя бы текстуры грузить из вашей системы.

Чтобы QMLEngine использовал ваш QQuickImageProvider, а не лез напрямую в файл, надо указывать путь к изображению в QML-файле не просто «images/button_up.png», а «image:/my_provider/images/button_up.png» (где «my_provider» — имя, с которым вы зарегистрировали ваш наследник QQuickImageProvider в QMLEngine). Очевидно, что если так сделать, то вы тут же перестанете видеть картинки в Qt Designer, который о вашем кастомном провайдере ничего не знает, и знать не хочет.

Нет такого костыля, который нельзя было бы подпереть другим костылём! В QMLEngine можно зарегистрировать ещё одни класс — QQmlAbstractUrlInterceptor. Через этот самый Interceptor проходят все URLы, что грузятся в процессе обработки QML-файла. И тут же их можно подменить на что-нибудь. Что нам и требуется! Как только мы видим, что тип URLа UrlString, а, для надёжности, сам URL содержит текст ".png", то мы сразу же делаем:

QUrl result = path;
QString short_path = result.path().right( result.path().length() - m_base_url.length() );
result.setScheme( "image" );
result.setHost( "my_provider" );
result.setPath( short_path );
return result;

setScheme — это чтобы QML понял, что надо искать подходящий ImageProvider
setHost — имя нашего провайдера
setPath — а вот тут надо уточнить. Дело в том, что в Interceptor URLы приходят уже дополненные base url нашего QMLEngine. По умолчанию, это QDir::currentPath. Нам, очевидно, это совершенно неудобно, вот и приходится отрезать ненужный кусок пути, чтобы вместо какого-нибудь «file:///C:/Work/Test/images/button_up.png» получить, в результате, «image:/my_provider/images/button_up.png».

Ресурсы 2 — ложный след

Дабы повеселить публику, расскажу, как я пытался обмануть Qt, и грузить таки ВСЕ ресурсы из своей системы.

QMLEngine содержит ещё и третий тип классов, которые можно ему установить — это NetworkAccessManagerFactory. Неудобоваримое имя скрывает за собой возможность установить свой собственный обработчик http запросов. А что если, подумал я, мы будем в QQmlAbstractUrlInterceptor запросы к QML файлам подменять на http запросы, а в нашем NetworkAccessManagerFactory (а точнее, в NetworkAccessManager и NetworkReply) на деле открывать файлы из нашей ресурсной системы?

План сработал почти до самого конца :) URLы перехватываются, http-запросы подменяются, даже qml файлы успешно грузятся. Вот только при попытке чтения содержимого служебного файла qmldir с http QQMLTypeLoader делает assert :( И обойти это поведение мне не удалось. А без этого, вся затея бесполезна — мы не сможем импортировать свои QML-модули из нашей ресурсной системы.

Ресурсы Redux

Кстати, у Qt же есть своя собственная ресурсная система! Она позволяет скомпилировать ресурсы в rcc файл, и потом их оттуда использовать. Для этого, глубого в недрах Qt таки сделана своя виртуальная файловая система, которая, если у ресурса указан префикс qrc:/ или даже просто :/, грузит его не с диска, а откуда надо. К сожалению, «откуда надо» — это всё равно не из нашей ресурсной системы.

Есть два способа зарегистрировать источник ресурсов. Оба — вызовы разных перегрузок статической функции QResource::registerResource. Первый принимает на вход имя ресурсного файла на диске. Тут всё понятно — с диска прочитали, и используем. Второй — принимает голый указатель на некую rccData. Документация в этом месте лаконично заявляет, что эта функция регистрирует rccData в качестве ресурса. И дальше ещё мелет какую-то ерунду про файлы. Это — результат неудачной копипасты, кочующей из версии в версию без изменений.

Исследование исходного кода второй перегрузки registerResource показало, что она таки принимает на вход именно содержимое rcc-файла. Почему вместе с указателем не передаётся размер данных? Оказывается — потому, что Qt не хочет ничего проверять, а хочет read-read-read и access violation. В этом месте, библиотека ожидает получить качественные бинарные данные, у которых есть хотя бы заголовок (магические буквы «qres» и данные про размер и другие свойства оставшейся части блока памяти). До того момента, как будет прочитан валидный заголовок, Qt будет жизнерадостно читать любую память, которую вы ей подсунете. Не очень надёжно, но ладно.

Казалось бы, этот вариант нам подходит — можно прочитать rcc-файл из нашей ресурсной системы, засунуть его в QResource, и далее без проблем использовать все ресурсы с префиксом qrc:/. Отчасти, это так. Но помните, что прежде, чем регистрировать данные в ресурсной системе, вам придётся их полностью загрузить в память. Поэтому запихнуть в один rcc все UI-текстуры — скорее всего, плохая идея. Придётся либо готовить отдельный набор для каждого экрана, либо, например, положить в rcc только QML-файлы, а картинки грузить из своей ресурсной системы описанным выше методом через Interceptor+ImageProvider.

Подготовка к релизу

Если вы думаете, что после того, как вы побороли все программные проблемы Qt, написали свой код, нарисовали красивый UI и упаковали ресурсы, у вас всё готово к релизу — то это не совсем так.

Дело в том, что Qt — это много-много DLLей и QML-модулей. Для того, чтобы распространять вашу программу, всё это добро придётся таскать с собой. Но чтобы его таскать, его сначала найти надо, а оно попрятано по уголкам огромной установочной директории Qt. Qt Creator сам всё найдёт и положит куда надо, а вот если мы по прежнему пользуемся другой IDE… Руками вырезать все нужные DLL и прочие файлы — занятие сложное и нудное, а главное — легко допустить ошибку.

Здесь авторы Qt пошли навстречу простым программистам, и предоставили инструменты, такие как windeployqt и androiddeployqt. Под каждую платформу, такой инструмент свой, со своими ключами и ведёт себя по разному. Например, windeployqt принимает на вход путь к вашему главному исполняемому файлу и к директории с вашими QML-файлами, а на выходе — просто копирует все нужный DLL и прочая в указанное место. Дальше сами-сами-сами.

А вот androiddeployqt — это тот ещё комбайн, занимающийся и сборкой APK-пакета, и ещё чёрт знает чем. На iOS ситуация схожая.

Выводы

Итак, можно ли использовать QtQuick/QML для создания UI в играх? Мой короткий опыт интеграции и использования этой библиотеки показал, что в принципе можно. Но многое зависит от конкретных целей и ограничений.

Скажем, если вы готовы для разработки использовать QtCreator — значительная часть мелких неудобств автоматически пропадает, но если вам, по каким-то причинам, хочется остаться с любимым Visual Studio, XCode или vi, то надо готовится к некоторой боли.

Если вы разрабатываете игру под PC, или это очень крупный мобильный проект с сотнями мегабайтов ресурсов (встречаются ведь и такие), то 25-40Мб библиотек для вас не являются проблемой. Если же вы пишите очередную казуалку под Android, да ещё с прицелом на китайский или иранский рынки, с их рекомендованными 50Мб на приложение, то стоит три раза подумать, прежде, чем занимать большую их часть этой не слишком полезной нагрузкой.

Однако, если вам отчаянно не хочется писать свою UI библиотеку, то QtQuick/QML, как мне кажется, выигрывает у конкурентов по производительности, если не по размерам и не по удобству использования.

Интеграция Qt в проект не слишком сложна, но зато может вынудить изменить логику основного цикла и инициализации. В новом проекте это почти наверняка можно пережить, а вот сменить быстро UI с другого на QtQuick/QML вряд ли получится без долгих страданий.

Документация Qt достаточно неплоха, но местами врёт или неполна. В этих случаях, придётся лезть в исходный код — и очень хорошо, что он полностью открытый! Объёмы его солидны, но на деле разобраться в том, как что-нибудь загружается или работает вполне можно.

Ещё одним минусом, по сравнению с Scaleform и Coherent, является то, что Scaleform позволяет создавать интерфейсы дизайнерам в привычных программах Adobe, а Coherent — нанять для разработки UI спеца по HTML. Разработка UI на QML потребует совместной работы программиста и дизайнера. Впрочем, в конце концов, всё равно к этому приходит, когда начинаются проблемы с производительностью и поведением UI внутри игры.

В общем, решать, как обычно, придётся вам самим!

Код примера интеграци Qt с Nya engine вы можете взять на GitHub MaxSavenkov/nya_qt.

Автор: MaxEdZX

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js