프로그래밍/Unreal Engine

230503_언리얼 포트폴리오(Project_Zombie_FPS) : Animal

이동헌 2023. 5. 3. 11:37

내 포트폴리오 프로젝트에는 사냥 컨텐츠를 만들기 위해 동물을 구현했다. 이 동물 구현에 들어간 것들을 정리하고자 한다.

 

  가장 먼저 동물 캐릭터 클래스를 만들어야 한다. 모든 동물이 공통적으로 가지는 특성을 정리하기 위해 가장 상위 클래스로 AAnimalCharacter 클래스를 만들었다. 모든 동물이 공통적으로 가져야 할 멤버들을 여기 구현하였다. 모든 것을 다루지는 않고, 동물 관련해서 다룰 것들은 아래와 같다.

1. 동물의 발소리

2. 동물이 경사면을 따라 몸을 기울임

3. 동물이 죽었을 때 상호작용을 통해 아이템을 줌

이때 3번은 상호작용 파트를 설명할때 한번에 설명하도록 할 것이다.

 

모든 동물 클래스의 부모가 되는 AnimalCharacter 클래스의 헤더 일부분이다.

#include "InteractInterface.h" // 상호작용 인터페이스 헤더

class PROJECT_ZOMBIE_FPS_API AAnimalCharacter : public ACharacter, public IInteractInterface
{
	// 생략...

protected:
	// * 능력치
	float MaxHp = 100.f;
	float CurrentHp = 100.f;
	float CurrenHp = 100.f;
	float Stamina = 4.f;
	float Hunger = 100.f;

	// * 생명
	bool Life = true;
	float LifeTime = 0.f;

	// * 상호작용 관련
	// * 아이템 데이터 테이블
	UPROPERTY()
		class UDataTable* ItemDataTable;
	// * 아이템 데이터 테이블 핸들
	UPROPERTY(VisibleAnywhere, meta = (AllowPrivateAccess = true))
		struct FDataTableRowHandle ItemRowHandle;
	// * 상호작용 시 얻을 아이템 데이터
	FItemData ItemData;
	// * 상호작용(아이템 파밍) 여부
	bool IsInteracted = false;

	// * 속도
	float WalkSpeed = 100.f;
	float RunSpeed = 600.f;

	// * 트리거
	bool ShouldRun = false;
	bool ShouldSleep = false;
	bool IsTired = false;
	bool IsHungry = false;

	// * 몽타주
	UPROPERTY()
		class UAnimMontage* Montage;

	// * 기타
	float RestToSleepTime = 0.f;

	bool StartRestToSleep = false;

	// * IK
	float IKInterpSpeed = 15.f;

	float IKLeftFootOffset;
	float IKRightFootOffset;
	float IKLeftHandOffset;
	float IKRightHandOffset;

	FRotator IKLeftFootAnkleRotator;
	FRotator IKRightFootAnkleRotator;
	FRotator IKLeftHandAnkleRotator;
	FRotator IKRightHandAnkleRotator;

	float IKHipOffset;

	FRotator IKPelvisRotator;
	FRotator IKLegRotator;

	bool IKFootTrace(float TraceDistance, FName Socket, float& Offset, FVector& HitPoint, FVector& Normal);

	bool DeadBodyTrace(float TraceDistance, FName Socket, FVector& Normal);

	// * 발소리
	UPROPERTY()
		class USoundCue* GrassSound;
	UPROPERTY()
		class USoundCue* GroundSound;
	UPROPERTY()
		class USoundCue* StoneSound;

public:
	// * 상호작용 인터페이스 함수
	UFUNCTION(BlueprintCallable, BlueprintNativeEvent)
		void Interaction();
	virtual void Interaction_Implementation() override;

	UFUNCTION()
		void FootStep();
        
    // 생략...
};

 

1. 동물의 발소리

FootStep이라는 함수 내부를 살펴보자.

