
레이캐스트와 트레이스는 게임 내에서 시야 감지 및 객체 간 상호작용을 구현하는 핵심 기술입니다. 이 시스템은 가상의 광선을 사용하여 공간 내 충돌을 감지하고, 게임플레이 메커니즘에 활용됩니다.
레이캐스트와 트레이스는 보이지 않는 광선을 발사하여 두 점 사이에 장애물이 있는지 확인합니다. 이를 통해 플레이어가 특정 객체를 바라보고 있는지, 또는 AI가 플레이어를 감지할 수 있는지 등을 판단할 수 있습니다. 충돌이 감지되면 해당 정보를 반환하여 게임 상태를 변경하는 데 활용합니다.
트레이스는 언리얼 엔진에서 공간 쿼리를 수행하는 주요 방법으로, 게임 월드 내에서 객체 간의 관계를 파악하는 데 사용됩니다.
👾 트레이스 시스템 사용 상황
- 무기 발사 및 탄도학 계산
- 캐릭터의 시야 및 타겟팅 시스템
- AI의 감지 및 인식 메커니즘
- 물리적 상호작용 및 장애물 감지
- 게임 내 객체 선택 및 하이라이팅

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();
// 추가 작업 수행
}
언리얼 엔진은 다양한 트레이스 채널을 제공합니다
사용자 정의 채널은 프로젝트 설정에서 추가할 수 있습니다.
트레이스는 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:
- 충돌 검사에서 직접적으로 충돌이 발생한 본의 이름입니다.
- 주로 물리 시뮬레이션과 충돌 감지 시스템에서 사용됩니다.
- 충돌 결과를 처리할 때 트레이스가 히트한 정확한 본을 참조할 때 사용합니다.
- 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;
}
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)과 같습니다.
즉, 너무 짧은 라인 트레이스는 제외합니다.
FPhysicsCommand::ExecuteRead(InInstance->ActorHandle, [&](const FPhysicsActorHandle& Actor)
{
// 물리 데이터에 읽기 전용으로 안전하게 접근
});
언리얼 엔진은 멀티스레드 환경에서 작동합니다:
하지만 동시성 문제가 생길 수 있기에 이를
ExecuteRead로 읽기 전용 락을 거는 것!
const FBodyInstance* TargetInstance = InInstance->WeldParent ? InInstance->WeldParent : InInstance;
🤔 WeldParent같은거 처음봅니다. 이게 뭘까요?
언리얼에서는 내부적으로 Welding이라는 여러 개의 물리 바디를 하나로 합치는 최적화 기법이 있습니다.아래의 조건이 만족되면 여러 물리 바디를 하나로 합칩니다.
- 같은 액터 안
- 모두 Static Collision
- 물리 시뮬레이션 꺼짐
- 서로 가까이 위치
-> 즉 Welding 처리된 것은 하나의 강체로 처리하겠다는 뜻입니다.
✏️ 그런데 인스턴스가 뭐죠?
지금 이 시점에서 인스턴스는 라인트레이스를 확인할 오브젝트입니다.
라인 트레이스를 사용하면 내부적으로 월드 상의 모든 객체(후술 하겠지만 모두는 아닙니다.)에 위의 라인 트레이스를 적용하는 방식으로 구현되어있는데 그렇기에인스턴스가 결정되었다고 볼 수 있는 것입니다.
PhysicsInterfaceTypes::FInlineShapeArray Shapes;
const int32 NumShapes = FillInlineShapeArray_AssumesLocked(Shapes, Actor);
💡 FInlineShapeArray
최적화 원리:
- Shape가 8개 이하면 → 스택 메모리 사용 (빠름)
- Shape가 8개 초과면 → 힙 메모리 동적 할당 (느리지만 필요시에만)
const FTransform WorldTM(RigidBody->GetGameThreadAPI().R(), RigidBody->GetGameThreadAPI().X());
const FVector LocalStart = WorldTM.InverseTransformPositionNoScale(WorldStart);
const FVector LocalDelta = WorldTM.InverseTransformVectorNoScale(Delta);
타겟 바디의 강체 정보에서 GetGameThreadAPI()를 통해 게임 스레드의 정보를 받아옵니다.
💡 NoScale의 최적화
// 일반 변환 (스케일 포함) FVector Transform(const FVector& Point) { return Rotation * (Point * Scale) + Translation; // 곱셈 2번 } // NoScale 변환 (스케일 제외) FVector TransformNoScale(const FVector& Point) { return Rotation * Point + Translation; // 곱셈 1번 }라인 트레이스는 매우 자주 호출되므로 작은 최적화도 중요
참고로 월드에서 계산하는 것도 가능하고, 로컬 좌표로 계산하는 것도 가능하지만 월드 좌표는 매우 큰 수 일 수도 있기에 부동소수점 문제로 오차가 발생할 수 있습니다.
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는 건너뛰기
}
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만 처리
}
마찬가지로 비트 연산을 통해 최적화
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로 단위 벡터 전달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