레이캐스트와 트레이스

Stupidiot·2025년 5월 25일

Unreal & C++

목록 보기
2/5
post-thumbnail

개요


레이캐스트와 트레이스는 게임 내에서 시야 감지 및 객체 간 상호작용을 구현하는 핵심 기술입니다. 이 시스템은 가상의 광선을 사용하여 공간 내 충돌을 감지하고, 게임플레이 메커니즘에 활용됩니다.

기본 원리

레이캐스트와 트레이스는 보이지 않는 광선을 발사하여 두 점 사이에 장애물이 있는지 확인합니다. 이를 통해 플레이어가 특정 객체를 바라보고 있는지, 또는 AI가 플레이어를 감지할 수 있는지 등을 판단할 수 있습니다. 충돌이 감지되면 해당 정보를 반환하여 게임 상태를 변경하는 데 활용합니다.

트레이스 유형 및 옵션

  • 히트 감지 방식
    • 싱글 트레이스: 첫 번째 충돌 객체만 반환
    • 멀티 트레이스: 경로상 모든 충돌 객체 반환
  • 충돌 감지 방법
    • 오브젝트 트레이스: 모든 물리 객체와의 충돌 감지
    • 채널 트레이스: 특정 트레이스 채널에 반응하도록 설정된 객체만 감지
  • 트레이스 형태
    • 라인(직선) 트레이스
    • 박스 트레이스
    • 구체 트레이스
    • 캡슐 트레이스

트레이스 시스템


트레이스는 언리얼 엔진에서 공간 쿼리를 수행하는 주요 방법으로, 게임 월드 내에서 객체 간의 관계를 파악하는 데 사용됩니다.

👾 트레이스 시스템 사용 상황

  • 무기 발사 및 탄도학 계산
  • 캐릭터의 시야 및 타겟팅 시스템
  • AI의 감지 및 인식 메커니즘
  • 물리적 상호작용 및 장애물 감지
  • 게임 내 객체 선택 및 하이라이팅

블루프린트 트레이스


C++ 트레이스


C++에서는 FCollisionQueryParams를 구성하고 World->LineTraceXXX() 함수를 호출하여 트레이스를 실행할 수 있습니다:

FHitResult HitResult;
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(OwnerActor);

bool bHit = GetWorld()->LineTraceSingleByChannel(
    HitResult,
    StartLocation,
    EndLocation,
    ECC_Visibility,
    QueryParams
);

if (bHit)
{
    // 히트 결과 처리
    AActor* HitActor = HitResult.GetActor();
    // 추가 작업 수행
}

트레이스 채널


언리얼 엔진은 다양한 트레이스 채널을 제공합니다

  • Visibility: 시야 및 카메라 관련 트레이스에 사용
  • Camera: 카메라 충돌에 특화된 채널
  • WorldStatic: 정적 메시와의 충돌 확인
  • WorldDynamic: 이동 가능한 객체와의 충돌 확인
  • Pawn: 캐릭터 및 AI와의 충돌 확인
  • PhysicsBody: 물리 시뮬레이션 객체와의 충돌 확인

사용자 정의 채널은 프로젝트 설정에서 추가할 수 있습니다.

충돌 결과


트레이스는 FHitResult 구조체(싱글 트레이스) 또는 TArray<FHitResult>(멀티 트레이스)로 결과를 반환합니다.

