Привет, Хабр! Представляю вашем вниманию перевод первой части статьи《Exploring in UE4》移动组件详解[原理分析](2019.7.14更新).

1. Глубокое понимания компонентов передвижения

В большинстве игр, движение игрока является базовой операцией. Даже если нет так называемого «игрока», то должны быть какие-то объекты, которыми вы можете управлять или которыми управляет ИИ.

GamePlay фреймворк от UE4 предоставляет разработчикам отличное решение для реализации передвижения. Поскольку UE использует компонентную идеологию проектирования (т.е. разделение и инкапсуляция различных функций в конкретные компоненты), основные функции этого решения полностью реализованы в компонентах. Логика передвижения будет обрабатываться по-разному в зависимости от сложности игры. Если это простая игра жанра RTS с видом сверху, она может обеспечивать только базовое движение по координатам. В RPG от первого лица игрок может улететь в небо. Для пикирования в полете требуется более сложное движение. Но, независимо от типа полета, UE помогает нам реализовать его, преимущественно из-за его предыдущего опыта разработки FPS игр.

Базовые передвижения предоставленные движком, не обязательно могут нам подойти и не должны ограничивать наш дизайн. Например, супер гравитация волшебного космического корабля, пружинные ботинки и управление полетом с помощью реактивного ранца. Эти эффекты требуют от нас доработки логики движения. Мы можем изменить ее на основе существующего режима движения или настроить свой собственный режим. В любом случае эти операции требуют кропотливой настройки компонентов передвижения, поэтому мы должны иметь глубокое понимание принципов реализации этих компонентов.

Кроме того, в онлайн-игре управление передвижением будет более сложным. Как обеспечить плавность передвижение игрока на разных клиентах? Как сделать так, чтобы персонаж не "телепортировался" из-за небольшой задержки? Давайте рассмотрим как UE решает эти проблемы.