void AAnimalCharacter::FootStep()
{
	FVector AnimalLocation = GetActorLocation();
	FVector Start(AnimalLocation.X, AnimalLocation.Y, AnimalLocation.Z);
	FVector End(AnimalLocation.X, AnimalLocation.Y, AnimalLocation.Z - 150.f);

	FHitResult HitResult;
	TArray<AActor*> ActorToIgnore;
	EDrawDebugTrace::Type DebugType = EDrawDebugTrace::None;

	bool bTrace = UKismetSystemLibrary::LineTraceSingle(GetWorld(), Start, End,
		UEngineTypes::ConvertToTraceType(ECC_Visibility), true, ActorToIgnore,
		DebugType, HitResult, true);

	USoundCue* FootStepSound = nullptr;
	// 사운드 종류 정해주기
	if (HitResult.PhysMaterial.Get() != nullptr)
	{
		switch (HitResult.PhysMaterial.Get()->SurfaceType)
		{
		case SurfaceType1: // Grass
			FootStepSound = GrassSound;
			break;
		case SurfaceType2: // Ground
			FootStepSound = GroundSound;
			break;
		case SurfaceType3: // Stone
			FootStepSound = StoneSound;
			break;
		case SurfaceType4: // Water, 동물 행동반경엔 안들어감
		default:
			FootStepSound = GrassSound;
			break;
		}
	}

	// 사운드 재생
	if(bTrace && FootStepSound != nullptr)
		UGameplayStatics::PlaySoundAtLocation(GetWorld(), FootStepSound, HitResult.ImpactPoint);

	// AI 인지를 위한 노이즈 발생
	MakeNoise(1.f, this);
}

  함수가 실행되면 몸통 기준으로 아래로 트레이스를 발사하고, 트레이스가 만난 부분의 지면의 PhysMaterial에 따라 소리 종류를 바꾸어 재생시켜준다. 소리 재생은 2D가 아닌 3D로 하여 거리에따른 소리 감쇠가 있고, MakeNoise를 통해 AI컨트롤러의 청각 인지가 가능하도록 하였다.

 

PhysMaterial은 PhysicalMaterial이고, 다음과 같이 적용한다.

PhysicalMaterial 생성 : 우클릭 > 피직스 > 피지컬 머티리얼 > PhysicalMaterial 선택

이후 세팅 > 프로젝트 세팅 > 엔진 > 피직스 > Physical Surface 에 SurfaceType을 내가 원하는대로 수정한다.

  이렇게 추가한 Surface를 아까 만든 피지컬 머티리얼에서 설정해준다.

  그러면 라인 트레이스가 해당 랜드스케이프 머티리얼에 닿아서 얻은 HitResult에 PhysMaterial 정보가 SurfaceType1, SurfaceType2, ...등등으로 얻어진다. 이를 이용해서 지형에 맞는 발소리를 재생하도록 한 것이다. 단, PhysicalMaterial헤더 포함은 필수!

  한편, 소리를 PlaySoundAtLocation으로 재생하였는데, 이를 이용하여 소리를 재생하기 위해서는 소리에 감쇠가능 옵션이 체크되어야한다. 우선 예로 Grass라는 사운드 큐를 열어보자. Attenuation 부분의 Override Attenuation을 체크해주면 아래 감쇠(볼륨), 감쇠(공간화) 옵션이 나온다. 이 옵션을 체크하지 않으면 소리가 PlaySoundAtLocation 함수를 사용하더라도 2D사운드, 즉 위치에 상관없이 플레이어에게 항상 들리게 된다.

 

이후 AnimNotify_FootStep에서 해당 FootStep함수를 실행하도록 구현한다.

void UAnimalAnimNotify_FootStep::Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation)
{
	AAnimalCharacter* Animal = Cast<AAnimalCharacter>(MeshComp->GetOwner());
	if (Animal != nullptr)
	{
		Animal->FootStep();
	}
}

그리고 동물의 애니메이션에서 발소리가 나길 원하는 곳에서 노티파이를 호출하면 발소리 구현은 완성이다.

 

2. 경사면에서의 동물

  사람과 달리, 네 발로 걷는 동물은 몸통의 각도가 경사면에 따라 달라진다. 사람은 경사면에 따라 다리의 구부러짐이 달라지고, 이는 언리얼엔진에 IK 시스템으로 잘 구현되어 있다. 하지만 네발 동물의 경우는 찾아보기가 힘들었다.

그래서 나는 이를 구현하기 위해 네 발 동물에 대한 고찰을 했고, 다음과 같은 규칙을 설정했다.

