Post

UE - 언리얼 C++ TPS 공부

C++

UE - 언리얼 C++ TPS 공부

오늘은 TPS에 대해 공부해봤다. C++로 간단한 TPS를 구현했으며, 다른 사람이 만든 프로젝트의 내부 구조도 조사해보았다.

내가 구현한 것

Character, Player

간단히 설명하자면 기본이 되는 캐릭터 클래스, 이걸 토대로 플레이어 클래스를 만들었다. Attack, Hit, KillCount와 같은 공통 기능과 hp, strength 와 같은 변수를 가진다.

PlayerBase에선 카메라, 무기, 입력 에 대해 정의한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
APlayerBase::APlayerBase()
{
	CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
	CameraBoom->SetupAttachment(RootComponent);

	CameraBoom->TargetArmLength = 120;
	CameraBoom->SocketOffset = FVector(0, 60, 60);
	
	FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollwCamera"));
	FollowCamera->SetupAttachment(CameraBoom);
	
	InputMappingContext = CreateDefaultSubobject<UInputMappingContext>(TEXT("InputMappingContext"));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void APlayerBase::BeginPlay()
{
	Super::BeginPlay();

	APlayerController* PC = Cast<APlayerController>(GetController());

	if (IsValid(PC))
	{
		ULocalPlayer* Player = PC->GetLocalPlayer();
		if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(Player))
		{
			Subsystem->AddMappingContext(InputMappingContext, 0);
		}
	}
	
	WeaponActor = GetWorld()->SpawnActor<AWeapon>(Weapon);
	if (IsValid(WeaponActor))
	{
		FAttachmentTransformRules TransformRules(EAttachmentRule::SnapToTarget, true); //.
		WeaponActor->AttachToComponent(GetMesh(), TransformRules, TEXT("WeaponSocket"));  //
		WeaponActor->SetOwner(this);
	}
}

입력 같은 기능들은 전에 블루프린트로 구현했던 것들과 굉장히 유사하다.

새로 소개할 만한 클래스는 Weapon과 Bullet이 있는데,

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
UCLASS()
class BASIS_API AWeapon : public AActor
{
	GENERATED_BODY()
	
public:	
	// Sets default values for this actor's properties
	AWeapon();

protected:
	// Called when the game starts or when spawned
	virtual void BeginPlay() override;

public:	
	void Fire();

	UPROPERTY(VisibleAnywhere)
	TObjectPtr<class USkeletalMeshComponent> Mesh;

	UPROPERTY(VisibleAnywhere)
	TObjectPtr<USceneComponent> MuzzleOffset;

	UPROPERTY(EditAnywhere)
	TObjectPtr<class UAnimMontage> FireMontage;

	UPROPERTY(EditAnywhere)
	TSubclassOf<class ABullet> Bullet;
};
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
UCLASS()
class BASIS_API ABullet : public AActor
{
	GENERATED_BODY()
	
public:
	// Sets default values for this actor's properties
	ABullet();

protected:
	// Called when the game starts or when spawned
	virtual void BeginPlay() override;

public:	
	// Called every frame
	virtual void Tick(float DeltaTime) override;

	UFUNCTION()
	void OnHit(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComponent, FVector NormalImpulse, const FHitResult& Hit);

private:
	UPROPERTY(VisibleAnywhere)
	TObjectPtr<class UStaticMeshComponent> StaticMeshComponent;

	UPROPERTY(VisibleAnywhere)
	TObjectPtr<class UProjectileMovementComponent> ProjectileMovementComponent;
};

여기서 볼만한 것들은 Weapon 클래스의 애님 몽타주, 그리고 Fire 함수 Bullet에선 UProjectileMovementComponent 정도가 있는 것 같다.

Bullet

강의에서 애니메이션 슬롯과 몽타주를 이용해 총을 발사하는 애니메이션을 재생했기에 조사해봤다.

애니메이션 몽타주

  • 여러 애니메이션 시퀀스를 단일 에셋으로 결합하여 재생할 수 있다.
  • 애니메이션 시퀀스를 동적으로 조합할 수 있어, 특정 분기에 따라 특정 애니메이션을 재생하거나, 애니메이션 전환 및 이벤트 실행 등 다양한 동작을 효율적으로 관리할 수 있다.
  • 애니메이션의 순서와 타이밍을 제어할 수 있어, 복잡한 애니메이션 흐름을 만들고 관리할 수 있다.
  • 이벤트 트리거를 사용하여 애니메이션 중간에 특정 작업을 실행할 수 있다.