Компоненты передвижения не так просты, они включаю в себя конечные автоматы, решения для синхронизации, физические модули, детальную обработку различных состояний передвижения, анимации и взаимодействие с другими компонентами (Actor'ы). Понадобиться много времени на изучение. В этой статье будут максимально подробно проанализированы базовые принципы передвижения, детально рассмотрены состояния передвижения, синхронизация передвижения, что поможет нам быстро разобраться и лучше использовать компоненты передвижения. А также, для ознакомления приводятся некоторые идеи для реализации специальных режимов передвижения.

2. Основные принципы реализации передвижения.

2.1 Компоненты передвижения и Character игрока.

Движение персонажа по существу заключается в разумном изменении положения координат, а суть движения персонажа в UE состоит в изменении положения координат RootComponent'a. Рисунок 2-1 — это общая структура компонентов Character'a. Видно, что мы обычно используем CapsuleComponent (тело в виде капсулы) как RootComponent. Таким образом координаты Character'a это координаты RootComponent'a. Сетка Mesh и другие компоненты будут двигаться вместе с капсулой. Когда компонент передвижения инициализируется, капсула устанавливается в UpdateComponent базового комопнента передвижения и все последующие операции вычисляют положение UpdateComponent.

Рисунок 2-1 Структура компонентов Character'a
Рисунок 2-1 Структура компонентов Character'a

Конечно, нам не обязательно задавать UpadateComponent как капсулу. Для DefaultPawn(наблюдатель) его UpadateComponent является SphereComponent, а для транспортного средства AWheeledVehicle его компонент сетки Mesh по умолчанию используется как UpdateComponent. Вы можете определить свой UpdateComponent самостоятельно, но ваш пользовательский компонент должен наследовать USceneComponent (другими словами, компонент должен иметь информацию о мировых координатах), чтобы он мог нормально реализовать свою логику движения.

2.2 Дерево наследования компонентов передвижения

Не существует одного единственного компонента передвижения. Через наследование расширяются возможности компонентов передвижения. От простейших функций передвижения, до правильной симуляции передвижения в разных состояниях. Как показано на рис. 2-2

Рис. 2-2 Диаграмма классов наследования компонентов передвижения.
Рис. 2-2 Диаграмма классов наследования компонентов передвижения.

Существует четыре класса компонентов передвижения. Первый — это UMovementComponent. Как базовый класса компонентов передвижения, он реализует базовый метод для передвижения - SafeMoveUpdatedComponent(). Вы можете вызвать эту функцию у UpdateComponent, чтобы обновить его позицию.

bool UMovementComponent::MoveUpdatedComponentImpl( const FVector& Delta, const FQuat& NewRotation, bool bSweep, FHitResult* OutHit, ETeleportType Teleport)
{
    if (UpdatedComponent)
    {
        const FVector NewDelta = ConstrainDirectionToPlane(Delta);
        return UpdatedComponent->MoveComponent(NewDelta, NewRotation, bSweep, OutHit, MoveComponentFlags, Teleport);
    }
 
 
    return false;
 }
  

На приведенном выше рисунке видно, что тип UpdateComponent является UScenceComponent, который предоставляет базовую информацию о местоположении в члене класса ComponentToWorld (FTransform), а также предоставляет метод InternalSetWorldLocationAndRotation() для изменения местоположения самого себя и своих компонентов. Класс UPrimitiveComponent напрямую наследуется от UScenceComponent, добавляя визуализацию и физическую информацию. Наши компоненты Mesh и капсулы унаследованы от UPrimitiveComponent, потому что для достижения эффекта реального движения, Actor должен контактировать с физическим миром и рендерить анимацию движения во время перемещения, для того чтобы показать это игрокам.

Следующий компонент — UNavMovementComponent, который в большей степени обеспечивает поиск пути ИИ и включает базовый статус движения, например, может ли он плавать, может ли он летать и т. д.

Компонент UPawnMovementComponent создан чтобы взаимодействовать с игроком. В первую очередь это основной интерфейс для передвижения, и ввод направления игрока не может быть реализованы без вызова его функций. UPawnMovementComponent предоставляет метод AddInputVector(), который может получать ввод направления игрока (FVector) и изменять позицию контролируемого Pawn в соответствии с введенными значениями. Следует отметить, что в UE Pawn является управляемым игровым персонажем (также может управляться ИИ), и его движение должно быть согласовано с соответсвующим компонентом UPawnMovementComponent. В целом это выглядит так — мы биндим клавиши игрока через UInputComponent, при нажатии клавиши вызываем метод APawn::AddMovementInput, затем в нем вызываем метод UPawnMovementComponent::AddInputVector. После завершения вызова операция будет использована через метод Apawn::ConsumeMovementInputVector() обнуляя значение ControlInputVector и завершая операцию перемещения.

И наконец основной компонент передвижения - UСharacterMovementComponent. Можно сказать, что этот компонент представляет собой интеграцию многолетнего игрового опыта Epic. Он точно обрабатывает различные общие сведения о состоянии движения и обеспечивает относительно плавную синхронизацию. Различные корректировки положения и обработка сглаживания положения при движении, и нам не нужно писать собственный код, чтобы использовать этот полностью завершенный компонент для передвижения. Можно сказать, что он действительно подходит для ролевых игр от первого и третьего лица.

На самом деле существует более часто используемый компонент передвижения UProjectileMovementComponent, который обычно используется для имитации движения луков и стрел, пуль и других снарядов. Однако в данной статье речь пойдет не об этом.

2.3 Краткий анализ отношений классов, связанных с компонентами передвижения

Предыдущий анализ в основном был сосредоточен на компонентах для передвижения. Здесь представлен более полный обзор всей системы (см. рис. 2-3)

Рисунок 2-3. Диаграмма классов фреймворка передвижения.
Рисунок 2-3. Диаграмма классов фреймворка передвижения.

В обычном трехмерном пространстве самым простым движением является непосредственное изменение координат персонажа. Следовательно, пока у нашего персонажа есть компонент, содержащий информацию о координатах, он может перемещаться через основной компонент передвижения. Однако, при усложнении игрового мира, например, при добавлении проходимой земли и океана, который можно исследовать, мы обнаружим, что движение усложняется.

  • Игрок может ходить по земле, поэтому ему необходимо постоянно обнаруживать информацию о столкновении с землей FFindFloorResult, FBasedMovementInfo).

  • Eсли игрок хочет плавать в воде, ему нужно определить объем воды (GetPhysicsVolume(), событие Overlap, также требует физики).

  • Скорость и эффекты сильно отличаются в воде и на суше, так что нужно написать два состояния (PhysSwimming, PhysWalking).

  • При движении, анимации совпадают с действиями (TickCharacterPose).

  • Что делать, когда вы сталкиваетесь с препятствиями при движении, как бороться с тем, что вас толкают другие игроки (в MoveAlongFloor есть соответствующие обработки).

  • Если я хочу добавить каких-нибудь NPC, которые могут находить путь, то нужно настроить навигационную сетку (включая FNavAgentProperties).

  • Если игрокам скучно, пусть играют онлайн (симуляция синхронизации передвижения FRepMovement, коррекция передвижения на стороне клиента ClientUpdatePositionAfterServerUpdate).

