Protos, дневник #3: вскрытие GASP, паркур без разметки и коридор для честных
В прошлой серии персонаж отыгрывал шаги разбега в воздух, а я обещал с этим разобраться. Разобрался радикально: вскрыл систему траверсала Epic до винтика, узнал, что половина моих представлений о ней - мифы, и переписал свою с нуля. Теперь у Protos паркур, которому не нужна разметка уровня. Вообще.
Вскрытие GASP
Тупик из прошлого дневника: монтажи умирали через полсекунды, персонажа «поднимало механикой», и все мои заплатки лечили симптомы. В какой-то момент я запустил демку Game Animation Sample, посмотрел, как их персонаж летает по их полосе препятствий, и признал очевидное: у них система работает, у меня нет, и надо перестать гадать - надо читать.
Что там оказалось на самом деле:
1. Они не трейсят лучами. Вообще. Четыре капсульных свипа размером с капсулу персонажа: вперёд до препятствия, от себя вверх к кромке, по верху препятствия, и вниз за ним. Философская разница: я проверял препятствие, они проверяют - пролезет ли ТЕЛО. Если телу негде подняться, действие просто не предлагается. У меня room-check'ов не было ни одного - отсюда и «затягивания» в стены.
2. Кромки у них размечены вручную. На каждом препятствии их уровня лежит невидимый актор со сплайнами: художник буквально нарисовал, где кромка. Отсюда их точность: FindLedgeClosestToActor проецирует персонажа на сплайн и зажимает результат с отступом от углов. Красиво. И совершенно не масштабируется на реальный уровень: размечать лес я не буду.
3. PlayRate - миф. Я был уверен, что их плавность - это умное масштабирование скорости анимации. Прочитал весь граф: поле есть, везде передаётся... и всегда равно 1.0. Ни одного вычисления. Вся плавность - это позовый вход (StartTime подбирается матчингом текущей позы прямо в кадры монтажа), контактные старты и варпы. Хорошая новость: за несуществующей формулой можно не гнаться.
4. Они не выключают свою коллизию. На время действия капсула игнорирует ТОЛЬКО преодолеваемый объект, весь остальной мир остаётся твёрдым. Изящно. Забегая вперёд: эта изящность мне ещё аукнется.
Прозрение и переписывание
Главная мысль недели пришла не за компом: я всё это время пытался перестроить свою систему под их эталон, а надо наоборот - взять их пайплайн (он правильный) и заменить в нём один слой: данные. Вместо сплайнов, нарисованных художником, - кромка, синтезированная трейсами в рантайме.
Выкинул старую детекцию целиком (1400 строк ужались до 420), завёл отдельный компонент - и собрал их конвейер на C++, шаг в шаг по их же комментариям, только вместо «спроси у сплайна» везде «измерь сам»:
- капсульный свип вперёд (дальность растёт со скоростью);
- кромка двумя трейсами: вертикаль об стену + спуск сверху;
- синтез кромки-линии: от найденной точки короткие пробы вбок вдоль кромки - и вот у меня сегмент, мой «виртуальный сплайн», с теми же проекциями и отступами от углов, что у Epic, только его никто не рисовал. Бежишь к забору под углом - точка хвата встаёт напротив тебя и скользит вдоль кромки;
- room-check подъёма в два колена: вертикаль, потом горизонталь. Сначала сделал одной диагональю - и неделю не мог понять, почему в упор к стене персонаж её «не видит»: диагональный свип чиркал по верхнему углу препятствия. Мантл движется вверх-потом-вперёд, и проверка обязана двигаться так же;
- глубина препятствия - обратным трейсом: встаю лучом в двух метрах ЗА препятствием и стреляю к себе, ловлю заднюю грань, которую спереди не видит ни один луч. Бонус: если стена толще двух метров, старт луча оказывается у неё внутри, а трейсы изнутри геометрии не срабатывают - «нет хита» честно означает «это толстяк, тебе на него мантлиться». Баг по построению стал фичей;
- задний пол, паспорт для чузера - и дальше родной GASP-выбор монтажа с позовым матчингом, как у них.
Результат на видео выше: те же GASP-анимации, но кромки система находит сама - на вайтбоксах, на подоконниках, на скалах Electric Dreams. Ноль размеченных объектов на уровне.
Коридор, латч и другие взрослые слова
Помните «изящное» игнорирование объекта? Вот как оно взорвалось: подоконник моего дома - это часть меша всего дома. Игнорируешь «объект» - становишься призраком для всего здания. Влетаешь на скорости в окно, персонаж решает сделать вольт - и уносится сквозь три комнаты, после чего варп рывком возвращает его назад. Незабываемо.
Лечение вышло в два слоя, и оба мне нравятся больше исходника:
Коридор действия. В момент старта система вычисляет, как далеко за кромку вообще можно уехать, из того, что реально разведал скан: есть пол за препятствием - до пола плюс чуть-чуть; вольт в пустоту - глубина плюс запас на перелёт; мантл на толстое - полметра и стой. Каждый кадр лишнее вторжение мягко выталкивается назад. Принцип: действие не имеет прав на неразведанную территорию. Никакой рут-моушен, никакая инерция этот инвариант не пробьют.
Высотный латч. Игнор коллизии теперь живёт ровно до момента, когда ноги поднялись выше кромки - дальше мир снова твёрдый. Фаза подъёма проходит сквозь угол (иначе застревание), а вот комнаты за окном снова встречают тебя честными стенами.
Плюс мелочи, из которых складывается ощущение: чейнинг (держишь пробел - персонаж считает путь каждый кадр и лепит вольт-мантл-вольт очередью с кулдауном 0.15), прыжок-фолбэк (если таблица анимаций не нашла вариант, персонаж не стоит столбом, а прыгает - и следующий кадр пробует уже другую геометрию), заглушка для вентиляций (в низкий проём заползает без анимации и сам садится - движок не даёт встать под низким потолком, ни строчки кода), и камера наконец перестала подсматривать сквозь стены (сфера-проба от оси тела к точке глаз).
Рубрика «компилируется и врёт»
Систему я набирал руками, и коллекция моих багов пополнилась экземплярами, которые компилятор пропускает молча:
- две одинаковые петли проб влево-вправо, в обеих «+»: копипаста цикла со сменой приёмника, но не знака. Кромка синтезировалась зеркально криво;
- Dist2D(точка, та же точка) - глубина любого забора равнялась нулю;
- жемчужина: A = B.Z = Hit.ImpactNormal.Z вместо A = B.Z - Hit.ImpactPoint.Z. Цепочка присваиваний - легальный C++: высота задней кромки любого препятствия стала «единицей», потому что Z нормали пола примерно 1.0. Ошибки нет. Есть враньё;
- и вечное: GetSafeNormal() там, где нужен GetSafeNormal2D() - переменная называется WallNormal2D, а внутри полная 3D-нормаль. Имя обещает, код не выполняет.
Мораль прежняя, но теперь с примерами: самые дорогие баги - не те, что роняют сборку, а те, что тихо возвращают правдоподобные числа.
Что дальше
Полировка выходов из монтажей (перехватчик GASP до сих пор режет хвосты - разбираюсь, где это штатно, а где нет), кроул-анимации для вентиляций вместо заглушки, и полоса препятствий в лесу - уже не для теста, а для съёмки. Вопрос про доворот тела за камерой из прошлого дневника всё ещё открыт, решения из комментариев собираю.
Спасибо, что читаете. Похвалить и поругать можно в комментариях - за конкретику по багам отдельная благодарность.