1. 동물이 가만히 서있다면 몸통이 기운만큼 반대로 다리를 기울여준다. 이 때 다리에 IK시스템을 적용한다.

2. 동물이 움직인다면 몸통만을 기울여준다.

3. 죽은 경우 몸통을 기준으로 다시 기울기를 적용해준다.

 

이 시스템을 위한 기본 함수 IKFootTrace를 살펴보자.

bool AAnimalCharacter::IKFootTrace(float TraceDistance, FName Socket, float& Offset, FVector& HitPoint, FVector& Normal)
{
	FVector SocketLocation = GetMesh()->GetSocketLocation(Socket);
	FVector ActorLocation = GetActorLocation();

	FVector Start(SocketLocation.X, SocketLocation.Y, ActorLocation.Z);
	FVector End(SocketLocation.X, SocketLocation.Y, SocketLocation.Z - TraceDistance);

	FHitResult HitResult;
	TArray<AActor*> ActorToIgnore;
	EDrawDebugTrace::Type DebugType = EDrawDebugTrace::None;

	bool bTrace = UKismetSystemLibrary::LineTraceSingle(GetWorld(), Start, End,
		UEngineTypes::ConvertToTraceType(ECC_Visibility), true, ActorToIgnore,
		DebugType, HitResult, true);

	if (bTrace)
	{
		HitPoint = HitResult.ImpactPoint;
		Normal = HitResult.ImpactNormal;
		Offset = (HitResult.ImpactPoint - GetMesh()->GetComponentLocation()).Z;// -IKHipOffset;
		return true;
	}
	else
	{
		HitPoint = FVector(0.f);
		Normal = FVector(0.f);
		Offset = 0.f;
		return false;
	}
}

  이 함수는 간단하게 정리하면 원하는 소켓으로부터 아래로 라인 트레이스를 쏴 부딪힌 곳과의 거리, 부딪힌 곳의 좌표 및 법선벡터 정보를 넘겨받는 함수이다. 이때 동물마다 소켓 이름이 다르므로 각 동물 클래스별로 Tick함수에서 구현해주었다. 아래는 사슴의 Tick함수 일부분이다.