Глядя на все это, видно что не просто быть отличным компонентом передвижения. Но несмотря ни на что, UE в основном делает это за вас. Благодаря приведенному выше описанию у вас теперь есть общее представление про обращении с компонентами для передвижения во всех аспектах, но вы, возможно, не сможете ничего сделать, когда столкнетесь с конкретными проблемами, поэтому давайте продолжим анализ.

3. Детальный разбор каждого состояния движения

В этом разделе мы сосредоточимся на компоненте UCharacterMovementComponent, и проанализируем, как он управляет персонажами игроков в различных состояниях движения. Прежде всего, он должен начинаться с Tick. Состояние определяется и обрабатывается в каждом кадре. Состояние отличается режимом движения MovementMode, который при необходимости изменяется на правильный режим движения. По умолчанию есть 6 режимов движения. Основными часто используемыми режимами являются ходьба (walking), плавание (swimming), падение (falling) и полет (flying). Для агентов ИИ предусмотрен режим ходьбы, и, наконец, есть настраиваемый (custom) режим движения.

Рисунок 3-1 Процесс обработки передвижения в режиме Standalone
Рисунок 3-1 Процесс обработки передвижения в режиме Standalone

3.1 Ходьба (Walking)

Можно сказать, что режим ходьбы является основой всех режимов движения, а также самым сложным из них. Чтобы имитировать эффект движения в реальном мире, под ногами игрока должен быть физический объект, такой же как земля, который может поддерживать его и не давать падать. В компоненте передвижения эта земля записывается переменной-членом FFindFloorResult CurrentFloor. В начале игры компонент передвижения установит режим MovementMode по умолчанию в соответствии с конфигурацией. Если это Walking, он найдет текущую землю с помощью функции FindFloor. Стек инициализации CurrentFloor показан на рис. 3-2 (Character::Restart() подменит Pawn::Restart()):

Рисунок 3-2
Рисунок 3-2

Давайте сначала проанализируем процесс FindFloor. FindFloor, по сути, находит землю под ногами с помощью Sweep'a капсулы, поэтому земля должна иметь физические данные, а тип физического канала должен быть настроен так, чтобы реагировать на Pawn игрока с блоком. Здесь есть маленькие детали. Например, когда мы ищем землю, мы учитываем только местоположение у ступни и игнорируем объекты возле талии. Sweep использует капсулу вместо Ray Tracing, что удобно для обработки движения по наклонной плоскости и расчета радиуса стояния и т. д. (См. рис. 3-3, значения Normal и ImpactNormal в HitResult могут не совпадать при обнаружении столкновения капсулы через Sweep). Кроме того, текущая реализация движения Character основана на теле капсулы, поэтому Actor без капсулы не может нормально использовать UCharacterMovementComponent.

Могут ли игроки стоять на земле, если найдут землю? Не обязательно, это снова добавляет новый параметр PerchRadiusThreshold, я называю его perch radius (радиус зацепа), который также называется радиусом стояния (standing radius). Значение по умолчанию равно 0, и компонент передвижения будет игнорировать расчеты для радиуса стояния. Как только это значение превысит 0.15, он будет принимать дальнейшие решения, достаточно ли у нас земли под ногами для того, чтобы игрок мог стоять.

