본문으로 바로가기

에이밍 시스템 구현

TPS 게임에서 에이밍 시스템 구현을 다뤄본다. TPS 게임에서 플레이어는 보통 크로스헤어에 총알이 피격되길 기대한다. 이를 위해서는 두 번의 선분과 물체의 충돌 검사를 진행해야 한다. 직관적으로 생각했을 때는 크로스 헤어에서부터 물체까지 한 번의 충돌 검사일 것 같지만 왜 두 번의 충돌 검사가 필요한지 이 글에서 다룬다.

 

▲ 그림 1

첫 번째 충돌 검사

먼저 화면 2D상의 좌표인 크로스헤어 텍스처 좌표에서 우리가 바라보는 3차원 깊은 방향으로 선분을 그어 물체와 충돌 여부를 파악해야 한다. 즉, 카메라에서 정면으로 레이저를 쏴서 피격되는 곳에 총알이 피격되길 플레이어는 기대한다. 이를 그림으로 표현하면 다음과 같다.

▲ 그림 2

이를 위해서는 먼저 2D상의 크로스 헤어의 좌표를 3D 공간상의 좌표로 계산해야하고, 크로스 헤어의 공간 좌표에서 전방에 해당하는 방향 좌표가 필요하다. 이때 우리는 UGameplayStatics::DeprojectScreenToWorld 함수를 사용할 수 있다. 이 함수의 원형을 살펴보면 다음과 같다.

bool UGameplayStatics::DeprojectScreenToWorld (APlayerController const* Player, 
	const FVector2D& ScreenPosition, 
	/* output */ FVector& WorldPosition , 
	/* output */ FVector& WorldDirection)

WorldPositionWorldDirection은 참조로 전달받는다. 그래서 입력을 위한 매개변수가 아니라 출력을 위한 매개변수이다. 이 두 변수에 결과를 얻을 크로스헤어의 3차원 좌표 변수와 크로스헤어가 나아갈 방향 변수를 전달하여 다음과 같이 사용할 수 있다. 참고로 3차원을 2차원에 투영하는 것을 Projection이라고 하고, 2차원을 다시 3차원으로 복원하는 과정을 Unprojection 혹은 Deprojection이라고 한다. Deprojection에 대해서는 예전에 다룬 이 글을 참고하자. 이 함수의 내부를 살펴보면 이 글에 나와있는 설명과 동일함을 확인할 수 있다.

// Get screen space location of crosshairs
FVector2D CrosshairLocation(ViewportSize.X / 2.f, ViewportSize.Y / 2.f);
CrosshairLocation.Y -= 50.f;
FVector CrosshairWorldPosition;
FVector CrosshairWorldDirection;

// Get world position and direction of crosshairs
bool bScreenToWorld = UGameplayStatics::DeprojectScreenToWorld
    (UGameplayStatics::GetPlayerController(this, 0),// Player의 controller
    CrosshairLocation,  // 2D 상의 Crosshair의 위치
    CrosshairWorldPosition,   // 3D상의 Crosshair 계산 결과를 얻을 변수
    CrosshairWorldDirection); // 3D상의 Crosshair 의 방향

이제 이렇게 크로스헤어의 3차원 공간상의 위치와 방향을 알았기 때문에 하나의 선분을 정의할 수 있게 되었다. 선분은 한 점과 다른 한 점을 잇는 것이기 때문에, 선분의 끝점은 다음과 같이 정의할 수도 있을 것이다.

// 선분의 시작점
const FVector Start{CrosshairWorldPosition};