void ADeerDoeCharacter::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	// * IK
	float LegLength = GetCapsuleComponent()->GetScaledCapsuleHalfHeight() / 2.f;

	float LFOffset;
	float RFOffset;
	float LHOffset;
	float RHOffset;

	float HipOffset;

	FVector LFHitPoint;
	FVector RFHitPoint;
	FVector LHHitPoint;
	FVector RHHitPoint;

	FVector LFNormal;
	FVector RFNormal;
	FVector LHNormal;
	FVector RHNormal;

	// 발에서 바닥으로 트레이스 발사, HitPoint와 지형 Normal vector 가져오기
	bool bTraceLeftFoot = IKFootTrace(LegLength, TEXT("DeerDoe_-L-Foot"), LFOffset, LFHitPoint, LFNormal);
	bool bTraceRightFoot = IKFootTrace(LegLength, TEXT("DeerDoe_-R-Foot"), RFOffset, RFHitPoint, RFNormal);
	bool bTraceLeftHand = IKFootTrace(LegLength, TEXT("DeerDoe_-L-Finger0"), LHOffset, LHHitPoint, LHNormal);
	bool bTraceRightHand = IKFootTrace(LegLength, TEXT("DeerDoe_-R-Finger0"), RHOffset, RHHitPoint, RHNormal);

	// 충돌 한 경우에만 체크하기
	if (!(bTraceLeftFoot && bTraceRightFoot && bTraceLeftHand && bTraceRightHand))
	{
		//GEngine->AddOnScreenDebugMessage(-1, 3.f, FColor::Blue, TEXT("No..Man"));
		return;
	}

	// HipOffset 구하기
	// Hip은 회전에 의해 위치가 변하지 않으므로 Offset으로 인위적인 변화를 줌
	// 딱 뒷발 중 가장 많이 떨어진 발이 떨어진 만큼 옮겨줄 것
	// 앞발쪽은 회전에 의해 위치가 알아서 잡히므로 상관없다.
	HipOffset = UKismetMathLibrary::FMin(LFOffset, RFOffset);
	
	/*-------------------------
			* 몸통 회전
	-------------------------*/

	// 앞발 충돌지점 평균
	FVector HandHitPoint = (LHHitPoint + RHHitPoint) / 2.f;

	// 앞발 평균, 뒷발 HitPoint를 모두 지나는 평면 Normal 구하기
	FVector LFtoRF = RFHitPoint - LFHitPoint;
	FVector RFtoHandAverage = HandHitPoint - RFHitPoint;
	FVector PlaneNormal = FVector::CrossProduct(RFtoHandAverage, LFtoRF);
	PlaneNormal.Normalize();
	//DrawDebugLine(GetWorld(), HandHitPoint, HandHitPoint + PlaneNormal, FColor::Red);

	// 전방 벡터와 z축을 포함하는 평면 Normal 구하기
	// 몸을 좌우로 기울이지 않도록 평면에 Projection 해줄 것
	FVector ForwardVector = GetActorForwardVector();
	FVector PlaneToProjection = FVector::CrossProduct(ForwardVector, FVector(0.f, 0.f, 1.f));
	FVector ProjectedPlaneNormal = UKismetMathLibrary::ProjectVectorOnToPlane(PlaneNormal, PlaneToProjection);

	// Normalize하기
	ProjectedPlaneNormal.Normalize();
	ForwardVector.Normalize();

	// 몸을 기울일 각도 구하기
	float XYLength = UKismetMathLibrary::Sqrt(UKismetMathLibrary::Square(ProjectedPlaneNormal.X) + UKismetMathLibrary::Square(ProjectedPlaneNormal.Y));
	// 방향판별을 위한 변수
	FVector PlaneDirection(ProjectedPlaneNormal.X, ProjectedPlaneNormal.Y, 0.f);
	PlaneDirection.Normalize();
	// 내적값을 곱해주기
	XYLength *= FVector::DotProduct(PlaneDirection, ForwardVector);

	// 골반 회전 각도
	FRotator PelvisRotator(0.f, 0.f, UKismetMathLibrary::DegAtan2(XYLength, ProjectedPlaneNormal.Z));

	// 다리는 반대로 회전
	FRotator LegRotator(0.f, 0.f, -PelvisRotator.Roll);

	/*-------------------------
			* 다리 보정
	-------------------------*/

	LFOffset -= HipOffset;
	RFOffset -= HipOffset;
	LHOffset -= HipOffset;
	RHOffset -= HipOffset;

	// 앞발은 몸통 회전 시 높낮이에 변화가 있으므로 추가 보정이 필요
	float BodyLength = 80.f;
	float RotationOffset = BodyLength * UKismetMathLibrary::DegSin(-PelvisRotator.Roll);

	LHOffset -= RotationOffset;
	RHOffset -= RotationOffset;

	/*-------------------------
			* 발목 회전
	-------------------------*/

	FRotator LFRotator(-UKismetMathLibrary::DegAtan2(LFNormal.X, LFNormal.Z), 0.f, UKismetMathLibrary::DegAtan2(LFNormal.Y, LFNormal.Z));
	FRotator RFRotator(-UKismetMathLibrary::DegAtan2(RFNormal.X, RFNormal.Z), 0.f, UKismetMathLibrary::DegAtan2(RFNormal.Y, RFNormal.Z));
	FRotator LHRotator(-UKismetMathLibrary::DegAtan2(LHNormal.X, LHNormal.Z), 0.f, UKismetMathLibrary::DegAtan2(LHNormal.Y, LHNormal.Z));
	FRotator RHRotator(-UKismetMathLibrary::DegAtan2(RHNormal.X, RHNormal.Z), 0.f, UKismetMathLibrary::DegAtan2(RHNormal.Y, RHNormal.Z));

	//GEngine->AddOnScreenDebugMessage(-1, 0.f, FColor::Red, FString::Printf(TEXT("LH : %f\nRH : %f\nLF : %f\nRF : %f\nHipOffset : %f"), LHOffset, RHOffset, LFOffset, RFOffset, HipOffset));
	// 보간
	IKHipOffset = UKismetMathLibrary::FInterpTo(IKHipOffset, HipOffset, DeltaTime, IKInterpSpeed);
	
	IKLeftFootOffset = UKismetMathLibrary::FInterpTo(IKLeftFootOffset, LFOffset, DeltaTime, IKInterpSpeed);
	IKRightFootOffset = UKismetMathLibrary::FInterpTo(IKRightFootOffset, RFOffset, DeltaTime, IKInterpSpeed);
	IKLeftHandOffset = UKismetMathLibrary::FInterpTo(IKLeftHandOffset, LHOffset, DeltaTime, IKInterpSpeed);
	IKRightHandOffset = UKismetMathLibrary::FInterpTo(IKRightHandOffset, RHOffset, DeltaTime, IKInterpSpeed);

	// 각도 보간
	IKPelvisRotator = UKismetMathLibrary::RInterpTo(IKPelvisRotator, PelvisRotator, DeltaTime, IKInterpSpeed);
	IKLegRotator = UKismetMathLibrary::RInterpTo(IKLegRotator, LegRotator, DeltaTime, IKInterpSpeed);

	IKLeftFootAnkleRotator = UKismetMathLibrary::RInterpTo(IKLeftFootAnkleRotator, LFRotator, DeltaTime, IKInterpSpeed);
	IKRightFootAnkleRotator = UKismetMathLibrary::RInterpTo(IKRightFootAnkleRotator, RFRotator, DeltaTime, IKInterpSpeed);
	IKLeftHandAnkleRotator = UKismetMathLibrary::RInterpTo(IKLeftHandAnkleRotator, LHRotator, DeltaTime, IKInterpSpeed);
	IKRightHandAnkleRotator = UKismetMathLibrary::RInterpTo(IKRightHandAnkleRotator, RHRotator, DeltaTime, IKInterpSpeed);
}

