일정표
| 시간 | 할 일 | 비고 |
| 08:30~10:00 | 코드카타, 오늘의 수학 | |
| 10:00~13:00 | 개인 프로젝트 | |
| 14:00~18:00 | 개인 프로젝트 | |
| 19:00~21:00 | 개인 프로젝트 | |
| 21:00~22:00 | 운동 | |
| 22:00~23:30 | 개인 프로젝트 |
*오늘의 코드카타*
문제. 바탕화면 정리
코딩테스트를 준비하는 머쓱이는 프로그래머스에서 문제를 풀고 나중에 다시 코드를 보면서 공부하려고 작성한 코드를 컴퓨터 바탕화면에 아무 위치에나 저장해 둡니다. 저장한 코드가 많아지면서 머쓱이는 본인의 컴퓨터 바탕화면이 너무 지저분하다고 생각했습니다. 프로그래머스에서 작성했던 코드는 그 문제에 가서 다시 볼 수 있기 때문에 저장해 둔 파일들을 전부 삭제하기로 했습니다.
컴퓨터 바탕화면은 각 칸이 정사각형인 격자판입니다. 이때 컴퓨터 바탕화면의 상태를 나타낸 문자열 배열 wallpaper가 주어집니다. 파일들은 바탕화면의 격자칸에 위치하고 바탕화면의 격자점들은 바탕화면의 가장 왼쪽 위를 (0, 0)으로 시작해 (세로 좌표, 가로 좌표)로 표현합니다. 빈칸은 ".", 파일이 있는 칸은 "#"의 값을 가집니다. 드래그를 하면 파일들을 선택할 수 있고, 선택된 파일들을 삭제할 수 있습니다. 머쓱이는 최소한의 이동거리를 갖는 한 번의 드래그로 모든 파일을 선택해서 한 번에 지우려고 하며 드래그로 파일들을 선택하는 방법은 다음과 같습니다.
드래그는 바탕화면의 격자점 S(lux, luy)를 마우스 왼쪽 버튼으로 클릭한 상태로 격자점 E(rdx, rdy)로 이동한 뒤 마우스 왼쪽 버튼을 떼는 행동입니다. 이때, "점 S에서 점 E로 드래그한다"고 표현하고 점 S와 점 E를 각각 드래그의 시작점, 끝점이라고 표현합니다.
점 S(lux, luy)에서 점 E(rdx, rdy)로 드래그를 할 때, "드래그 한 거리"는 |rdx - lux| + |rdy - luy|로 정의합니다.
점 S에서 점 E로 드래그를 하면 바탕화면에서 두 격자점을 각각 왼쪽 위, 오른쪽 아래로 하는 직사각형 내부에 있는 모든 파일이 선택됩니다.
머쓱이의 컴퓨터 바탕화면의 상태를 나타내는 문자열 배열 wallpaper가 매개변수로 주어질 때 바탕화면의 파일들을 한 번에 삭제하기 위해 최소한의 이동거리를 갖는 드래그의 시작점과 끝점을 담은 정수 배열을 return하는 solution 함수를 작성해 주세요. 드래그의 시작점이 (lux, luy), 끝점이 (rdx, rdy)라면 정수 배열 [lux, luy, rdx, rdy]를 return하면 됩니다.
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
vector<int> solution(vector<string> wallpaper) {
int row_len = wallpaper.size();
int col_len = wallpaper[0].size();
int lux = row_len, luy = col_len;
int rdx = 0, rdy = 0;
for (int i = 0; i < row_len; ++i) {
for (int j = 0; j < col_len; ++j) {
if (wallpaper[i][j] == '#') {
lux = min(lux, i);
luy = min(luy, j);
rdx = max(rdx, i + 1);
rdy = max(rdy, j + 1);
}
}
}
return {lux, luy, rdx, rdy};
}
min / max 활용
바탕화면 전체를 한 번만 순회하면서 #을 만날 때마다 좌표의 최댓값과 최솟값을 갱신한다.
i + 1, j + 1
드래그의 끝점 E(rdx, rdy)는 격자 칸의 오른쪽 아래 꼭짓점을 가리켜야 칸 내부의 파일이 완전히 선택되기 때문에, 파일 칸의 인덱스 i, j에 각각 1을 더해준 값이 끝점이 된다.
*수학 공부*
주제 : 몬테카를로 적분
오프라인 리얼리스틱 렌더러(패스 트레이싱)는 물론, 언리얼 엔진 5의 패스 트레이서(Path Tracer), PBR(물리 기반 렌더링) 셰이더, 그리고 GI(글로벌 일루미네이션)의 근간을 이루는 수학적 무기이다.
1. 몬테카를로 적분?
"공식이 너무 복잡해서 직접 적분(구적법)할 수 없을 때, 무작위 주사위(무작위 샘플링)를 수천~수만 번 던져서 평균을 내 적분값을 도출하는 확률적 수치 적분법"이다.
3D 공간에서 한 점 $P$에 도착하는 빛의 양(반사광)을 구하려면, 표면을 중심으로 한 반구(Hemisphere) 상의 모든 3D 방향에서 들어오는 빛을 모조리 더해야 한다. 즉, 적분을 해야하는데, 문제는 실시간 게임에서 이 적분식을 해석적으로(Analytical) 푸는 것이 수학적으로 불가능하다는 것이다.
- 어디서 빛이 올지 모르고 (주위의 복잡한 물체들)
- 표면의 거칠기(Roughness)에 따라 빛이 튕기는 양상도 제각각이고
- 반구 상의 무한한 방향의 정적분을 프레임마다 풀 수 없기 때문이다.
이때 몬테카를로 적분의 "모든 방향을 다 계산하지 말고, 무작위로 몇 개의 방향(Ray)만 쏴보고 그 빛의 평균값을 내자!"라는 논리를 사용하게 된다.
2. 수학적 원리와 공식
몬테카를로 적분의 기본 형태는 아주 직관적이다.
어떤 함수 f(x)를 구간 [a, b]에서 적분하려 할 때, 확률밀도함수(PDF: Probability Density Function) p(x)를 따르는 N개의 무작위 샘플 X_1, X_2, dots, X_N을 뽑아 다음과 같이 근사한다.

