Почему контроллер персонажа кажется скользким в игре
Когда игроки говорят, что персонаж скользит, они описывают четыре разные проблемы: торможение, определение земли, работу с уклонами и интерполяцию.
Что на самом деле значит «персонаж скользит»
В прошлом году я провёл плейтест на шесть человек по прототипу от третьего лица. Все шестеро сказали одно и то же: персонаж как будто скользит. Никто не смог добавить деталей, и первые два дня я искал не там — крутил сглаживание камеры и время блендинга анимаций.
Проблема проявилась, когда я покадрово разобрал запись на 240 FPS. После того как игрок отпускал стик, персонаж проезжал ещё 11 кадров — примерно 180 мс при 60 Гц. На рампе горизонтальная скорость сохранялась, но меняющаяся нормаль поверхности подталкивала тело вперёд. На ступеньке в 20 см capsule цеплялась и на мгновение застревала.
То есть «скольжение» — это не один баг. Кривая замедления, проверка земли, работа с уклонами и синхронизация физики с рендером были сломаны по отдельности, и все четыре вылезали как одна и та же жалоба. В этой статье я разбираю, как чинил их по очереди.
Rigidbody или кинематическое движение?
Первая версия была классической связкой Rigidbody и AddForce. На бумаге всё правильно: движок симулирует, ты прикладываешь силу. На практике, если обнулить трение physics material, персонаж съезжает с любого склона, а если трение включить, скорость падает при каждом касании стены. Хорошего значения посередине нет, потому что один коэффициент обслуживает и пол, и стены.
В итоге я пришёл к кинематическому движению. Rigidbody остался, но с включённым isKinematic; скорость я интегрирую сам и на каждом шаге физики делаю свип через capsule cast. Пробовал и CharacterController из Unity: он не даёт ничего, кроме step offset и slope limit, поведение депенетрации не посмотреть, а на спуске с рампы он принимает решения за тебя.
Правило получилось простое. Запросы столкновений — дело физического движка, решения о скорости — моё. Класс CharacterMotor занимает около 300 строк, и все числа, связанные с движением, лежат в нём, в одном месте. Плата за это — писать вручную отбрасывание и перенос на движущихся платформах. Время, которое выигрываешь при настройке ощущений, несопоставимо больше.
Самое заметное, чем приходится жертвовать, — взаимодействие с динамическими телами. Персонаж больше не толкает ящики сам по себе, потому что движок перестаёт видеть в нём массу. Когда свип попадает в динамический Rigidbody, я прикладываю к нему импульс, отмасштабированный по относительной скорости и соотношению масс; эти пятнадцать строк вернули ту часть потерянного поведения, которую игрок действительно замечает.
Определение земли и уклоны через capsule cast
Один луч вниз разваливается на краях. Когда центр персонажа выходит за край платформы на 3-4 см, луч проходит мимо, тело на кадр считается в воздухе и на следующем кадре снова оказывается на земле. Вместо этого я использую CapsuleCast того же радиуса, что и тело: на 0,15 м вниз, с запасом skin в 0,02 м.
Проверка проходимости смотрит на вертикальную составляющую нормали попадания: hit.normal.y >= 0.5736f, косинус 55 градусов. Чтобы убрать дребезг прямо на границе, я добавил гистерезис: в состояние «на земле» вход происходит на 55 градусах, а выход — только после 58. Одно это сравнение сняло колебания между землёй и воздухом на кромках крутых рамп.
На уклонах основная работа — спроецировать скорость на правильную плоскость. Если применять горизонтальный ввод как есть, вниз по склону разгоняешься, а вверх еле ползёшь. Выравнивание вектора ввода через ProjectOnPlane по нормали поверхности делает скорость ходьбы независимой от наклона. Ещё я притягиваю тело к земле на расстоянии до 0,3 м на спусках; без этого каждый переход вниз превращается в маленький подскок.
Один cast возвращает одно попадание, а на стыке двух поверхностей приходящая нормаль скачет от кадра к кадру. Переход на CapsuleCastNonAlloc с буфером на восемь результатов и выбор самой крутой проходимой нормали убрали эту нестабильность. Помогло и вынести запросы на отдельную layer mask: исключение триггеров и объёмов урона срезало стоимость cast примерно на треть.
Step offset и скольжение вдоль стен
Заход на ступеньку — это три свипа: вверх на высоту ступени (0,35 м), вперёд на величину перемещения и снова вниз. Если все три чистые и нижний свип попадает в проходимую нормаль, я переношу тело туда; иначе отменяю и обращаюсь с поверхностью как со стеной. Эти три дополнительных cast дают около 0,06 мс из 0,28 мс на шаг, которые я намерил для мотора.
Скольжение вдоль стен — это и есть цикл collide-and-slide. При попадании я проецирую остаток перемещения на плоскость контакта и делаю свип заново. Во внутренних углах одной проекции не хватает, поэтому я разрешаю максимум четыре итерации и отбрасываю остаток, если четвёртая всё ещё во что-то упирается. В сборке, где итерации не были ограничены, время кадра в узких углах подскакивало до 4 мс.
Каким бы аккуратным ни был свип, тело со временем заползает внутрь геометрии, особенно когда его толкает движущаяся платформа. В конце каждого шага физики я измеряю перекрытие через Physics.ComputePenetration и выталкиваю наружу, не больше чем на 0,2 м за шаг. Без этого ограничения персонаж однажды телепортировался сквозь стену: измеренное перекрытие превысило 3 м, потому что у одного mesh collider на сцене был отрицательный масштаб.
У свипа ступеньки есть две защиты. Первая — он работает только на земле; когда я оставил его включённым в воздухе, персонаж сам взбирался по любой стене, в которую вбегал. Вторая — верхний свип полностью отменяет попытку захода, если упирается в потолок, иначе в низких коридорах тело на мгновение проваливается внутрь потолка.
Ускорение, торможение и управление в воздухе
Торможение — самая прямая причина ощущения скольжения. Вместо того чтобы тянуть скорость к цели через Lerp, я держу отдельные величины: 45 м/с² на разгон по земле, 60 м/с² на торможение и 12 м/с² в воздухе. При скорости ходьбы 6,2 м/с это полная остановка примерно через 100 мс после отпускания стика — почти вдвое быстрее тех 180 мс, на которые жаловались тестеры.
То, что торможение выше разгона, сделано намеренно. Если сделать их симметричными, персонаж кажется вялым; если сильно задрать торможение, он ощущается роботом. Соотношение от 1,3 до 1,5 стабильно работало во всех моих проектах. Я пробовал вынести это в AnimationCurve для дизайнеров: лучше двух чисел не стало, а отладка усложнилась.
В воздухе правила меняются. Я сохраняю набранный импульс, торможение не применяю, ввод добавляю с низким ускорением и обрезаю горизонтальную скорость по наземному максимуму. Игрок может подправить направление в прыжке, но не может разогнаться прыжками. В сборке, где я забыл про обрезку, тестеры связкой прыжка и спринта выходили на скорость в 1,4 раза выше задуманной и улетали за границы уровня.
На стороне ввода остались две настройки. Аналоговую мёртвую зону я обрезаю на 0,15 и оставшийся диапазон перекладываю обратно от нуля до единицы, иначе медленная ходьба недостижима. Ещё я развёл направление взгляда и направление движения: тело поворачивается со скоростью 720 градусов в секунду, а вектор скорости меняется мгновенно, потому что при повороте впереди скорости резкие смены направления ощущались с задержкой.
Фиксированный шаг, интерполяция и порядок работ
Мотор работает с фиксированным шагом 50 Гц, а экран обновляется на 144 Гц. Если писать позицию прямо в шаге физики, часть кадров рендера показывает одну и ту же позу дважды. Возникающее микродёрганье возвращается в виде жалобы «подтормаживает», и большинство считает это просадкой кадров и идёт искать её в профиле GPU.
Решение — хранить предыдущую и текущую физические позы и интерполировать между ними в Update по доле оставшегося времени. Визуальный корень, то есть меш и цель камеры, едет на интерполированной позе, а капсула столкновений остаётся на физической. Привязать камеру к той же интерполированной позе в LateUpdate тоже обязательно: чтение с опозданием на кадр выглядит так, будто персонаж плавает перед камерой.
Второй плюс фиксированного шага — воспроизводимость. Поскольку движение всегда проходит одно и то же число шагов, один и тот же ввод даёт одну и ту же траекторию даже при скачущем времени кадра; без этого дальность прыжка отличалась примерно на 20 см между 60 Гц и 144 Гц. Когда приходит длинный кадр, я ограничиваю накопленное время четырьмя шагами, иначе после загрузочного рывка симуляция уже никогда не догоняет.
Если вы слышите ту же жалобу, работайте в таком порядке: сначала измерьте и почините время остановки, потом добавьте притягивание к земле, затем свип ступеньки, а интерполяцию оставьте на конец. На каждом этапе рисуйте на экране capsule cast и нормаль поверхности; на эту визуализацию у меня ушло полдня, и следующие три недели она показывала каждую ошибку с первого взгляда. «Персонаж скользит» — не расплывчатая фраза, а сумма четырёх чисел, которые вы ещё не измерили.