Предыдущие подготовительные работы завершены, и теперь официально вводится расчет перемещения в режиме Walking. Этот фрагмент кода выполняется в PhysWalking. Для плавной работы UE4 делит тики движения на N сегментов (время каждого сегмента не может превышать MaxSimulationTimeStep). При обработке каждого сегмента сначала записывается информация о текущем местоположении и о земле. В TickComponent текущее ускорение рассчитывается в соответствии с нажатыми клавишами игрока. Затем CalcVelocity() вычисляет скорость на основе ускорения, а также учитывает трение о землю, находится ли он в воде и т. д.

// apply input to acceleration
Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));

После вычисления скорости, вызывается функция MoveAlongFloor(), чтобы изменить положение координат текущего объекта. Перед вызовом метода SafeMoveUpdatedComponent() обрабатывается особая ситуация, когда игрок идет по склону. Обычно в состоянии ходьбы игрок будет двигаться только вперед, назад, влево и вправо, и скорости движения в направлении Z не будет меняться. Что делать, если вы столкнулись с уклоном? Если по склону можно пройти, будет вызвана функция ComputeGroundMovementDelta() для расчета новой параллели и наклонной скорости на основе текущей горизонтальной скорости. Это простая симуляция эффекта ходьбы по склону или, проще говоря, при движении в гору. Горизонтальная скорость игрока может быть уменьшена, что может быть обработано автоматически, установив параметр bMaintainHorizontalGroundVelocity в значение false.

Теперь кажется, что мы можем идеально смоделировать процесс движения, но если подумать, есть еще одна ситуация, которая не рассматривалась - как бороться с препятствиями? Обычно в играх при столкновении с препятствиями движение останавливается, и игрок может немного скользить вдоль стены. Также и в UE. В процессе движения персонажа (SafeMoveUpdatedComponent) будет происходить процесс обнаружения столкновений. Поскольку компонент UPrimitiveComponent имеет физические данные, эта операция обрабатывается в функции UprimitiveComponent::MoveComponentImpl. Следующий код определяет, встречаются ли препятствия во время движения, и возвращает HitResult, если препятствия встречаются.

FComponentQueryParams Params(PrimitiveComponentStatics::MoveComponentName, Actor);
FCollisionResponseParams ResponseParam;
InitSweepCollisionParams(Params, ResponseParam);
bool const bHadBlockingHit = MyWorld->ComponentSweepMulti(Hits, this, TraceStart, TraceEnd, InitialRotationQuat, Params);

После получения HitResult, возвращаемого функцией SafeMoveUpdatedComponent(), столкновение с препятствием будет обработано в следующем коде.

Если Hit.Normal имеет значение в направлении Z и все еще можно ходить, значит, это наклонная плоскость, по которой можно идти вверх, и игроку разрешено двигаться по ней.

Определяем, можно ли наступить на текущий коллайдер, если можно, попытаться взобраться на него. Если этого не получилось, то вызывается метод SlideAlongSurface() для скольжения по коллизии.