- N (샘플 수): 주사위를 던진 횟수 (샘플 수가 많아질수록 진짜 적분값에 완벽히 수렴).
- f(X_i): i번째 무작위 방향으로 쏜 광선이 받아온 빛의 양 (Radiance).
- p(X_i): 해당 방향으로 광선을 뽑을 확률 밀도(PDF).
주사위(샘플)를 N번 많이 던지면 던질수록, 적분값의 오차(노이즈)가 점점 줄어들면서 '현실적인 물리 빛'에 도달하게 된다.
3. 그래픽스의 핵심 기법: 중요도 샘플링 (Importance Sampling)
무작위로 광선을 쏘는 건 좋은데, 완전히 똑같은 확률(Uniform Sampling)로 쏘면 엄청난 연산 손실과 노이즈가 발생한다.
예를 들어 완벽한 거울에 가까운 매끄러운 금속 표면이라면, 빛은 거울 반사 각도 근처로 대부분 튕겨 나가게 된다. 또는 어두운 구석을 향해 광선을 쏴봤자 f(X_i) = approx 0이라 적분값에 도움이 되지 않는다.
그래서 등장한 고급 수학이 중요도 샘플링(Importance Sampling)이다.
- 원리: 빛이 많이 들어오거나 반사될 확률이 높은 '중요한 방향'으로 무작위 광선을 더 많이 쏠 수 있도록 확률밀도함수 p(x)를 표면의 BRDF(물리 반사 공식) 모양에 맞게 변형한다.
- 결과: 단 16~32개의 광선 샘플(Low Sample Count)만 가지고도 1,000개의 무작위 샘플을 쏜 것과 맞먹는 깨끗하고 노이즈 없는 라이팅 결과를 초고속으로 얻을 수 있게 된다.
4. HLSL 셰이더 실무 개념: 반구 몬테카를로 샘플링
실제 HLSL 셰이더에서 표면 상의 앰비언트 오클루전(AO)이나 GI를 구하기 위해 반구(Hemisphere) 상으로 몬테카를로 샘플링을 진행하는 기본 수학 코드이다.
// 0.0 ~ 1.0 사이의 난수 2개(u1, u2)를 받아 반구 표면상의 무작위 3D 방향 벡터(PDF = 1 / 2PI) 도출
float3 SampleHemisphereUniform(float2 u)
{
float phi = 2.0 * 3.14159265 * u.x;
float cosTheta = u.y;
float sinTheta = sqrt(1.0 - cosTheta * cosTheta);
// 구면 좌표계(Spherical Coordinates)를 직교 좌표계(X, Y, Z)로 변환
return float3(
cos(phi) * sinTheta,
sin(phi) * sinTheta,
cosTheta
);
}
// 몬테카를로 적분을 이용한 반구 입사광(AO/GI) 추정
float3 CalculateMonteCarloGI(float3 WorldPos, float3 Normal, int SampleCount)
{
float3 AccumulatedLight = float3(0, 0, 0);
for (int i = 0; i < SampleCount; ++i)
{
// 1. 의사 난수(Pseudo Random Number) 추출
float2 RandomUV = GetHammersleyRandom(i, SampleCount);
// 2. 난수를 바탕으로 반구 상의 무작위 방향 3D 벡터 생성
float3 LocalRayDir = SampleHemisphereUniform(RandomUV);
// 3. 표면 법선(Normal) 기준으로 방향 벡터를 월드 공간으로 회전
float3 WorldRayDir = AlignToNormal(LocalRayDir, Normal);
// 4. 광선(Ray)을 쏘아 부딪힌 지점의 빛(f(Xi))을 가져옴
float3 IncomingLight = TraceRayAndGetColor(WorldPos, WorldRayDir);
// 5. 몬테카를로 적분 누적: f(Xi) * cos(Theta) / p(Xi)
// Uniform Sampling의 경우 p(Xi) = 1 / 2PI 이므로 2PI를 곱함
float NdotL = max(0.0, dot(Normal, WorldRayDir));
AccumulatedLight += (IncomingLight * NdotL) * (2.0 * 3.14159265);
}
// 6. N으로 나누어 무작위 샘플들의 평균(Mean) 반환!
return AccumulatedLight / float(SampleCount);
}
언리얼 엔진 5에서의 몬테카를로 적분
- 언리얼 패스 트레이서 (Path Tracer): 영화급 기지국 렌더링을 할 때 픽셀당 수백~수천 개의 몬테카를로 샘플(SPP, Samples Per Pixel)을 던져서 현실과 완벽히 똑같은 오프라인 물리 광학 적분을 수행한다.
- 루멘 (Lumen): 실시간 60fps를 맞춰야 하므로 픽셀당 샘플 수를 극한(1~4개)으로 줄인다. 그렇게 발생한 몬테카를로 노이즈는 수학적 디노이저(Temporal Denoiser / Spatial Filter) 필터로 문질러 지워버림으로써 실시간성을 확보한다.
몬테카를로 적분의 핵심 : '해석적으로 풀 수 없는 난이도의 물리 적분식을 "무작위 확률 샘플링과 가중 평균"이라는 통계학으로 풀어내는 것.
이제 몬테카를로 적분이 실제 렌더링 엔진과 PBR(물리 기반 렌더링) 셰이더에서 적용되는 실제 메커니즘을 3단계로 나누어 살펴보자.
1단계. 우리가 적분하려는 실체: "렌더링 방정식 (The Rendering Equation)"
3D 공간의 어떤 한 점 P에서 카메라 방향으로 나가는 빛의 색상 L_o를 구하려면, 그 점을 둘러싼 반구(Hemisphere) 상의 모든 방향(omega_i)에서 들어오는 빛을 모조리 더해야 한다.
이것을 수학 기호로 적은 것이 3D 그래픽스의 '렌더링 방정식'이다.