네개의 발을 대상으로 하기 때문에 복잡해 보이지만, 다음과 같은 과정이다.

1. 네 발 모두 IKFootTrace 함수를 실행시켜 Offset, 좌표, 법선벡터 저장

2. 두 뒷다리 기준 가장 많이 떨어진 만큼 HipOffset으로 지정, HipOffset만큼 골반 위치 변경

3. 앞 다리 충돌 좌표 평균값과 뒷 다리 충돌 좌표들로 평면을 구성(좌표 3개), 해당 평면의 법선벡터 구하기

4. 3에서 구한 법선벡터를 YZ평면에 사영시킨 벡터구하기(*사슴 기준 YZ평면, 몸이 좌우로 기울면 안되기 때문)

5. 사영시킨 벡터의 Y축 기준 각도를 구하여 해당 각도만큼 몸을 기울이기

6. 기울인 만큼 다리는 반대로 회전시키기

7. 처음에 구한 법선벡터를 이용해 발목 회전시키기

  하지만 위 Tick함수는 값을 구할 뿐이고 애니메이션 블루프린트에서 값을 이용해주어야 한다. 애니메이션 블루프린트는 다음과 같이 구성하였다.

1. HipOffset과 법선벡터 각도만큼 골반 위치 조정해주기(본 트랜스폼) > 캐시 포즈 저장(BodyTilt)

2. BodyTilt 캐시 포즈를 이용하여 네 다리 회전해주기(본 트랜스폼) > 캐시 포즈 저장(LegTilt)

3. LegTilt 캐시 포즈를 이용하여 네 다리에 IK시스템 적용(2본IK) > 캐시 포즈 저장(Leg)

4. Leg 캐시 포즈를 이용하여 발목 회전 적용(본 트랜스폼) > 캐시 포즈 저장(FullIK)

5. 움직이는 경우 BodyTilt, 서있는 경우 FullIK, 죽은 경우 아무것도 적용하지 않은 기본 스테이트 머신을 최종 애니메이션 포즈로 적용

이렇게 적용해주어 사슴의 움직임을 완성하였다. 아래는 IK시스템 확인 및 사슴의 움직임을 확인한 영상들이다.

  처음에 이를 구현하기 위해서 사람의 IK시스템을 그대로 적용해보려 했는데, 생각해보니 고려할 부분이 너무 많았다. 사람과 달리 네 발이 달린 동물은 몸통 자체가 기울어야 하며, 다리가 네개라 어떤 기준을 잡아야할지 고민한 끝에 위와 같이 구현하게 되었다. 구체적인 본 트랜스폼 옵션이나 2본 IK 옵션 등은 필요한 분들이 계신다면 나중에 추가해야겠다.