// UCharacterMovementComponent::PhysWalking
else if (Hit.IsValidBlockingHit())
{
    // We impacted something (most likely another ramp, but possibly a barrier).
    float PercentTimeApplied = Hit.Time;
    if ((Hit.Time > 0.f) && (Hit.Normal.Z > KINDA_SMALL_NUMBER) && IsWalkable(Hit))
    {
        // Another walkable ramp.
        const float InitialPercentRemaining = 1.f - PercentTimeApplied;
        RampVector = ComputeGroundMovementDelta(Delta * InitialPercentRemaining, Hit, false);
        LastMoveTimeSlice = InitialPercentRemaining * LastMoveTimeSlice;
        SafeMoveUpdatedComponent(RampVector, UpdatedComponent->GetComponentQuat(), true, Hit);
        const float SecondHitPercent = Hit.Time * InitialPercentRemaining;
        PercentTimeApplied = FMath::Clamp(PercentTimeApplied + SecondHitPercent, 0.f, 1.f);
    }
 
 
    if (Hit.IsValidBlockingHit())
    {
        if (CanStepUp(Hit) || (CharacterOwner->GetMovementBase() != NULL && CharacterOwner->GetMovementBase()->GetOwner() == Hit.GetActor()))
        {
            // hit a barrier, try to step up
            const FVector GravDir(0.f, 0.f, -1.f);
            if (!StepUp(GravDir, Delta * (1.f - PercentTimeApplied), Hit, OutStepDownResult))
            {
                UE_LOG(LogCharacterMovement, Verbose, TEXT("- StepUp (ImpactNormal %s, Normal %s"), *Hit.ImpactNormal.ToString(), *Hit.Normal.ToString());
                HandleImpact(Hit, LastMoveTimeSlice, RampVector);
                SlideAlongSurface(Delta, 1.f - PercentTimeApplied, Hit.Normal, Hit, true);
            }
            else
            {
                // Don't recalculate velocity based on this height adjustment, if considering vertical adjustments.
                UE_LOG(LogCharacterMovement, Verbose, TEXT("+ StepUp (ImpactNormal %s, Normal %s"), *Hit.ImpactNormal.ToString(), *Hit.Normal.ToString());
                bJustTeleported |= !bMaintainHorizontalGroundVelocity;
            }
        }
        else if ( Hit.Component.IsValid() && !Hit.Component.Get()->CanCharacterStepUp(CharacterOwner) )
        {
            HandleImpact(Hit, LastMoveTimeSlice, RampVector);
            SlideAlongSurface(Delta, 1.f - PercentTimeApplied, Hit.Normal, Hit, true);
        }
    }
}

Базовая обработка движения завершена, и сразу после движения будет оцениваться, вошел ли игрок в воду или в состояние Falling (Падение), и если да, то сразу переключится в новое состояние. Поскольку игрок за кадр может постоянно переключаться между состояниями Walking, Swiming, Falling и других состояний, будет итерация для записи количества передвижений в текущем кадре перед каждым передвижением. Если предел превышен, симуляция движениям будет отменена.

3.2 Падение (Falling)

Состояние Падения также является наиболее распространенным состоянием, помимо Walking. Пока игрок находится в воздухе (будь то прыжок или падение), он будет находиться в состоянии Falling. Подобно Walking, чтобы показать более плавное движение, расчет падения также делит тик движение на N сегментов (время каждого сегмента не может превышать MaxSimulationTimeStep). При обработке каждого шага падения сначала вычисляется горизонтальная скорость игрока, контролируемая вводом, потому что на Pawn также может влиять управление игроком в воздухе. Затем рассчитывается скорость гравитации. Получение гравитации интересно тем что берется она из Volume.

float UMovementComponent::GetGravityZ() const
{
    return GetPhysicsVolume()->GetGravityZ();
}
APhysicsVolume* UMovementComponent::GetPhysicsVolume() const
{
    if (UpdatedComponent)
    {
        return UpdatedComponent->GetPhysicsVolume();
    }
    return GetWorld()->GetDefaultPhysicsVolume();
}

Volume берет GlobalGravityZ из WorldSetting. Напомню, что мы можем иметь разные гравитации для разных Volume, для достижения кастомного геймплея. Обратите внимание, что даже если мы находимся не в каком-либо Volume, он привяжет DefaultVolume по умолчанию к нашему UpdateComponent. Почему здесь должен быть DefaultVolume? Потому что во многих логических обработках вам нужно получить DefaultVolume и связанные данные внутри. Например, DefaultVolume имеет TerminalLimit, который не может превышать установленную скорость при расчете скорости спуска под действием силы гравитации. Мы можем изменить лимит этой скорости. По умолчанию многие свойства в DefaultVolume инициализируются с помощью связанной с физикой конфигурации в ProjectSetting. См. Рисунок 3-4.

Рисунок 3-4
Рисунок 3-4

Рассчитываем текущу новую FallSpeed через полученную Gravity (рассчитывается в NewFallVelocity, правило расчета очень простое CurrentSpeed + Gravity * deltaTime). Затем вычисляем смещение и двигаемся в соответствии с текущей и предыдущей скоростью кадра, формула выглядит следующим образом.