그러나 사실 이 렌더링 방정식은 수학 공식으로 적분할 수 없는데, 그 이유는 '외부에서 들어오는 빛' 때문이다.주변에 보스 몬스터가 서 있는지, 창문이 있는지, 촛불이 있는지에 따라 들어오는 빛의 양이 제각각이기 때문이다.
2단계. 적분 기호를 코드로 바꾸는 5단계 변환 기법
여기서 몬테카를로 적분이 사용되는데, 수반구 위로 무작위 방향으로 광선(Ray)을 몇 개 쏴서 받아온 빛을 평균을 낸다.
이 이론이 실제 셰이더 코드(HLSL)로 매핑되는 변환 과정을 살펴보자.
| 수학의 단계 | 몬테카를로 적분 개념 | 실전 셰이더 코드 매핑 |
| 1. 적분 범위 | 반구 상의 무한한 방향 | for(int i = 0; i < N; ++i) 루프 생성 |
| 2. 샘플 추출 (X_i) | 확률분포 p(x)를 따르는 무작위 방향 | 난수 2개로 무작위 3D 방향 벡터 RayDir 생성 |
| 3. 피적분함수 평가 (f(X_i)) | 그 방향의 들어오는 빛 * 재질 * 입사각 | RayTrace(RayDir)로 빛 색상 가져와서 BRDF 곱함 |
| 4. 가중치 보정 (/p(X_i)) | 해당 방향을 뽑을 확률(PDF)로 나눔 | 미리 계산된 PDF 값으로 나눔 |
| 5. 적분 완료 | 샘플들의 평균을 구함 | TotalColor / N 으로 최종 픽셀 색상 출력 |
// [실제 PBR 셰이더에서 몬테카를로 적분이 적용되는 코드 구조]
float3 IntegratedColor = float3(0, 0, 0);
int N = 32; // 몬테카를로 샘플 개수
for (int i = 0; i < N; ++i)
{
// Step 1 & 2: 무작위 방향(Xi) 및 해당 방향의 확률밀도(PDF) 추출
float2 Xi = GetRandom2D(i, N);
float3 RayDir = SampleBRDFDirection(Xi, Normal, Roughness); // 광선 방향
float Pdf = GetBRDFPdf(RayDir, Normal, Roughness); // 해당 방향의 PDF (p(Xi))
// Step 3: 적분 내부 함수 f(Xi) 계산 (들어오는 빛 * BRDF * NdotL)
float3 IncomingLight = TraceRayInScene(WorldPos, RayDir); // L_i (장면에서 읽어온 빛)
float3 BRDF = EvaluateGGX(Normal, ViewDir, RayDir); // f_r (재질 반사율)
float NdotL = max(0.0, dot(Normal, RayDir)); // 입사각
float3 f_Xi = IncomingLight * BRDF * NdotL;
// Step 4: 몬테카를로 공식 적용 - f(Xi) / p(Xi) 를 누적!
if (Pdf > 0.001)
{
IntegratedColor += f_Xi / Pdf;
}
}
// Step 5: N으로 나누어 무작위 샘플들의 평균(적분 값) 반환!
float3 FinalPixelColor = IntegratedColor / (float)N;
3. 실무에서의 핵심: 중요도 샘플링(Importance Sampling)으로 식 단순화하기
위 코드에서 가장 핵심은 "광선 방향 RayDir을 어떻게 무작위로 만드는가?"에 있다. 만약 완전 거울 같은 표면(금속)인데, 거울 반사각이 아닌 어두운 구석 바닥 쪽으로 광선을 쏘면, BRDF 값이 0이 나와서 f(X_i) = 0 이 되므로, 연산량이 매우 낭비된다.
그래서 언리얼 엔진의 Epic PBR 셰이더 등 실무 PBR에서는 BRDF 분모와 확률밀도함수 p(X_i)가 서로 약분되어 사라지게 한다.
1) 반사 확률이 높은 각도 쪽으로 광선을 더 많이 뽑아내도록 확률 omega를 설정했다고 가정해보자.

2) 이 p(omega_i)를 몬테카를로 적분 분모에 대입하면, 복잡한 BRDF 연산식과 N * omega_i 가 약분되어서 날아가버리게 된다.

