Post

UE - 캐릭터 무브먼트와 커스텀 중력

UnrealEngine

UE - 캐릭터 무브먼트와 커스텀 중력

VSCode에 이슈가 있어서 일단 깃허브 리포지트리에서 작성 중이다.

저번 프로젝트에서 행성 맵의 커스텀 중력 이용 시 카메라와 이동에 문제가 발생했었는데, 시간이 없어서 포기했었다.

프로젝트도 끝났고, 이번에 다시 한 번 도전해보는 중이다.

일단 커스텀 중력이 캐릭터 무브먼트의 중력을 변경하는 방식으로 동작했기에 캐릭터 무브먼트에 대해 조금 공부해봤다.

공식 문서도 생각보다 설명이 부실해 그냥 직접 내부 코드를 분석해봤다.

Tick

캐릭터 무브먼트는 movement component를 상속받았고, super tick 실행 후 추가적인 동작이 들어있다.

1
2
3
4
SCOPED_NAMED_EVENT(UCharacterMovementComponent_TickComponent, FColor::Yellow);  
SCOPE_CYCLE_COUNTER(STAT_CharacterMovement);  
SCOPE_CYCLE_COUNTER(STAT_CharacterMovementTick);  
CSV_SCOPED_TIMING_STAT_EXCLUSIVE(CharacterMovement);

여기는 성능 측정 관련 매크로들이라고 한다.

이건 나중에 최적화 부분에서 따로 공부해야겠다.

1
2
3
4
5
6
7
8
9
10
11
12
FVector InputVector = FVector::ZeroVector;  
bool bUsingAsyncTick = (CharacterMovementCVars::AsyncCharacterMovement == 1) && IsAsyncCallbackRegistered();  
if (!bUsingAsyncTick)  
{  
    // Do not consume input if simulating asynchronously, we will consume input when filling out async inputs.  
    InputVector = ConsumeInputVector();  
}  
  
if (!HasValidData() || ShouldSkipUpdate(DeltaTime))  
{  
    return;  
}

캐릭터 무브먼트는 입력을 받고 소모할 수 있나보다.
만약 비동기 처리를 활성화하면 입력을 Tick에서 소모하지 않고, 별도로 소모하는 것으로 보인다.

character movement comp의 부모인 movement comp의 tick을 실행한다.
여기선 단순히 움직일 대상이 있는지 확인하고

1
2
3
4
5
6
7
8
9
10
11
  
void UMovementComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction *ThisTickFunction)  
{  
    Super::TickComponent(DeltaTime, TickType, ThisTickFunction);  
  
    // Don't hang on to stale references to a destroyed UpdatedComponent.  
    if (UpdatedComponent != NULL && !IsValid(UpdatedComponent))  
    {  
       SetUpdatedComponent(NULL);  
    }  
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
void UMovementComponent::SetUpdatedComponent(USceneComponent* NewUpdatedComponent)  
{  
    if (UpdatedComponent && UpdatedComponent != NewUpdatedComponent)  
    {  
       if (bAutoRegisterPhysicsVolumeUpdates)  
       {  
          UpdatedComponent->SetShouldUpdatePhysicsVolume(false);  
          if (IsValid(UpdatedComponent))  
          {  
             UpdatedComponent->SetPhysicsVolume(NULL, true);  
          }  
       }  
       UpdatedComponent->PhysicsVolumeChangedDelegate.RemoveDynamic(this, &UMovementComponent::PhysicsVolumeChanged);  
  
       // remove from tick prerequisite  
       UpdatedComponent->PrimaryComponentTick.RemovePrerequisite(this, PrimaryComponentTick);   
}  
  
    // Don't assign pending kill components, but allow those to null out previous UpdatedComponent.  
    UpdatedComponent = IsValid(NewUpdatedComponent) ? NewUpdatedComponent : NULL;  
    UpdatedPrimitive = Cast<UPrimitiveComponent>(UpdatedComponent);  
  
    // Assign delegates  
    if (UpdatedComponent)  
    {  
       // Listen to events regardless of whether enabled, in case physics volume updates are later enabled.  
       UpdatedComponent->PhysicsVolumeChangedDelegate.AddUniqueDynamic(this, &UMovementComponent::PhysicsVolumeChanged);  
  
       // Handle auto registration  
       if (bAutoRegisterPhysicsVolumeUpdates)  
       {  
          UpdatedComponent->SetShouldUpdatePhysicsVolume(bComponentShouldUpdatePhysicsVolume);  
          if (bComponentShouldUpdatePhysicsVolume)  
          {  
             if (!bInOnRegister && !bInInitializeComponent)  
             {  
                // UpdateOverlaps() in component registration will take care of this.  
                UpdatedComponent->UpdatePhysicsVolume(true);  
             }  
          }  
          else  
          {  
             UpdatedComponent->SetPhysicsVolume(NULL, true);  
          }  
       }  
         
       // force ticks after movement component updates  
       UpdatedComponent->PrimaryComponentTick.AddPrerequisite(this, PrimaryComponentTick);   
}  
  
    UpdateTickRegistration();  
  
    if (bSnapToPlaneAtStart)  
    {  
       SnapUpdatedComponentToPlane();  
    }  
}

없다면 null로 둔다.

여기서 updated component가 움직일 대상으로 보인다.

movement comp의 Tick에서 updated comp를 확인하고,

추가적인 업데이트를 실행한다.

  1. 비동기 tick
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    if (bUsingAsyncTick)  
    {  
     check(CharacterOwner && CharacterOwner->GetMesh());  
     USkeletalMeshComponent* CharacterMesh = CharacterOwner->GetMesh();  
     if (CharacterMesh->ShouldTickPose())  
     {  
        // Keep track of if we're playing root motion, just in case the root motion montage ends this frame.  
        // Also cache the root motion translation scale, in case the root motion ends in TickPose and       // translation scale is reset by a blend out listener.       const bool bWasPlayingRootMotion = CharacterOwner->IsPlayingRootMotion();  
        const float RootMotionTranslationScale = CharacterOwner->GetAnimRootMotionTranslationScale();  
      
        CharacterMesh->TickPose(DeltaTime, true);  
        // We are simulating character movement on physics thread, do not tick movement.  
        const bool bIsPlayingRootMotion = CharacterOwner->IsPlayingRootMotion();  
        if (bIsPlayingRootMotion || bWasPlayingRootMotion)  
        {  
           FRootMotionMovementParams RootMotion = CharacterMesh->ConsumeRootMotion();  
           if (RootMotion.bHasRootMotion)  
           {  
              RootMotion.ScaleRootMotionTranslation(RootMotionTranslationScale);  
              RootMotionParams.Accumulate(RootMotion);  
           }  
        }  
     }
    

    비동기 틱이 활성화 상태라면

캐릭터 메시와 루트모션만 업데이트 하는 것으로 보인다.

  1. 피직스
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    if (bIsSimulatingPhysics)  
    {  
     // Update camera to ensure client gets updates even when physics move it far away from point where simulation started  
     if (CharacterOwner->GetLocalRole() == ROLE_AutonomousProxy && IsNetMode(NM_Client))  
     {  
        MarkForClientCameraUpdate();  
     }  
      
     ClearAccumulatedForces();  
     return;}
    

    피직스 켜져있으면 클라이언트 카메라가 업데이트 받을 수 있도록 업데이트, 축적된 힘들을 초기화 해준다.

  2. 이동 동기화
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    if (CharacterOwner->GetLocalRole() > ROLE_SimulatedProxy)
    {
     // AutonomouProxy 로컬 컨트롤 중인 클라이언트나 서버 (아마 리슨?)
    }
    else if (CharacterOwner->GetRemoteRole() == ROLE_AutonomousProxy)
    {
     // 서버가 클라가 보낸 이동 정보를 바탕으로 위치 갱신
    }
    else if (CharacterOwner->GetLocalRole() == ROLE_SimulatedProxy)
    {
     // 로컬에서 제어할 수 없는 캐릭터 처리
    }
    

    그 다음은 네트워크 롤에 따라 캐릭터 움직임 처리를 해준다.

  3. 회피 기능
    1
    2
    3
    4
    
    if (bUseRVOAvoidance)  
    {  
     UpdateDefaultAvoidance();  
    }
    

AI 관련 로직인 것 같다.
내비게이션 시스템으로 회피를 사용할 때 이 부분이 필요한 것으로 보인다.
아마 MoveTo와도 관련이 있을 것으로 예상한다.

  1. 물리 기반 상호작용
    1
    2
    3
    4
    5
    6
    
    if (bEnablePhysicsInteraction)  
    {  
     SCOPE_CYCLE_COUNTER(STAT_CharPhysicsInteraction);  
     ApplyDownwardForce(DeltaTime);  
     ApplyRepulsionForce(DeltaTime);  
    }
    

캐릭터가 서 있을 때 아래로 누르는 힘과 캐릭터가 겹치는 물리 객체를 밀어내는 힘을 적용한다.

ApplyDownwardForce

1
2
3
4
5
6
7
8
9
10
11
12
13
void UCharacterMovementComponent::ApplyDownwardForce(float DeltaSeconds)  
{  
    if (StandingDownwardForceScale != 0.0f && CurrentFloor.HitResult.IsValidBlockingHit())  
    {  
       UPrimitiveComponent* BaseComp = CurrentFloor.HitResult.GetComponent();  
       const FVector Gravity = -GetGravityDirection() * GetGravityZ();  
  
       if (BaseComp && BaseComp->IsAnySimulatingPhysics() && !Gravity.IsZero())  
       {  
          BaseComp->AddForceAtLocation(Gravity * Mass * StandingDownwardForceScale, CurrentFloor.HitResult.ImpactPoint, CurrentFloor.HitResult.BoneName);  
       }  
    }  
}

캐릭터가 바닥에 서있다고 판단되는 경우 닿고 있는 바닥에 힘을 주는 함수

주는 힘은 -GravityDir * GravityZ * Mass * StandingDownwardForceScale

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
void UCharacterMovementComponent::ApplyRepulsionForce(float DeltaSeconds)  
{  
    if (UpdatedPrimitive && RepulsionForce > 0.0f && CharacterOwner!=nullptr)  
    {  
       const TArray<FOverlapInfo>& Overlaps = UpdatedPrimitive->GetOverlapInfos();  
       if (Overlaps.Num() > 0)  
       {  
          FCollisionQueryParams QueryParams (SCENE_QUERY_STAT(CMC_ApplyRepulsionForce));  
          QueryParams.bReturnFaceIndex = false;  
          QueryParams.bReturnPhysicalMaterial = false;  
  
          float CapsuleRadius = 0.f;  
          float CapsuleHalfHeight = 0.f;  
          CharacterOwner->GetCapsuleComponent()->GetScaledCapsuleSize(CapsuleRadius, CapsuleHalfHeight);  
          const float RepulsionForceRadius = CapsuleRadius * 1.2f;  
          const float StopBodyDistance = 2.5f;  
          const FVector MyLocation = UpdatedPrimitive->GetComponentLocation();  
  
          for (int32 i=0; i < Overlaps.Num(); i++)  
          {  
             const FOverlapInfo& Overlap = Overlaps[i];  
  
             UPrimitiveComponent* OverlapComp = Overlap.OverlapInfo.Component.Get();  
             if (!OverlapComp || OverlapComp->Mobility < EComponentMobility::Movable)  
             {   
                continue;   
			 }  
  
             // Use the body instead of the component for cases where we have multi-body overlaps enabled  
             FBodyInstance* OverlapBody = nullptr;  
             const int32 OverlapBodyIndex = Overlap.GetBodyIndex();  
             const USkeletalMeshComponent* SkelMeshForBody = (OverlapBodyIndex != INDEX_NONE) ? Cast<USkeletalMeshComponent>(OverlapComp) : nullptr;  
             if (SkelMeshForBody != nullptr)  
             {  
                OverlapBody = SkelMeshForBody->Bodies.IsValidIndex(OverlapBodyIndex) ? SkelMeshForBody->Bodies[OverlapBodyIndex] : nullptr;  
             }  
             else  
             {  
                OverlapBody = OverlapComp->GetBodyInstance();  
             }  
  
             if (!OverlapBody)  
             {  
                UE_LOG(LogCharacterMovement, Warning, TEXT("%s could not find overlap body for body index %d"), *GetName(), OverlapBodyIndex);  
                continue;             
            }  
  
             if (!OverlapBody->IsInstanceSimulatingPhysics())  
             {  
                continue;  
             }  
  
             FTransform BodyTransform = OverlapBody->GetUnrealWorldTransform();  
  
             FVector BodyVelocity = OverlapBody->GetUnrealWorldVelocity();  
             FVector BodyLocation = BodyTransform.GetLocation();  
  
             // Trace to get the hit location on the capsule  
             FHitResult Hit;  
             bool bHasHit = UpdatedPrimitive->LineTraceComponent(Hit, BodyLocation,  
                                                    ProjectToGravityFloor(MyLocation) + GetGravitySpaceComponentZ(BodyLocation),  
                                                    QueryParams);  
  
             FVector HitLoc = Hit.ImpactPoint;  
             bool bIsPenetrating = Hit.bStartPenetrating || Hit.PenetrationDepth > StopBodyDistance;  
  
             // If we didn't hit the capsule, we're inside the capsule  
             if (!bHasHit)   
             {  
                HitLoc = BodyLocation;  
                bIsPenetrating = true;  
             }  
  
             const float DistanceNow = ProjectToGravityFloor(HitLoc - BodyLocation).SizeSquared();  
             const float DistanceLater = ProjectToGravityFloor(HitLoc - (BodyLocation + BodyVelocity * DeltaSeconds)).SizeSquared();  
  
             if (bHasHit && DistanceNow < StopBodyDistance && !bIsPenetrating)  
             {  
                OverlapBody->SetLinearVelocity(FVector::ZeroVector, false);  
             }  
             else if (DistanceLater <= DistanceNow || bIsPenetrating)  
             {  
                FVector ForceCenter = MyLocation;  
  
                if (bHasHit)  
                {  
                   SetGravitySpaceZ(ForceCenter, GetGravitySpaceZ(HitLoc));  
                }  
                else  
                {  
                   const FVector::FReal MyLocationZ = GetGravitySpaceZ(MyLocation);  
                   SetGravitySpaceZ(ForceCenter, FMath::Clamp(GetGravitySpaceZ(BodyLocation), MyLocationZ - CapsuleHalfHeight, MyLocationZ + CapsuleHalfHeight));  
                }  
  
                OverlapBody->AddRadialForceToBody(ForceCenter, RepulsionForceRadius, RepulsionForce * Mass, ERadialImpulseFalloff::RIF_Constant);  
             }  
          }  
       }  
    }  
}

UpdatedPrimitive에서 오버랩 정보를 받아와 다중 충돌 검사 및 겹침 정도 판단 후 RadialForce를 주어 밀어내기를 처리한다.

밀어내는 강도는 RepulsionForce * Mass 다.

함수 중 PhysWalking 이라는 함수를 발견

좀 더 살펴보니까 PerformMovement 함수에서 움직임을 처리하며, 이 때 StartNewPhysics를 실행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
void UCharacterMovementComponent::StartNewPhysics(float deltaTime, int32 Iterations)  
{  
    if ((deltaTime < MIN_TICK_TIME) || (Iterations >= MaxSimulationIterations) || !HasValidData())  
    {  
       return;  
    }  
  
    if (UpdatedComponent->IsSimulatingPhysics())  
    {  
       UE_LOG(LogCharacterMovement, Log, TEXT("UCharacterMovementComponent::StartNewPhysics: UpdateComponent (%s) is simulating physics - aborting."), *UpdatedComponent->GetPathName());  
       return;    }  
  
    const bool bSavedMovementInProgress = bMovementInProgress;  
    bMovementInProgress = true;  
  
    switch ( MovementMode )  
    {  
    case MOVE_None:  
       break;  
    case MOVE_Walking:  
       PhysWalking(deltaTime, Iterations);  
       break;    case MOVE_NavWalking:  
       PhysNavWalking(deltaTime, Iterations);  
       break;    case MOVE_Falling:  
       PhysFalling(deltaTime, Iterations);  
       break;    case MOVE_Flying:  
       PhysFlying(deltaTime, Iterations);  
       break;    case MOVE_Swimming:  
       PhysSwimming(deltaTime, Iterations);  
       break;    case MOVE_Custom:  
       PhysCustom(deltaTime, Iterations);  
       break;    default:  
       UE_LOG(LogCharacterMovement, Warning, TEXT("%s has unsupported movement mode %d"), *CharacterOwner->GetName(), int32(MovementMode));  
       SetMovementMode(MOVE_None);  
       break;    }  
  
    bMovementInProgress = bSavedMovementInProgress;  
    if ( bDeferUpdateMoveComponent )  
    {  
       SetUpdatedComponent(DeferredUpdatedMoveComponent);  
    }  
}

이제 여기서 이동 모드에 따라 이동 방식이 달라진다.

1
2
3
4
5
6
7
void UCharacterMovementComponent::PhysCustom(float deltaTime, int32 Iterations)  
{  
    if (CharacterOwner)  
    {  
       CharacterOwner->K2_UpdateCustomMovement(deltaTime);  
    }  
}

switch문 마지막 MOVE_Custom 에서 내가 만든 이동 방식으로 적용할 수 있는 걸로 보인다.

중력

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void UCharacterMovementComponent::SetGravityDirection(const FVector& InNewGravityDir)  
{  
    FVector NewGravityDir = InNewGravityDir.GetSafeNormal();  
    if (ensure(!NewGravityDir.IsNearlyZero()))  
    {  
       if (!GravityDirection.Equals(NewGravityDir))  
       {  
          UE_LOG(LogCharacterMovement, Verbose, TEXT("SetGravityDirection: From(%s) To(%s)"), *GravityDirection.ToCompactString(), *NewGravityDir.ToCompactString());  
          GravityDirection = NewGravityDir;  
          WorldToGravityTransform = FQuat::FindBetweenNormals(FVector::UpVector, -NewGravityDir);  
          GravityToWorldTransform = WorldToGravityTransform.Inverse();  
          bHasCustomGravity = !GravityDirection.Equals(DefaultGravityDirection);  
       }  
    }  
}

중력을 바꿔야하니 중력 관련 기능들도 찾아봤다.

WorldToGravityTransform과 GravityToWorldTransform은 쿼터니언인데,
world에서 중력 방향으로, 중력에서 world 방향 회전을 저장한다.

WorldToGravityTransform으로 커스텀 중력 방향으로 회전을 시킬 수 있다.

WorldToGravityTransform * 벡터 = 중력 방향으로 회전된 벡터

DefualtGravityDirection과 방향이 다르면 bHasCustomGravity를 true로 만들어준다.

1
const FVector UCharacterMovementComponent::DefaultGravityDirection = FVector(0.0, 0.0, -1.0);

BeginPlay에서 SetGravityDir를 하면 중력 방향이 거꾸로 적용되는 걸 확인 가능하다. (사진은 VSCode가 고쳐지면 첨부할 예정)

생각보다 쉽게 바뀌어서 놀랐다…

문제는 스프링 암이 중력의 영향을 받지 않는다. 폰 제어 회전 사용을 false로 세팅하니 같이 회전된다.

bUsePawnControlRotation은 Pawn의 컨트롤러의 회전값을 따르도록 세팅하는 설정값이다.

때문에 캐릭터가 중력의 영향을 받아 회전해도 바뀌지 않는 것이다.

작업물 동영상 자리

04-22

이번 프로젝트의 이동 로직은 카메라 방향 기준으로 입력받은 값에 회전값을 보간해 적용하는 방식으로 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void AMovementCompAndAICharacter::Move(const FInputActionValue& Value)
{
	const FVector2D MovementVector = Value.Get<FVector2D>();

	const FRotator CameraRot = FollowCamera->GetComponentRotation();
	const FRotator MovementRot(0.f, CameraRot.Yaw, 0.f);

	FVector MovementDir = FVector::ZeroVector;

	if (MovementVector.Y != 0.f)
	{
		MovementDir += MovementRot.RotateVector(FVector::ForwardVector) * MovementVector.Y;
	}
	if (MovementVector.X != 0.f)
	{
		MovementDir += MovementRot.RotateVector(FVector::RightVector) * MovementVector.X;
	}

	if (!MovementDir.IsNearlyZero())
	{
		FRotator CurrentRot = GetActorRotation();
		FRotator TargetRot = MovementDir.Rotation();
		FRotator NewRot = FMath::RInterpTo(CurrentRot, TargetRot, GetWorld()->GetDeltaSeconds(), 0.1f);
		SetActorRotation(NewRot);
		AddMovementInput(MovementDir.GetSafeNormal());
	}
}

멀티 구조를 뺀 버전이다.

그리고 이게 카메라 회전 로직

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void ADFCharacter::Look(const FInputActionValue& Value)
{
	const FVector2D LookValue = Value.Get<FVector2D>();
	
	if (!SpringArm) return;
	
	FRotator CurrentRotation = SpringArm->GetRelativeRotation();
	
	if (LookValue.X != 0.0f)
	{
		CurrentRotation.Yaw += LookValue.X;
	}
	
	SpringArm->SetRelativeRotation(CurrentRotation);
}

이 때 2가지 문제를 찾았다.

  1. 스프링암이 폰 회전 제어 사용 활성화 상태
  2. 캐릭터 Yaw 상속 비활성화 상태

우선 이 커스텀 중력의 경우, 캐릭터의 CharacterMovement의 Gravity Dir를 변경하는 방식이다.

때문에 컨트롤러 자체 회전에는 영향이 없었고, 스프링암도 캐릭터가 뒤집혀도 같이 따라가지 않는 문제가 있었다.

이건 단순히 스프링암이 컨트롤러에 영향을 받지 않도록 하니 해결되었다.

문제는 2 번째.

캐릭터의 회전을 카메라 기준으로 잡기에, 스프링 암이 캐릭터 Yaw 회전에 영향을 받으면 캐릭터 회전 시 스프링 암도 같이 회전하게 되어 회전이 멈추지 않게 된다.

때문에 Yaw 상속을 해제했지만, 캐릭터가 받는 중력이 변하게 되면 캐릭터 기준으로 스프링 암의 회전도 변해야한다.

특히 구 형태의 행성의 경우, 이동할 때 마다 미세하게 받는 중력의 방향이 달라지기에 변할 때 마다 매번 변경해줘야한다.

그렇다고 Yaw를 상속받으면 캐릭터의 이동이 꼬이게 되어서 문제다.

이건 아직 해결하지 못했다.

작업물 동영상 자리

내일 예비군도 가야하고, 오늘 정말 뜻밖의 소식을 들어서 집중이 되지 않았다…

아직 다음 달 까지 시간이 있으니까 좀 더 생각해봐야겠다.

This post is licensed under CC BY 4.0 by the author.