애니메이션 슬롯

  • 애니메이션을 결합하고 레이어링하는 기능을 제공하여, 여러 애니메이션을 동시에 실행할 수 있도록 돕는다.
  • 우선순위 설정을 통해 특정 애니메이션이 다른 애니메이션을 덮어쓰거나 우선시되도록 할 수 있다.
  • 애니메이션 레이어를 활용하여, 예를 들어 상체와 하체에 별도의 애니메이션을 적용하고, 각 레이어에서 독립적인 애니메이션을 실행할 수 있다.

코드에선 Fire 함수에서 애니메이션 몽타주를 사용한다.

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
void AWeapon::Fire()
{
	UAnimInstance* AnimInstance = Mesh->GetAnimInstance();

	if (IsValid(AnimInstance) && IsValid(FireMontage))
	{
		AnimInstance->Montage_Play(FireMontage);
	}
	
	if (IsValid(Bullet))
	{
		FRotator SpawnRotation = MuzzleOffset->GetComponentRotation();
		FVector SpawnLocation = MuzzleOffset->GetComponentLocation();

		FActorSpawnParameters SpawnParams;
		SpawnParams.Owner = GetOwner();

		APlayerBase* PB = Cast<APlayerBase>(GetOwner());

		if (!IsValid(PB))
		{
			GetWorld()->SpawnActor<ABullet>(Bullet, SpawnLocation, SpawnRotation, SpawnParams);
			return;
		}

		APlayerController* PC = Cast<APlayerController>(PB->GetController());
		int32 x, y;

		if (!IsValid(PC))
		{
			GetWorld()->SpawnActor<ABullet>(Bullet, SpawnLocation, SpawnRotation, SpawnParams);
			return;
		}
		
		PC->GetViewportSize(x,y); //
		FVector WorldCenter;
		FVector WorldFront;
		PC->DeprojectScreenPositionToWorld(x/2.0f, y/2.0f, WorldCenter, WorldFront); //
		WorldCenter += WorldFront * 10000; //
		SpawnRotation = UKismetMathLibrary::FindLookAtRotation(SpawnLocation, WorldCenter); //
		GetWorld()->SpawnActor<ABullet>(Bullet, SpawnLocation, SpawnRotation, SpawnParams); //
	}
}

이렇게 구현했다. 원래 Anim State Machine에서 isFire 변수를 만들고 검사하는 방식으로도 만들 수 있을 것 같지만, 아마 연발처럼 연속적이고 빠른 동작을 관리할 때, 각 동작에 대한 상태 전환과 조건을 계속 관리해야 하므로, 애니메이션 몽타주를 이용한 것 이라고 생각한다.

총알 위치와 방향

총알을 생성하는 방식을 보면 뷰포트 사이즈를 구한 다음, DeprojectScreenPositionToWorld 이 함수를 사용하는데, 이 함수는 스크린 좌표계의 위치를 월드 좌표계로 변환 시켜주는 함수다. 원래 로컬 -> 월드 -> 뷰 -> 스크린 좌표니 프로젝션 매트릭스와 카메라 매트릭스의 역행렬을 순서대로 곱한 것이 아닐까 라는 추측을 해보고 계속 들어가 봤다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool UGameplayStatics::DeprojectScreenToWorld(APlayerController const* Player, const FVector2D& ScreenPosition, FVector& WorldPosition, FVector& WorldDirection)
{
	ULocalPlayer* const LP = Player ? Player->GetLocalPlayer() : nullptr;
	if (LP && LP->ViewportClient)
	{
		// get the projection data
		FSceneViewProjectionData ProjectionData;
		if (LP->GetProjectionData(LP->ViewportClient->Viewport, /*out*/ ProjectionData))
		{
			FMatrix const InvViewProjMatrix = ProjectionData.ComputeViewProjectionMatrix().InverseFast();
			FSceneView::DeprojectScreenToWorld(ScreenPosition, ProjectionData.GetConstrainedViewRect(), InvViewProjMatrix, /*out*/ WorldPosition, /*out*/ WorldDirection);
			return true;
		}
	}

	// something went wrong, zero things and return false
	WorldPosition = FVector::ZeroVector;
	WorldDirection = FVector::ZeroVector;
	return false;
}

예상대로 스크린 좌표계의 점(ScreenPosition) → 뷰-프로젝션 역행렬(View-Projection Matrix Inverse)을 통해 → 월드 좌표계(WorldPosition) 및 월드 방향 벡터(WorldDirection)로 변환되는 함수였다.

여기선 뷰포트 사이즈의 절반, 즉 화면의 중앙을 구한 뒤, 월드 좌표로 변환시켜 총알의 위치와 방향을 정해주는 로직을 사용한다.

Bullet

Bullet 클래스의 경우,