멤버정의타입
Blocking Hit트레이스가 물체에 막혔는지 여부를 나타냅니다. 채널로 멀티 트레이스 실행 시 특히 중요하며, 트레이스가 물체를 통과하지 못하고 멈췄는지 확인할 때 사용합니다.bool
Initial Overlap트레이스 시작 시점에 이미 물체와 겹쳐 있었는지 여부를 나타냅니다. 여러 결과 중 첫 번째 겹침 여부를 확인할 때 사용합니다.bool
Time트레이스의 시작(0.0)부터 끝(1.0) 사이에서 충돌이 발생한 상대적 위치를 나타냅니다. 충돌이 없으면 1.0을 반환합니다.float
Distance트레이스 시작점에서 충돌 지점까지의 실제 거리(월드 유닛) 입니다. 시작 시점에 이미 겹침이 있다면 이 값은 0입니다.float
Location충돌 위치의 월드 좌표입니다. 트레이스 형태(박스, 구체 등)에 따라 조정된 위치를 반환합니다.FVector
Impact Point실제 충돌이 발생한 정확한 지점의 월드 좌표입니다. 트레이스 형태를 고려하지 않은 순수한 충돌 지점입니다.FVector
Normal트레이스의 진행 방향을 나타내는 벡터입니다.FVector
Impact Normal충돌 표면의 법선 벡터입니다. 충돌 지점에서 표면이 향하는 방향을 나타내며, 충돌 반응 계산에 활용됩니다.FVector
Phys Mat충돌한 표면의 물리적 재질 정보입니다. 소리, 입자 효과, 데미지 계산 등에 활용됩니다.UPhysicalMaterial*
Hit Actor트레이스가 충돌한 액터(게임 오브젝트)에 대한 참조입니다.AActor*
Hit Component충돌이 발생한 특정 컴포넌트에 대한 참조입니다. 액터의 어떤 부분과 충돌했는지 식별할 때 사용합니다.UPrimitiveComponent*
Hit Bone Name스켈레탈 메시와 충돌한 경우, 충돌이 발생한 본(뼈대)의 이름입니다. 특정 신체 부위 타격 판정에 활용됩니다.FName
Bone Name스켈레탈 메시와 충돌 시 히트한 본의 이름입니다. Hit Bone Name과 유사하지만 다른 컨텍스트에서 사용됩니다.FName
Hit Item프리미티브(기본 형태) 컴포넌트와 충돌 시, 어떤 부분에 충돌했는지 식별하는 내부 데이터입니다.int32
Element Index여러 부분으로 구성된 프리미티브와 충돌 시, 충돌이 발생한 특정 부분의 인덱스 번호입니다.int32
Face Index트라이앵글 메시(3D 모델) 또는 랜드스케이프와 충돌 시, 충돌이 발생한 특정 면(폴리곤)의 인덱스 번호입니다.int32
Trace Start트레이스가 시작된 월드 좌표입니다.FVector
Trace End트레이스가 끝나는 월드 좌표입니다.FVector

👓 프리미티브(Primitive)
언리얼 엔진에서 '프리미티브'는 물리적 충돌이 가능한 기본 형태의 컴포넌트를 의미합니다. UPrimitiveComponent는 월드에서 렌더링되고 충돌할 수 있는 모든 컴포넌트의 기본 클래스입니다. 주요 프리미티브 컴포넌트 유형은 다음과 같습니다:

  • StaticMeshComponent: 정적 3D 모델
  • SkeletalMeshComponent: 애니메이션이 있는 캐릭터 등의 메시
  • BoxComponent: 박스 형태의 충돌체
  • SphereComponent: 구 형태의 충돌체
  • CapsuleComponent: 캡슐 형태의 충돌체
  • LandscapeComponent: 지형 컴포넌트

모든 프리미티브 컴포넌트는 충돌 감지, 물리 상호작용, 렌더링 등의 기능을 제공합니다.

Hit Bone Name과 Bone Name의 차이
이 두 필드는 혼동을 일으킬 수 있지만, 언리얼 엔진의 내부 구현에서 약간 다른 목적으로 사용됩니다
1. Hit Bone Name:

  • 충돌 검사에서 직접적으로 충돌이 발생한 본의 이름입니다.
  • 주로 물리 시뮬레이션과 충돌 감지 시스템에서 사용됩니다.
  • 충돌 결과를 처리할 때 트레이스가 히트한 정확한 본을 참조할 때 사용합니다.
  1. Bone Name:
  • 일반적으로 같은 값을 가지지만, 특정 경우에 Hit Bone Name과 다를 수 있습니다.
  • 본 계층 구조에서 사용되며, 추가적인 처리나 변환 후에 결정된 본 이름일 수 있습니다.
  • 일부 레거시 코드와의 호환성을 위해 유지되는 경우도 있습니다.

