[UE5] 발사체의 탄성 구현
예전에 언리얼 엔진에서 제공하는 Projectile Movement Component를 이용해서 발사체를 구현했었다.
그러던 중에 필요한 기능이 있어서 Projectile Movement Component의 Velocity
를 직접 조작하게 되었는데 이때 발사체가 물체와 부딪혔을 때 탄성으로 인해 튀어오르는 동작도 구현했었다.
이제와서 생각해보면 이런 동작은 Projectile Movement Component에서 발사체 바운스
라는 옵션으로 제공하고있고, 추가로 필요한 기능은 블루프린트만으로도 구현이 가능하지만 이전에는 언리얼 엔진에 익숙하지 않던 때라서 직접 구현하게 되었다.
그래도 직접 구현해보면서 알게된 것도 있고, 이왕 시작한거 제대로 해보자는 생각이 들어서 Projectile Movement Component의 바운스와 최대한 비슷하게 구현해보고 결과를 정리해봤다.
참고로 글에서 발사체가 탄성으로 인해 튀어오르는 현상을 반사라고 표현한다.
반사는 빛이나 소리의 경우에 해당하는 표현인데 발사체의 경우 적절한 표현이 없어서 그냥 반사라고 썼다.
내가 설정한 발사체는 기본적으로 Projectile Movement Component을 이용해서 움직인다.
그리고 월드 스태틱을 포함해서 모든 물체와 오버랩 하게 설정되어있다.
그래서 발사체의 충돌은 오버랩 이벤트를 통해 판단한다.
추가로 발사체가 빠른 경우 한 틱에 두번 이상 반사될 수도 있어야한다.
따라서 반사될 방향으로 오버랩 검사를 해서 그 방향에서 오버랩 이벤트가 다시 발생할지 확인해야한다.
그리고 반사는 탄성과 마찰도 고려한다.
먼저 한 틱 동안 발사체가 반사되면서 다른 물체와 또 충돌할 수 있다는걸 고려해서 반사될 방향을 계산한 뒤에는 반사되는 방향으로 오버랩을 확인해야하고, 오버랩이 더이상 발생하지 않을때까지 반복되어야한다.
그리고 오버랩 여부를 판단하기 위해선 트레이싱을 사용해야한다.
언리얼 엔진에는 트레이싱을 위한 다양한 함수를 제공하는데, 대부분은 오버랩이 아닌 블록을 판단하는 트레이싱이다.
오버랩을 판단하기 위해서는 TraceMultiByProfile
함수를 사용해야한다.
BoxTraceMultiByProfile
, CapsuleTraceMultiByProfile
, LineTraceMultiByProfile
, SphereTraceMultiByProfile
이렇게 4개의 함수가 있다.
이 함수들은 다른 대부분의 트레이싱 함수와는 다르게 트레이스 채널이 아닌 콜리전 프리셋을 기준으로 트레이싱한다.
그리고 오버랩 이벤트는 블록 판정이 아니므로 트레이싱 결과인 FHitResult 구조체에서 bBlockingHit
은 false로 되어있다.
따라서 FHitResult에 담겨있는 액터가 Valid한지 판단해서 Valid하지 않은 경우 이번 틱의 반사가 끝났다고 판단한다.
간략한 진행방식은 위와 같고, 아래는 구현 내용이다.
void AProjectileBase::BeginPlay()
{
Super::BeginPlay();
// ...
if (SphereComponent) // USphereComponent
{
SphereComponent->OnComponentBeginOverlap.AddDynamic(this, &AProjectileBase::OnProjectileHit);
}
}
오버랩 이벤트를 받기 위해 델리게이트에 OnProjectileHit
함수를 바인딩했다.
bool AProjectileBase::CanHit(AActor* OtherActor) const
{
if (!IsValid(OtherActor))
{
return false;
}
if (bReflectionCalculated)
{
return false;
}
if (OtherActor == GetInstigator())
{
return false;
}
return true;
}
void AProjectileBase::OnProjectileHit(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
if (!CanHit(OtherActor))
{
return;
}
// 기본적으로 반사되는 행동을 함.
ReflectAction(SweepResult);
}
OnProjectileHit
함수에는 기본적으로 반사를 하도록 ReflectAction
함수를 호출한다.
그 전에 CanHit
함수로 Hit(사실은 오버랩) 판정을 받아들일지를 결정한다.
CanHit
함수에는 반사가 이전에 계산되었는지 확인하는 과정이 있다.
이걸 확인하는 이유를 알기위해 먼저 오버랩 이벤트가 어떻게 발생하는지를 알아야한다.
한 틱동안 액터는 먼저 목표 지점으로 이동한 뒤에 그 과정중에 발생한 오버랩 이벤트들을 거리가 가까운 순서대로 Broadcast한다.
그래서 가장 먼저 발생한 오버랩 이벤트를 먼저 확인하고 그 오버랩을 기준으로 반사되는 행동을 하게 된다.
그렇게 반사된 방향과 위치를 계산해서 액터의 위치를 옮기게 되는데, 오버랩 판정은 이전에 이미 되어있고 가까운 순서대로 호출되고있는 중이기 때문에 반사된 이후에도 이전의 오버랩 이벤트는 그대로 Broadcast된다.
따라서 이번 틱에 반사를 계산했으면 그 이후에 Broadcast된 오버랩 이벤트에서는 아무런 행동을 하지 말아야하므로 이전에 반사가 계산되었는지를 판단하는 bool 타입 변수 bReflectionCalculated
를 만들었다.
물론 매 틱마다 false로 초기화 해야한다.
다음으로 반사되는 행동을 구현해야한다.
일단은 완전 탄성체에 마찰이 없는 경우를 기준으로 진행한다.
오버랩 이벤트를 통해 받는 FHitResult인 SweepResult
를 통해 다양한 정보를 알 수 있다.
그 중에서 TraceStart
와 TraceEnd
를 통해 입사 벡터와 길이를 알 수 있고, 부딛친 표면의 노멀 벡터도 알 수 있으니 반사 벡터도 쉽게 알 수 있다.
유닛 벡터 기준으로는 이렇게 반사 벡터를 구할 수 있다.
그 다음에는 반사 벡터 방향으로 또 다른 오버랩 이벤트가 발생하는지 확인하기 위해 트레이싱을 해야하는데 이때 트레이싱 길이를 결정해야한다.
이 경우에는 FHitResult의 Time
을 사용하면 된다.
Time은 0 에서 1 사이의 값을 가지고있고, 부딪친 물체가 얼마나 멀리 떨어져있는지를 나타낸다.
그림으로 표현하면 아래와 같다.
따라서 반사된 후의 트레이싱 길이는 아래와 같이 정할 수 있다.
float TraceLength = (SweepResult.TraceEnd - SweepResult.TraceStart).Length() * (1.f - SweepResult.Time);
이렇게 정해진 반사 방향과 길이로 트레이싱을 해서 다른 물체와 오버랩 되지 않을 때까지 반복하면 한 틱에서의 반사 결과를 구할 수 있다.
다시 코드로 돌아와서 ReflectAction
은 아래와 같이 구현했다.
void AProjectileBase::ReflectAction(FHitResult SweepResult)
{
FHitResult HitRes = SweepResult;
FVector FinalDirection = ProjectileMovement->Velocity.GetSafeNormal();
while (ReflectCountMax < 0 || ReflectCountCurrent <= ReflectMaxCount)
{
if (!HitRes.GetActor())
{
break;
}
// Hit 판정을 시각적으로 표현하고 적용하는 함수
AcceptHit(HitRes);
bool bContinue = true; // bCountinue가 false면 발사체를 Destory
AActor* OtherActor = HitRes.GetActor();
if (OtherActor->IsA(APawn::StaticClass()))
{
bContinue = OnHitPawn(HitRes, FinalDirection);
}
else
{
bContinue = OnHitObject(HitRes, FinalDirection);
}
if (!bContinue)
{
Destroy();
return;
}
}
if (ReflectCountMax >= 0 && ReflectCountCurrent > ReflectCountMax)
{
Destroy();
return;
}
SetActorLocation(HitRes.TraceEnd);
SetActorRotation(FinalDirection.Rotation());
float ProjectileSpeed = ProjectileMovement->Velocity.Length();
ProjectileMovement->Velocity = FinalDirection * ProjectileSpeed;
}
while 루프로 현재 반사 횟수가 최대 반사 횟수를 넘지 않는 동안만 반사되도록 제한했다.
만약 최대값에 -1을 넣으면 제한없이 반사된다.
그리고 실제로 반사되는 작업은 OnHitObject
함수에서 진행된다.
이 함수의 파라미터인 FHitResult와 FVector는 레퍼런스를 받아서 값을 변경하게 했다.
그래서 HitRes
변수의 값이 OnHitObject
함수 내부에서 변경되고, 그 변경된 값을 기준으로 루프를 계속 돌아야하는지와 중단해야하는지를 결정한다.
경우에 따라서 반사하지 않고 다른 작업을 할 가능성을 열어두기 위한 것이다.
루프가 종료되면 FinalDirection
벡터를 기준으로 발사체 액터의 위치와 회전을 변경하고 Projectile Movement Component의 Velocity
를 변경한다.
OnHitPawn
의 경우 기본적으로는 반사되지 않도록 되어있으므로 다루지 않겠다.
아래는 OnHitObject
함수다.
bool AProjectileBase::OnHitObject(FHitResult& OutHitRes, FVector& OutDirection)
{
FVector InputDirection = OutDirection;
// 반사 가능 여부 확인
double CosVal = FVector::DotProduct(-InputDirection, OutHitRes.Normal);
double IncidenceDegree = FMath::RadiansToDegrees(FMath::Acos(CosVal));
if (IncidenceDegree < (90.f - ReflectableAngleFromSurface))
{
return false;
}
// Do Reflect
bReflectionCalculated = true;
ReflectCountCurrent++;
float InputDotNormal = FVector::DotProduct(InputDirection, OutHitRes.Normal);
FVector IdN_Normal = InputDotNormal * OutHitRes.Normal;
// 완전 탄성일 경우의 반사 방향
FVector BaseReflectDirection = InputDirection - 2 * IdN_Normal;
// 최종 결과
FVector FinalDirection = BaseReflectDirection;
OutDirection = FinalDirection;
// Trace
float TraceLength = (OutHitRes.TraceEnd - OutHitRes.TraceStart).Length() * (1.f - OutHitRes.Time);
FVector Start = OutHitRes.Location;
FVector End = Start + FinalDirection * TraceLength;
TArray<FHitResult> OutHits;
UKismetSystemLibrary::SphereTraceMultiByProfile(
this,
Start,
End,
SphereComponent->GetScaledSphereRadius(),
SphereComponent->GetCollisionProfileName(),
false,
DamagedActors, // AcceptHit 함수에서 업데이트 됨
EDrawDebugTrace::None,
OutHits,
true
);
// Set next HitResult
FHitResult NextHit = FHitResult();
NextHit.TraceStart = Start;
NextHit.TraceEnd = End;
NextHit.Time = 1.f;
for (const FHitResult HR : OutHits)
{
// 오버랩 이벤트이므로 오브젝트에 파고들어가있을 가능성이 있음.
// 그런 경우 현재 부딪힌 오브젝트와 또 오버랩 이벤트가 발생할 수 있으니
// Time이 0인 경우는 제외.
if (!FMath::IsNearlyZero(HR.Time))
{
NextHit = HR;
break;
}
}
OutHitRes = NextHit;
return true;
}
한번 반사하는 과정이 담긴 코드다.
반사가 가능한 각도는 표면에서부터의 각도를 기준으로 설정하게 했다.
마지막에 반사된 방향으로의 Trace 결과를 설정하는 코드가 있는데 Time
이 0인 FHitResult는 결과에서 제외했다.
그 이유는 이 발사체는 오버랩으로 판단하므로 오브젝트에 파고들어가있기 때문이다.
현재 발사체가 다른 오브젝트와 오버랩되어서 이 과정을 진행하는건데 이 상태에서 반사된 방향으로 오버랩 검사를 하면 지금 오버랩 되어있는 오브젝트도 다시 오버랩되는 결과가 나온다.
그래도 그런 경우에는 Time
이 0 이므로 구분이 가능하므로 이런 과정을 거친다.
그런데 트레이싱 함수에는 트레이싱을 무시할 액터들을 지정할 수 있다.
여기에 현재 오버랩 된 오브젝트를 넣으면 쉽게 해결될수도 있지만, 액터 자체를 무시하기 때문에 동일한 액터의 다른 메시와 오버랩 되지 않는 등 의도와는 다르게 작동하는 문제가 있다.
여기까지 해서 반사되는 동작의 기본적인 구조를 완성했다.
반사를 해야할 상황이면 반사 방향을 계산하고 이전의 트레이싱 길이를 통해 반사 방향으로의 트레이싱 길이를 정한 다음 트레이싱 한 후 결과를 보고 반사할 필요가 없을 때까지 반복한다.
ReflectAction
함수에서 반복되는 작업을 하고, 한번 반사되는 작업은 OnHitObject
에서 진행된다.
완전 탄성체를 기준으로 해서 Projectile Movement Component의 발사체 바운스
옵션과 비교해봤다.
빨간색 화살표에는 직접 구현한 발사체가 스폰되고, 파란색 화살표에는 발사체 바운스
를 사용한 발사체가 스폰된다.
비교를 위해서 모든 각도에서 반사되게 설정했고, 중력은 적용하지 않았다.
작동은 잘 하지만 반사된 후의 위치가 발사체 바운스
와는 조금 다르다.
아마도 Hit 판정과 오버랩 판정이 발생하는 시점이 다르기 때문에 차이가 있는듯 하다.
오버랩으로 구현한 발사체가 물체에 더 깊게 들어간 뒤에 반사된다.
그로 인해 발생하는 트레이싱 할 때의 문제점은 위에서 언급했다.
한 틱에 두번 이상 반사되는 경우도 확인하기 위해서 속도를 높이고 물체와의 거리를 조절해봤다.
발사체가 반사될때마다 반사되어 이동한 경로와 반사가 끝난 뒤의 방향과 거리를 DebugDrawLine
으로 선을 그리게 되어있는데 위 이미지가 그 결과다.
여기에서도 빨간색은 직접 구현한 발사체고, 파란색은 발사체 바운스
기능을 사용한 결과다.
초록색 선은 한 틱의 반사가 모두 끝난 최종 방향이다.
따라서 왼쪽 위와 오른쪽 아래의 선을 보면 한 틱에 두번씩 반사되었다는걸 알 수 있고, 의도한 대로 잘 작동하는것도 확인했다.
이제 탄성와 마찰을 적용한다.
탄성이 1인 경우에는 완전 탄성체고 반사될때 에너지를 잃지 않는다.
완전 탄성체가 아니어서 에너지를 잃는다면, 에너지를 잃는 방향은 부딪힌 표면의 노멀 벡터와 평행한 방향이다.
마찰은 0인 경우에는 마찰이 없고, 1인 경우는 아주 강한 마찰이 적용된다.
그리고 마찰이 적용되는 방향은 부딪힌 표면과 평행한 방향이다.
그림으로 표현하면 아래와 같다.
기존에 알고있던 벡터들로 쉽게 적용할 수 있다.
식으로 표현하면 이렇다.
$${O}^{\prime}=O+(1-Bounciness)(I\cdot N)N+((I\cdot N)N-I)Friction$$
$$=I-2(I\cdot N)N +(1-Bounciness)(I\cdot N)N+((I\cdot N)N-I)Friction$$
$$=(1-Friction)I-(Bounciness+1-Friction)(I\cdot N)N$$
코드는 이렇게 된다.
bool AProjectileBase::OnHitObject(FHitResult& OutHitRes, FVector& OutDirection)
{
// ...
// Do Reflect
bReflectionCalculated = true;
ReflectCountCurrent++;
float InputDotNormal = FVector::DotProduct(InputDirection, OutHitRes.Normal);
FVector IdN_Normal = InputDotNormal * OutHitRes.Normal;
float Bounciness = ProjectileMovement->Bounciness;
float Friction = FMath::Min(ProjectileMovement->Friction, 1.f);
/*
// 완전 탄성일 경우의 반사 방향
FVector BaseReflectDirection = InputDirection - 2 * IdN_Normal;
// 표면의 노멀벡터와 평행한 방향으로 에너지를 잃음
FVector BouncinessEnergyLoss = (1 - Bounciness) * IdN_Normal;
// 마찰 적용
FVector FrictionEnergyLoss = (IdN_Normal - InputDirection) * Friction;
// 최종 결과
FVector FinalDirection = BaseReflectDirection + BouncinessEnergyLoss + FrictionEnergyLoss; // Not unit vector
*/
FVector FinalDirection = (1 - Friction) * InputDirection - (Bounciness + 1 - Friction) * IdN_Normal; // Not unit vector
OutDirection = FinalDirection;
// ...
}
탄성과 마찰은 Projectile Movement Component의 필드 값을 사용했다.
그런데 마찰의 경우에는 1 이상인 값도 허용하기 때문에 최대 1로 제한했다.
하나씩 풀어서 쓰면 블록 주석 처리된 코드처럼 되고, 간략하게 한 줄로 적을 수도 있다.
이렇게 계산된 결과 벡터는 유닛벡터가 아니므로 이 결과에 기존의 속력을 곱하면 감소된 속도를 얻을 수 있다.
속도를 구하는 과정은 ReflectAction
함수의 마지막에 하고있으니 이 함수에서는 OutDirection
벡터에 길이가 감소한 반사 벡터 그대로 넣어주면 된다.
결과 비교를 위해서 아래처럼 탄성과 마찰을 설정해서 테스트했다.
동일한 반사 각도로 테스트한 두 발사체의 반사 방향은 거의 일치했다.
탄성과 마찰로 인한 속도 감소도 마찬가지다.
다음으로 한 틱에 두번 이상 반사되는 경우를 테스트해봤다.
이때에는 결과에 차이가 있다.
초록색 선이 한 틱의 마지막 트레이싱 결과이자 최종 속도인데 직접 구현한 반사가 더 느려지는 결과가 나왔다.
이런 차이가 발생하는 이유는 float 연산의 정밀도 때문에 발생한다.
첫번째 반사에서는 유닛벡터를 사용하므로 큰 오차가 발생하지 않지만, 반사되어 길이가 감소된 벡터를 이용해서 다시 반사된 벡터를 구하면 오차가 조금씩 증가하게 된다.
그렇게 계산된 벡터에 발사체의 속력이 곱해지게 되는데 속력은 m/s 단위이므로 그 값이 커서 오차도 그만큼 커지게 되어 위와 같은 결과가 나오게 된다.
따라서 이 문제를 해결하려면 반사 벡터를 구할때 입사 벡터는 항상 유닛 벡터로 계산해야한다.
그러기 위해 함수 OnHitObject
의 파라미터인 OutDirection
을 OutVelocity
로 바꿔서 방향이 아닌 속도를 계산한 값이 담기게 했다.
수정된 전체 코드와 결과는 다음과 같다.
void AProjectileBase::ReflectAction(FHitResult SweepResult)
{
FHitResult HitRes = SweepResult;
FVector FinalVelocity = ProjectileMovement->Velocity;
while (ReflectCountMax < 0 || ReflectCountCurrent <= ReflectCountMax)
{
if (!HitRes.GetActor())
{
break;
}
// Hit 판정을 시각적으로 표현하고 적용하는 함수
AcceptHit(HitRes);
bool bContinue = true; // bCountinue가 false면 발사체를 Destory
AActor* OtherActor = HitRes.GetActor();
if (OtherActor->IsA(APawn::StaticClass()))
{
bContinue = OnHitPawn(HitRes, FinalVelocity);
}
else
{
bContinue = OnHitObject(HitRes, FinalVelocity);
}
if (!bContinue)
{
Destroy();
return;
}
}
if (ReflectCountMax >= 0 && ReflectCountCurrent > ReflectCountMax)
{
Destroy();
return;
}
SetActorLocation(HitRes.TraceEnd);
SetActorRotation(FinalVelocity.Rotation());
ProjectileMovement->Velocity = FinalVelocity;
}
bool AProjectileBase::OnHitObject(FHitResult& OutHitRes, FVector& OutVelocity)
{
FVector InputDirection = OutVelocity.GetSafeNormal();
// 반사 가능 여부 확인
double CosVal = FVector::DotProduct(-InputDirection, OutHitRes.Normal);
double IncidenceDegree = FMath::RadiansToDegrees(FMath::Acos(CosVal));
if (IncidenceDegree < (90.f - ReflectableAngleFromSurface))
{
return false;
}
// Do Reflect
bReflectionCalculated = true;
ReflectCountCurrent++;
float InputDotNormal = FVector::DotProduct(InputDirection, OutHitRes.Normal);
FVector IdN_Normal = InputDotNormal * OutHitRes.Normal;
float Bounciness = ProjectileMovement->Bounciness;
float Friction = FMath::Min(ProjectileMovement->Friction, 1.f);
/*
// 완전 탄성일 경우의 반사 방향
FVector BaseReflectDirection = InputDirection - 2 * IdN_Normal;
// 표면의 노멀벡터와 평행한 방향으로 에너지를 잃음
FVector BouncinessEnergyLoss = (1 - Bounciness) * IdN_Normal;
// 마찰 적용
FVector FrictionEnergyLoss = (IdN_Normal - InputDirection) * Friction;
// 최종 결과
FVector FinalDirection = BaseReflectDirection + BouncinessEnergyLoss + FrictionEnergyLoss; // Not unit vector
*/
FVector FinalDirection = (1 - Friction) * InputDirection - (Bounciness + 1 - Friction) * IdN_Normal; // Not unit vector
OutVelocity = FinalDirection * OutVelocity.Length();
// Trace
float TraceLength = (OutHitRes.TraceEnd - OutHitRes.TraceStart).Length() * (1.f - OutHitRes.Time);
FVector Start = OutHitRes.Location;
FVector End = Start + FinalDirection * TraceLength;
TArray<FHitResult> OutHits;
UKismetSystemLibrary::SphereTraceMultiByProfile(
this,
Start,
End,
SphereComponent->GetScaledSphereRadius(),
SphereComponent->GetCollisionProfileName(),
false,
DamagedActors,
EDrawDebugTrace::None,
OutHits,
true
);
// Set next HitResult
FHitResult NextHit = FHitResult();
NextHit.TraceStart = Start;
NextHit.TraceEnd = End;
NextHit.Time = 1.f;
for (const FHitResult HR : OutHits)
{
// 오버랩 이벤트이므로 오브젝트에 파고들어가있을 가능성이 있음.
// 그런 경우 현재 부딪힌 오브젝트와 또 오버랩 이벤트가 발생할 수 있으니
// Time이 0인 경우는 제외.
if (!FMath::IsNearlyZero(HR.Time))
{
NextHit = HR;
break;
}
}
OutHitRes = NextHit;
return true;
}