FVector Adjusted = 0.5f*(OldVelocity + Velocity) * timeTick;
SafeMoveUpdatedComponent( Adjusted, PawnRotation, true, Hit);

Затем вычисляем смещение и двигаемся в соответствии с требованиями и скоростью обращения к кадру, формула выглядит следующим образом.

Первый случай: это обычное приземление. Если игрок обнаружит, что он сталкивается в землей, то он напрямую вызывает ProcessLanded для выполнения операции приземления (этот расчет основан на высоте точки столкновения, таким образом, стена может быть отфильтрована).

Второй случай: сталкиваемся с платформой в процессе прыжка, а затем проверяем, находятся ли координаты игрока и текущая точка столкновения в допустимом диапазоне (IsWithinEdgeTolerance), если да, выполняем FindFloor, чтобы повторно проверить землю, и, если она обнаружена, выполняем процесс приземления.

Третий случай: это стена и другие вещи, на которые нельзя наступать. Если в процессе падения встречается препятствие, HandleImpact будет выполняться первым, чтобы придать силу объекту, которого коснулись. Затем вызоваем ComputeSlideVector, чтобы вычислить смещение скольжения. Поскольку скорость игрока будет меняться после столкновения с препятствием, пересчитываем скорость в это время и снова отрегулируем положение и направление игрока. Если в это время у игрока есть горизонтальное смещение, LimitAirControl также будет использован для ограничения скорости игрока. Ведь игрок не может свободно управлять персонажем в воздухе. Расширим еще больше третью ситуацию, при корректировке столкновения может быть встреча с другой стеной. Обработка Falling позволяет игроку найти подходящую позицию на двух стенах. Но она все еще не может решить ситуацию, когда игрок застрял на двух склонах, но не может приземлиться (или постоянно переключается между Walking и Falling). Если у нас будет время, мы можем попытаться решить эту проблему позже. Решение можно начать с функции ComputeFloorDist в FindFloor. Цель состоит в том, чтобы позволить игроку найти проходимую землю в этой ситуации.

Рисунок 3-5 Застревание между двумя наклонными плоскостями вызывает постоянное переключение
Рисунок 3-5 Застревание между двумя наклонными плоскостями вызывает постоянное переключение

3.2.1 Прыжок (Jump)

Теперь, когда мы рассмотрели Падение, я должен рассказать про базовые операции прыжка. Ниже я примерно описываю основной поток прыжка.

1. Биндинг на событие прыжка.

void APrimalCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{
    // Set up gameplay key bindings
    check(PlayerInputComponent);
    PlayerInputComponent->BindAction("Jump", IE_Pressed, this, &ACharacter::Jump);
    PlayerInputComponent->BindAction("Jump", IE_Released, this, &ACharacter::StopJumping);
}
void ACharacter::Jump()
{
    bPressedJump = true;
    JumpKeyHoldTime = 0.0f;
}
 
 
void ACharacter::StopJumping()
{
    bPressedJump = false;
    ResetJumpState();
}
  1. Установите bPressedJump в true при нажатии кнопки. Цикл в TickComponent вызывает Acharacter::CheckJumpInput, для немедленного определения выполнения прыжка.

  • Реализуйте функцию CanJump() в блупринте, чтобы обработать ограничивающую логику в блупринте. Если функция не реализованна в блупринте, то по дефолту будет выполняться Acharacter::CanJumpInternal_Implementation().Это основа контроля того, когда игрок может прыгать, например когда он приседает, он не может прыгать и плавать. Кроме того, есть параметр JumpMaxHoldTime, что означает, что игрок не будет запускать прыжок выше этого значения после нажатия клавиши. JumpMaxCount представляет количество сегментов, на которые игрок может совершить прыжки. (например, двойной прыжок)

  • Выполнение функции CharacterMovement->DoJump (bClientUpdating), запускает операцию прыжка, устанавливает скорость прыжка в JumpZVelocity(>= 0) и переходит в Falling.

  • Если const bool bDidJump = CanJump() && CharacterMovement->DoJump(bClientUpdating) равно true, то выполняем другие связанные операции.

  • Добавляем счетчик прыжков JumpCurrentCount и вызываем связанное с ним событие OnJumped

  1. Далее, в фукнции PerformMovement будет выполнен ClearJumpInput и для bPressedJump будет установлено значение false. Но JumpCurrentCount не будет очищен, чтобы вы могли продолжать обрабатывать несколько прыжков.

  2. Когда игрок отпускает кнопку прыжка, параметру bPressedJump присваивается значение false, очищая соответствующее состояние. Если игрок все еще находится в воздухе, JumpCurrentCount не будет очищен. Если bPressedJump имеет значение false, операции перехода выполняться не будут.

  3. Если игрок нажмет кнопку прыжка в воздухе, он также войдет в ACharacter::CheckJumpInput. Если JumpCurrentCount меньше, чем JumpMaxCount, игрок может продолжить выполнение операции прыжка.