실용적인 목적으로는 대부분의 경우 Hit Bone Name을 사용하면 됩니다. 특수한 경우가 아니라면 두 값이 동일한 경우가 많습니다.

라인 트레이스 내부 구현분석


언리얼 엔진의 라인 트레이스 내부 구현을 분석해보겠습니다.
Chaos 물리 엔진을 사용하는 라인 트레이스입니다.

bool FBodyInstance::LineTrace(struct FHitResult& OutHit, const FVector& Start, const FVector& End, bool bTraceComplex, bool bReturnPhysicalMaterial) const  
{  
    SCOPE_CYCLE_COUNTER(STAT_Collision_SceneQueryTotal);  
    SCOPE_CYCLE_COUNTER(STAT_Collision_FBodyInstance_LineTrace);  
  
    return FPhysicsInterface::LineTrace_Geom(OutHit, this, Start, End, bTraceComplex, bReturnPhysicalMaterial);  
}
  • FHitResult& OutHit: 히트 결과를 저장할 구조체

  • const FVector& Start: 라인 트레이스 시작점 (월드 좌표)

  • const FVector& End: 라인 트레이스 종료점 (월드 좌표)

  • bool bTraceComplex: true면 복잡한 콜리전, false면 단순 콜리전 사용

  • bool bReturnPhysicalMaterial: 물리 머티리얼 정보 반환 여부

  • FPhysicsInterface::LineTrace_Geom에서 실제 구현이 이어집니다.

🤔 SCOPE_CYCLE_COUNTER이란
SCOPE_CYCLE_COUNTER: 성능 측정을 위한 프로파일링 매크로입니다.
RAII 패턴을 사용해서:

  • 매크로가 실행되는 순간 타이머 시작
  • 스코프를 벗어날 때 (함수 종료 시) 자동으로 타이머 종료
  • 측정된 시간을 통계에 자동 누적
  • 에디터에서 stat collision 명령어로 실시간 성능 데이터를 볼 수 있습니다.

간단히 말해서 "이 함수가 얼마나 오래 걸렸는지 자동으로 측정해줘"라는 의미입니다.

