Получится ли сделать полноценную 3D-игру на Qt Quick3D?
Именно такой вопрос у меня возник, когда я начал изучать Quick3D. Казалось бы, рендер и партиклы есть, базовая физика в лице Quick3D Physics тоже присутствует. Пример CharacterController из Qt указывал на то, что проблем быть не должно.
Но хотелось проверить это самому на чём-то реальном.
Поскольку моделлер и художник из меня никакой, да и в геймдеве опыта у меня меньше нуля, я решил переписать Quake — любимую игру своего детства. В ней я провёл сотни (тысячи?) часов, играя в мультиплеер на бесплатных серверах МТУ-Информ через модем US Robotics 33600.
В итоге получился проект Kwayk — попытка переписать Quake на Quick3D.
q1pak
Первые шаги были простыми: формат архива PAK в Quake устроен прямолинейно. По сути, это обычный архив со списком файлов и смещениями, без какой-то сложной магии.
Я написал утилиту q1pak: она распаковывает архив и вытаскивает модели (MDL для монстров и оружия), карты (BSP) и текстуры. По мере того как игра обрастала возможностями, я дописывал функционал, в основном вокруг работы с геометрией.
Главная задача здесь — перевести геометрию BSP в родной для Quick3D формат *.mesh. Сначала конвертер парсит BSP, собирает сцену и через Assimp экспортирует её в промежуточный формат Collada (DAE).
И при этом надо учесть, что оси в Quake и Quick3D направлены по-разному:

Затем с помощью Qt-шной утилиты balsam файлы DAE преобразуются в бинарные *.mesh и генерируется QML-файл, где меши подключаются через компоненты Model {source: ...}.
Модели формата MDL я решил оставить «как есть»: они содержат все необходимые кадры анимации в одном файле. Когда дело дойдёт до анимации монстров и оружия, у меня не будет проблем с их движениями — всё уже упаковано внутри.
Почему Jolt Physics?
Экспериментировать я начал с картой Quake — start.bsp. Когда она успешно загрузилась и удалось по ней «полетать», пришло время попробовать ходьбу. Основываясь на уже упомянутом примере CharacterController, я добавил объект CharacterController и настроил капсулу игрока так, чтобы она соответствовала размерам bbox персонажа из Quake.
И вот оно — Quake на Quick3D был готов на 99%. Я мог бегать по карте, перепрыгивать через лаву (вернее, там, где она будет), подниматься и спускаться по ступенькам. И всё это буквально в несколько строк кода из Qt-примера. Дальше всё шло как по маслу. Мне не терпелось увидеть, как стреляет дробовик, вылетают ракеты из ракетницы, а гвозди из гвоздомёта.
Неожиданно вылез главный затык разработки: ракета должна взрываться при столкновении со стеной. Но иногда она взрывалась, а иногда спокойно пролетала сквозь неё. Решилось это довольно просто — включением enableCCD: true в PhysicsWorld. Continuous Collision Detection снижает шанс того, что быстрое тело «проскочит» коллизию за кадр. И вроде всё ок… пока я не добавил телепорты через TriggerBody.
С включённым CCD игрок начал телепортироваться с какой-то странной вероятностью, примерно 50%. А с выключенным CCD ракеты «рандомно» пропускали столкновения. Я оформил багрепорт в Qt bugtracker по этой проблеме. В итоге со стороны Qt решение оказалось весьма «элегантным»: добавить в документацию предупреждение:
“Warning: Using trigger bodies with CCD enabled is not supported and can result in missing or false trigger reports”.
И тут я подумал: «Ну всё. Кина не будет. Электричество кончилось!»
Открыл исходники Quick3D Physics, с грустью посмотрел на весь этот C++ код… под капотом у них PhysX. Пошёл гуглить альтернативы и выяснил, что сейчас самая горячая тема в геймдеве — это Jolt Physics.
Плагин Quick3D Jolt Physics
Первой мыслью было взять плагин Quick3D Physics и заменить часть с PhysX на Jolt. Второй мыслью — что я, скорее всего, нарвусь на какие-нибудь ограничения или архитектурные нестыковки. В итоге решил взять только структуру проекта из Quick3D Physics, а основу написать уже ближе к Jolt. Третьей мысли, к счастью, не возникло, и я приступил к реализации. Расписывать документацию Jolt смысла нет, поэтому опишу здесь только основные моменты (якобы я в этом разбираюсь).
Как и любой физический движок, Jolt предоставляет тела с разными формами, которые могут быть трёх типов:
-
Static (Объект, который не двигается: карта, триггеры и т.д.)
-
Dynamic (Движущийся объект с массой и скоростью: ракеты, гвозди, lavaball и т.д.)
-
Kinematic (Движущийся объект с бесконечной массой: двери, платформы, кнопки и т.д.)
Пример того, как это выглядит в QML:
Body { position: Qt.vector3d(-100, 100, 0) shape: BoxShape { extents: Qt.vector3d(100, 100, 100) } objectLayer: moving motionType: Body.Dynamic restitution: 0.5 Model { source: "#Cube" materials: PrincipledMaterial { baseColor: "yellow" } }}
Для управления игроком используется класс CharacterVirtual (есть ещё более простой класс Character, который рекомендуется для простых AI-существ). В Kwayk я использую CharacterVirtual и для игрока, и для монстров, чтобы они могли корректно подниматься и спускаться по ступенькам.
В Quick3D Jolt Physics я добавил Collision Filtering: если два объектных слоя не должны пересекаться, тела на них не получают контактов. Для Kwayk я определил такие типы ObjectLayer:
-
Solid — Главная статика уровня: BSP (стены, пол, платформы и т.д.).
-
NonSolid — Объёмы без контактной физики (вода, слизь, лава).
-
Walker — Игрок (капсула CharacterVirtual).
-
Monster — Монстры.
-
Toss — Ракеты, гвозди, гранаты, ошмётки, lavaball и т.д.
-
Item — Подбираемые предметы.
-
Pushed — Объект, который двигает платформа.
-
Dead — Труп монстра.
-
Sensor — Триггеры (телепорты, зоны дверей и платформ).
-
Normal — Слой для трассировки объектов с учётом монстров, которые находятся на пути луча.
-
NoMonsters — То же самое, что Normal, но монстров трассировка пропускает.
Логика столкновений слоёв настраивается через переопределение виртуальной функции shouldCollide плагина Quick3D Jolt Physics. В Kwayk это определено таким образом:
-
Walker vs Solid, Monster vs Solid — Игрок и монстры ходят по миру и упираются в геометрию.
-
Walker vs Walker, Walker vs Monster, Monster vs Walker, Monster vs Monster — Взаимодействие игрока и монстров друг с другом и столкновения монстров между собой.
-
Walker vs Sensor, Monster vs Sensor — Существа активируют триггеры.
-
Toss vs Solid, Toss vs Walker, Toss vs Monster, Toss vs Dead — Снаряды и ошмётки сталкиваются с окружением, игроком и монстрами (включая трупы).
-
Item vs Solid — Предметы лежат на полу и не проваливаются сквозь статику.
-
Normal vs Solid, Normal vs Walker, Normal vs Monster, Normal vs Dead — Лучи «видят» статику и монстров, включая трупы.
-
NoMonsters vs Solid, NoMonsters vs Walker — Лучи «видят» игрока и статику, но не монстров.
Слои Normal и NoMonsters выделены специально для трассировки: когда нужно учитывать монстров на пути луча, используется тип Normal, в противном случае — NoMonsters.
Lightmaps и мигающие лампочки
В общем, теперь игрок бегает, прыгает, ракеты сталкиваются о стены и взрываются, телепорты работают как надо. Но Kwayk не Quake без lightmaps и мигающих лампочек.
Сами лайтмапы в Quake не считаются в рантайме: запечённый свет хранится в файле BSP-карты в секции освещения. При конвертации q1pak читает эти семплы для каждой грани и упаковывает их в одну атласную текстуру размером 1024×1024 (с тем же принципом выделения блоков, что в классическом движке), после чего сохраняет её как lightmap.png. В сгенерированном меше у вершины два UV: по UV0 берётся сэмпл из этого атласа, по UV1 — лицевая текстура стены.
В формате BSP-карты Quake для каждой грани может быть до четырёх «стилей» освещения (style) — это индексы в таблице яркости. Чтобы прокинуть их в шейдер Quick3D, я не стал изобретать велосипед: при парсинге карты в утилите q1pak я сохраняю эти индексы прямо в атрибут COLOR каждой вершины.
В самом шейдере (я написал свой CustomMaterial) я просто достаю их оттуда и использую для смешивания каналов лайтмапы. Весь прикол в том, что свет читается по четырём каналам сразу. Каждый канал — это отдельный «слой» освещения на пиксель, который умножается на свой коэффициент яркости. В итоге получаем тот же трюк, что и в 1996-м: статичная карта плюс анимированная яркость. В шейдере это выглядит так:
// brush.vert — индексы стилей хранятся в COLORVARYING vec4 lightStyle;void MAIN() { lightStyle = COLOR; // Те самые индексы, запеченные в q1pak POSITION = MODELVIEWPROJECTION_MATRIX * vec4(VERTEX, 1.0f);}// brush.frag — магия смешиванияVARYING vec4 lightStyle;void MAIN(){ vec4 baseColor1 = texture(colorTex1, UV1); float blocklight = 1.0; if (useLightmap) { vec4 lightmapColor = texture(lightmapColorTex, UV0); vec4 style0 = texture(lightStyleColorTex, vec2(lightStyle.r * 255.0 / 64.0 + 1.0 / 128.0, 0.0)); vec4 style1 = texture(lightStyleColorTex, vec2(lightStyle.g * 255.0 / 64.0 + 1.0 / 128.0, 0.0)); vec4 style2 = texture(lightStyleColorTex, vec2(lightStyle.b * 255.0 / 64.0 + 1.0 / 128.0, 0.0)); vec4 style3 = texture(lightStyleColorTex, vec2(lightStyle.a * 255.0 / 64.0 + 1.0 / 128.0, 0.0)); float scale0 = style0.r * 255.0 + style0.g * 255.0 * 256.0; float scale1 = style1.r * 255.0 + style1.g * 255.0 * 256.0; float scale2 = style2.r * 255.0 + style2.g * 255.0 * 256.0; float scale3 = style3.r * 255.0 + style3.g * 255.0 * 256.0; blocklight = (lightmapColor.r * scale0 + lightmapColor.g * scale1 + lightmapColor.b * scale2 + lightmapColor.a * scale3) / 128.0; } vec3 color = baseColor1.rgb * blocklight; if (useFullbright1) color += texture(fullbrightColorTex1, UV1).rgb; // ... BASE_COLOR = vec4(color, 1.0);}
Сами коэффициенты для мигания хранятся в текстуре-полоске. В оригинале каждый тип освещения (мерцание, пульсация и т. д.) задается строкой-последовательностью, где каждый символ — это шаг яркости. Например, знаменитая «mmnmmommommnonmmonqnmmo». Шейдер подхватывает текущее значение, и лампочки мигают ровно так же, как в оригинале.
Вода, лава, слизь
Следующим встал вопрос — как сделать, чтобы Kwayk мог определять, находится ли игрок в воде, лаве или слизи. В оригинальном Quake в BSP-файле хранится не только геометрия карты, но и вспомогательные оболочки для расчёта столкновений: hull0, hull1 и hull2. Они представлены в виде набора плоскостей. Если hull0 в точности повторяет геометрию карты, то в остальных оболочках стены и полы заранее «выдвинуты» навстречу объектам. Это позволяет движку Quake проверять столкновение не всего объёма существа, а одной его центральной точки. Выбор оболочки зависит от размера объекта: для ракет, гвоздей и трассировки луча используется «точный» hull0, для игрока или солдата — hull1, а для крупных монстров (Ogre, Shambler) — hull2.
Идея была такая: взять плоскости из hull0, перебирать их тройками и находить точки их пересечения. Эти точки будут потенциальными вершинами объёма, из которых собирается замкнутая геометрия жидкости. Тогда я смогу с помощью Jolt определить, находится ли центр капсулы игрока внутри многоугольника жидкости. Математически это решается поиском пересечения трёх плоскостей.
bool intersect_3(const aiPlane &plane0, const aiPlane &plane1, const aiPlane &plane2, aiVector3D *result){ aiVector3D normal0 = aiVector3D(plane0.a, plane0.b, plane0.c); aiVector3D normal1 = aiVector3D(plane1.a, plane1.b, plane1.c); aiVector3D normal2 = aiVector3D(plane2.a, plane2.b, plane2.c); aiVector3D cross; aiVector3CrossProduct(&cross, &normal0, &normal1); float denom = aiVector3DotProduct(&cross, &normal2); if (qFuzzyIsNull(denom)) return false; if (result) { aiVector3D cross1; aiVector3CrossProduct(&cross1, &normal1, &normal2); aiVector3D cross2; aiVector3CrossProduct(&cross2, &normal2, &normal0); aiVector3D cross3; aiVector3CrossProduct(&cross3, &normal0, &normal1); *result = ((cross1 * plane0.d) + (cross2 * plane1.d) + (cross3 * plane2.d)) / denom; } return true;}
Полученные геометрии также экспортируются в формат *.mesh. На поздних этапах разработки я экспортировал hull0 не только для жидкостей, но и для всей геометрии карты. Оказалось, что игрок плавнее двигается по такому мешу, а монстры не залипают на стыках полигонов.
AI
Пришло время поговорить про AI, Claude и вайбкодинг? Нет. А вот про AI монстров — да.
Вся игровая логика в Quake, включая искусственный интеллект монстров, написана на языке QuakeC. Это такой JavaScript на минималках (или Python на стероидах?). Впрочем, не время устраивать холивары.
Перенести логику с QuakeC на QML не составило особых проблем, код портирован практически один к одному. Я его слегка облагородил, внес иерархию и учёл, что оси в Quake и Quick3D направлены по-разному. Как я уже говорил, и игрок, и монстры используют CharacterVirtual, поэтому я ввёл достаточно логичную иерархию:
AI монстров в Quake весьма примитивен: они просто бегут на игрока, время от времени постреливая в него, а иногда и друг в друга. Чтобы они не падали с обрыва, я использовал функции Jolt для проверки опорных точек по углам и по центру bbox монстра: если под новой позицией нет опоры или препятствие слишком высокое, шаг не принимается, а монстр пытается найти другое направление.
Анимация в моделях (MDL) рассчитана на скорость воспроизведения 10 кадров в секунду, поэтому монстры при беге дёргаются как паралитики. И тут я вспомнил про замечательную книжку, которую зачитывал до дыр ещё в студенчестве: Андре Ламот «Программирование трехмерных игр для Windows». Там автор создает свой 3D-движок с софтверным рендером, и в одной из глав как раз было про анимацию моделей из Quake 2. Чтобы анимация была плавной, нужно добавить интерполяцию вершин/кадров.
Для каждой вершины следующее положение берётся как линейное смешивание двух соседних дискретных поз из MDL — предыдущей и текущей:
V = V₀ · (1 − α) + V₁ · α
Где α за 100 мс между двумя ключевыми положениями анимации плавно идёт от 0 до 1.
Хотя в демке Kwayk показана небольшая часть «зоопарка», вообще я реализовал логику на QML для всех существ из оригинального Quake.
Декали
Во всех современных играх на стенах должны появляться дырки от пуль, пятна крови или копоть от взрывов. Делается это с помощью декалей — небольших изображений (текстур), которые «накладываются» поверх уже существующих 3D-объектов. Я решил, что и в Kwayk надо сделать такой эффект. Только вот в отличие от «больших» движков, где декали уже реализованы, в Quick3D пришлось собрать свой велосипед. Зато он идеально подружился с Jolt Physics.
Технология стандартная: при столкновении снаряда или луча трассировки со статикой мы получаем точку контакта и нормаль. В этом месте я создаю невидимую «коробку», а дальше прошу Jolt (функция getTriangles) выгрузить все треугольники карты, которые попали в этот объём. Полученные треугольники я режу шестью плоскостями этой коробки, чтобы декаль не вылезала за границы. Отсечение выполняется по классическому алгоритму Сазерленда–Ходжмена.
У нас получается набор новых полигонов, которые лежат строго на поверхности стены, но ограничены размерами коробки. Из них я на лету собираю новый меш и накладываю на него текстуру декали. Чтобы это работало, я проецирую текстурные координаты (UV) прямо по координатам коробки: центр попадания — это центр текстуры, а края коробки — её границы.
Забавно было, когда я не учёл, что декали могут располагаться и на подвижных объектах — тех же дверях. В итоге дырки от пуль и кровь просто висели в воздухе, когда дверь открывалась. Решилось всё логично: теперь я делаю декаль дочерним объектом к тому телу, на котором она рисуется.
Такой подход работает отлично, хотя без нюансов всё же не обошлось. Проблема появляется в момент, когда декаль попадает на грань или острый угол объекта. Поскольку текстура проецируется вдоль определённого направления, поверхности, расположенные почти параллельно проекции, начинают получать некорректные UV-координаты. В результате текстура растягивается.
В Kwayk я решил эту проблему довольно просто: такие области скрываются с помощью fade-фактора. В итоге артефакты на углах становятся практически незаметны, а сама декаль выглядит аккуратнее.
Звуки
Для звуков я использовал модуль Qt Quick3D Spatial Audio. Библиотека простая и удобная: к камере игрока ставится объект AudioListener, а к источнику звука — SpatialSound. Когда игрок подходит ближе — звук громче, когда отходит — тише, а при поворотах звук перетекает из одной колонки в другую.
Впрочем, как и ожидалось, Quick3D Spatial Audio на WebAssembly работать отказался. Поэтому мне пришлось написать на C++/Emscripten свою обёртку над Web Audio API. Я старался сделать её максимально близкой к структуре оригинального модуля.
В итоге всё работает как надо: WAV-файлы проигрываются и на десктопе, и в вебе одинаково.
Всякое разное
Если в Quake Enhanced есть Weapon Wheel, то и в Kwayk тем более:
А при телепортации пространство должно искажаться как в Матрице:
В оригинальном Quake партиклы квадратные, а я решил выделиться и сделал их кубическими:
Заключение
Не хочу слишком сильно хлопать себя по плечам, но результат, на мой взгляд, получился весьма достойным. Конечно, Quick3D — это не совсем про геймдев, но кто знает — может быть, со временем и он займёт свою нишу?
А я, в свою очередь, надеюсь, что Kwayk будет полезен новичкам в 3D и поможет им, как помог мне, лучше разобраться с программированием на QML и Quick3D.
История Kwayk на этом не заканчивается. Предстоит ещё внедрить мультиплеер, да и Quick3D Jolt Physics требует изрядных доработок и улучшений.
ссылка на оригинал статьи https://habr.com/ru/articles/1037418/