Рисунок 3-6
Рисунок 3-6

3.3 Плавание (Swiming)

В каждом состоянии есть три существенных отличия:

  1. Разница в скорости

  2. Сила гравитации

  3. Инерция

Плавание - это состояние инерционнго движения (не останавливается сразу после отпускания), меньшее влияние силы тяжести (медленное падение или отсутствие движения в воде) и более медленное, чем обычно (проявление сопротивления воде). Дефолтная логика определения того, находится ли игрок в воде, также относительно проста, то есть определить, является ли Volume, в котором находится текущий UpdateComponent, WaterVolume. (Вытащите PhysicsVolume в редакторе и измените атрибут WaterVolume в true)

Компонент CharacterMovement имеет конфигурацию плавучести Buoyancy, а конечную плавучесть можно рассчитать по степени погружения игрока в воду (ImmersionDepth возвращает 0-1). После этого нам нужно рассчитать скорость. В этот раз нам нужно получить силу трения в Volume, а затем передать ее в CalcVelocity, которая отражает эффект более медленного движения игрока в воде. Затем рассчитаем плавучесть в направлении оси Z, чтобы рассчитать скорость в этом направлении. Когда игрок ныряет, вы обнаружите, что скорость игрока в направлении Z становится все меньше и меньше. Как только все тело будет погружено в воду, скорость гравитации в направлении оси Z будет полностью проигнорирована.

// UCharacterMovementComponent::PhysSwimming
const float Friction = 0.5f * GetPhysicsVolume()->FluidFriction * Depth;
CalcVelocity(deltaTime, Friction, true, BrakingDecelerationSwimming);
Velocity.Z += GetGravityZ() * deltaTime * (1.f - NetBuoyancy);
 
 
// UCharacterMovementComponent::CalcVelocity Apply fluid friction
if (bFluid)
{
    Velocity = Velocity * (1.f - FMath::Min(Friction * DeltaTime, 1.f));
}
Рисунок 3-7 Персонаж, плавающий в Volume воды
Рисунок 3-7 Персонаж, плавающий в Volume воды

После того, как скорость рассчитана, игрок может двигаться. В UE написан отдельный интерфейс Swim для выполнения операции перемещения. Он считает, что если игрок двигаясь покидает объем воды и слишком сильно превышает поверхность воды, у него есть возможность заставить игрока приспособиться к положению водной поверхности, и скорость будет больше.

Более того, вы, возможно, догадались, что нужно иметь дело с ситуацией, когда во время движения обнаруживается столкновение с препятствием. В основном логика аналогична предыдущей. Если вы можете наступить на него (StepUp()), установите положение игрока и наступите на него. Если вы не можете наступить на него, придайте препятствию силу, а затем скользите некоторое расстояние вдоль поверхности препятствия (HandleImpact, SlideAlongSurface).

Как быть с инерционными показателями движения воды? Инерционность это не какая-то особенность обработки в воде, а просто расчет скорости с помощью двух входящих параметров, отличных от тех, что у Ходьбы. Первый — Трение (Friction), а второй - BrakingDeceleration, что означает обратную скорость.