bool FPhysInterface_Chaos::LineTrace_Geom(FHitResult& OutHit, const FBodyInstance* InInstance, const FVector& WorldStart, const FVector& WorldEnd, bool bTraceComplex, bool bExtractPhysMaterial)
{
   using namespace ChaosInterface;

   // Need an instance to trace against
   check(InInstance);

   OutHit.TraceStart = WorldStart;
   OutHit.TraceEnd = WorldEnd;

   bool bHitSomething = false;

   const FVector Delta = WorldEnd - WorldStart;
   const float DeltaMag = Delta.Size();
   if (DeltaMag > UE_KINDA_SMALL_NUMBER)
   {
       // #PHYS2 Really need a concept for "multi" locks here - as we're locking ActorRef but not TargetInstance->ActorRef
       FPhysicsCommand::ExecuteRead(InInstance->ActorHandle, [&](const FPhysicsActorHandle& Actor)
       {
           // If we're welded then the target instance is actually our parent
           const FBodyInstance* TargetInstance = InInstance->WeldParent ? InInstance->WeldParent : InInstance;
           if(const FPhysicsActorHandle RigidBody = TargetInstance->ActorHandle)
           {
               FRaycastHit BestHit;
               BestHit.Distance = FLT_MAX;

               // Get all the shapes from the actor
               PhysicsInterfaceTypes::FInlineShapeArray Shapes;
               const int32 NumShapes = FillInlineShapeArray_AssumesLocked(Shapes, Actor);

               const FTransform WorldTM(RigidBody->GetGameThreadAPI().R(), RigidBody->GetGameThreadAPI().X());
               const FVector LocalStart = WorldTM.InverseTransformPositionNoScale(WorldStart);
               const FVector LocalDelta = WorldTM.InverseTransformVectorNoScale(Delta);

               // Iterate over each shape
               for (int32 ShapeIdx = 0; ShapeIdx < NumShapes; ShapeIdx++)
               {
                   // #PHYS2 - SHAPES - Resolve this single cast case
                   FPhysicsShapeReference_Chaos& ShapeRef = Shapes[ShapeIdx];
                   Chaos::FPerShapeData* Shape = ShapeRef.Shape;
                   check(Shape);

                   if (TargetInstance->IsShapeBoundToBody(ShapeRef) == false)
                   {
                       continue;
                   }

                   // Filter so we trace against the right kind of collision
                   FCollisionFilterData ShapeFilter = Shape->GetQueryData();
                   const bool bShapeIsComplex = (ShapeFilter.Word3 & EPDF_ComplexCollision) != 0;
                   const bool bShapeIsSimple = (ShapeFilter.Word3 & EPDF_SimpleCollision) != 0;
                   if ((bTraceComplex && bShapeIsComplex) || (!bTraceComplex && bShapeIsSimple))
                   {
                       Chaos::FReal Distance;
                       Chaos::FVec3 LocalPosition;
                       Chaos::FVec3 LocalNormal;

                       int32 FaceIndex;
                       if (Shape->GetGeometry()->Raycast(LocalStart, LocalDelta / DeltaMag, DeltaMag, 0, Distance, LocalPosition, LocalNormal, FaceIndex))
                       {
                           if (Distance < BestHit.Distance)
                           {
                               BestHit.Distance = Distance;
                               BestHit.WorldNormal = LocalNormal; //will convert to world when best is chosen
                               BestHit.WorldPosition = LocalPosition;
                               BestHit.Shape = Shape;
                               BestHit.Actor = Actor->GetParticle_LowLevel();
                               BestHit.FaceIndex = FaceIndex;
                           }
                       }
                   }
               }

               if (BestHit.Distance < FLT_MAX)
               {
                   BestHit.WorldNormal = WorldTM.TransformVectorNoScale(BestHit.WorldNormal);
                   BestHit.WorldPosition = WorldTM.TransformPositionNoScale(BestHit.WorldPosition);
                   SetFlags(BestHit, EHitFlags::Distance | EHitFlags::Normal | EHitFlags::Position);

                   // we just like to make sure if the hit is made, set to test touch
                   FCollisionFilterData QueryFilter;
                   QueryFilter.Word2 = 0xFFFFF;

                   FTransform StartTM(WorldStart);
                   const UPrimitiveComponent* OwnerComponentInst = InInstance->OwnerComponent.Get();
                   ConvertQueryImpactHit(OwnerComponentInst ? OwnerComponentInst->GetWorld() : nullptr, BestHit, OutHit, DeltaMag, QueryFilter, WorldStart, WorldEnd, nullptr, StartTM, true, bExtractPhysMaterial);
                   bHitSomething = true;
               }
           }
       });
   }

   return bHitSomething;
}

1. 초기 설정과 검사

check(InInstance);  // 디버그 빌드에서 널 체크

OutHit.TraceStart = WorldStart;  // 결과에 시작점 저장
OutHit.TraceEnd = WorldEnd;      // 결과에 종료점 저장

bool bHitSomething = false;  // 히트 여부 플래그 기본은 false

const FVector Delta = WorldEnd - WorldStart;  // 방향 벡터
const float DeltaMag = Delta.Size();           // 거리

// 거리가 충분히 큰지 확인합니다.
if (DeltaMag > UE_KINDA_SMALL_NUMBER)

💡 UE_KINDA_SMALL_NUMBER
UE_KINDA_SMALL_NUMBER(1.e-4f) (0.00001)과 같습니다.


즉, 너무 짧은 라인 트레이스는 제외합니다.

2. 안전하게 물리 시스템 접근


FPhysicsCommand::ExecuteRead(InInstance->ActorHandle, [&](const FPhysicsActorHandle& Actor)
{
    // 물리 데이터에 읽기 전용으로 안전하게 접근
});
  • 멀티스레드 안전성: 물리 시뮬레이션과 동시에 안전하게 데이터 읽기
  • 람다 함수: 클로저로 지역 변수들을 캡처하여 사용

