2026-04-30 TIL (48일차)
Trace와 DamageType 과제
강의 복습
콜리전 정리
언리얼 엔진에서 콜리전(충돌)은 크게 두 가지 완전히 다른 시스템으로 나뉘어 돌아갑니다.
쿼리 (Query - 감지/탐색)
- 역할: 물리적인 실체라기보다는 ‘센서’나 ‘스캐너’에 가깝습니다.
- 하는 일: 엔진이 공간을 향해 “이 선(레이저)에 닿는 거 있어?”, “이 구역(박스) 안에 들어온 사람 있어?” 하고 질문(Query)을 던지고 결과를 받습니다.
- 대표 기능: 라인 트레이스(총알 궤적 검사), 오버랩(트리거 구역 진입 감지), 스윕(이동 중 충돌 예측).
피직스 (Physics - 물리 연산)
- 역할: 현실 세계의 ‘물리 법칙’을 계산하는 시스템입니다.
- 하는 일: 중력을 받아 떨어지고, 부딪히면 밀려나거나 튕겨 나가고, 캐릭터가 벽을 뚫고 지나가지 못하게 단단하게 막아섭니다. (리지드 바디 연산)
- 대표 기능: 중력 적용, 래그돌(캐릭터가 죽었을 때 축 늘어지는 효과), 블록(가로막기).
Collsion 설정창
밑의 시스템은 컴퓨터의 성능(CPU)을 꽤 많이 잡아먹습니다. 그래서 언리얼 엔진은 “안 쓰는 기능은 확실하게 꺼서 게임을 최적화하자!”라는 목적으로 세 가지 옵션을 제공합니다.
쿼리 온리 (Query Only)
“나는 물리적인 몸통은 없는 유령이야. 하지만 센서 역할은 확실하게 할게!”
- 상태: 오버랩(겹침)과 트레이스(레이저 검사)는 정상적으로 작동하지만, 중력이나 부딪힘 같은 물리 연산은 아예 무시합니다. 아무리 때려도 밀리지 않습니다.
- 언제 쓸까?
- 캐릭터가 다가가면 문이 열리는 투명 트리거 박스
- 총알을 쏘면 맞았는지 판정만 해야 하는 표적지
- 건드리면 점수가 오르는 아이템 (물리엔진이 켜져 있으면 캐릭터가 아이템을 발로 차서 날려버릴 수 있기 때문)
피직스 온리 (Physics Only)
“나는 오직 물리 법칙에만 따를 거야. 센서나 레이더에는 아예 안 잡힐 거니까 찾지 마!”
- 상태: 중력을 받아 떨어지고 플레이어의 길을 가로막지만, 레이캐스트(총알)나 오버랩(트리거 박스) 감지에는 절대 걸리지 않습니다. 즉, 투명 인간의 몸통 같은 상태입니다.
- 언제 쓸까?
- 폭발 후 바닥에 굴러다니는 작은 파편이나 돌멩이 (굴러다녀야 하니 물리 연산은 필요하지만, 이 파편들이 트리거 박스를 밟아서 문을 열거나 총알을 대신 맞아주면 안 될 때)
- 캐릭터가 밟고 올라갈 수는 있지만 상호작용은 필요 없는 단순 배경 장식물
콜리전 이네이블드 (Collision Enabled - Query and Physics)
“나는 센서 기능도 쓰고, 물리 법칙도 전부 다 적용받을래!”
- 상태: 쿼리와 피직스 모두 켜져 있는 가장 무거운 상태입니다.
- 언제 쓸까?
- 플레이어 캐릭터, 몬스터, 굴려서 퍼즐을 풀어야 하는 상자 등 게임 플레이의 핵심이 되는 액터.
요약 표
| 콜리전 설정 | 라인 트레이스 (총알) | 오버랩 (트리거 진입) | 중력 / 밀림 / 가로막음 | 주요 용도 |
|---|---|---|---|---|
| No Collision | ❌ | ❌ | ❌ | 완전한 배경, 구름, 환영 |
| Query Only | ⭕ | ⭕ | ❌ | 아이템, 스위치, 감지 구역 |
| Physics Only | ❌ | ❌ | ⭕ | 배경의 파편, 굴러다니는 돌 |
| Collision Enabled | ⭕ | ⭕ | ⭕ | 캐릭터, 움직이는 함정 |
오브젝트의 정체성과 상호작용
언리얼 엔진의 콜리전 시스템은 크게 “나는 누구인가?(오브젝트 채널)”와 “남들을 어떻게 대할 것인가?(프리셋)” 두 가지 축으로 돌아갑니다.
오브젝트 채널 (Object Channel)
“나의 정체성은 무엇인가?”
- 정의: 이 오브젝트가 물리적으로 어떤 종류의 물체인지 정체성을 부여하는 이름표입니다.
- 특징: 언리얼이 기본으로 제공하는 채널(WorldStatic, Pawn 등) 외에도, 개발자가 프로젝트 세팅에서 직접 원하는 이름(예:
Enemy,Projectile,Barrier)으로 커스텀 채널을 추가하여 정의할 수 있습니다.
콜리전 프리셋 (Collision Preset)
“내가 다른 채널들을 만났을 때 어떻게 반응할 것인가?”
- 정의: 오브젝트 채널들이 서로 만났을 때 어떻게 상호작용할지(충돌, 겹침, 무시)를 미리 세팅해 둔 ‘행동 지침서(규칙표)’입니다.
- 작동 방식: 특정 오브젝트에 프리셋(예:
BlockAll,OverlapOnlyPawn)을 적용하면, 그 프리셋에 미리 설정된 규칙에 따라 다른 오브젝트 채널들을Block(막음),Overlap(겹침),Ignore(무시)처리하게 됩니다.
프리셋과 ‘오브젝트 타입’의 관계 (자동 적용 원리)
- 프리셋 설정 창을 열어보면 가장 맨 위에 ‘Object Type’이라는 항목이 있습니다.
- 자동 적용: 내가 만든 액터나 컴포넌트에 특정 프리셋을 지정하면, 엔진은 “아, 이 프리셋을 쓰니까 너의 오브젝트 채널(정체성)은 이거구나!” 하고 알아서 프리셋 안에 설정된 오브젝트 타입으로 채널을 자동 갱신(동기화)해 줍니다.
- 즉, 일일이 채널을 지정할 필요 없이 프리셋만 골라주면 정체성과 행동 규칙이 한 번에 세팅됩니다.
트레이스의 Visibility
콜리전 채널에는 물리적 물체를 나타내는 ‘오브젝트 채널’ 외에도, 눈에 보이지 않는 레이저를 쏘기 위한 트레이스 채널이 존재합니다. 그중 기본 채널이 Visibility입니다. (오브젝트 채널처럼 트레이스의 채널이다.)
Visibility (시야/가시성) 채널의 의미
- 정의: 카메라나 시야, 레이저가 통과할 수 있는지 없는지를 판별하기 위해 쏘는 ‘시야 검사 전용 레이저’입니다.
- 하는 일: “내 눈앞에(또는 마우스 클릭 위치에) 시야를 가로막고 있는 무언가가 있는가?”를 질문(Query)합니다.
- 활용 예시:
- FPS 게임의 히트스캔 총알: 화면 중앙 정면을 향해
Visibility레이저를 쏴서 가장 먼저 걸리는(Block) 적을 맞춥니다. - 카메라 장애물 회피: 3인칭 캐릭터와 카메라 사이에 벽이 있는지
Visibility로 검사해서, 벽이 있으면 카메라를 캐릭터 쪽으로 줌인시킵니다. - 마우스 클릭 상호작용: 플레이어가 화면(UI) 위에서 마우스를 클릭했을 때, 3D 월드의 어떤 물체를 클릭한 것인지 알아낼 때 씁니다.
- FPS 게임의 히트스캔 총알: 화면 중앙 정면을 향해
요약
- 유리가벽(투명한 벽)을 만든다면? 👉 캐릭터(Pawn) 채널은 Block(못 지나감)하게 막고, 트레이스(Visibility) 채널은 Ignore(시야 통과)하게 설정하면 됩니다!
스태틱 메쉬의 콜리전(Collision)
눈에 보이는 모델링 vs 실제 충돌체
게임 엔진에서 눈에 보이는 물체(렌더링 메쉬)와 부딪히는 물체(콜리전 메쉬)는 완전히 별개의 데이터입니다. 아무리 화려하고 정교한 모델링이라도 콜리전 데이터가 없다면 캐릭터는 그 물체를 유령처럼 통과하게 됩니다.
“Default” 프리셋의 비밀: 왜 알아서 막아줄까?
월드에 언리얼 기본 Shape(큐브, 구체 등)를 배치하면 디테일 패널의 콜리전 프리셋이 Default로 되어 있는데도 플레이어를 잘 막아줍니다.
- 이유:
Default는 “콜리전이 없다”는 뜻이 아니라, “이 액터에 장착된 ‘원본 스태틱 메쉬(Static Mesh)’ 에셋 안에 저장된 기본 콜리전 세팅을 그대로 가져다 쓰겠다”는 뜻입니다. - 덮어쓰기 (Override): 만약 월드에 배치된 액터의 디테일 패널에서 프리셋을
BlockAll이나OverlapAll등으로 수동 변경하면, 원본 메쉬의 설정을 무시하고 현재 설정한 값으로 덮어씌워져(Override) 작동합니다.
단순 콜리전(Simple) vs 복합 콜리전(Complex)
콜리전은 생성 방식과 무게(성능 비용)에 따라 두 가지로 나뉩니다.
- 단순 콜리전 (Simple Collision):
- 박스, 구체, 캡슐 등 수학적으로 연산하기 매우 가벼운 형태입니다.
- 우리가 에디터에서 직접 추가하거나 기본적으로 세팅되는 형태입니다.
- 복합 콜리전 (Complex Collision):
- 눈에 보이는 모델링의 폴리곤(삼각형) 형태를 100% 그대로 충돌체로 사용합니다.
- 매우 정교하지만 연산 비용이 엄청나게 비쌉니다(무겁습니다).
- 팁 (자동 컨벡스 콜리전): 울퉁불퉁한 바위나 지형에서 캐릭터가 공중에 뜨는 등 이상하게 걸어 다닌다면, 스태틱 메쉬 에디터 창에서 기존 콜리전을 지우고 ‘자동 컨벡스 콜리전(Auto Convex Collision)’을 생성해 주면 가벼우면서도 형태에 꽤 알맞은 콜리전을 얻을 수 있습니다.
콜리전 복잡도 (Collision Complexity) 정리
스태틱 메쉬 에디터의 디테일 패널에 있는 Collision Complexity 설정은 단순과 복합을 어떤 상황에서 쓸 것인지 엔진에게 지시하는 매우 중요한 옵션입니다.
1. Project Default (프로젝트 디폴트)
- 프로젝트 세팅(Project Settings)에 설정된 전역 규칙을 그대로 따릅니다.
2. Default (Simple And Complex) - 기본값
- 평소(캐릭터 이동, 물리 엔진 충돌): 가벼운 ‘단순 콜리전’을 사용합니다.
- 특수 상황(정밀한 트레이스 등): 외부에서 “나 복합 콜리전 검사할래!”라고 요청하면 그때는 ‘복합 콜리전’을 내어줍니다.
- 용도: 가장 균형 잡힌 설정이며 대부분의 오브젝트에 사용됩니다.
3. Use Simple Collision As Complex (단순을 복합으로 사용)
- 엔진에게 “누가 복합 콜리전을 요구하더라도, 무조건 가벼운 단순 콜리전만 줘버려!“라고 강제하는 옵션입니다.
- 용도: 모바일 게임이나 최적화가 극도로 중요한 프로젝트에서 성능을 아끼기 위해 사용합니다. (복합 콜리전 연산을 원천 차단)
4. Use Complex Collision As Simple (복합을 단순으로 사용)
- 엔진에게 “평소에 캐릭터가 걸어 다니거나 부딪히는 물리 연산에도 무조건 폴리곤 덩어리(복합 콜리전)를 사용해!“라고 강제하는 옵션입니다.
- 용도: 성능을 매우 많이 깎아먹기 때문에 남용하면 안 됩니다. 울퉁불퉁한 산 지형(Landscape)이나, 플레이어가 아주 정교하게 밟고 올라가야 하는 복잡한 나선형 계단 등에만 제한적으로 사용합니다.
코드 활용: bTraceComplex의 진짜 의미
“trace를 쏠 때 단순이 맞으면 인지를 해서, 이거는 complex랑 상호작용해야해 하고 넘기는 건가요?”
아닙니다! 단계적으로 감지하는 것이 아니라, 아예 검사하는 타겟을 바꿔버립니다.
C++나 블루프린트에서 LineTrace 함수를 쓸 때 bTraceComplex (블루프린트에서는 Trace Complex 체크박스) 매개변수를 넘기게 됩니다.
bTraceComplex = false일 때: 레이저(Trace)는 물체를 감싸고 있는 ‘단순 콜리전’에 부딪히고 그 위치를 반환합니다. (물체 주변의 투명한 박스 허공에 총알이 맞는 현상이 발생할 수 있습니다.)bTraceComplex = true일 때: 엔진은 이 레이저를 쏠 때 단순 콜리전을 완전히 무시(투명 취급)하고, 그 안에 있는 ‘복합 콜리전(실제 폴리곤 메쉬)’에 닿을 때까지 레이저를 관통시킵니다.- 언제 쓰나요?
- 적 캐릭터의 머리, 팔, 다리 중 정확히 어디에 총알이 맞았는지(Hit Bone) 정밀하게 판정해야 하는 FPS 게임의 히트스캔 무기를 구현할 때 주로 사용합니다.
Kismet
GetWorld() vs Kismet 차이점
트레이스(레이캐스트)를 쏠 때 언리얼에서는 크게 두 가지 접근 방식을 사용합니다.
GetWorld() (순수 C++ 방식)
- 특징: 언리얼 엔진의 핵심 코어인
UWorld에 직접 접근하여 물리 연산을 요청하는 날것(Raw)의 C++ 방식입니다. - 장단점: 포장지가 없기 때문에 연산 속도가 미세하게 가장 빠르지만, 디버그 선(레이저)을 화면에 그리려면 별도의 코드를 길게 작성해야 해서 개발 과정에서는 다소 불편합니다. (보통 출시 전 최적화 단계에서 사용합니다.)
Kismet (UKismetSystemLibrary)
- 특징: 과거 언리얼 초창기의 비주얼 스크립팅 이름에서 유래했습니다. 현재는 복잡한 C++ 함수들을 블루프린트 노드에서 쓰기 편하도록 한 겹 포장(Wrap)해둔 라이브러리를 뜻합니다.
- 장점: “디버깅이 압도적으로 편합니다.” 함수 호출 한 번으로 빨간색/초록색 디버그 레이저를 화면에 쉽게 그릴 수 있어서, 개발 및 테스트 단계에서 가장 많이 사용됩니다.
Trace의 3가지 종류
트레이스는 목적에 따라 레이저가 물체를 판별하고 결과를 반환하는 방식이 다릅니다.
- 싱글 (Single Trace)
- 의미: 레이저가 날아가다가 최초로 막히는(Block) 물체 딱 하나만 찾고 즉시 멈춥니다.
- 용도: 일반적인 총알, 바닥 감지(지면 체크), 카메라 시야 검사 등 가장 기본적으로 쓰입니다.
- 멀티 (Multi Trace)
- 의미: 레이저가 날아가면서 겹쳐지는(Overlap) 모든 물체들을 전부 뚫고 지나가며 배열(Array)에 기록하고, 막히는(Block) 물체를 만났을 때 최종적으로 멈춥니다.
- 용도: 적을 관통하는 스나이퍼 총, 광역 폭발 데미지 판정, 샷건의 산탄 등에 사용됩니다.
- 비동기 (Async Trace)
- 의미: 메인 스레드(게임의 메인 작업대)를 멈추지 않고, 뒷단(백그라운드 스레드)에서 트레이스를 계산한 뒤 “계산 다 끝났어!” 하고 나중에 결과를 알려주는 방식입니다.
- 용도: 수십 마리의 몬스터가 매 프레임마다 시야 검사 레이저를 쏘면 게임에 렉이 걸립니다. 이런 무거운 연산을 최적화할 때 반드시 사용해야 합니다.
UKismetSystemLibrary::LineTraceSingle 매개변수
1
2
3
4
5
6
7
8
9
10
11
12
13
14
UKismetSystemLibrary::LineTraceSingle(
WorldContextObject, // 1. 월드 컨텍스트
Start, // 2. 시작점
End, // 3. 끝점
TraceChannel, // 4. 트레이스 채널
bTraceComplex, // 5. 복잡한 콜리전 검사 여부
ActorsToIgnore, // 6. 무시할 액터 배열
DrawDebugType, // 7. 디버그 선 그리기 방식
OutHit, // 8. 충돌 결과 (반환값)
bIgnoreSelf, // 9. 자기 자신 무시 여부
TraceColor, // 10. 디버그 선 색상
TraceHitColor, // 11. 부딪힌 지점 색상
DrawTime // 12. 디버그 선 표시 시간
);
매개변수 상세 설명
WorldContextObject(const UObject*)- 의미: 이 트레이스가 어느 월드(레벨)에서 실행되는지 월드 값을 넣습니다. 보통
this를 넣습니다.
- 의미: 이 트레이스가 어느 월드(레벨)에서 실행되는지 월드 값을 넣습니다. 보통
2~3. Start / End (FVector)
- 의미: 레이저를 쏘기 시작할 3D 좌표(Start)와 끝나는 목표 좌표(End)입니다.
TraceChannel(ETraceTypeQuery)- 의미: 어떤 콜리전 채널을 검사할 것인지 묻습니다. (예:
Visibility,Camera등). 이 채널에 Block으로 설정된 물체만 레이저에 맞습니다.
- 의미: 어떤 콜리전 채널을 검사할 것인지 묻습니다. (예:
bTraceComplex(bool)- 의미:
true면 모델링의 정밀한 폴리곤 덩어리(복합 콜리전)를 검사하고,false면 캡슐이나 박스 같은 가벼운 단순 콜리전을 검사합니다.
- 의미:
ActorsToIgnore(const TArray<AActor*>&)- 의미: 트레이스 레이저가 무시하고 그냥 통과해야 할 액터들의 목록(배열)입니다. 팀킬을 막거나 특정 투명벽을 무시할 때 씁니다.
DrawDebugType(EDrawDebugTrace::Type)- 의미: 디버그 레이저를 어떻게 그릴지 정합니다. (
None: 안 그림,ForOneFrame: 1프레임만 그림,ForDuration: 지정한 시간만큼 그림)
- 의미: 디버그 레이저를 어떻게 그릴지 정합니다. (
OutHit(FHitResult&)- 의미: 레이저가 무언가에 맞았을 때, 맞은 물체의 정보(이름, 맞은 위치, 표면의 방향 등)를 담아주는 빈 바구니(구조체)입니다.
bIgnoreSelf(bool)- 의미:
true로 설정하면 레이저를 쏜 자기 자신은 맞지 않고 통과합니다. (총알이 내 캐릭터 안에서 터지는 것을 방지)
- 의미:
10~11. TraceColor / TraceHitColor (FLinearColor)
- 의미: 날아가는 레이저의 색상과, 물체에 부딪혔을 때 찍히는 네모난 표식의 색상입니다. (기본값: 빨강 / 초록)
DrawTime(float)- 의미:
DrawDebugType에서ForDuration을 선택했을 때, 디버그 선을 몇 초 동안 화면에 남겨둘지 설정합니다.
- 의미:
####Tip
트레이스(레이캐스트) 함수를 사용할 때, 매개변수로 들어가는 예외 배열에 ActorsToIgnore.Add(this);를 넣고, 추가로 bIgnoreSelf 옵션까지 true로 켜주는 것이 안전한 방식입니다.
이중으로 막는 이유
- 파츠(부착물) 충돌 방지: 게임 속 캐릭터(액터)는 보통 단순한 하나의 콜리전 덩어리가 아닙니다. 손에 들고 있는 무기, 장착한 방어구, 백팩 등 캐릭터에 부착된(Attached) 여러 하위 파츠나 컴포넌트들이 덕지덕지 붙어있기 마련입니다.
- 버그 예방 (Double Check):
bIgnoreSelf만 켜둘 경우 메인 액터의 루트 자체는 무시되지만, 간혹 충돌 설정에 따라 캐릭터 몸에 부착된 하위 파츠에 트레이스가 걸려버리는 불상사가 발생할 수 있습니다. (예: 내가 쏜 총알이 내 총열이나 방패에 막혀서 터져버리는 어이없는 버그)
따라서, 엔진이 메인 액터뿐만 아니라 연관된 모든 덩어리들을 완벽하게 예외 처리할 수 있도록 “예외 배열에 나 자신을 명시적으로 넣고 + 자기 자신 무시 스위치도 켜는” 이중 안전장치를 걸어주는 것이 좋습니다.
트레이스(Trace)
싱글 트레이스와 멀티 트레이스의 차이
싱글(Single) 트레이스는 오직 Block(막힘)만 찾고, 멀티(Multi) 트레이스는 Overlap(겹침)들도 함께 찾습니다. 엔진이 이렇게 설계된 이유는 ‘목적’과 ‘성능(최적화)’ 때문입니다.
싱글 트레이스 (LineTraceSingle)
- 목적: “내 앞을 가장 먼저 가로막는(Block) 단단한 물체 딱 하나만 찾아줘!”
- 작동 방식: 레이저를 쏘다가 유리창이나 풀숲 같은
Overlap을 만나면 그냥 무시하고 통과합니다. 그러다 벽이나 적의 몸뚱이 같은Block을 만나는 순간, 더 이상 뒤쪽은 검사하지 않고 레이저 검사를 즉시 종료합니다. - 사용하는 이유: 일반적인 총알은 벽에 맞으면 끝이니까, 굳이 그 뒤에 뭐가 있는지 컴퓨터가 힘들게 계산할 필요가 없기 때문입니다. (최적화)
멀티 트레이스 (LineTraceMulti)
- 목적: “레이저가 뻗어나가는 길에 닿은 모든 것(Overlap)을 다 알려주고, 가로막히면(Block) 그때 멈춰!”
- 작동 방식: 레이저를 쏘면서 통과하는 모든
Overlap객체들을 바구니(배열, Array)에 담습니다. 그러다가Block을 만나면 그 녀석까지 바구니에 담고 검사를 종료합니다. - 사용하는 이유: 적을 관통하는 스나이퍼 총알, 넓은 범위를 베어버리는 검기, 폭발 반경 내에 있는 모든 적을 스캔할 때 등 ‘여러 명’을 한 번에 스캔해야 할 때 사용합니다.
레이저가 Block에 맞은 뒤 선 색깔이 변하는 이유?
“라인 트레이스를 쏠 때 Block에 맞으면 그 뒤로 선 색깔이 다르게 나오던데, 뒷부분은 다 충돌했다는 소리인가?”
A. 아닙니다! 뒷부분은 충돌했다는 뜻이 아니라, 오히려 “벽에 막혀서 못 간 가상의 거리”를 뜻합니다.
코드를 작성하실 때 보통 디버그 선 색상을 이렇게 지정합니다.
- 기본 선 색상 (
TraceColor): Red (빨간색) - 맞았을 때 색상 (
TraceHitColor): Green (초록색)
만약 길이 1000짜리 레이저를 쐈는데, 중간 지점인 500 위치에서 벽(Block)에 맞았다면 엔진은 화면에 다음과 같이 그려줍니다.
- 시작점 ~ 벽에 맞은 곳 (0 ~ 500) 🔴 빨간색:
- 실제로 레이저가 날아가서 부딪힌 궤적입니다. 끝에 네모난 충돌 박스 표시가 찍힙니다.
- 벽 뒤 ~ 원래 목표점 (500 ~ 1000) 🟢 초록색:
- 충돌한 게 아닙니다! “만약 벽이 없었더라면 레이저가 여기까지 날아갔을 텐데, 벽 때문에 짤렸어!” 라고 알려주는 가상의 나머지 궤적입니다.
결론: 초록색으로 변한 선은 뒤에 있는 물체들과 충돌했다는 뜻이 아니라, “여기서 가로막혀서 뒤쪽 공간은 레이저가 아예 가지도 못했다”는 것을 시각적으로 보여주는 개발자용 안내선(Debug Line)일 뿐입니다.
비동기(Asynchronous)
1. 게임의 기본 흐름 (동기 처리 방식)
게임 엔진은 기본적으로 한 줄씩 코드를 읽고 실행하는 ‘한 방향(순차적)’ 흐름을 가집니다.
- 문제점: 만약 메인 스레드(Main Thread)에서 “1억 마리의 몬스터를 스폰하라!”는 무거운 명령을 내리면, 메인 스레드는 1억 마리를 다 스폰할 때까지 다음 코드로 넘어가지 못하고 멈춰버립니다.
- 결과: 이 멈춰있는 시간 동안 화면이 정지하고 조작이 먹히지 않게 되는데, 이것이 바로 우리가 흔히 말하는 ‘렉(Lag)이 걸렸다’ 또는 ‘프레임 드랍’이 발생했다는 상태입니다.
비동기(Asynchronous) 처리의 핵심
비동기 처리는 무거운 작업을 메인 스레드가 직접 다 하지 않고, ‘다른 일꾼(스레드)에게 외주를 맡기는 방식’입니다.
- 작동 원리:
- 메인 스레드는 무거운 작업(예: 1억 마리 몬스터 스폰, 수많은 트레이스 연산)을 백그라운드 워커 스레드(Background Worker Thread)에게 휙 던져줍니다.
- 메인 스레드는 기다리지 않고 바로 다음 코드로 넘어가서 게임을 끊김 없이 계속 진행시킵니다. (렉 발생 ❌)
- 백그라운드 스레드에서 1억 마리 스폰 작업이 완료되면, “작업 끝났습니다!” 하고 메인 스레드에 최종 결과물(데이터)만 깔끔하게 전달해 줍니다.
활용(과제): 왜 산탄총(샷건) 트레이스에 비동기를 쓸까?
- 상황: 산탄총은 한 번 쏠 때마다 수십 가닥의 레이저(트레이스)를 동시에 쏴야 합니다. 만약 여러 명의 적이나 플레이어가 동시에 산탄총을 쏜다면, 한 프레임 안에 계산해야 할 트레이스의 양이 엄청나게 늘어납니다.
- 해결 (비동기 트레이스 적용):
- 이렇게 많은 트레이스를 동기(순차적) 방식으로 쏘면 메인 스레드가 병목 현상을 일으켜 게임이 느려집니다.
- 따라서 이번 과제처럼 “트레이스 연산은 백그라운드에서 비동기로 처리하고, 나중에 맞은 결과만 메인으로 돌려받아 데미지를 처리”하는 방식을 사용하면, 수많은 레이저를 쏘면서도 게임의 퍼포먼스(프레임 속도)를 부드럽게 유지할 수 있습니다.
AsyncLineTraceByChannel
순수 C++ 네이티브 방식인 GetWorld()를 통해 비동기(Async) 트레이스를 쏠 때 사용하는 함수입니다. 렉(프레임 드랍)을 방지하기 위해 백그라운드 스레드에서 연산을 처리하며, 이 때문에 매개변수의 구성이 Kismet(블루프린트) 방식과는 다릅니다.
1
2
3
4
5
6
7
8
9
10
FTraceHandle AsyncLineTraceByChannel(
const EAsyncTraceType InTraceType, // 단일(Single) 혹은 다중(Multi) 감지 여부 선택
const FVector& Start, // 레이저 발사 시작 지점
const FVector& End, // 레이저 발사 종료 지점
const ECollisionChannel TraceChannel, // 검사할 콜리전 채널 (예: ECC_Visibility)
const FCollisionQueryParams& Params = FCollisionQueryParams::DefaultQueryParam, // 복합 콜리전, 무시 액터 등 세부 설정 구조체
const FCollisionResponseParams& ResponseParam = FCollisionResponseParams::DefaultResponseParam, //충돌 반응에 대한 추가 설정 (기본값 가능)
FTraceDelegate* InDelegate = nullptr, //연산 완료 시 호출될 콜백 함수 (알람 수신처)
uint32 UserData = 0 //결과 식별을 위해 부여하는 고유 번호 (ID)
);
매개변수 상세 설명
InTraceType(EAsyncTraceType)- 의미: 이 트레이스를 싱글(Single)로 쏠지, 멀티(Multi)로 쏠지 결정합니다.
- 사용 예시:
EAsyncTraceType::Single(가장 처음 맞는 것 하나만) 또는EAsyncTraceType::Multi(관통하며 모두 감지) 또는EAsyncTraceType::Test존재 여부 테스트 상세 정보는 필요 없고, 거기에 뭐가 있긴 해?라는 결과만 반환합니다.
2~3. Start / End (const FVector&)
- 의미: 레이저를 쏘기 시작할 3D 좌표(
Start)와 끝나는 목표 좌표(End)입니다.
TraceChannel(ECollisionChannel)- 의미: 어떤 콜리전 채널을 검사할 것인지 설정합니다.
- 주의점: Kismet(블루프린트)에서는
ETraceTypeQuery를 쓰지만, 순수 C++ 방식에서는ECC_Visibility,ECC_Camera,ECC_Pawn같은 엔진 네이티브 채널 열거형(Enum)을 직접 사용합니다.
Params(const FCollisionQueryParams&)- 의미: 트레이스의 세부 옵션을 담아서 넘겨주는 ‘설정 구조체입니다.
- 하는 일: Kismet에서 따로따로 넣었던
bTraceComplex(복합 콜리전 검사 여부),bIgnoreSelf(나 자신 무시),ActorsToIgnore(무시할 액터 배열) 등을 이 구조체 하나에 다 담아서 통째로 넘겨줍니다.
ResponseParam(const FCollisionResponseParams&)- 의미: 충돌 반응을 추가로 덮어쓰거나 조정할 때 쓰는 구조체입니다.
- 특징: 생략하거나 기본값(
DefaultResponseParam)을 그대로 사용합니다.
InDelegate(FTraceDelegate*) - 핵심- 의미: 백그라운드 스레드에서 1억 개의 연산(트레이스)이 끝났을 때, “사장님, 연산 다 끝났습니다!” 하고 결과를 보고받을 ‘알람 수신처(콜백 함수)’를 연결해 주는 곳입니다.
- 작동 방식: 트레이스를 쏴놓고 메인 스레드는 자기 할 일을 하다가, 트레이스 연산이 완료되면 엔진이 이 델리게이트에 연결된 함수를 자동으로 실행시키면서 맞은 결과(
FHitResult)를 넘겨줍니다.
UserData(uint32)- 의미: 델리게이트가 실행될 때 결과와 함께 돌려받을 ‘나만의 번호표(ID)’입니다.
- 언제 쓸까? 산탄총처럼 한 번에 여러 발의 비동기 트레이스를 동시에 쐈을 때, 델리게이트 함수 입장에서는 “이 결과가 1번 파편인지, 10번 파편인지” 구분할 수 없습니다. 이때 쏠 때 붙여준
UserData번호를 보고 “아! 이건 3번 레이저의 결과구나” 하고 식별하는 용도로 사용합니다.
- 반환값 (Return Value):
FTraceHandle- 이 함수는 트레이스를 쏘자마자 맞은 결과를 주지 않습니다 (비동기니까 아직 계산 중임).
- 대신 “고객님의 주문(트레이스)이 접수되었습니다”라는 영수증(Handle)을 즉시 반환합니다.
- 나중에 이 영수증 번호를 조회해서 “아직 계산 중인가요?” 상태를 묻거나, 도중에 트레이스를 취소할 때 사용합니다.
FCollisionResponseParams와 충돌 응답 우선순위
FCollisionResponseParams의 역할 트레이스(Trace)를 쏠 때 매개변수로ResponseParams를 넘겨주는 것은, “원래 설정된 콜리전 규칙을 무시하고, 이번 트레이스에 한해서만 내 규칙을 강제로 적용해라!”라고 명령하는 오버라이드(Override) 기능입니다.- 예시:
1 2 3
FCollisionResponseParams ResponseParams; // 원래 WorldDynamic 채널이 어떻게 설정되어 있든, 이번 트레이스에서는 무조건 Block으로 취급해! ResponseParams.CollisionResponse.WorldDynamic = ECR_Block;
- 강제로 바뀌지 않는 이유: 응답 우선순위
ResponseParams를 통해 명령을 내렸음에도 결과가 바뀌지 않는 경우가 있습니다. 이는 언리얼 콜리전 시스템이 ‘더 보수적인 판정을 우선시하는’ 내부 규칙을 가지고 있기 때문입니다.
- 충돌 응답의 우선순위
Ignore(무시) > Overlap(겹침) > Block(막힘)
- 변경 가능한 경우 : 원래 설정이
Block인 상태라면,ResponseParams를 통해 더 높은 우선순위인Ignore나Overlap으로 변경하는 것은 잘 작동합니다. (Overlap은 Ignore로 변경 가능) - 변경 불가능한 경우 : 원래 설정이
Ignore라면, 시스템은 이를 “절대 충돌하면 안 되는 상태”로 인지하기 때문에 코드로 강제해도Block으로 판정을 격상시키지 못합니다. (Overlap은 Block으로 판정 변경 불가
핵심 요약:
FCollisionResponseParams는 설정을 완화(Block → Ignore)할 수는 있어도, 이미 견고하게 닫혀 있는 설정(Ignore → Block)을 뚫고 강제로 충돌을 일으키기는 어렵습니다. 따라서 트레이스가 아예 무시되는 문제를 해결하려면, 오버라이드 코드를 짜기 전에 대상 오브젝트의 기본 콜리전 설정을 먼저 확인해야 합니다.
InDelegate (FTraceDelegate*) 사용법
- 델리게이트(Delegate)란? 델리게이트는 쉽게 말해 “특정 작업이 끝났을 때 대신 실행해 줄 함수를 지정해 두는 대리인(명함)”입니다. 비동기 트레이스는 결과를 즉시 알 수 없기 때문에, 엔진에게 “나 지금 다른 일 하러 갈 테니까, 백그라운드에서 레이저 계산 다 끝나면 내가 미리 만들어둔 이 함수로 결과 좀 가져다줘!” 하고 연락처(함수)를 남겨두는 셈입니다.
비동기 트레이스의 결과를 받으려면 딱 3단계만 기억하시면 됩니다.
1단계: 콜백 함수 선언 (헤더 파일 .h) 결과를 전달받을 알람 수신처 함수를 만듭니다. 매개변수 형태(FTraceHandle, FTraceDatum)를 반드시 엔진이 정해둔 규칙대로 맞춰야 합니다.
1
2
3
public:
// 비동기 트레이스 연산이 끝나면 자동으로 호출될 함수
void OnTraceCompleted(const FTraceHandle& Handle, FTraceDatum& Data);
Handle(const FTraceHandle&) - “영수증 대조용”
- 역할: 트레이스를 쏠 때 받았던 ‘고유 주문 번호(영수증)’입니다.
- 왜 넘겨줄까?
- 산탄총을 쏘면 0.1초 만에 비동기 트레이스 10발이 동시에 발사됩니다.
- 백그라운드 스레드는 계산이 끝나는 대로 콜백 함수를 10번 호출하며 결과를 마구 던져줍니다.
- 이때 콜백 함수 입장에서는 “지금 들어온 이 결과가 1번 파편인지, 5번 파편인지” 구분이 안 갑니다.
- 그래서 엔진이 “이건 아까 5번 영수증으로 접수하셨던 트레이스의 결과입니다!” 하고 친절하게 영수증 번호를 같이 들고 와서, 우리가 대조해 볼 수 있게 해주는 것입니다.
Data(FTraceDatum&) - “종합 택배 상자”
- 역할: 트레이스에 관한 모든 정보(과거와 현재)가 담겨 있는 거대한 데이터 구조체입니다.
- 왜 넘겨줄까? (상자 안의 내용물)
- 비동기 특성상 쐈을 때의 시점과 결과를 받는 시점 사이에 시간 차이가 있습니다. 그래서 결과뿐만 아니라 쏠 때의 기억도 같이 포장해서 줍니다.
Data.OutHits(중요): 레이저에 맞은 녀석들의 상세 정보(FHitResult)가 들어있는 배열입니다. 우리가 실질적으로 데미지를 줄 때 여기서 데이터를 꺼내 씁니다.Data.Start/Data.End: “아까 이 레이저 어디서 쐈더라?” 까먹었을까 봐, 처음 쐈던 시작점과 끝점 좌표도 다시 알려줍니다.Data.UserData: 트레이스를 쏠 때 맨 마지막 인자에 꼬리표처럼 달아뒀던 ‘나만의 번호표(ID)’입니다. 영수증 번호(Handle) 대신 이 번호표를 보고 “아! 이건 샷건 우측 파편이구나!” 하고 식별할 때 씁니다.
2단계: 콜백 함수 구현 (소스 파일 .cpp) 알람이 울렸을 때(트레이스가 끝났을 때) 무엇을 할지 작성합니다. 맞은 결과는 Data.OutHits라는 배열 바구니에 담겨옵니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void AMyCharacter::OnTraceCompleted(const FTraceHandle& Handle, FTraceDatum& Data)
{
// 1. 레이저가 무언가에 맞았는지 확인
if (Data.OutHits.Num() > 0)
{
// 2. 맞은 결과 꺼내보기
for (const FHitResult& Hit : Data.OutHits)
{
AActor* HitActor = Hit.GetActor();
if (HitActor)
{
UE_LOG(LogTemp, Warning, TEXT("비동기 레이저 적중: %s"), *HitActor->GetName());
// 여기서 데미지를 주거나 이펙트를 터뜨리면 됩니다!
}
}
}
}
3단계: 델리게이트 묶어서 쏘기 (소스 파일 .cpp) 트레이스를 쏘기 직전에 델리게이트 객체를 만들고, 1단계에서 만든 함수를 명함에 파서(BindUObject) 엔진에 넘겨줍니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void AMyCharacter::FireAsyncShot()
{
// 1. 델리게이트(명함) 생성 및 내 함수 묶어주기
// 2. 비동기 트레이스 발사! (명함 같이 넘겨줌)
GetWorld()->AsyncLineTraceByChannel(
EAsyncTraceType::Single,
StartLocation,
EndLocation,
ECC_Visibility,
Params,
FCollisionResponseParams::DefaultResponseParam,
&TraceDelegate, // ⭐ 여기에 명함을 넘김!
123 // UserData (필요하다면 고유 식별 번호 부여)
);
}
- 바인딩(Binding)은 ‘실행’이 아니라 ‘연락처만 적어주는 것’
이 줄의 의미는 그저 엔진에게 “나중에 일 다 끝나면 실행할 함수 주소야. 델리게이트 하나 생성할 테니까 들고 있어.” 하고 넘겨준 것입니다. 이때는 당연히 누가 맞았는지 결과(Data)가 아예 존재하지 않는 시점입니다.TraceDelegate.BindUObject(this, &AMyCharacter::OnTraceCompleted);
- 매개변수를 넣고 부르는 건 ‘언리얼 엔진’ 엔진의 백그라운드 스레드가 델리게이트를 들고 뒤로 가서 열심히 레이저를 쏘고 충돌 계산을 합니다.
계산이 다 끝나고 나면 일꾼의 손에는 “누가 맞았는지에 대한 결과물(Data)”과 “주문 번호(Handle)”가 들려있습니다. 이제 스레드는 아까 우리가 준 델리게이트에 접근합니다.
엔진: 아까 바인딩했던
OnTraceCompleted함수한테 연산 다 끝났다고 알리고, 지금 매개변수 칸에 영수증(Handle)이랑 결과물(Data) 채워서 실행할게요!
즉, 우리 눈에 보이지 않는 언리얼 엔진 내부 코드 어딘가에서 일꾼이 아래와 같이 직접 변수를 채워 넣고 함수를 대신 실행시켜 줍니다.
1
2
3
4
5
6
// (언리얼 엔진 내부의 보이지 않는 코드 작동 방식)
FTraceHandle ResultHandle = ... // 자기들이 계산한 주문 번호
FTraceDatum ResultData = ... // 자기들이 계산한 충돌 결과 덩어리
// 엔진이 우리가 넘겨준 바인딩을 보고 '대신' 함수를 쏘아 올립니다!
우리가_지정한_함수(ResultHandle, ResultData);
함수가 실행되는 시점엔 이미 데이터가 전달되어있다.
1
2
3
4
5
6
7
8
9
10
11
12
void AMyCharacter::OnTraceCompleted(const FTraceHandle& Handle, FTraceDatum& Data)
{
// 이 코드가 읽히는 순간은, 이미 엔진이 매개변수를 꽉 채워서 함수를 호출해 준 직후입니다!
if (Data.OutHits.Num() > 0) // Data 안에는 이미 엔진이 담아준 결과가 들어있음!
{
for (const FHitResult& Hit : Data.OutHits)
{
AActor* HitActor = Hit.GetActor(); // 여기서 누가 맞았는지 꺼내볼 수 있습니다.
}
}
}
비동기 트레이스 전체 흐름도
① 발사 준비 및 작업 지시 (메인 스레드)
- 실행 위치:
ATrace_Test::Tick➡️ATrace_Test::StartAsyncTrace() - 상황: 매 프레임(Tick)마다 메인 스레드(게임 스레드)가
StartAsyncTrace함수 안으로 들어옵니다. - 하는 일:
TraceDelegate라는 명함을 만들고, 나중에 연락받을OnAsyncTraceCompleted함수의 주소를 적어둡니다(바인딩).AsyncLineTraceByChannel을 호출하여 엔진의 백그라운드 스레드(워커 스레드)에게 “앞으로 1000.f 만큼 레이저 쏴서 확인해 줘! 다 끝나면 아까 만든 명함으로 연락 주고!”라고 외치며 작업을 던집니다.
- 핵심 포인트: 지시를 내린 메인 스레드는 결과를 절대 기다리지 않고 바로 함수를 빠져나갑니다. 그리고 원래 하던 일(화면 그리기, 캐릭터 움직이기 등)을 계속하기 때문에 화면에 렉(프레임 드랍)이 걸리지 않습니다.
② 보이지 않는 연산 (엔진 백그라운드 스레드)
- 실행 위치: 코드에는 보이지 않는 엔진 내부의 워커 스레드 공간
- 상황: 메인 스레드가 바쁘게 화면을 그리고 있는 동안, 뒤에서는 엔진의 백그라운드 스레드들이 열심히 일합니다.
- 하는 일:
QueryParams와ResponseParams조건에 맞춰서, 액터의 앞쪽 1000.f 거리에 선을 긋고 누가 부딪혔는지 복잡한 충돌(물리) 계산을 수행합니다. - 포장 완료: 계산이 끝나면, 영수증 번호(
Handle)와 누가 맞았는지에 대한 상세 결과물(Data.OutHits)을 택배 상자에 꽉꽉 채워 포장합니다.
③ 결과 배달 대기 (백그라운드 스레드 ➡️ 메인 스레드)
- 상황: 연산이 다 끝났지만, 백그라운드 스레드가
OnAsyncTraceCompleted함수를 직접 실행하면 스레드가 꼬여서 게임이 튕깁니다(Crash). - 하는 일: 그래서 백그라운드 스레드는 함수를 직접 쏘아 올리지 않고, 메인 스레드의 ‘우체통(작업 대기열)’에 “계산 끝난 택배 상자(Data) 여깄습니다. 시간 나실 때 함수 실행하세요!” 하고 살포시 올려두고 퇴근합니다.
④ 콜백 함수 실행 및 시각화 (다시 메인 스레드)
- 실행 위치:
ATrace_Test::OnAsyncTraceCompleted - 상황: 메인 스레드가 게임을 돌리다가 우체통에 도착한 택배 상자를 발견하는 아주 안전한 타이밍(보통 다음 프레임 Tick 즈음)이 됩니다.
- 하는 일:
- 메인 스레드가 직접
OnAsyncTraceCompleted함수를 짠! 하고 실행시키며, 전달받은Handle과Data를 매개변수에 쏙 넣어줍니다. - 함수 내부의
for문이 돌아가기 시작합니다. 상자 안에 들어있는Data.OutHits를 열어보고 부딪힌 액터(HitActor)를 꺼냅니다. - 초록색 글씨로 로그(
AddOnScreenDebugMessage)를 띄우고, 충돌한 정확한 좌표(Hit.ImpactPoint)에 초록색 구(DrawDebugSphere)를 그립니다!
- 메인 스레드가 직접