QtQuick/QML в якості ігрового UI

В іграх зі складним 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 (в разжатом вигляді — АПК буде помітно легше), але для мобільної платформи це просто ДУЖЕ багато! 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++, і навпаки.

Однак, ви можете захотіти жити без МОСа. Це можливо! Але доведеться дістати зі схованки деяку кількість милиць.

Сюди (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.0 f;
else if ( sender == "b2" )
m_speed /= 2.0 f;
} );


У коді тестового проекту, посилання на якій ви знайдете в кінці статті, є приклад огортання цього механізму для трохи більш зручного використання.

Туди (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.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.