결과적으로 셰이더 내부에서는 무거운 BRDF 계산을 매번 할 필요 없이, 광선이 가져온 빛의 색상(L_i)에 가중치만 더해서 평균을 내는 성능 최적화가 이뤄지게 된다.
4. 게임 엔진의 노이즈 해결책: Quasi-Monte Carlo (QMC)
매 프레임 난수(GetRandom2D)를 뽑으면 화면이 모래알처럼 자글자글거리는 노이즈가 생기게 된다. C++의 일반 난수(rand()) 같은 백색 노이즈를 쓰면 픽셀끼리 무작위성이 너무 심해 화면이 튀게 되는 것이다.
그래서 현대 게임 엔진(언리얼, 유니티)은 순수한 난수 대신 저불일치 수열(Low-Discrepancy Sequence / Quasi-Monte Carlo)이라는 통계 수학을 적용하고 있다.
- 해머슬리(Hammersley) / 할톤(Halton) 수열: 난수처럼 보이지만, 샘플 점들이 뭉치지 않고 반구상에 가장 고르고 균일하게 흩어지도록 수학적으로 배치된 균일 난수 수열.
- 블루 노이즈(Blue Noise): 픽셀 간의 노이즈 패턴을 인체 시각이 인지하기 힘든 고주파 영역으로 밀어버리는 특수 노이즈 텍스처.
이 QMC 수열을 몬테카를로 샘플링에 사용하고, 언리얼 5의 TSR(Temporal Super Resolution)이나 디노이저(Denoiser)가 이전 프레임의 적분값을 누적(Blending)해주면, 샘플을 단 2~4개만 쏴도 노이즈 없는 60fps 실시간 PBR 라이팅이 완성된다.
다시 요약해보자면,
- 우리는 장면 전체에서 튕겨 오는 '빛의 총합(적분)'을 구하고 싶음.
- 적용 방법: 빛의 수식이 없으므로, 무작위 방향으로 광선을 쏴서(for문) 얻은 빛들을 가중 평균 낸다.
- 핵심: 반사 각도 쪽으로 광선을 많이 쏘는 중요도 샘플링을 적용하면 복잡한 수식이 약분되어 셰이더 연산량이 기하급수적으로 줄어든다.
몬테카를로 적분의 개념을 확장해서 이해하면, PBR 셰이더 내부에서 광선을 쏘고 노이즈를 지우는 것이 실제 C++/HLSL 코드로 어떻게 굴러가는지 파악할 수 있다.
*언리얼 강의*
1. 기초반
원거리 몬스터(Howitzer) 및 투사체 시스템 구현
1. 사망 처리 핫픽스 (Death Hotfix)
1.1 플레이어 상태 제어 (입력 및 피격 차단)
- 문제점: HP가 0 이하로 떨어져 사망하였으나, 입력 핸들러 및 AI가 해당 상태를 감지하지 못해 계속 이동/공격 및 피격 반응이 발생하는 현상.
- 해결 방법: HealthComponent->bIsDead 변수를 활용하여 사망 여부를 판별하고, 주요 입력 핸들러 및 피격 함수 상단에 가드 코드(Gate Code) 추가.
- 적용 대상:
- 입력 함수: InputActionMove, InputActionJump, InputActionFire 등
- 피격 함수: OnDamage (사망 후 피격 애니메이션 및 데미지 수용 차단)
- 사망 애니메이션 함수 (OnDeathAnimation): HealthComp->MarkDead() 호출 및 bUseControllerRotationYaw = false 설정으로 카메라 방향 연동 해제.
// APlayerCharacter 예시 (입력 및 피격 처리 상단)
if (HealthComp && HealthComp->bIsDead)
{
return;
}
1.2 AI 타겟 해제 (BTService_FindPlayer)
- 문제점: 플레이어가 사망했음에도 AI가 계속해서 타겟으로 인식하여 추격 및 공격을 수행.
- 해결 방법: 플레이어를 검색하는 BTService 내에서 대상이 사망 상태일 경우 Blackboard의 TargetPlayer 키를 비움(ClearValue).
- 동작 원리: Decorator(TargetPlayer IsSet) 조건이 깨지며 추격/공격 시퀀스가 즉시 중단되고 Patrol 상태로 복귀.
2. 몬스터 투사체 클래스 (AMonsterProjectile)
2.1 주요 역할
- 원거리 몬스터가 발사하는 독립 액터로, 플레이어와 오버랩(Overlap) 시 데미지를 입히고 스스로 파괴됨.
2.2 주요 구성 요소
- USphereComponent (CollisionComp): 콜리전 프로필 OverlapAllDynamic 설정.
- UStaticMeshComponent (MeshComp): 투사체 외형 (NoCollision).
- UProjectileMovementComponent (MovementComp):
- InitialSpeed / MaxSpeed: 2000.0f
- ProjectileGravityScale: 0.0f (직선 비행 처리)
- bRotationFollowsVelocity: true
2.3 오버랩 및 수명 처리
- OnOverlap: APlayerCharacter로 캐스팅 성공 시 Player->OnDamage(Damage) 호출 후 Destroy().
- LifeTimeTimer: 일정 시간(기본 3.0초) 동안 타격하지 못할 경우 OnLifeTimeExpired()를 통해 자동 소멸.
3. 원거리 몬스터 클래스 (AMonsterHowitzer)
3.1 클래스 구조
- AMonster를 상속받아 기본 속성(Health, Mesh 등)을 공유.
- BT(Behavior Tree) 기반 동작으로 기존 C++ MonsterAI Tick 스태이트 머신은 비활성화.
3.2 핵심 기능 (FireProjectile)
- 애니메이션 노티파이(UAnimNotify_FireProjectile)에 의해 발사 프레임에서 호출됨.
- 발사 위치 결정: 스켈레탈 메쉬의 소켓(MuzzleSocketName, 기본값 "Muzzle_01") 위치를 우선 사용하며, 소켓이 없을 경우 Actor Location + Z Offset으로 대체.
- 발사 방향 계산: 발사 위치에서 FireTarget(플레이어) 방향 벡터를 구하여 회전값(FRotator) 생성 후 SpawnActor 실행.
void AMonsterHowitzer::FireProjectile()
{
if (!ProjectileClass || !FireTarget) return;
FVector SpawnLoc = GetMesh()->DoesSocketExist(MuzzleSocketName)
? GetMesh()->GetSocketLocation(MuzzleSocketName)
: GetActorLocation() + FVector(0, 0, MuzzleFallbackZ);
FVector AimDir = (FireTarget->GetActorLocation() - SpawnLoc).GetSafeNormal();
FActorSpawnParameters Params;
Params.Owner = this;
Params.Instigator = this;
Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
GetWorld()->SpawnActor<AMonsterProjectile>(ProjectileClass, SpawnLoc, AimDir.Rotation(), Params);
}
4. 발사 애니메이션 노티파이 (UAnimNotify_FireProjectile)
- 역할: 공격 애니메이션 몽타주의 특정 발사 시점 프레임에서 이벤트를 발생시켜 몬스터의 FireProjectile()을 호출.
- 구현 특징: 단발성 액션이므로 UAnimNotifyState가 아닌 UAnimNotify 상속.
void UAnimNotify_FireProjectile::Notify(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, const FAnimNotifyEventReference& EventReference)
{
Super::Notify(MeshComp, Animation, EventReference);
if (MeshComp)
{
if (AMonsterHowitzer* Owner = Cast<AMonsterHowitzer>(MeshComp->GetOwner()))
{
Owner->FireProjectile();
}
}
}
5. 원거리 공격 AI 태스크 (UBTTask_RangedAttack)
- 역할: Behavior Tree에서 원거리 공격 명령을 하사할 때 실행.
- 동작 흐름:
- Blackboard에서 TargetPlayer 액터 정보를 가질어와 AMonsterHowitzer::SetFireTarget()으로 타겟을 잠금.
- UMonsterAnim 인스턴스를 얻어 공격 몽타주(PlayAttackMontage) 실행.
- 사운드 및 이펙트 재생 후 EBTNodeResult::Succeeded 즉시 반환. (실제 투사체 생성은 몽타주 내의 AnimNotify_FireProjectile이 담당)
6. AI 및 에셋 구성 (BT & Blueprint)
6.1 BT_Howitzer (행동 트리) 구조
근접 몬스터(Khaimera)와의 핵심 차이점은 거리 유지 및 사격 전 정렬임.
- Service: BTService_FindPlayer (감지 범위 1500) & BTService_DriveAnimState
- Combat Sequence Branch:
- MoveTo (TargetPlayer): Acceptable Radius를 700.0으로 설정하여 멀찍이서 멈춤.
- Rotate to face BB entry: 타겟을 향해 몸을 정확히 회전 (미적용 시 측면/후면 발사 문제 발생).
- Ranged Attack: 커스텀 태스크 실행.
- Wait: 공격 쿨타임 대기 (예: 2.0초).
6.2 주요 에셋 체크리스트
- BP_MonsterProjectile:
- Mesh: Sphere, Scale 0.2~0.3
- Collision Profile: OverlapAllDynamic
- AM_Howitzer_Attack (Montage):
- 총구가 번쩍이는 정확한 프레임에 FireProjectile 노티파이 배치.
- 몽타주 종료 시점에 AttackEnd 노티파이 배치.
- BP_MonsterHowitzer:
- ProjectileClass: BP_MonsterProjectile 지정.
- MuzzleSocketName: 스켈레톤 내 소켓명(Muzzle_01)과 일치 여부 확인.
- CharacterMovement: Use Controller Desired Rotation = true, Rotation Rate = 540 설정으로 부드러운 회전 적용.
2. 심화반
모듈 시스템과 리플렉션 아키텍처
1. 언리얼 모듈 시스템 및 플러그인 구조
1.1 엔진 소스 코드 구조 및 구분
- Engine/Source 주요 폴더:
- Runtime: 게임 실행 시 핵심이 되는 저수준/고수준 시스템 (Core, CoreUObject, Engine, Renderer, PhysicsCore, AudioMixer 등).
- Editor: 에디터 동작 시에만 필요한 전용 시스템 (UnrealEd, LevelEditor, BlueprintGraph, PropertyEditor 등).
- Developer: 프로파일링, 자동화 테스트 등 개발 빌드 전용 모듈.
- ThirdParty: 외부 오픈소스 및 벤더 라이브러리 (zlib, OpenSSL, Vulkan 등).
- Programs: 빌드 및 에셋 가공용 툴 (UnrealBuildTool, UnrealHeaderTool, ShaderCompileWorker 등).
- 구분 단위 비교:
- Module: 코드의 최소 빌드 단위. 단일 Build.cs와 Public/Private 구조를 가지며 하나의 DLL/LIB로 컴파일됨.
- Plugin: 하나 이상의 모듈과 에셋(Content)을 포함하는 컨테이너. .uplugin 파일로 제어되며 프로젝트 간 이식성 확보.
- Project: 게임 어플리케이션 자체. .uproject 파일로 정의되며 Primary Game Module을 포함함.
1.2 모듈화의 필요성 및 접근 제어
- 컴파일 시간 단축: 모듈 단위 독립 컴파일을 통해 변경되지 않은 모듈의 재컴파일을 방지.
- Public / Private 폴더를 통한 캡슐화:
- Public: 외부 모듈에서 #include 가능한 공개 헤더 배치.
- Private: 해당 모듈 내부에서만 사용하는 헤더 및 구현 파일(.cpp) 배치.
- 효과: 내부 구현 변경 시 외부 코드 컴파일 파급 효과 최소화, 의도치 않은 내부 구현체 참조 차단.
1.3 모듈 타입 및 로딩 페이즈 (Loading Phase)
- 모듈 타입:
- Runtime: 최종 타겟 패키징(Shipping)에 포함됨.
- Editor / Developer: 에디터 및 개발 환경에서만 포함되며 최종 빌드에서 제외됨.
- 주의: Runtime 모듈 내에서 에디터 전용 코드가 필요한 경우 #if WITH_EDITOR 조건부 컴파일 필수.
- Loading Phase:
- EarliestPossible → PostConfigInit → PreDefault → Default → PostEngineInit
- 모듈 간 초기화 순서 제어가 필요할 때 .uplugin 또는 .uproject에서 로딩 페이즈를 명시.
- MODULE_API 매크로:
- 모듈 간 클래스/함수 노출용 매크로 (DLL Export/Import 처리).
- Modular 빌드 (에디터/개발): __declspec(dllexport) / __declspec(dllimport)로 확장.
- Monolithic 빌드 (Shipping): 단일 Executable로 묶이므로 빈 매크로로 처리됨.
2. Build.cs 및 UBT(UnrealBuildTool) 파이프라인
2.1 Build.cs의 역할 및 의존성 설정
- Build.cs는 C#으로 작성된 빌드 규칙 파일로 ModuleRules를 상속받음.
- Public vs Private Dependency의 차이:
- PublicDependencyModuleNames: 본 모듈의 Public 헤더에서 참조하는 모듈. 의존성이 상위 모듈로 전파(Propagate)됨.
- PrivateDependencyModuleNames: 본 모듈의 Private(.cpp 및 Private 헤더)에서만 사용하는 모듈. 의존성이 전파되지 않음.
// Build.cs 예시
public class MyGame : ModuleRules
{
public MyGame(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
bEnforceIWYU = true; // Include What You Use 강제
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine" });
PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore", "UMG" });
}
}
2.2 UBT의 해석 및 빌드 순서 결정
- 스캔 및 그래프 생성: .uproject, .uplugin, Build.cs를 수집하여 방향 그래프(Directed Graph) 구성.
- 순환 의존성 검사: ModuleA -> ModuleB -> ModuleA 형태의 순환 참조 발생 시 빌드 에러 출력.
- 위상 정렬(Topological Sort): 의존성이 없는 모듈(Core)부터 최상위 모듈(MyGame) 순으로 컴파일 순서 확정.
- Include Path 및 UHT 대상 확정: 의존성에 따라 Include 경로를 수집하고, GENERATED_BODY() 매크로 및 .generated.h를 포함한 파일들을 UHT 스캔 대상으로 지정.
3. UBT → UHT 빌드 파이프라인 및 Generated 파일 구조
3.1 전체 빌드 흐름
- UBT 실행: 빌드 오케스트레이션 및 설정 파싱.
- 의존성 그래프 생성: 빌드 순서 확정.
- UHT (UnrealHeaderTool) 실행: 헤더 파일 스캔, 리플렉션 매크로 파싱 후 Intermediate/ 폴더에 .generated.h 및 .gen.cpp 생성.
- C++ 컴파일: MSVC / Clang 등의 컴파일러를 통해 C++ 코드 및 생성된 코드 컴파일. (UE 5.5+ 은 UBA(Unreal Build Accelerator)를 통한 분산 컴파일 지원).
- 링킹: 모듈별 DLL 생성(Modular) 또는 단일 실행 파일 생성(Monolithic).
3.2 생성 파일 구조 및 메커니즘
- .generated.h:
- 클래스 내부에 주입되는 StaticClass(), GetClass(), DECLARE_CLASS 매크로 구현 포함.
- 반드시 헤더 파일의 마지막 #include로 위치해야 함.
- .gen.cpp:
- Z_Construct_UClass_XXX(): 런타임에 UClass 인스턴스를 동적으로 생성 및 구축하는 함수.
- StaticRegisterNatives: UFUNCTION으로 지정된 함수와 C++ 네이티브 포인터를 바인딩.
- execXXX (Thunk 함수): 블루프린트 VM 호출을 네이티브 C++ 함수 호출로 중계.
3.3 리플렉션 대상 및 정적 리플렉션의 장점
- UHT 파싱 대상: UCLASS, USTRUCT, UENUM, UINTERFACE, UPROPERTY, UFUNCTION, UDELEGATE.
- UHT 미대상: 일반 C++ 클래스, 템플릿 클래스, constexpr, 전처리기 조건문으로 과도하게 감싸진 코드.
- UE 정적 리플렉션(Static Reflection)의 특징:
- 컴파일 타임에 리플렉션 메타데이터를 C++ 코드로 생성하여 실행 파일에 내장.
- 런타임 동적 문자열 검색 오버헤드를 줄이고 컴파일 타임 타입 안전성을 확보.
- 에디터 디테일 패널, 블루프린트 핀 자동 생성, 가비지 컬렉션(GC) 추적의 기반이 됨.
4. 리플렉션 시스템 내부 메커니즘
4.1 FProperty / FField 시스템 (UE5)
- UE4의 UProperty(UObject 상속)에서 UE5의 FProperty(FField 상속)로 전환.
- 이유: 프로퍼티 객체 자체가 UObject일 필요가 없어짐에 따라, GC 추적 오버헤드 축소 및 메모리 사용량 감소.
4.2 플래그(Flags) vs 메타데이터(Metadata)
- Property/Class Flags (64bit Bitmask):
- EditAnywhere, BlueprintReadWrite, Replicated 등.
- 런타임 메모리에 상주하며 게임 로직 및 메모리 관리에 직접 사용됨.
- Metadata (meta=(...)):
- DisplayName, ToolTip, ClampMin, Category 등.
- TMap<FName, FString> 형태로 저장되며 에디터 전용(WITH_EDITOR).
- 주의: Shipping 빌드에서는 메타데이터가 제거되므로 게임플레이 로직에서 메타데이터를 읽으면 안 됨.
4.3 런타임 메타데이터 활용
// 런타임 프로퍼티 순회 및 동적 접근 예시
UClass* TargetClass = AMyCharacter::StaticClass();
// 특정 프로퍼티 검색 및 값 수정
FProperty* HealthProp = TargetClass->FindPropertyByName(TEXT("Health"));
if (HealthProp)
{
void* ValuePtr = HealthProp->ContainerPtrToValuePtr<void>(MyCharacterInstance);
float NewHealth = 100.0f;
HealthProp->CopySingleValue(ValuePtr, &NewHealth);
}
// UFUNCTION 동적 호출 (ProcessEvent)
UFunction* DamageFunc = TargetClass->FindFunctionByName(TEXT("TakeDamage"));
if (DamageFunc)
{
struct FTakeDamageParams { float Amount; };
FTakeDamageParams Params{ 25.0f };
MyCharacterInstance->ProcessEvent(DamageFunc, &Params);
}
4.4 RepNotify 및 네트워크 복제 처리
- ReplicatedUsing=OnRep_Health 지정 시 NetDriver가 수신받은 프로퍼티를 리플렉션 테이블에서 조회.
- 값이 변경되었음을 감지하면 생성된 리플렉션 함수 포인터를 통해 OnRep_Health()를 자동 호출.
- 파라미터로 이전 값(OldValue)을 받는 시그니처도 지원하며, 리플렉션 시스템이 시그니처 일치 여부를 런타임에 검증함.
5. 런타임 로딩 파이프라인
5.1 모듈 로딩 시퀀스
- Core, CoreUObject 모듈 로드 (리플렉션 뼈대 구축).
- Engine, Renderer 등 핵심 시스템 로드.
- Plugin 모듈 로드 (LoadingPhase 설정값 및 플러그인 의존성에 따라 순차 실행).
- Project 모듈 로드 (Primary Game Module).
- Editor 모듈 로드 (에디터 실행 시에만).
- 각 모듈 로드 시 IModuleInterface::StartupModule()이 호출되어 커스텀 서브시스템, 콘솔 명령어, 에셋 타입 등을 등록.
5.2 CDO (Class Default Object) 및 생성자 제약사항
- CDO의 개념:
- 각 UClass는 단 하나의 기본 템플릿 인스턴스인 CDO를 가짐.
- 새 객체 스폰 시 메모리 할당 후 CDO의 메모리 상태를 복사(FMemory::Memcpy)하는 방식으로 동작.
- 생성자(Constructor) 실행 시점 및 주의사항:
- C++ 클래스 생성자는 CDO를 생성하는 시점(엔진 초기화 단계)에 호출됨.
- 금지 사항: 생성자 내부에서 GetWorld(), GetOwner(), 외부 액터 참조 등 런타임 월드 상태에 의존하는 코드 작성 금지 (CDO 생성 시점에는 World가 존재하지 않음).
- 런타임 초기화 로직은 반드시 BeginPlay() 또는 PostInitializeComponents()에서 수행.
5.3 블루프린트 로딩 구조
- UBlueprint vs UBlueprintGeneratedClass:
- UBlueprint: 에디터에서 노드를 배치하고 편집하는 에셋 객체.
- UBlueprintGeneratedClass: 컴파일을 통해 생성된 실제 UClass 상속 객체 (게임 런타임에서 사용).
- 클래스 경로 작성 규칙:
- C++ 코드에서 블루프린트 클래스 타입에 직접 접근 시 에셋 경로 뒤에 _C 접미사를 붙여야 함.
- 예: LoadClass<AActor>(nullptr, TEXT("/Game/Blueprints/BP_MyCharacter.BP_MyCharacter_C"));
<개인 추가 내용>
언리얼 엔진 가비지 컬렉션(GC) 및 메모리 관리 심화
1. 언리얼 엔진 GC의 기본 메커니즘
1.1 마크 앤 스위프(Mark-and-Sweep) 알고리즘
- 언리얼 엔진의 가비지 컬렉션은 추적 기반(Tracing) Mark-and-Sweep 알고리즘을 사용함.
- Mark Phase (표기 단계): GC Root에서 출발하여 참조 연결된 모든 UObject를 순회하고, 접근 가능한 객체에 플래그를 표시함.
- Sweep Phase (수거 단계): Mark 단계에서 도달 불가능(Unreachable)하다고 판명된 객체들을 메모리에서 해제 및 소멸시킴.
1.2 전역 객체 관리 배열 (GUObjectArray)
- 엔진에서 생성되는 모든 UObject 인스턴스는 전역 중앙 배열인 GUObjectArray에 등록됨.
- 각 객체는 고유한 인덱스를 부여받으며, 내부 플래그(EInternalObjectFlags, EObjectFlags)를 통해 현재 생명주기 및 GC 상태를 추적함.
- EInternalObjectFlags::Reachable: GC 순회 시 도달 가능한 상태.
- EInternalObjectFlags::Unreachable: GC 순회 시 어떤 Root에서도 참조되지 않는 삭제 대상 상태.
- EInternalObjectFlags::Garbage: 수거 대상으로 명시적 지정된 상태.
2. GC 수거 단계 및 실행 흐름
[ Root Set 수집 ] ──> [ Reachability 추적 (Mark) ] ──> [ Unreachable 객체 추출 ] ──> [ ConditionalBeginDestroy ] ──> [ FinishDestroy & 메모리 해제 (Sweep) ]
2.1 Mark Phase (도달 가능성 검사)
- Root Set 수집:
- AddToRoot()가 호출된 객체.
- UGameEngine, UWorld, 활성화된 레벨/액터 등 엔진 핵심 싱글톤 객체.
- FGCObject를 상속받아 등록된 C++ 객체.
- 참조 그래프 순회:
- Root 객체부터 시작하여 UPROPERTY()로 마킹된 UObject* 포인터 및 AddReferencedObjects()를 통해 등록된 포인터를 따라 재귀적으로 순회.
- 순회된 객체의 Unreachable 플래그를 제거하여 유효 상태임을 증명함.
2.2 Sweep Phase (파괴 및 메모리 해제)
- ConditionalBeginDestroy() 호출:
- Unreachable 객체들의 비동기 파괴 절차 개시.
- 렌더링 스레드 및 물리 스레드 등 타 스레드에 종속된 리소스 해제 명령 전달.
- FinishDestroy() 호출:
- 비동기 리소스 정리가 완료되면 호출되어 객체 내부 데이터를 최종 정리함.
- GUObjectArray에서 제거:
- 객체의 C++ 소멸자가 실행되고 GUObjectArray 인덱스가 반환되며 메모리가 해제됨.
2.3 점진적/비동기 GC (Incremental & Async GC)
- 프레임 드랍(Stuttering) 방지 메커니즘:
- 단일 프레임에서 모든 수거 작업을 완료하면 병목이 발생하므로, GC 작업을 프레임당 설정된 시간 할당량(Time Slice) 단위로 분할하여 실행함.
- gc.IncrementalBeginPurging: 수거 절차를 여러 프레임에 나누어 점진적으로 수행.
3. UObject 참조 관리 및 안전성 확보
3.1 UPROPERTY() 매크로의 역할
- 일반 C++ 원시 포인터(UObject*)는 GC 가비지 컬렉터가 참조 관계를 인식하지 못함.
- UPROPERTY()를 선언하지 않은 원시 포인터는 대상 객체가 GC에 의해 수거되었을 때 자동으로 nullptr로 초기화되지 않아 댕글링 포인터(Dangling Pointer) 및 널 포인터 역참조 크래시를 유발함.
3.2 UObject 전용 스마트 포인터
| 포인터 종류 | 주요 특징 및 용도 |
| TObjectPtr<T> | UE5 표준 포인터. 릴리즈 빌드에서는 원시 포인터와 동일한 성능을 가지며, 에디터 빌드에서는 동적 접근 감지 및 디버깅 메타데이터 기능을 제공함. |
| TWeakObjectPtr<T> | 참조 카운트(GC Reachability)에 영향을 주지 않는 약한 참조 포인터. IsValid() 함수를 통해 객체의 소멸 여부를 안전하게 검사할 수 있음. |
| TSoftObjectPtr<T> | 에셋의 비동기 로딩을 위한 포인터 (FSoftObjectPath 관리). 메모리에 로드되지 않은 에셋을 에셋 경로 형태로 참조함. |
// UObject 스마트 포인터 사용 예시
UPROPERTY()
TObjectPtr<UStaticMeshComponent> MeshComp; // UE5 권장 표준 포인터
TWeakObjectPtr<APlayerCharacter> WeakPlayer; // Target 추적용 약한 참조
void CheckTarget()
{
if (WeakPlayer.IsValid()) // 안전한 유효성 검사
{
APlayerCharacter* Player = WeakPlayer.Get();
}
}
3.3 비-UObject 클래스에서의 UObject 참조 (FGCObject)
- UObject를 상속받지 않는 일반 C++ 클래스/구조체에서 UObject*를 소유해야 하는 경우, FGCObject를 상속받아 GC 시스템에 알려야 함.
class FMyCustomNativeClass : public FGCObject
{
public:
UTexture2D* CachedTexture;
// FGCObject 인터페이스 구현
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
// Collector에 포인터 주소를 전달하여 GC 마킹 대상에 포함시킴
Collector.AddReferencedObject(CachedTexture);
}
virtual FString GetReferencerName() const override
{
return TEXT("FMyCustomNativeClass");
}
};
4. 비-UObject 전용 스마트 포인터 (Raw C++ 메모리 관리)
UObject를 상속받지 않는 일반 C++ 데이터 구조는 언리얼 고유의 C++ 스마트 포인터 라이브러리를 사용함. (C++ 표준 std::shared_ptr 사용 금지)
- TSharedPtr<T>: 참조 카운팅 방식의 공유 포인터. (스레드 안전성 옵션 제공: ESPMode::Fast vs ESPMode::ThreadSafe).
- TWeakPtr<T>: TSharedPtr 간의 순환 참조(Circular Reference)를 방지하기 위한 약한 참조 포인터.
- TUniquePtr<T>: 단일 소유권을 보장하는 포인터 (복사 불가, Move 전용).
- TSharedRef<T>: 절대 nullptr이 될 수 없음을 보장하는 레퍼런스 포인터 (초기화 필수).
5. 언리얼 메모리 할당자 및 컨테이너 관리
5.1 FMalloc 메모리 할당자 구조
- 언리얼 엔진은 OS 기본 malloc 대신 런타임 성능 및 메모리 파편화(Fragmentation) 최소화를 위해 고유 할당자 계층을 사용함.
- FMallocBinned2 / FMallocBinned3: 메모리 요청 크기별로 빈(Bin)을 분할하여 메모리 할당 속도를 가속화함 (64-bit 시스템 최적화).
5.2 TArray / TMap Slack 메모리 관리
- dynamic array/map 확장 시 잦은 재할당을 막기 위해 요청 크기보다 더 큰 메모리를 할당(Slack)함.
- 메모리 절감/해제 관련 함수 제어:
- Reset(): 엘리먼트 소멸자 호출 및 수량(Num)을 0으로 만듦. 메모리 공간(Slack)은 유지함.
- Empty(): 엘리먼트 소멸자 호출 및 지정한 Slack 크기로 메모리를 축소/해제함.
- Shrink(): 현재 사용되지 않는 잔여 Slack 메모리를 실제로 OS/할당자에 반환함.
TArray<int32> DataArray;
DataArray.Reserve(1000); // 1000개 공간 재할당 없이 사전 확보
// ... 사용 후 ...
DataArray.Reset(); // 메모리는 유지하고 재사용
DataArray.Shrink(); // 불필요한 Slack 메모리 즉시 해제
6. GC 최적화 전략 및 클러스터링 (Clustering)
6.1 GC 클러스터 (GC Clusters)
- 같이 생성되고 한 번에 파괴되는 복합 객체 구조(예: 파티클 에미터 컴포넌트, 복잡한 블루프린트 구성 요소)를 단일 단위(Cluster)로 묶는 기능.
- GC가 클러스터 내부의 하위 프로퍼티를 개별 순회하지 않고 Root 단위만 검사하므로 Mark Phase 성능이 대폭 상승함.
6.2 GC 주기 제어 및 수동 실행
- GC 시간 간격 조절: GARBAGE_COLLECTION_INTERVAL (기본값: 30초).
- 수동 GC 호출: 레벨 이동 시점이나 메모리 부담이 큰 시점에 수동 호출 가능.
// 수동 GC 호출
GEngine->ForceGarbageCollection(true); // true: 즉시 강제 수거 수행
6.3 GC 영구 방지 (AddToRoot)
- 글로벌 매니저 객체 등 GC에 의해 절대 삭제되면 안 되는 UObject 인스턴스는 AddToRoot()를 직접 호출함.
- 객체 소멸 시에는 반드시 RemoveFromRoot()를 호출해야 메모리 누수가 발생하지 않음.
UMyManager* Manager = NewObject<UMyManager>();
Manager->AddToRoot(); // GC 수거 대상에서 영구 제외
// 객체 정리 시
Manager->RemoveFromRoot(); // Root 해제 후 GC 대상화
*오늘의 총평*
강의 듣는 것에 집중하고 정리까지 했다. 오랜만에 차분하게 공부할 수 있었다.