Kwayk: как я сделал Quake на Qt Quick3D и прикрутил физику из Death Stranding 2

от автора

Босс на e1m7

Босс на e1m7

Получится ли сделать полноценную 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?

Экспериментировать я начал с картой Quakestart.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, но на Jolt Physics

Пример из Quick3D Physics, но на 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 не только для жидкостей, но и для всей геометрии карты. Оказалось, что игрок плавнее двигается по такому мешу, а монстры не залипают на стыках полигонов.

Jolt позволяет обнаружить жидкость

Jolt позволяет обнаружить жидкость

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-фактора. В итоге артефакты на углах становятся практически незаметны, а сама декаль выглядит аккуратнее.

Декаль с fade-фактором и без него

Декаль с fade-фактором и без него

Звуки

Для звуков я использовал модуль Qt Quick3D Spatial Audio. Библиотека простая и удобная: к камере игрока ставится объект AudioListener, а к источнику звука — SpatialSound. Когда игрок подходит ближе — звук громче, когда отходит — тише, а при поворотах звук перетекает из одной колонки в другую.

Впрочем, как и ожидалось, Quick3D Spatial Audio на WebAssembly работать отказался. Поэтому мне пришлось написать на C++/Emscripten свою обёртку над Web Audio API. Я старался сделать её максимально близкой к структуре оригинального модуля.

В итоге всё работает как надо: WAV-файлы проигрываются и на десктопе, и в вебе одинаково.

Всякое разное

Если в Quake Enhanced есть Weapon Wheel, то и в Kwayk тем более:

Weapon wheel

Weapon wheel

А при телепортации пространство должно искажаться как в Матрице:

Эффект Матрицы

Эффект Матрицы

В оригинальном Quake партиклы квадратные, а я решил выделиться и сделал их кубическими:

Кубические партиклы

Кубические партиклы

Заключение

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

А я, в свою очередь, надеюсь, что Kwayk будет полезен новичкам в 3D и поможет им, как помог мне, лучше разобраться с программированием на QML и Quick3D.

История Kwayk на этом не заканчивается. Предстоит ещё внедрить мультиплеер, да и Quick3D Jolt Physics требует изрядных доработок и улучшений.

ссылка на оригинал статьи https://habr.com/ru/articles/1037418/