Когда ускорение равно 0 (что указывает на то, что ввод игрока был очищен), входящее трение в воде намного меньше, чем трение о землю (0,15:8), а скорость торможения равна 0 (у ходьбы равна 2048), поэтому при выполнении ApplyVelocityBraking() кажется, что игрок сразу же тормозит при ходьбе, но он имеет инерцию движения в таких состояниях как Плавание и Полет.

3.4 Полет (Flying)

Наконец, мы поговорим о последнем состоянии движения. Если вы хотите отладить это состояние, вы можете изменить DefaultLandMovementMode на Flying в компоненте движения персонажа.

Процедура Полета похожа на другие состояния и даже немного проще. Сначала рассчитаем ускорение на основе предыдущего ввода, а затем начинаем вычислять текущую скорость на основе трения. После расчета скорости вызывается SafeMoveUpdatedComponent для перемещения. Если вы столкнулись с препятствием, сначала посмотрите, сможете ли вы наступить на него, если нет, обработайте столкновение и скользите по поверхности препятствия.

//UCharacterMovementComponent::PhysFlying
//RootMotion Relative
RestorePreAdditiveRootMotionVelocity();
 
 
if( !HasAnimRootMotion() && !CurrentRootMotion.HasOverrideVelocity() )
{
    if( bCheatFlying && Acceleration.IsZero() )
    {
        Velocity = FVector::ZeroVector;
    }
    const float Friction = 0.5f * GetPhysicsVolume()->FluidFriction;
    CalcVelocity(deltaTime, Friction, true, BrakingDecelerationFlying);
}
//RootMotion Relative
ApplyRootMotionToVelocity(deltaTime);

В состоянии полета существует феномен о котором у вас могут возникнуть вопросы. Когда я устанавливают метод движения по умолчанию на Полет, игрок может скользить (по инерции) после отпускания клавиш клавиатуры. Но при использовании консольных команд для перемещения в состояние Ходьбы, он все равно остановится сразу после отпускания кнопки.

Это специальный код обрабатывает читерский полет. После того как игрок отпустит кнопку, ускорении становится равным 0. В это время скорость игрока принудительно устанавливается в 0. Таким образом эффективность использования GM команд сходит на нет.

3.5 Отложенное обновление FScopedMovementUpdate

FScopedMovementUpdate — это не состояние, а оптимизация программы движения. Когда вы смотрите код движка, вы можете увидеть следующий код перед выполнением перемещения:

// Scoped updates can improve performance of multiple MoveComponent calls.
{
    FScopedMovementUpdate ScopedMovementUpdate(UpdatedComponent, bEnableScopedMovementUpdates ? EScopedUpdate::DeferredUpdates : EScopedUpdate::ImmediateUpdates);
 
 
    MaybeUpdateBasedMovement(DeltaSeconds);
 
 
    //...Other logic processing, no specific code is given here
 
 
    // Clear jump input now, to allow movement events to trigger it for next update.
    CharacterOwner->ClearJumpInput();
    // change position
    StartNewPhysics(DeltaSeconds, 0);
 
 
         //...Other logic processing, no specific code is given here
 
 
    OnMovementUpdated(DeltaSeconds, OldLocation, OldVelocity);
} // End scoped movement update

Зачем помещать код перемещения в эту фигурную скобку и что такое FScopedMovementUpdate? Внимательно вспомним нашу предыдущую логику обработки движения. В кадре мы можем сбрасывать или изменять наше движение много раз из-за недопустимого движения и препятствий. Изменение только лишь капсулы недостаточно, на самом деле нам также нужно изменить положение субкомпонентов, обновить физический объем, обновить физическое положение и т. д., и данные движения в процессе расчета фактически бесполезны. Нужны только последние данные перемещения.

Поэтому, использование FScopedMovementUpdate может заблокировать перемещение таких объектов, как физические и другие объекты в пределах его области действия, а затем выполнить обновление после того, как перемещение будет действительно завершено. (ожидая, пока FScopedMovementUpdate не будет уничтожен перед обработкой) Есть много мест, где используется FScopedMovementUpdate, и в основном там будет логика, связанная с откатом перемещений.

Комментарии (0)