惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Apple Machine Learning Research
Apple Machine Learning Research
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
S
Security @ Cisco Blogs
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Cisco Talos Blog
Cisco Talos Blog
Cyberwarzone
Cyberwarzone
SecWiki News
SecWiki News
Webroot Blog
Webroot Blog
L
LINUX DO - 最新话题
V
Vulnerabilities – Threatpost
T
Troy Hunt's Blog
Cloudbric
Cloudbric
L
LINUX DO - 热门话题
Google DeepMind News
Google DeepMind News
H
Heimdal Security Blog
S
Schneier on Security
NISL@THU
NISL@THU
The Hacker News
The Hacker News
Attack and Defense Labs
Attack and Defense Labs
A
Arctic Wolf
V2EX - 技术
V2EX - 技术
Security Latest
Security Latest
AWS News Blog
AWS News Blog
Scott Helme
Scott Helme
W
WeLiveSecurity
S
Secure Thoughts
Y
Y Combinator Blog
GbyAI
GbyAI
H
Hackread – Cybersecurity News, Data Breaches, AI and More
博客园 - Franky
量子位
人人都是产品经理
人人都是产品经理
雷峰网
雷峰网
K
Kaspersky official blog
P
Privacy & Cybersecurity Law Blog
T
Tenable Blog
The GitHub Blog
The GitHub Blog
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
J
Java Code Geeks
Vercel News
Vercel News
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Schneier on Security
Schneier on Security
云风的 BLOG
云风的 BLOG
小众软件
小众软件
Engineering at Meta
Engineering at Meta
宝玉的分享
宝玉的分享
C
CERT Recently Published Vulnerability Notes
Security Archives - TechRepublic
Security Archives - TechRepublic
C
CXSECURITY Database RSS Feed - CXSecurity.com
P
Palo Alto Networks Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Kwayk: как я сделал Quake на Qt Quick3D и прикрутил физику из Death Stranding 2
glazunov999 · 2026-05-20 · via Все публикации подряд на Хабре

Время на прочтение10 мин

Охват и читатели49

Босс на 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 — индексы стилей хранятся в COLOR
VARYING 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 требует изрядных доработок и улучшений.