1
2
3
4
5
6
7
8
9
10
11
12
ABullet::ABullet()
{
	PrimaryActorTick.bCanEverTick = true;

	StaticMeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMeshComponent"));
	RootComponent = StaticMeshComponent;

	ProjectileMovementComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovementComponent"));
	ProjectileMovementComponent->InitialSpeed = 20000.0f;
	ProjectileMovementComponent->MaxSpeed = 20000.0f;
	InitialLifeSpan = 5.0f;
}

언리얼에서 이미 투사체의 기능을 구현한 ProjectileMovementComponent가 있어 쉽게 투사체를 구현할 수 있었다.

C++로 구현하면서 배운 몇 가지들이 있기에 그걸 정리하겠다.

생성자는 게임의 실행 유무와 관련이 없고, 단지 월드 내에서 객체가 생성되는 한 순간에만 호출이 된다. 객체가 에디터에서 배치되었거나, SpawnActor와 같은 방식으로 생성되었을 때 생성자를 호출한다. 그 이후 게임을 아무리 재시작해도 생성자는 호출되지 않는다.

InputMappingContext = CreateDefaultSubobject< UInputMappingContext >(TEXT(“InputMappingContext”)); 이 코드는 이 변수를 소유한 클래스의 블루프린트의 디테일 창에 TEXT로 정의한 이름과 동일한 슬롯을 생성하고, 그 슬롯에 클래스를 정해주면 이 변수에 그 클래스의 인스턴스를 생성하는 동작을 한다.

블루프린트 디테일 창에서 찾은 모습

SetupPlayerInputComponent 함수는 플레이어의 입력을 처리하는 InputComponent를 통해, 특정 동작에 대해 내가 정의한 함수를 입력 액션에 바인딩하는 함수다. 이 함수는 BeginPlay 함수가 동작된 이후 한 번만 실행한다.

결과

다른 사람이 구현한 TPS 분석

이번엔 팹에서 찾을 수 있는 에셋의 내부 구현을 살펴보았다. 모두 블루프린트로 되어있고, 양도 많아서 완전히 해석하지는 못했지만, 배운 것들을 정리해보겠다.

이 분은 위젯 매니저, 장비 매니저, 매니저 슈터 등 다양한 매니저를 플레이어 컴포넌트에 넣어두셨다. 이 매니저들을 가지고, 총알을 발사 할 때 필요한 정보들을 가져온다.

예를 들어 총알을 발사하기 전, 우선 3가지를 검사하는데,

  1. 위젯 매니저를 통해 인벤토리가 열려있는가 확인.
  2. 장비 매니저를 통해 커스터마이제이션 모드가 켜져있나 확인.
  3. 매니저 슈터를 통해 캐릭터가 장전 중인가 확인한다.

왠지는 모르겠지만 똑같은 비교문을 굉장히 많이 사용하시는데, 총알, 무기, 현 상태 등 거의 모든 함수에서 이런 것들을 비교하고 있었다.

또 기억에 남는건, 오브젝트 풀링이다. 이건 최적화 기법인데, 총알을 필요한 순간마다 생성하는 것이 아닌, 미리 여러 개 만들어 놓고 필요할 때 활성화 하지 않은 총알을 활성화 후 보여주는 방식이다. DX로 게임을 구현할 때도 구현이 쉬워서 자주 이용한 기법이다.

여기선 총알의 위치와 방향을 계산하는 방식이 조금 다른데,

우선 앞 방향 벡터를 구한다. 카메라 위치와 총구의 위치 사이를 오프셋으로 구하기 위해, 카메라의 트랜스폼을 총구 트랜스폼의 좌표계로 변환시켜 서로의 거리를 구한다. 이건 앞 방향 벡터를 구할 때도 사용이 가능하기에 새로운 트랜스폼의 스케일에 넣어놨다.

또한 탄 퍼짐을 구현했는데, RandomUnitVectorinElipticalConeinDegress 함수를 이용했다. 이 함수는 기준 방향에서 랜덤한 벡터를 생성하여 탄의 퍼짐을 구현하는 함수다.

start 는 카메라 트랜스폼의 위치에서 카메라 위치 - 총구 위치 의 반전 거리 만큼 이동한 거리 즉 총구의 위치를 의미한다. end 는 탄환이 목표 지점에 도달하는 위치를 구한 것인데, 여기서는 탄 퍼짐을 고려한 방향 벡터를 구하기 위해 함수를 사용한다.

아까와는 다르게 여기선 방금 구한 start와 end를 이용해 라인트레이스를 한다. 탄환이 실시간으로 충돌을 검사하는 대신, 라인 트레이스를 사용하여 미리 충돌 정보를 구해놓은 방식이라고 한다.

오늘은 여기서 마무리 짓겠다. 아직 부족한 부분이 꽤 많지만, 천천히 나머지 부분도 알아갈 예정이다.

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