// 선분의 끝점: 선분의 길이는 사정거리와 같다.(50,0000.f)
const FVector End{ CrosshairWorldPosition + CrosshairWorldDirection * 50'000.f };

이제 선분을 구했기 때문에 그림 2의 빨간 선분을 그어 충돌검사를 할 수 있다. 선과 물체의 충돌 검사를 위해서는 UWorld::LineTraceSingleByChannel 함수를 사용한다. 이 함수는 월드상의 물체와 매개변수로 전달하는 선분의 충돌을 검사한다. 함수 원형은 다음과 같다.

bool UWorld::LineTraceSingleByChannel(
    struct FHitResult& OutHit,
    const FVector& Start,
    const FVector& End,
    ECollisionChannel TraceChannel,
    const FCollisionQueryParams& Params /* = FCollisionQueryParams::DefaultQueryParam */, 
    const FCollisionResponseParams& ResponseParam /* = FCollisionResponseParams::DefaultResponseParam */) const

앞서 구한 선분의 시작점과 끝점을 넣으면 충돌에 대한 정보(충돌 여부, 충돌 위치, 충돌 객체 등)를 FHitResult구조체 변수 OutHit 에 담아준다. 우리는 이 정보를 활용하 선분과 물체의 충돌 여부나, 충돌 위치를 계산할 수 있다.

 

두 번째 충돌 검사

이 방식에는 아직 플레이어가 기대하는 대로 작동하지 않는다. 아래와 같은 경우를 살펴보자.

▲ 그림 3

플레이어가 물체에 근접한 상태라면 우리가 계산한 피격 위치가 아니라 플레이어 바로 앞에 있는 물체가 피격되어야 한다. 이를 아래의 그림처럼 위에서 보면 조금 더 직관적으로 이해하기 쉽다.

▲ 그림 4

우리가 계산한 목표지점으로 향하는 선분의 시작점은 카메라이다. 하지만 플레이어는 총신에서부터 시작해서 목표지점으로 총알이 날아가길 기대한다. 때문에 총신에서 시작해서 계산된 피격 위치까지 충돌하는 물체가 있는지 한 번 더 충돌 검사를 해야 한다. 이 때문에 두 번의 충돌 검사가 필요한 것이다.

// 두 번째 충돌 검사
FHitResult WeaponTraceHit;
const FVector WeaponTraceStart{ SocketTransform.GetLocation() }; // 총신에서 시작
const FVector WeaponTraceEnd{ BeamEndPoint }; // BeamEndPoint는 계산된 피격위치
GetWorld()->LineTraceSingleByChannel(WeaponTraceHit, WeaponTraceStart, WeaponTraceEnd, ECollisionChannel::ECC_Visibility);

if (WeaponTraceHit.bBlockingHit)    // 충돌이 발생했다면
{
    BeamEndPoint = WeaponTraceHit.Location;  // 피격위치를 갱신한다.
}

// 피격위치에 피격 파티클을 실행
if (ImpactParticles)
{
    UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ImpactParticles, BeamEndPoint);
}

이렇게 플레이어가 직관적으로 기대하는 에이밍 시스템을 구현하였다.

 

문제점

3인칭 TPS 게임에서 이렇게 이중으로 충돌검사를 하는 에이밍 시스템을 사용할 때 공통적으로 발생하는 문제점이 있다. 특정 상황에서 조준점을 올렸지만 피격점이 낮아지는 경우가 발생한다. 이유는 다음과 같다.

▲ 그림 5

플레이어가 물체의 모서리를 조준하여 발사하면 정확하게 물체의 모서리가 피격된다. 만약 플레이어가 조준점을 높여 모서리 살짝 위 허공을 조준하게 되면 총신도 그 허공을 조준하게 된다. 이때 총구에서 조준되는 선분은 오히려 낮아지는 현상이 발생한다. 그래서 조준점은 올렸지만 플레이어의 기대와는 다르게 아랫쪽이 피격됨을 관찰할 수 있다. 이러한 현상은 배틀그라운드와 같은 상용 게임에서도 발견된다.

▲ 그림 6

배틀그라운드에서 모서리를 조준 했을 때 피격점은 플레이어가 기대한 곳과 일치한다.

▲ 그림 7

하지만 조준점을 살짝 올려 허공을 조준하게 되면 오히려 피격점이 더 낮아지는 현상을 관찰할 수 있다. 현재 사용 중인 충돌 검사 방법으로는 이 문제를 피할 길이 없다. 카메라의 오프셋을 살짝 조정해서 문제를 최소화를 하는 방법뿐이다. 그리고 플레이에 큰 영향을 미치지 않기 때문에 배틀그라운드에서도 그대로 사용하고 있는 것이 아닐까 짐작해본다. 이 부분에 대해서는 추후 좋은 해결방안을 발견한다면 수정할 예정이다.