왜 필요한가?

언리얼 엔진은 멀티스레드 환경에서 작동합니다:

  • 게임 스레드: 게임 로직, UI, 블루프린트 등
  • 물리 스레드: 물리 시뮬레이션 (Chaos 엔진)
  • 렌더 스레드: 그래픽 렌더링

하지만 동시성 문제가 생길 수 있기에 이를 ExecuteRead로 읽기 전용 락을 거는 것!

3. Welded Body


const FBodyInstance* TargetInstance = InInstance->WeldParent ? InInstance->WeldParent : InInstance;
  • Welded Body: 여러 바디가 하나로 합쳐진 경우
  • 자식 바디에 트레이스하면 실제로는 부모 바디를 대상으로 함

🤔 WeldParent같은거 처음봅니다. 이게 뭘까요?
언리얼에서는 내부적으로 Welding이라는 여러 개의 물리 바디를 하나로 합치는 최적화 기법이 있습니다.

아래의 조건이 만족되면 여러 물리 바디를 하나로 합칩니다.

  • 같은 액터 안
  • 모두 Static Collision
  • 물리 시뮬레이션 꺼짐
  • 서로 가까이 위치

-> 즉 Welding 처리된 것은 하나의 강체로 처리하겠다는 뜻입니다.

✏️ 그런데 인스턴스가 뭐죠?
지금 이 시점에서 인스턴스는 라인트레이스를 확인할 오브젝트입니다.
라인 트레이스를 사용하면 내부적으로 월드 상의 모든 객체(후술 하겠지만 모두는 아닙니다.)에 위의 라인 트레이스를 적용하는 방식으로 구현되어있는데 그렇기에

인스턴스가 결정되었다고 볼 수 있는 것입니다.

4. Shape 배열 수집


PhysicsInterfaceTypes::FInlineShapeArray Shapes;
const int32 NumShapes = FillInlineShapeArray_AssumesLocked(Shapes, Actor);
  • 타겟 액터가 가진 모든 콜리전 Shape들을 배열로 수집
  • FInlineShapeArray: 스택 메모리를 우선 사용하는 최적화된 배열

💡 FInlineShapeArray
최적화 원리:

  • Shape가 8개 이하면 → 스택 메모리 사용 (빠름)
  • Shape가 8개 초과면 → 힙 메모리 동적 할당 (느리지만 필요시에만)

5. 좌표 변환


const FTransform WorldTM(RigidBody->GetGameThreadAPI().R(), RigidBody->GetGameThreadAPI().X());
const FVector LocalStart = WorldTM.InverseTransformPositionNoScale(WorldStart);
const FVector LocalDelta = WorldTM.InverseTransformVectorNoScale(Delta);

타겟 바디의 강체 정보에서 GetGameThreadAPI()를 통해 게임 스레드의 정보를 받아옵니다.

  • R(): 회전 (Rotation)
  • X(): 위치 (Position)
  • 월드 좌표를 액터의 로컬 좌표계로 변환
  • NoScale: 스케일 변환은 제외 (성능 최적화)

💡 NoScale의 최적화

// 일반 변환 (스케일 포함)
FVector Transform(const FVector& Point)
{
   return Rotation * (Point * Scale) + Translation;  // 곱셈 2번
}

// NoScale 변환 (스케일 제외)  
FVector TransformNoScale(const FVector& Point)
{
   return Rotation * Point + Translation;  // 곱셈 1번
}

라인 트레이스는 매우 자주 호출되므로 작은 최적화도 중요

참고로 월드에서 계산하는 것도 가능하고, 로컬 좌표로 계산하는 것도 가능하지만 월드 좌표는 매우 큰 수 일 수도 있기에 부동소수점 문제로 오차가 발생할 수 있습니다.

6. Shape 순회 및 필터링


