{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[코딩의탑] 1층: 탄도 예측(Trajectory Prediction)

탄도 예측이란 무엇인가요?

  첫번째 층에서 우리가 무엇을 할지부터 살펴보도록 하죠. FPS 게임에서 수류탄을 던져본 적이 있나요? 플레이어는 수류탄을 던지기 이전에 그 궤적을 미리 볼 수 있죠. 또, 당구 게임에서 당구공이 부딪힌 이후의 궤적을 플레이어들은 미리 보고 각도를 조절할 수 있습니다. <Angry Birds>에서 플레이어는 예측되는 궤적을 보고 고무줄의 각도를 조절해 새를 날려보내죠. 우리가 어렸을 적 한 번쯤은 해봤을 고전 게임인 <Puzzle Bobble>의 시스템 역시 이러한 탄도 예측의 좋은 예가 될 수 있습니다.


<Apex Legends>


<Angry Birds 2>

<Puzzle Bobble>

  많은 게임들에서는 이렇게 무언가가 날아갈 때, 그 궤적을 미리 예측해서 보여주는 시스템을 갖추고 있습니다. 우리는 특히, 3D 게임에서 이루어지는 탄도 예측에 대해서 다뤄볼 것입니다.


어떻게 해결할 수 있을까요?

  우리는 다음 두 가지 작은 목표를 해결해나갈 것입니다.

  • 시간을 의미하는 t를 변수로 하는 궤적 그래프를 그릴 수 있습니다.
  • 임의의 평면에 튕겨진 이후의 궤적 그래프를 그릴 수 있습니다.


준비운동을 시작해 보자구요..

 변위, 속도, 가속도

  • 변위란, 특정 시간 t에서 원점으로부터 이동한 거리를 의미합니다. 
  • 속도란, 단위 시간 t동안 움직인 변위를 의미합니다. 
  • 가속도란, 단위 시간 t동안 증가한 속도를 의미합니다.

  

변위, 속도, 가속도의 관계

  위 그림에서 변위(distance) 그래프를 한 번 봐보죠. 우리가 그래프에서 알 수 있는 사실은 다음과 같습니다: 첫째, 0초부터 15초까지는 특정 점으로부터 거리가 계속 멀어졌습니다. 둘째, 15초부터 27초까지는 특정 점으로부터 거리가 다시 가까워졌습니다. 셋째, 27초부터는 정지했습니다.

  이를 속도(velocity) 그래프와 함께 비교해보세요. 속도의 값은 곧 변위의 기울기와 동일합니다. 당연하죠. 속도는 변위가 얼마나 급격하게 변하는지를 결정합니다. 속도가 높으면 높을수록 단위시간 동안 더 많은 거리를 이동하죠. 곧, 속도는 변위의 미분입니다.

  속도와 가속도(acceleration)의 관계 역시 마찬가지입니다. 서로 미분과 적분이 되는 관계를 갖죠. 속도의 변화량이 곧 가속도이기 때문입니다. 우리는 다음과 같은 식을 얻을 수 있겠네요.



합력

  우리는 물리에서의 힘을 벡터로 나타낼 수 있습니다. 두 힘의 합은 두 벡터의 합으로 나타낼 수 있죠. 삼각형법이나 평행사변형법을 기억하시나요? 두 벡터 중 하나를 다른 벡터의 끝점으로 평행이동하면, 두 벡터의 합을 구할 수 있습니다.


  이것이 왜 중요할까요? 한 힘을 벡터의 합으로 나타낼 수 있다는 것은, 다시 말하면 한 벡터를 여러 벡터로 쪼갤 수 있다는 것입니다. 예를 들어, 임의의 벡터를 각각 x, y, z축에 평행하는 벡터들로 쪼개볼 수 있겠죠. 이를 각 축에 투영(projection)한다고 합니다.


자유낙하

  그렇다면, 우리가 물리 세계에서 흔히 일어나는 자유낙하를 벡터로 쪼개볼 수 있겠네요. 여러분이 높은 곳에서 공을 앞으로 던졌다고 생각해봅시다. 공은 떨어지면서 오로지 중력의 힘만을 받을 것입니다. 물론 공기저항도 있겠지만, 이는 무시하도록 하자구요. 다음 그림을 참고해보세요.


  위 그림은 떨어지는 공을 일정한 간격으로 촬영한 그림입니다. 떨어지는 공은 관성에 의해서 자신이 받은 힘만큼 계속 앞으로 나아가죠. 물론, 중력이 작용하므로 점점 더 빨리 낙하하게 됩니다. 이 때, 중력과 수직인 힘은 영향을 받지 않는 다는 점에 주목하세요. 


본격적으로 시작해봅시다!

  우리는 시간에 대한 함수인 3차원의 그래프가 필요합니다. 이 그래프는 탄도를 그리죠. 우리가 무언가를 발사할 때, 알 수 있는 단서는 다음과 같습니다:

  • 발사 시 위치 벡터
  • 초기 속도 벡터
  • 중력

   이를 이용해서 그래프를 도출해보죠. 중력은 곧 가속도입니다. 물체의 자유낙하 동안 작용하는 힘은 중력뿐이기 때문입니다. 초기 속도 벡터는 속도 그래프에서 t가 0일 때의 값입니다. 발사 시의 위치 벡터는 변위 그래프에서 t가 0일 때의 값입니다. 이들을 수학적으로 다음과 같이 표현해볼 수 있겠군요.

 이 세 가지 단서를 이용해 결론적으로 f(t)를 도출해볼 것입니다. 아주 간단한 미적분을 통해서 말이죠. 그 과정은 다음과 같습니다.


  이를 소스코드로 표현해보죠. CalculateProjectileLocationOnTime() 함수는 StartLocation, InitialVelocity, Gravity, Time을 인자로 받아 탄환의 위치를 FVector로 반환해줍니다.


소스 코드: Character::CalculatePrjectileLocationOnTime()

FVector ATowerOfCodeThrowingCharacter::CalculateProjectileLocationOnTime(
	const FVector StartLocation, 
	const FVector InitialVelocity, 
	const FVector Gravity, 
	const float Time)
{
	FVector ResultVector;
	ResultVector = 
		StartLocation + InitialVelocity * Time + 0.5f * Gravity * Time * Time;
	return ResultVector;
}


  하지만 이것만으로는 시각화할 수 없죠. 탄도의 궤적을 시각화하는 함수 역시 함께 만들어서, 이를 호출하여 그리도록 합시다. 곡선은 어떻게 그려야할까요? 수학에서 곡선은 곧 무한한 직선의 집합입니다. 멋진 수학의 철학을 우리 코드에도 적용시키도록 하죠. 궤적 그래프에 수많은 점들을 찍어서, 이를 직선으로 이어주도록 합시다. 

  왜 굳이 CalculateProjectileLocationOnTime(), DrawTrajectory() 두 개의 함수로 나누었냐고 물어보는 사람이 있겠네요. 물론 한 함수에서 이를 전부 처리해도 되죠. 하지만 명심하세요. 작업은 나누면 나눌수록 좋은 법입니다.


소스 코드: Character::DrawTrajecctory()

void ATowerOfCodeThrowingCharacter::DrawTrajectory(
    const FVector InitialLocation, 
    const FVector InitialVelocity, 
    const FVector Gravity) 
{
// 초기 설정입니다.
	FVector StartLocation = InitialLocation;
	FVector StartVelocity = InitialVelocity;

	// TraceStart와 TraceEnd를 이용해 수많은 직선들을 이을 것입니다.
	FVector TraceStart;
        FVector TraceEnd;
        TraceStart = TraceEnd = 
            CalculateProjectileLocationOnTime(
                StartLocation, 
                StartVelocity, 
                Gravity, 
                0.0f);

	// 2초동안, 0.01초의 간격으로 점을 찍어낼 것입니다.
	const float MaxSimTime = 2.0f;
	const float SimFrequency = 1.e-2f;

	// 처음은 0초부터, 곧 현재 속도는 초기 속도와 동일합니다.
	float SimTime = 0.f;
	FVector CurrentVelocity = StartVelocity;

	// 혹시 이전에 그려진 궤적이 있다면 이를 모두 삭제합니다.
	DestroyTrajectory();

	// 2초가 넘어가지 않는 동안에
	while (SimTime < MaxSimTime) 
	{
        // 직선 그리기
		FVector StartTangent = CurrentVelocity;
		FVector EndTangent = StartVelocity + Gravity * SimTime;
		StartTangent.Normalize();
		EndTangent.Normalize();

		USplineMeshComponent* pSplineMesh = 
                    NewObject<USplineMeshComponent>(
                        this, 
                        USplineMeshComponent::StaticClass());
		pSplineMesh->CreationMethod = 
                    EComponentCreationMethod::UserConstructionScript;
		pSplineMesh->SetStartScale(FVector2D(3, 3));
		pSplineMesh->SetEndScale(FVector2D(3, 3));
		pSplineMesh->SetStaticMesh(MyMesh);
		pSplineMesh->SetStartAndEnd(
                    TraceStart, 
                    StartTangent, 
                    TraceEnd, 
                    EndTangent);

	        // 다음 직선을 그리기 위해, 현재 속도와 시간을 조정해줍니다.
		CurrentVelocity = StartVelocity + Gravity * SimTime;
		SimTime += SimFrequency;
                TraceStart = TraceEnd;
                TraceEnd = 
                    CalculateProjectileLocationOnTime(
                        StartLocation, 
                        StartVelocity, 
                        Gravity, 
                        SimTime);
}
	RegisterAllComponents();
}


  그러나 이 코드는 문제가 있습니다. 탄환은 예측된 탄도를 잘 따라가지만, 예측된 탄도는 탄환의 충돌을 고려하지 않았다는 사실입니다. 


이런, 물체가 있음에도 계속해서 궤적을 그리네요!


총을 쏘는 이유를 잊고 있었네요. 맞추려고 탄환을 쏘는 건데!

  아무 물체와도 충돌하지 않는 탄환이라면, 플레이어의 손에 문제가 있었던 것이겠죠. 어떻게 탄환의 충돌을 인식할 수 있을까요? 아주 다양한 방법이 있겠지만 우리는 다음의 방법으로 해결해보죠. 바로 고전적 기법인 레이 캐스팅(ray casting)으로부터 기인하는 방법입니다. (사실, 앞으로 설명할 내용보다 더 직관적이고 단순한 방법은 곡선의 방정식과 평면의 방정식을 이용해 접점을 특정하는 것입니다. 하지만 언리얼 엔진에 대한 이해가 부족하여, 우회적인 방법으로 대체합니다) 


레이 캐스팅(Ray casting)

  과거 <울펜슈타인3D>는 레이 캐스팅을 이용해서 유저들에게 놀라운 3D 세계의 경험을 선사했습니다. 어떻게 3D의 모습을 모니터에 그려낼 수 있었을까요? 실제 물리가 작용하는 과정에서 그 아이디어를 얻어냈죠. 우리가 무언가를 본다는 것은, 특정한 물체에 부딪힌 빛이 우리 눈으로 들어온다는 것입니다.

  따라서 <울펜슈타인3D>는 플레이어가 보는 시야각만큼 작은 빛 입자들을 쏘기로 했습니다. 각 빛 입자들이 일정한 속도로 앞으로 나아가면서, 특정한 물체에 부딪히면 해당하는 물체를 그리기로 한 것이죠. 다음 의사코드를 참고하세요.


for each Light 
	while (Light - Player < SightDistance)
	{
		Light += dt;
		if (Light hitted Object) 
		{
			Draw(Object, Light);
			break;
		}
	}


  그렇다면 이를 이용해서, 우리 궤적의 빛이 앞으로 나아가다가 물체에 부딪히면 반복문을 빠져나가는 것은 어떨까요? 아주 좋은 방법일 것 같습니다. 마침 우리는 이미 곡선을 그리기 위해서 수많은 점들을 검사하고 있었죠. 따라서 아주 약간의 코드 추가만으로 이를 손쉽게 해결할 수 있습니다.


소스 코드: Character::DrawTrajecctory()

void ATowerOfCodeThrowingCharacter::DrawTrajectory(
    const FVector InitialLocation, 
    const FVector InitialVelocity, 
    const FVector Gravity) 
{
	// ...

	// 초기 설정입니다.
	bool bObjectHit;
	FHitResult ObjectTraceHit(NoInit);
	UWorld const* const World = GetWorld();
	const float ProjectileRadius = 
            ProjectileClass.GetDefaultObject()->GetSimpleCollisionRadius();
	FCollisionQueryParams QueryParams(NAME_None, false, NULL);
	FCollisionObjectQueryParams ObjQueryParams;

	// 다른 탄환과의 충돌은 인식하지 않습니다.
	TArray<AActor*> FoundActors;
	UGameplayStatics::GetAllActorsOfClass(
            GetWorld(), 
            ProjectileClass.GetDefaultObject()->StaticClass(), 
            FoundActors);
	QueryParams.AddIgnoredActors(FoundActors);

	while (SimTime < MaxSimTime) 
	{
		bObjectHit = 
                    World->SweepSingleByObjectType(
                        ObjectTraceHit, 
                        TraceStart, 
                        TraceEnd,
			FQuat::Identity, 
                        ObjQueryParams, 
                        FCollisionShape::MakeSphere(ProjectileRadius),
                        QueryParams);
		// ...
		if (bObjectHit) 
			break;
		// ...
	}
	// ...
}


만족스럽네요!


아주 좋습니다! 하지만 조금 더 해보죠..

  우리는 탄환이 특정한 물체에 부딪히기 전까지의 궤적을 그려낼 수 있었습니다. 하지만 이쯤 되니 조금 더 궁금증이 생기는군요. 물체가 부딪히고 난 이후의 궤적은 어떻게 될까요? 이는 사실 아주 복잡한 문제입니다. 하지만 다음의 전제와 함께 이 문제를 단순화해보죠.

  • 첫째, 탄환이 부딪힌 물체는 아주 무거운 물체입니다. 탄환이 부딪혀도 꿈쩍도 하지 않습니다!
  • 둘째, 탄환은 언리얼 엔진의 세상에 존재하는 탄환입니다. 언리얼 엔진의 규칙을 철저하게 따르죠.


반사각

  우리가 물리 시간에서 배운 기본적인 법칙이 있었죠. 빛은 거울에 반사될 때, 그 입사각과 반사각이 동일하다는 사실입니다! 물론 우리의 탄환은 빛과는 한참 거리가 있는 물체이긴 합니다만, 어쨌거나 좋은 출발점이 될 수 있을 것 같네요.

입사각과 반사각

  하지만 이를 어떻게 구현할 수 있을까요? 우리는 벡터를 이용하고 있습니다. 따라서 벡터를 이용해서 특정한 평면에 부딪힌 반사 벡터를 도출해보도록 하죠. 바로 내적을 이용하는 것입니다. 앞으로 벡터는 굵은 글씨로 표현할 것입니다.



  내적은 한 벡터(a)를 다른 벡터(b)에 정사영해 평행한 두 벡터를 도출한 뒤, 둘(a` b)의 크기를 곱한 것입니다. 정사영을 한다는 것은 곧 그림자를 그리는 것과 같습니다. 위 그림의 초록색 벡터에 해당하죠. 

  이는 다시 말하면, a·b의 값은 곧 ab의 성분으로 얼마나 이루어져있는지의 정보를 포함한다는 것입니다. 다음 그림을 참고해보세요. 우리는 ai+j로 표현할 수 있습니다. 이 때, i는 곧 i와 평행한 단위벡터b에 a·b의 값을 곱한 것과 같죠. 

  만약 ij가 각각 x축, y축에 해당한다면 어떨까요? 우리는 내적을 이용해서 각 축에 해당하는 좌표를 구할 수 있습니다. 합력의 원리에 의해서 서로 다른 벡터 두 개로 쪼개보는 것이죠. 또 어떤 두 벡터로 쪼개볼 수 있을까요? 바로 부딪힌 평면에 수직한 벡터와, 부딪힌 평면에 평행한 벡터입니다.

  입사 벡터, 곧 부딪힌 순간의 속도 벡터는 xy로 쪼갤 수 있습니다. 우리는 충돌 이후에 속도 벡터가 -x+y가 될 것임을 알고있죠. 즉 기존의 입사 벡터에서 -2x를 해주면 간단하게 반사 벡터를 구할 수 있습니다. x는 바로 내적을 통해서 구할 수 있죠. 부딪힌 평면의 법선 벡터와 입사 벡터를 내적하는 것입니다. 다음 그림과 수식, 그리고 코드를 참고하세요.



소스코드: Character::DrawTrajectory(), Character::GetReflectedVector()

void ATowerOfCodeThrowingCharacter::DrawTrajectory(
    const FVector InitialLocation, 
    const FVector InitialVelocity, 
    const FVector Gravity) 
{
	// ...
	while (SimTime < MaxSimTime) 
	{
		// ...
		if (bObjectHit) 
		{
			SimBounce++;
			bObjectHit = false;

			CurrentVelocity =
                            StartVelocity 
                            + Gravity 
                            * (SimTime - SimFrequency * (1 - ObjectTraceHit.Time));
			StartVelocity = GetReflectedVector(
                            ObjectTraceHit.ImpactNormal,
                            CurrentVelocity);
			StartLocation = ObjectTraceHit.Location;

			SimTime = 0.0005f;
			TraceEnd = CalculateProjectileLocationOnTime(
                            StartLocation, 
                            StartVelocity, 
                            Gravity, 
                            SimTime);
			CurrentVelocity = 
                            StartVelocity 
                            + Gravity 
                            * (SimTime - SimFrequency * (1 - ObjectTraceHit.Time));

			if (SimBounce >= MaxSimBounce) {
				break;
			}
		}
		// ...
	}
	// ...
}

FVector ATowerOfCodeThrowingCharacter::GetReflectedVector(
    const FVector ImpactNormal,
    const FVector Velocity)
{
	FVector ComponentAlongImpactNormal = 
            ImpactNormal 
            * FVector::DotProduct(ImpactNormal, Velocity);
        FVector ResultVector =
            Velocity
            - ComponentAlongImpactNormal * 2;

	return ResultVector;
}


  DrawTrajectory()는 탄환이 오브젝트와 충돌 시, 기존의 StartLocation, StartVelocity 등을 적절히 수정합니다. 곧, 몇 번 충돌했는지를 알아야 종료를 적절히 할 수 있습니다. 따라서 SimBounce와 MaxSimBounce 변수를 이용해서 충분한 충돌 시 더 이상 탄도를 그리지 않도록 했습니다.


잘 됐을까요? 아쉽게도, 아닙니다.

  세상에 쉬운 일은 없죠. 이러한 계산은 정확한 탄도를 예측하지 못합니다. 이전에 말했듯이, 탄환은 빛이 아니기 때문이죠! 왜 이런 결과가 나타났는지 알아봅시다.


언리얼 엔진4의 마찰력(friction)과 탄성(bounciness/restitution)

  언리얼 엔진4에서는 각 물체가 마찰력과 탄성에 대해 0~1의 값을 지닙니다. 언리얼 엔진4의 마찰력이란, 충돌한 평면과 평행한 벡터의 성분에 대한 힘의 반발입니다. 마찰력이 1이라면 물체는 앞으로 나아가지 못하죠. 다음 영상을 참고해보세요.




  또한 언리얼 엔진4의 탄성이란, 충돌한 평면과 수직한 벡터의 성분에 대한 힘의 보존입니다. 탄성이 0이라면 물체는 위로 튕기지 않죠. 다음 영상을 참고해보세요.




  따라서 우리는 이러한 물체의 성질을 적용해주어야 합니다. 다음 그림에서, x의 경우 충돌한 평면과 수직한 벡터의 성분이고, y의 경우 충돌한 평면과 평행한 벡터의 성분입니다. 이를 보정해주도록 하죠.


소스코드: Character::DrawTrajectory(), Character::GetReflectedVector()

void ATowerOfCodeThrowingCharacter::DrawTrajectory(
    const FVector InitialLocation, 
    const FVector InitialVelocity, 
    const FVector Gravity) 
{
	// ...
	float Friction = 
            ProjectileClass.
            GetDefaultObject()->
            GetProjectileMovement()->
            Friction;
	float Bounciness = 
            ProjectileClass.
            GetDefaultObject()->
            GetProjectileMovement()->
            Bounciness;
	// ...
	while (SimTime < MaxSimTime) 
	{
		// ...
		if (bObjectHit) 
		{
			// ...
			StartVelocity = 
                            GetReflectedVector(
                                ObjectTraceHit.ImpactNormal,
                                CurrentVelocity, 
                                Friction, 
                                Bounciness);
		}
		// ...
	}
	// ...
}

FVector ATowerOfCodeThrowingCharacter::GetReflectedVector(
    const FVector ImpactNormal, 
    const FVector Velocity, 
    float Friction, 
    float Bounciness)
{
	FVector ComponentAlongImpactNormal = 
            ImpactNormal 
            * FVector::DotProduct(ImpactNormal, Velocity);
	FVector FrictionVector = 
            (Velocity - ComponentAlongImpactNormal) * Friction;
	FVector BouncinessVector = 
            ComponentAlongImpactNormal * Bounciness;

	FVector ResultVector = 
            Velocity 
            - ComponentAlongImpactNormal 
            - BouncinessVector 
            - FrictionVector;

	return ResultVector;
}


드디어 완성했네요! 이젠 정말로 끝입니다.

 탄환이 예측된 경로를 잘 따라가는군요.

   우리는 많은 게임들에서 사용되는 탄도 예측을 직접 구현해보았습니다. 물리 세계의 다양한 성질과, 수학의 벡터를 간단하게 응용해 탄환의 궤적을 예측해볼 수 있었습니다. 또한 언리얼 엔진이 물리 세계를 어떻게 구현하는지 간단하게 엿볼 수도 있었네요.

  이상으로, 코딩의 탑 1층의 모험을 마칩니다.


끝.

댓글

이 블로그의 인기 게시물

[코딩의탑] 4층: 툰 쉐이딩

[코딩의탑] 3층: 바다 렌더링

[코딩의탑] 5층: 포탈(Portal), 더 나아가기