for (int32 ShapeIdx = 0; ShapeIdx < NumShapes; ShapeIdx++)
{
    FPhysicsShapeReference_Chaos& ShapeRef = Shapes[ShapeIdx];
    Chaos::FPerShapeData* Shape = ShapeRef.Shape;
    check(Shape);

    if (TargetInstance->IsShapeBoundToBody(ShapeRef) == false)
    {
        continue;  // 이 바디에 속하지 않는 Shape는 건너뛰기
    }
  • 위에서 언급한 Welding으로 인해 PrimitiveComponent의 구조가 뒤섞이는데 우리는 타겟 PrimitiveComponent의 Shape만 검사하면 되기에 바디(PrimitiveComponent)에 속하지 않는 Shape는 건너뜁니다.

7. 콜리전 필터링


FCollisionFilterData ShapeFilter = Shape->GetQueryData();
const bool bShapeIsComplex = (ShapeFilter.Word3 & EPDF_ComplexCollision) != 0;
const bool bShapeIsSimple = (ShapeFilter.Word3 & EPDF_SimpleCollision) != 0;

if ((bTraceComplex && bShapeIsComplex) || (!bTraceComplex && bShapeIsSimple))
{
    // 조건에 맞는 Shape만 처리
}
  • EPDF_ComplexCollision: 복잡한 콜리전 플래그
  • EPDF_SimpleCollision: 단순 콜리전 플래그
  • 비트 연산: Word3의 특정 비트를 확인하여 콜리전 타입 판별

마찬가지로 비트 연산을 통해 최적화

8. 레이캐스트


Chaos::FReal Distance;      // 히트까지의 거리
Chaos::FVec3 LocalPosition; // 히트 위치 (로컬 좌표)
Chaos::FVec3 LocalNormal;   // 히트 노말 (로컬 좌표)
int32 FaceIndex;            // 히트된 면의 인덱스

if (Shape->GetGeometry()->Raycast(LocalStart, LocalDelta / DeltaMag, DeltaMag, 0, Distance, LocalPosition, LocalNormal, FaceIndex))
{
    if (Distance < BestHit.Distance)
    {
        // 가장 가까운 히트 정보 업데이트
        BestHit.Distance = Distance;
        BestHit.WorldNormal = LocalNormal;     // 나중에 월드 좌표로 변환
        BestHit.WorldPosition = LocalPosition; // 나중에 월드 좌표로 변환
        BestHit.Shape = Shape;
        BestHit.Actor = Actor->GetParticle_LowLevel();
        BestHit.FaceIndex = FaceIndex;
    }
}
  • 정규화된 방향: LocalDelta / DeltaMag로 단위 벡터 전달
  • 최근접 히트: 여러 히트 중 가장 가까운 것만 저장

9. 결과 변환 및 최종 처리


if (BestHit.Distance < FLT_MAX)  // 유효한 히트가 있다면
{
    // 로컬 좌표를 월드 좌표로 재변환
    BestHit.WorldNormal = WorldTM.TransformVectorNoScale(BestHit.WorldNormal);
    BestHit.WorldPosition = WorldTM.TransformPositionNoScale(BestHit.WorldPosition);
    
    // 히트 플래그 설정
    SetFlags(BestHit, EHitFlags::Distance | EHitFlags::Normal | EHitFlags::Position);

    // 쿼리 필터 설정
    FCollisionFilterData QueryFilter;
    QueryFilter.Word2 = 0xFFFFF;  // 모든 채널에 대해 활성화

    FTransform StartTM(WorldStart);
    const UPrimitiveComponent* OwnerComponentInst = InInstance->OwnerComponent.Get();
    
    // 내부 히트 데이터를 언리얼의 FHitResult로 변환
    ConvertQueryImpactHit(
        OwnerComponentInst ? OwnerComponentInst->GetWorld() : nullptr, 
        BestHit, 
        OutHit, 
        DeltaMag, 
        QueryFilter, 
        WorldStart, 
        WorldEnd, 
        nullptr, 
        StartTM, 
        true, 
        bExtractPhysMaterial
    );
    
    bHitSomething = true;
}

참고자료 https://dev.epicgames.com/documentation/ko-kr/unreal-engine/traces-with-raycasts-in-unreal-engine

profile
행복하세요

0개의 댓글