TIL
07.27 TIL
2026. 7. 27. 19:14

일정표

시간 할 일 비고
08:30~10:00 코드카타  
10:00~18:00 커리어 데이  
19:00~21:00 개인 프로젝트  
21:00~22:00 운동  
22:00~23:30 개인 프로젝트  

*오늘의 코드카타*

 

문제. 성격 유형 검사하기

더보기

나만의 카카오 성격 유형 검사지를 만들려고 합니다.
성격 유형 검사는 다음과 같은 4개 지표로 성격 유형을 구분합니다. 성격은 각 지표에서 두 유형 중 하나로 결정됩니다.
지표 번호성격 유형1번 지표라이언형(R), 튜브형(T)2번 지표콘형(C), 프로도형(F)3번 지표제이지형(J), 무지형(M)4번 지표어피치형(A), 네오형(N)
4개의 지표가 있으므로 성격 유형은 총 16(=2 x 2 x 2 x 2)가지가 나올 수 있습니다. 예를 들어, "RFMN"이나 "TCMA"와 같은 성격 유형이 있습니다.
검사지에는 총 n개의 질문이 있고, 각 질문에는 아래와 같은 7개의 선택지가 있습니다.
매우 비동의
비동의
약간 비동의
모르겠음
약간 동의
동의
매우 동의
각 질문은 1가지 지표로 성격 유형 점수를 판단합니다.
예를 들어, 어떤 한 질문에서 4번 지표로 아래 표처럼 점수를 매길 수 있습니다.
선택지성격 유형 점수매우 비동의네오형 3점비동의네오형 2점약간 비동의네오형 1점모르겠음어떤 성격 유형도 점수를 얻지 않습니다약간 동의어피치형 1점동의어피치형 2점매우 동의어피치형 3점
이때 검사자가 질문에서 약간 동의 선택지를 선택할 경우 어피치형(A) 성격 유형 1점을 받게 됩니다. 만약 검사자가 매우 비동의 선택지를 선택할 경우 네오형(N) 성격 유형 3점을 받게 됩니다.
위 예시처럼 네오형이 비동의, 어피치형이 동의인 경우만 주어지지 않고, 질문에 따라 네오형이 동의, 어피치형이 비동의인 경우도 주어질 수 있습니다.
하지만 각 선택지는 고정적인 크기의 점수를 가지고 있습니다.
매우 동의나 매우 비동의 선택지를 선택하면 3점을 얻습니다.
동의나 비동의 선택지를 선택하면 2점을 얻습니다.
약간 동의나 약간 비동의 선택지를 선택하면 1점을 얻습니다.
모르겠음 선택지를 선택하면 점수를 얻지 않습니다.
검사 결과는 모든 질문의 성격 유형 점수를 더하여 각 지표에서 더 높은 점수를 받은 성격 유형이 검사자의 성격 유형이라고 판단합니다. 단, 하나의 지표에서 각 성격 유형 점수가 같으면, 두 성격 유형 중 사전 순으로 빠른 성격 유형을 검사자의 성격 유형이라고 판단합니다.
질문마다 판단하는 지표를 담은 1차원 문자열 배열 survey와 검사자가 각 질문마다 선택한 선택지를 담은 1차원 정수 배열 choices가 매개변수로 주어집니다. 이때, 검사자의 성격 유형 검사 결과를 지표 번호 순서대로 return 하도록 solution 함수를 완성해주세요.
제한사항
1 ≤ survey의 길이 ( = n) ≤ 1,000survey의 원소는 "RT", "TR", "FC", "CF", "MJ", "JM", "AN", "NA" 중 하나입니다.
survey[i]의 첫 번째 캐릭터는 i+1번 질문의 비동의 관련 선택지를 선택하면 받는 성격 유형을 의미합니다.
survey[i]의 두 번째 캐릭터는 i+1번 질문의 동의 관련 선택지를 선택하면 받는 성격 유형을 의미합니다.
choices의 길이 = survey의 길이choices[i]는 검사자가 선택한 i+1번째 질문의 선택지를 의미합니다.
1 ≤ choices의 원소 ≤ 7

#include <string>
#include <vector>
#include <unordered_map>

using namespace std;

string solution(vector<string> survey, vector<int> choices) {
    string answer = "";
    unordered_map<char, int> score;

    for (size_t i = 0; i < survey.size(); ++i) {
        int choice = choices[i];

        if (choice < 4) {
            score[survey[i][0]] += (4 - choice);
        } else if (choice > 4) {
            score[survey[i][1]] += (choice - 4);
        }
    }

    answer += (score['R'] >= score['T']) ? 'R' : 'T';
    answer += (score['C'] >= score['F']) ? 'C' : 'F';
    answer += (score['J'] >= score['M']) ? 'J' : 'M';
    answer += (score['A'] >= score['N']) ? 'A' : 'N';

    return answer;
}

4개 지표별로 최종 유형을 결정하는 해시 맵(Hash Map) / 시뮬레이션 문제.

 

 

동점 처리 조건 > 문제 조건에 따라 점수가 같으면 사전 순으로 앞선 성격 유형을 선택한다.

알파벳순으로 보았을 때 R < T, C < F, J < M, A < N 이므로, 동점일 경우 항상 앞에 위치한 성격 유형(R, C, J, A)이 선택된다. (즉, >= 조건 사용)


*개인 브로셔 작성 / 커리어 데이*

개인 브로셔의 경우 노션에 작성하였다. 주요 항목을 채우고 그에 덧붙여 내가 프로젝트에서 담당한 역할과 구현한 기능에 대해서 상세히 써 놓았다. 특히, 멀티플레이 구현 경험과 트러블 슈팅 및 그 해결 방법에 신경을 많이 썼다.

 

<팀프로젝트 회고록>

# 프로젝트 종료 회고록

## 1. 프로젝트 개요

- 프로젝트명: Parcel Knight
- 진행 기간:  26.07.01 ~ 26.07.24
- 팀 인원: 한수현, 김로운, 양석현, 조영웅, 김경수
---

## 2. 프로젝트 목표

### 목표

- 한수현 : 라일라 스타일 장착 및 리슨서버 구현
- 김로운 : 캐릭터 레그돌 구현
- 양석현 : 수익실현 가능 컨텐츠 게임 개발
- 조영웅 : 언리얼 프로젝트 필수 게임루프 패키징 학습하기
- 김경수 : 리슨서버 구현

### 주요 기능

한수현
- 게임룰
- 세션 서브 시스템(멀티), 게임 저장
- 인벤토리 상점, 아이템 기능
- 캐릭터 변환, 체력기능

김로운
- 캐릭터 애니메이션(블루프린트), 래그돌

양석현
- 맵 제작(에셋)
- 함정 제작
- 스팀 플러그인 붙이기, 친구추가/방참가로 호스트 방 들어가기
- 사운드 매니저
- ai 제작

조영웅
- 캐릭터 엑터 클래스, 관련 컴포넌트 구현
- Delivery DT 구현
- 멀티 플레이 UI 코드 아키텍처

김경수
- 택배 물리기반 배송, 택배 스포너 구현
- 상자 투척 시스템(상자의 상호작용, 캐릭터 피격)
- Delivery Zone 구현
- 캐릭터 스킨 구현

### 사용 기술 스택
- Engine: UE 5.5.4 (에셋 호환성)
- Language: C++, BP
- Version Control: Github

---

# 3. 프로젝트 결과

## 미완료 또는 축소된 사항

한수현
- 칭호 - 아이템 구현에는 성공했으나 ui와 NamePlate 관련 버그로 인해 실제 테스트를 해보지 못한 부분이 아쉬웠다.
- 커스터마이징 - 시간이 부족해서 경수님에게 넘기게 되었다.
- 로딩 - 로딩창 UI를 활용한 비동기 로딩으로 랙과 버튼을 눌렀을 때
몇초뒤 작동 하는 부분을 개선하고 싶었지만, 실제로 작업 시작도 못해봐서 아쉽다.

김로운
- 캐릭터, 다른 애니메이션도 있었지만 래그돌에 많은시간을 쓰는바람에 구현하지 못한게 아쉽다,
- 자체 모션 제작의 한계 > 외부 에셋의 캐릭터와 애니메이션을 가져와 리타게팅하여 적용하는 방식으로 사용했다.

양석현
- 함정이 부족하여 더 늘리고 싶었지만, 컨셉을 잡지 못했다.
- 첫 번째 맵은 직접 만들었지만, 다른 레벨에 신경을 쓰지 못해 펩을 사용하였다.
- ai가 단순히 생각하지 않고 플레이어를 쫒아가기만 하는점이 아쉽다.

조영웅
- 코드 오염의 문제로 캐릭터에 부착하는 컴포넌트의 완전 분리 부족 > 
소통의 부족과 컴포넌트의 이해 부족이 원인이었다.
- 단순한 2D 위젯 출력을 보완해서 호버/클릭 시의 버튼 상호작용과 물리작용 피드백을 추가하고 싶었지만 시간이 부족했다.
- 게임플레이 태그 시스템을 도입하긴 했으나 여러 파트에서 태그 기능을 유연하게 사용하지는 못했음. 
실제로 태그 충돌이 나거나 태그를 삽입하지 않고 작업을 진행한 경우가 많았다.

김경수
- 택배 종류 구현 제한 > 일반, 무거운 깨지 쉬운 상자 3가지 밖에 구현하지 못해 아쉽다.
- 상자 물리 충격 대미지 밸런싱 > 진작에 물리 스케일을 정립하고 시작했어야했다.
- 상자 머리 위 체력 및 Zone > UI디자인을 신경썼으면 좋을 거 같다.

---

# 4. 스프린트 운영 회고

## 잘된 점

한수현
- 게임룰: 관련 클래스들을 제작할 때 라이라스타일로 최대한 클래스는 가볍게 구현하려 했고, 의도한 대로 작업되었다.
- 아이템: 만들 땐 어려워도 신규 아이템 제작이 쉬울 수 있도록 하는 것이 의도였다. >  
이펙트,사운드,태그,수치를 데이터베이스에 넣고 사용 시 작동하는 코드를 집어넣는 것으로 괜찮은 아이템이 만들어지는 것을 확인했다.
- 멀티 기능: SessionSubsystem으로 구현했는데, 게임을 실행할 때 스팀에 뜨는 것을 보니 매우 뿌듯했습니다
- 캐릭터 전환: 로운님이 만드신 캐릭터를 StickMan으로 전환하는 작업을 진행했다 > 전환한 캐릭터가 매끄럽게 움직이는 것을 확인했다.

김로운
- 캐릭터 래그돌이 단순히 쓰러지고 일어나는모션을 성공적으로 구현함.
- Ragdoll과 Animation Blueprint를 유기적으로 연동했다.
- 에셋을 리타케팅하여 C++캐릭터에 물리 상태와 애니메이션이 자연스럽게 구현한게 만족스럽다.

양석현
- 함정을 데이터 에셋방식으로 만들어 확장가능성을 넓게 만들었다.
- 맵에 맞는 기믹함정들을 만든것이 잘됐다.
- 스팀 플러그인이 잘 연결되어 친구초대로 참가 가능하게 만들었다.

조영웅
- UI 및 상호작용 구현시 Tick 기능을 사용해야 하는 경우가 많았는데 Tick 연산을 배제하고,
Delegate/RepNotify 기반의 UI 레이어를 활용해서 프레임 드랍을 잘 관리한 것 같다.
- C++ 기반으로 위젯 클래스를 만든 후 해당 버튼과 세션을 연결해서 방 만들기 > 로비 입장 > 레벨 이동의 로직이 잘 작동되게 했다.
- 캐릭터의 움직임 데이터를 따로 관리하는 컴포넌트를 분리하고, 
이를 서버에서 관리하게 해서 클라이언트가 임의로 데이터를 변조하지 못하게 한게 만족스러웠다.

김경수
- 컨베이어 벨트 물리엔진 >  수평 물리 선형 속도(SetPhysicsLinearVelocity)만 제어하여 자연스럽게 물리 흐름을 완성.
- 스킨 커스터마이징 동기화 > 데이터 테이블 구조체와 RepNotify(OnRep_CurrentSkin)를 잘 엮어서, 
메인 메뉴 선택 스킨이 멀티플레이어 접속 시 전원 화면에 동적으로 잘 반영됨.
- 벽 뒤 UI 자동 숨김 > 라인트레이스로 벽 뒤 UI 자동 숨김 처리까지 깔끔하게 구현.

---

## 아쉬웠던 점

한수현
- 아이템 관련: 게임 플레이 태그를 사용해 식별자(ID) + 타입(Enum)을 하나로 묶은건 좋았지만,
이후 기능성 아이템 하나마다 게임플레이태그 추가+데이터베이스등록을 해야한다. 
- 추가 코드가 필요하면 플레이어 클래스에 추가해야하는 부분이 현재 프로젝트 규모에선 상관없지만 만약 아이템 개수가 100개가 넘어갔다면 
플레이어에게 컴포넌트를 하나 더 붙여야했을정도로 늘어났을 것 같아서 좀 더 좋은 방법이 있을지 생각해봐야할 것 같다.
- 컴포넌트 부착: 컴포넌트는 c++로 만들었지만 블루프린트에서 직접 추가 > 브랜치 머지 마다 캐릭터 컴포넌트에 에셋이나 데이터를 다시 넣는 문제 발생 
생성자나 동적으로 부착할 필요성을 느낌
- 멀티 기능 : 세션서브시스템 리팩토링을 한번 해보고싶었는데, 못해서 아쉽다.

김로운
- 래그돌 전환 및 기상 로직을 구현하는 과정에서, 
복잡한 조건문과 상태 관리를 전적으로 블루프린트에 의존하다 보니 노드가 과도하게 복잡해져 디버깅과 유지보수 측면에서 한계를 느꼈다 > 
성능과 핵심 제어 로직은 C++ 코드로 작성하고, 시각적인 연결 및 조정은 블루프린트가 담당하는 식의 체계적인 역할 분담이 아쉽다.

양석현
- 에셋을 받는 과정에서 FBX나 GLB의 대한 지식이 부족하여 자주 오류가 나왔다. > 간단하게 공부해야겠다.
- 데이터 에셋을 활용하였지만 변수명들이 맞지 않는것이 있어 가독성이 떨어졌다.
- 사운드 매니저를 만들었지만 전체볼륨이 하나라서 확장성이 떨어졌다. > 세팅에서 세부 사항 늘리기.
- UI를 C++을 사용해 제작해서, 코드와 블루프린트가 헷갈렸다. > 블루프린트 공부도 해야한다.

조영웅
- 로컬 환경 테스트에서는 잘 동작하던 기능이 멀티플레이 환경에서는 오작동하거나 Null Pointer 문제가 발생하는 경우가 많았음. > 
언리얼 엔진 네트워크 생명주기를 확실히 이해할 필요가 있다.
- C++ & 블루프린트 연동 방식으로 위젯을 구현할 때 여전히 이벤트 그래프에서 노드 편집을 많이 했음 > 
좀 더 기획자 친화적인 마인드로 위젯 클래스를 만드는 연습이 필요.
- 유저 피드백 연출 시 머티리얼 편집이나 Anim 작업을 할 때 익숙하지 않아서 시간도 많이 걸리고, 계획한 기능을 구현하지 못했음 > 
다양한 경험을 해봐야함.
- 정보를 담고 있는 컴포넌트에서 델리게이트를 열어주지 않아 UI 작업하는데 시간이 많이 걸리고 코드 오염이 많이 발생 > 
사전에 프로젝트 전에 공유하고 가는 것이 중요해보인다.

김경수
- 상태 태그 Lifecycle 설계 미숙 > 사망 후 리스폰 시 PlayerStateComponent 잔여 태그 초기화를 놓쳐, 
래그돌을 겪기 전까지 피격이 무시되는 버그 발생 > 뒤늦게 Possess 시점에 ResetStateTags()로 우회 고침 > 태그 생명주기를 처음부터 촘촘하게 기획해야겠다.
- 물리 충격 대미지 밸런싱 시행착오 > 프레임이나 핑에 따라 언리얼 물리 Impulse 수치가 불안정해 상자가 뜬금없이 파손되는 현상 발생 > 
초기에 체계적인 물리 충격-대미지 환산 공식을 먼저 잡고 시작해야겠다.
- 3D UI 라인트레이스 매 틱 연산 최적화 미흡 > 상자 벽 뒤 가림 처리를 위해 Tick에서 로컬 카메라 대상 LineTraceSingleByChannel을 매 프레임 수행함 > 
상자가 맵에 다량 스폰될 때를 대비해 타이머 인터벌이나 거리별 업데이트 분동 처리를 못 해둔 점이 아쉽다.

---




# 5. 팀 협업 회고

## 좋았던 협업 방식

- 하루2번 스크럼이 도움이 되었다.
- 먼저 작업이 끝난 사람이 다른분의 작업을 도와주거나 새로운 기능을 추가하는게 좋았다.
- Miro를 사용해 소통과 진행상황을 잘 공유하여 좋았다.
- 브랜치 전략이 잘 지켜졌다.

---

## 개선이 필요한 부분

- 코드리뷰를 하지않아 코드의 대한 이해도가 떨어졌다.
- 마이크와 캠이 켜져있지 않아 실시간 커뮤니케이션이 부족했다.
- 작업 분배 시 1/n이었다면 좋았을 것 같다.
- 디버깅,테스트 리스트를 만들어두면 좋았을 것 같다.


---
Parcel Knight 회고.md
11KB

*수학 공부*

<엔진 코어 및 하이엔드 개발. 부호 거리 장 (SDF)>

 

부호 거리 장(Signed Distance Field, SDF)은 최신 게임 그래픽스의 패러다임을 바꾼 수학 개념이다. 언리얼 엔진 5의 핵심 라이팅 시스템인 루멘(Lumen), 디스턴스 필드 섀도우(Distance Field Shadow), 그리고 Vector 그래픽처럼 깨지지 않는 UI/폰트 렌더링까지 모두 이 SDF에 기반하고 있다.

 

1. SDF란? (공간을 거리로 채우기)

 

기존의 3D 그래픽스는 정점(Vertex)과 삼각형(Triangle)의 집합인 '메쉬(Mesh)'로 물체를 표현했다. 하지만 SDF는 공간을 표현하는 완전히 다른 수학적 접근 방식이다.

SDF는 "공간상의 임의의 점 P에서 '가장 가까운 표면'까지의 최소 거리"를 계산하는 수학 함수 또는 데이터 배열이다. 여기에 '부호(Signed)'가 붙는 이유는 점이 물체의 안에 있는지 밖에 있는지 구분하기 위해서다.

 

중심이 C이고 반지름이 r인 구(Sphere)의 SDF 함수는 다음과 같이 단 한 줄로 표현된다.

 

2. SDF의 장점 3가지

 

① 해상도 독립성 (Vector 스타일 렌더링)

 

2D 텍스처나 폰트를 확대하면 픽셀이 뭉개지거나 계단 현상이 생긴다. 하지만 SDF 알파 맵을 만들어 두면, 알파 값이 0.5 (표면)가 되는 경계선만 수학적으로 렌더링하면 되기 때문에 아무리 크게 확대해도 폰트나 알파 아이콘의 테두리가 칼같이 선명하게 유지된다. (Valve가 이 기법으로 2D 폰트 렌더링을 혁명했어).

 

② 레이마칭(Raymarching)과 스피어 트레이싱(Sphere Tracing)

 

기존의 레이트레이싱은 광선(Ray)이 수만 개의 삼각형과 부딪히는지 복잡한 교차 연산을 해야 했다.

하지만 SDF 공간에서는 광선을 쏠 때 "현재 위치의 SDF 값(=안전하게 나아갈 수 있는 최대 거리)"만큼 광선을 건너뛰며 전진시킬 수 있다. 연산량이 기하급수적으로 줄어들어서 삼각형 메쉬가 없어도 실시간 3D 볼륨(구름, 안개, 유체)이나 복잡한 도형을 빛의 속도로 렌더링할 수 있게 된다.

 

③ 언리얼 엔진 5의 Mesh Distance Field (MDF) & Lumen

 

언리얼 엔진은 3D 메쉬를 로딩할 때, 그 메쉬를 감싸는 3D SDF 데이터(Mesh Distance Field)를 백그라운드에서 구워낸다.

디스턴스 필드 섀도우: 광선을 쏠 때 메쉬 근처를 스쳐 지나가는 SDF 거리 값에 따라, 빛이 물체에 가까울수록 날카롭고 멀어질수록 부드럽게 퍼지는 소프트 섀도우(Soft Shadow)를 초고속으로 연산한다.

Lumen(루멘): 거대한 오픈월드의 빛 반사(Global Illumination)를 계산할 때 복잡한 폴리곤 메쉬 대신 이 가벼운 MDF 공간을 활용해 광선을 튕겨냄으로써 실시간 단방향 라이팅을 구현해 낸다.

 

3. 실무 HLSL/C++ 수학 코드: 부드러운 합성 (Smooth Minimum)

 

SDF의 진짜 마법 중 하나는 "두 물체가 닿았을 때 찰흙처럼 부드럽게 뭉쳐지는 효과(Metaball)"를 수학 공식 하나로 만들 수 있다는 점이다.

일반적인 3D 메쉬는 두 물체를 합치려면 복잡한 불리언(Boolean) 연산을 돌려 정점을 자르고 재연결해야 하지만, SDF는 두 SDF 거리 값의 부드러운 최솟값(Smooth Min)을 구하는 공식만 셰이더에 집어넣으면 끝난다.

 

[HLSL 셰이더 개념 코드: 두 SDF 물체의 부드러운 융합]

// 두 SDF 거리 값(d1, d2)을 k라는 유연성 비율로 부드럽게 섞어주는 수학 함수
float SmoothMin(float d1, float d2, float k)
{
    float h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0);
    return lerp(d2, d1, h) - k * h * (1.0 - h);
}

// 3D 공간상의 특정 좌표 P에서의 SDF 값 계산
float GetSceneSDF(float3 P)
{
    // 1. 첫 번째 구(Sphere)의 SDF (위치: -0.5, 반지름: 1.0)
    float Sphere1 = length(P - float3(-0.5, 0.0, 0.0)) - 1.0;

    // 2. 두 번째 구(Sphere)의 SDF (위치: 0.5, 반지름: 0.8)
    float Sphere2 = length(P - float3(0.5, 0.0, 0.0)) - 0.8;

    // 3. 두 구가 가까워지면 진흙처럼 쫀득하게 합쳐지는 부드러운 SDF 반환!
    return SmoothMin(Sphere1, Sphere2, 0.3);
}

 

4. SDF 심화

 

1) 스피어 트레이싱 (Sphere Tracing) : 삼각형 없는 3D 렌더링

 

기존 그래픽스는 폴리곤(삼각형)의 꼭짓점 위치를 변환해서 정점 셰이더로 넘겨 그린다. 하지만 SDF 공간에는 삼각형이 아예 없고, 대신 스피어 트레이싱(Sphere Tracing)이라는 기법으로 카메라의 광선(Ray)을 쏘아 물체를 그려낸다.

 

원리:

카메라 위치 O에서 특정 픽셀 방향 D로 광선을 쏜다. 광선의 위치는 P(t) = O + tD

현재 위치 P(t)에서의 SDF 거리 값 d = f(P(t)) 를 구한다.

이 거리 d는 "내가 어떤 방향으로 d만큼 움직여도 표면에 부딪히지 않는 안전 구역의 반지름"이다.

광선을 바로 d만큼 건너뛴다. (t(new) = t + d)

d0.001 이하로 아주 작아질 때까지 반복한다. d가 약 0 이 되는 순간이 바로 "광선이 표면에 부딪힌 지점"이다.

실무적 이점: 0.01씩 조금씩 전진하는 일반 레이마칭(Raymarching)과 달리, SDF 스피어 트레이싱은 안전거리만큼 거대하게 스킵하기 때문에 수십~수백 배 빠르게 렌더링된다.

 

2) SDF 불리언 연산 (CSG: Constructive Solid Geometry)

 

SDF의 엄청난 강점 중 하나는 수학 공식 단 한 줄로 복잡한 3D 도형을 자르고, 합치고, 구멍을 뚫을 수 있다는 점이다.

두 개 물체의 SDF 값이 각각 d1, d2라고 할 때:

 

float GetSceneSDF(float3 P)
{
    float BoxD = SdBox(P, float3(1.0, 1.0, 1.0)); // 상자의 SDF
    float SphereD = SdSphere(P, 1.3);            // 구의 SDF

    // 상자에서 구 모양을 깍아버린(차집합) 단 한 줄의 수학!
    return max(BoxD, -SphereD);
}

[HLSL 셰이더 예시: 구 구멍이 뚫린 상자 렌더링]. 이렇게 하면 복잡한 CAD 소프트웨어나 불리언 툴 없이 셰이더 코드 안에서 기하학적 파내기가 가능해진다.

 

3. 표면 법선(Normal Vector) 구하기: 수치 미분 (Finite Difference)

 

광선이 SDF 표면에 닿았을 때(d = 0). 이제 이 지점에 빛을 비춰서 명암을 넣으려면 "표면이 바라보는 수직 방향(법선 벡터, Normal)"을 알아야 한다. 정점(Vertex)이 없는데 법선 벡터를 어떻게 구한다는 걸까.

여기서 미적분학의 기울기(Gradient) 개념이 쓰이게 된다. SDF 값 f(P)는 표면에서 멀어질수록 커지고, 표면으로 갈수록 0이 되는 특성을 이용하는 것인데, SDF 값이 가장 가파르게 증가하는 방향이 곧 표면의 수직 방향(Normal Vector)이 된다.

실제 HLSL 셰이더에서는 아주 작은 값 epsilon = 0.001을 이용해 중앙 미분(Central Difference)으로 Normal을 구한다.

 

[HLSL 셰이더: SDF 표면 법선 벡터 도출]

float3 GetSDFNormal(float3 P)
{
    float e = 0.001; // 아주 작은 오차 (Epsilon)
    
    // P 지점 근처 X, Y, Z 축의 SDF 변화량을 측정하여 기울기(Gradient) 도출
    float3 N = float3(
        GetSceneSDF(P + float3(e, 0, 0)) - GetSceneSDF(P - float3(e, 0, 0)),
        GetSceneSDF(P + float3(0, e, 0)) - GetSceneSDF(P - float3(0, e, 0)),
        GetSceneSDF(P + float3(0, 0, e)) - GetSceneSDF(P - float3(0, 0, e))
    );

    return normalize(N); // 정규화하면 완벽한 3D 법선 벡터 완성!
}

 

4. 언리얼 엔진 5의 핵심: 디스턴스 필드 소프트 섀도우 (DFSS)

 

언리얼 엔진 5에서 메쉬의 Distance Field(MDF)를 이용해 초고속 소프트 섀도우(Soft Shadow)를 만드는 원리도 이 SDF 스피어 트레이싱의 응용이다.

 

원리:

 

그림자를 만드려면 표면 P에서 광원(Light)을 향해 광선을 쏘아 올린다. 이후 광선이 날아가는 도중, 광선 위치 P(t)에서의 SDF 값 f(P(t))를 계속 검사한다. 만약 f(P(t)) <= 0 이면 차양에 완벽히 가려졌다는 뜻이므로 100% 진한 그림자(Umbra)를 내보낸다.

핵심은 "광선이 물체를 살짝 비껴갈 때" 인데, 광선이 날아간 거리 t 대비 차양 물체와의 최소 거리 f(P(t))의 비율인 f(P(t)) / t 값을 계속 추적한다. 이 비율이 작을수록(광선이 물체 옆을 아슬아슬하게 스쳐 지나갈수록) 그림자가 옅어지는 반음영(Penumbra, 소프트 섀도우)을 수학적으로 바로 계산해 내게 된다. 이 공식 덕분에 광원과의 거리와 차양 물체와의 거리에 따라 가까운 곳은 날카롭고, 멀어질수록 아지랑이처럼 부드럽게 퍼지는 소프트 섀도우를 폴리곤 교차 연산 없이 실시간 60fps로 계산할 수 있게 된 것이다.


*Unreal C++*

<1. 언리얼 C++ 케이스 스터디 3 : 대규모 유닛 처리를 위한 고성능 이벤트 중심 AI 및 센싱 시스템>

 

액션 RPG나 경영/오토 배틀러, 뱀파이어 서바이버즈 스타일의 게임에서 화면에 100~500마리 이상의 몬스터 AI가 등장하는 순간, CPU는 극심한 병목 현상에 부딪히게 된다. 그 원인은 몬스터의 '이동'이 아니라, 매 프레임마다 수백 마리의 AI가 "내 눈앞에 플레이어가 보이는가?", "내 귀에 소리가 들리는가?"를 확인하기 위해 무거운 레이캐스트(Raycast)나 3D 거리 연산을 쏘아대는 '폴링(Polling) 방식의 AI 센싱' 때문이다.

 

이 문제를 해결하기 위해, 상업용 AAA 게임에서는 퍼사드(Facade) + 서비스 로케이터(Service Locator) 패턴과 함께 이벤트 중심 처리(Event-driven) 및 주기 분할(Throttling / Time-Slicing) 기법을 활용하게 된다.


 

1. C++ 개념 및 코드

전통적인 소프트웨어 디자인에서 대규모 객체의 연산 병목을 막는 핵심 원리는 '타임 슬라이싱(Time-Slicing) / 쓰로틀링(Throttling)'과 '이벤트 중심(Event-Driven) 구독'이다.

 

 

타임 슬라이싱(쓰로틀링): 1,000개의 AI를 한 프레임에 전부 연산하지 않는다. 프레임 0에는 0~249번 유닛, 프레임 1에는 250~499번 유닛을 연산하는 방식으로 프레임 분할(Interleaving)을 수행한다.

 

이벤트 중심 센싱: AI가 매 프레임 주변을 훑어보는 대신, "총소리가 났다", "폭탄이 터졌다" 같은 시각/청각 이벤트가 발생했을 때만 중앙 이벤트 매니저(Facade/Service Locator)가 해당 범위 내의 AI들에게 직접 통지(Push)해 주는 방식이다.

 

 

#include <iostream>
#include <vector>
#include <cmath>

struct Vector2D { float X, Y; };

// AI 에이전트 객체
class AIAgent {
public:
    int32_t AgentID;
    Vector2D Position;
    bool bIsAlerted = false;

    AIAgent(int32_t InID, Vector2D InPos) : AgentID(InID), Position(InPos) {}

    // 자극(소리, 시각 등)을 전달받는 이벤트 수신기
    void OnHearNoise(const Vector2D& NoisePos) {
        float Dist = std::sqrt(std::pow(Position.X - NoisePos.X, 2) + std::pow(Position.Y - NoisePos.Y, 2));
        if (Dist <= 500.0f) { // 반경 500 이내의 소리만 반응
            bIsAlerted = true;
            std::cout << "[AI " << AgentID << "] 소리를 감지하고 경계 상태로 전환!\n";
        }
    }
};

// AI 중앙 센싱 지휘관 (Time-Slicing & Event Distribution)
class AISensingManager {
private:
    std::vector<AIAgent*> AllAgents;
    int32_t CurrentFrameSlot = 0;
    const int32_t TOTAL_SLOTS = 4; // 4개 프레임으로 연산 분할

public:
    void RegisterAgent(AIAgent* Agent) { AllAgents.push_back(Agent); }

    // 1. [타임 슬라이싱] 매 프레임 전체의 1/4만 갱신
    void UpdateThrottledAI(float DeltaTime) {
        int32_t Total = AllAgents.size();
        for (int32_t i = 0; i < Total; ++i) {
            // 자신의 프레임 슬롯에 해당하는 AI만 주기적 로직(기본 탐색) 실행
            if (i % TOTAL_SLOTS == CurrentFrameSlot) {
                // 가벼운 상태 체크 및 시뮬레이션
            }
        }
        CurrentFrameSlot = (CurrentFrameSlot + 1) % TOTAL_SLOTS; // 다음 프레임 슬롯 이동
    }

    // 2. [이벤트 중심] 소리가 났을 때만 주변 AI에게 직접 방송 (폴링 탈피)
    void BroadcastNoiseEvent(Vector2D NoiseLocation) {
        std::cout << "\n=== [이벤트 발생] 플레이어가 총을 쏘았습니다! ===\n";
        for (AIAgent* Agent : AllAgents) {
            Agent->OnHearNoise(NoiseLocation);
        }
    }
};

1,000개의 AI가 있더라도 매 프레임 연산량은 250개 수준으로 급감하며, 시각/청각 체크를 매 프레임 돌릴 필요 없이 '사건(Event)'이 터졌을 때만 분기하므로 CPU 사용량이 기하급수적으로 줄어들게 된다.


2. 언리얼 C++ 적용

언리얼 엔진 5(UE5)에서 기본 제공하는 UAIPerceptionComponent와 BehaviorTree를 100마리 이상의 몬스터에 부착하면 심각한 프레임 드랍이 발생하게 된다. Default AI Perception이 무겁기 때문인데, 언리얼의 기본 AI 센싱은 매 틱마다 모든 소스(Sight, Sound, Damage)에 대해 3D 라인트레이싱과 캡슐 오버랩을 수행하므로, 몬스터가 조금만 늘어나도 CPU Physics / AI Bottleneck이 터지게 된다.

 

따라서 다음 두가지 방식으로 최적화를 수행해야 한다.

 

 

거리 기반 시그니피컨스(Significance): 플레이어와 가까운 몬스터(0~10m)는 매 프레임 AI 틱을 돌리고, 중거리(10~30m)는15Hz,(4프레임에 한 번), 먼 거리(30m+)는 2Hz(30프레임에 한 번)로 AI 틱 간격을 동적으로 조절(SetComponentTickInterval)한다.

 

가상 센싱 서브시스템 (UGameInstanceSubsystem): 무거운 물리 쿼리 대신, 앞서 배운 공간 분할(Spatial Partitioning) 해시 격자서비스 로케이터 서브시스템을 결합하여 소리/시각 자극을 '이벤트 방식'으로 전달한다.

 


3. 언리얼 C++ 실무 코드

플레이어가 총을 쏘거나 소리를 냈을 때, 무거운 물리 체크 없이 글로벌 AI 센싱 서브시스템이 해당 지역 가상 격자의 몬스터들에게만 이벤트를 쏴주고, 몬스터 컴포넌트는 플레이어와의 거리에 따라 스스로 Tick 주기를 분할(Throttling)하는 아키텍처의 사례를 보자.

 

(1. AI 센싱 컴포넌트, OptimizedSensingComponent.h)

#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "OptimizedSensingComponent.generated.h"

// AI의 경계 상태
UENUM(BlueprintType)
enum class EAIAlertState : uint8
{
	Idle,
	Investigating,
	Combat
};

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class UNREALDESIGNPATTERN_API UOptimizedSensingComponent : public UActorComponent
{
	GENERATED_BODY()

public:	
	UOptimizedSensingComponent();

	virtual void BeginPlay() override;
	virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

	// [이벤트 수신기] 서브시스템이 소리 이벤트를 보냈을 때 직접 호출됨 (폴링 안 함)
	void OnHearNoiseEvent(const FVector& NoiseLocation, float Loudness);

	// 현재 AI 상태
	UPROPERTY(BlueprintReadOnly, Category = "AI")
	EAIAlertState CurrentAlertState = EAIAlertState::Idle;

	// 현재 조사해야 할 목표 위치
	UPROPERTY(BlueprintReadOnly, Category = "AI")
	FVector TargetInvestigationLocation;

private:
	// 플레이어와의 거리에 따라 TickComponent 실행 주기를 동적으로 튜닝하는 함수
	void UpdateTickThrottling();

	// 프레임 분할(Interleaving)을 위한 개별 식별용 고유 ID
	int32 AgentID = 0;
	static int32 GlobalAgentIDCounter;
};

 

(2. AI 센싱 컴포넌트 로직, OptimizedSensingComponent.cpp)

#include "OptimizedSensingComponent.h"
#include "GameFramework/Actor.h"
#include "Kismet/GameplayStatics.h"

int32 UOptimizedSensingComponent::GlobalAgentIDCounter = 0;

UOptimizedSensingComponent::UOptimizedSensingComponent()
{
	PrimaryComponentTick.bCanEverTick = true;
	AgentID = ++GlobalAgentIDCounter;
}

void UOptimizedSensingComponent::BeginPlay()
{
	Super::BeginPlay();

	// 프레임 인터리빙(Interleaving): 생성되는 시점에 시작 지연시간을 분산시켜
	// 수백 마리가 한 프레임에 동시에 틱을 푸는 것을 방지
	float InitialDelay = (AgentID % 10) * 0.033f;
	SetComponentTickInterval(0.1f); // 기본 틱은 10Hz (0.1초)로 시작
}

void UOptimizedSensingComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

	// 1. 플레이어와의 거리에 따라 틱 주기 동적 조절 (Throttling)
	UpdateTickThrottling();

	// 2. 가벼운 AI 상태 업데이트 수행
	if (CurrentAlertState == EAIAlertState::Investigating)
	{
		// 의심 지역으로 이동 중인지 확인하는 로직 등...
	}
}

void UOptimizedSensingComponent::UpdateTickThrottling()
{
	APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
	if (!PlayerPawn) return;

	// DistSquared 연산으로 제곱근 계산 제거 (최적화)
	float DistSq = FVector::DistSquared(GetOwner()->GetActorLocation(), PlayerPawn->GetActorLocation());

	// [거리 기반 Throttling 적용]
	if (DistSq < 1000.0f * 1000.0f) // 10m 이내 (최우선 근거리)
	{
		SetComponentTickInterval(0.0f); // 매 프레임 풀 틱 (60Hz)
	}
	else if (DistSq < 3000.0f * 3000.0f) // 30m 이내 (중거리)
	{
		SetComponentTickInterval(0.1f); // 1초에 10번만 틱 (10Hz)
	}
	else // 30m 초과 (원거리)
	{
		SetComponentTickInterval(0.5f); // 1초에 2번만 틱 (2Hz)
	}
}

void UOptimizedSensingComponent::OnHearNoiseEvent(const FVector& NoiseLocation, float Loudness)
{
	// 무거운 Tick 확인 없이, 사건이 터졌을 때 즉시 반응
	float Dist = FVector::Distance(GetOwner()->GetActorLocation(), NoiseLocation);
	
	// 소리 크기 대비 거리 계산
	if (Dist <= Loudness)
	{
		CurrentAlertState = EAIAlertState::Investigating;
		TargetInvestigationLocation = NoiseLocation;
		
		// 소리를 들었으므로 반응성을 위해 즉시 근거리 틱 모드로 전환
		SetComponentTickInterval(0.0f);
		
		UE_LOG(LogTemp, Warning, TEXT("[%s] 소리 감지! %s 지점으로 이동 개시"), *GetOwner()->GetName(), *NoiseLocation.ToString());
	}
}

 

(3. 전역 지휘관, AI 센싱 서브시스템, AISensingSubsystem.h & cpp)

// AISensingSubsystem.h
#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "OptimizedSensingComponent.h"
#include "AISensingSubsystem.generated.h"

UCLASS()
class UNREALDESIGNPATTERN_API UAISensingSubsystem : public UGameInstanceSubsystem
{
	GENERATED_BODY()

public:
	// 센싱 컴포넌트들의 자동 등록 및 해제
	void RegisterSensingComponent(UOptimizedSensingComponent* Comp);
	void UnregisterSensingComponent(UOptimizedSensingComponent* Comp);

	// [통합 퍼사드 창구] 총소리, 폭발음 등이 발생했을 때 외부에 노출되는 유일한 함수
	UFUNCTION(BlueprintCallable, Category = "AI|Sensing")
	void ReportNoiseEvent(FVector NoiseLocation, float MaxRange);

private:
	// GC 안전 추적을 받지 않고 고성능 순회를 위해 WeakPtr 사용 (댕글링 방지)
	TArray<TWeakObjectPtr<UOptimizedSensingComponent>> RegisteredAIList;
};

// AISensingSubsystem.cpp
#include "AISensingSubsystem.h"

void UAISensingSubsystem::RegisterSensingComponent(UOptimizedSensingComponent* Comp)
{
	if (Comp)
	{
		RegisteredAIList.AddUnique(Comp);
	}
}

void UAISensingSubsystem::UnregisterSensingComponent(UOptimizedSensingComponent* Comp)
{
	RegisteredAIList.Remove(Comp);
}

void UAISensingSubsystem::ReportNoiseEvent(FVector NoiseLocation, float MaxRange)
{
	// 매 프레임 탐색하는 대신, 소리가 난 딱 '그 프레임'에 등록된 AI들에게만 찌름 (Push)
	for (int32 i = RegisteredAIList.Num() - 1; i >= 0; --i)
	{
		if (RegisteredAIList[i].IsValid())
		{
			RegisteredAIList[i]->OnHearNoiseEvent(NoiseLocation, MaxRange);
		}
		else
		{
			RegisteredAIList.RemoveAtSwap(i); // 유효하지 않은 죽은 AI 자동 청소
		}
	}
}

 

(4. 플레이어가 총을 쏠 때 호출 방식, MyWeapon.cpp)

void AMyWeapon::Fire()
{
	// 1. 총알 발사 및 파티클 재생 로직...

	// 2. 서비스 로케이터 패턴: 글로벌 AI 센싱 서브시스템을 꺼내와 소리 발생 리포트!
	if (UWorld* World = GetWorld())
	{
		if (UAISensingSubsystem* SensingSys = World->GetGameInstance()->GetSubsystem<UAISensingSubsystem>())
		{
			// 소음 위치(내 위치)와 음량 범위(2000cm = 20m) 전달
			SensingSys->ReportNoiseEvent(GetActorLocation(), 2000.0f);
		}
	}
}

 

<아키텍처 핵심 요약>

 

 

SetComponentTickInterval을 통한 Throttling:

플레이어와 먼 거리(30m+)에 있는 몬스터는 틱을 아예 끄지 않고 2Hz(0.5초에 1번)로 고속 연산 스케줄링만 줄여준다. 이를 통해 플레이어 눈에 띄지 않는 곳에서의 CPU 부하를 95% 이상 덜어낸다.

 

소음 발생 시 즉시 틱 인터벌 복구:

평소에는 2Hz로 바보처럼 멍하니 서 있던 먼 거리의 AI라도, 총소리 이벤트(ReportNoiseEvent)를 받는 즉시 SetComponentTickInterval(0.0f)로 틱 주기를 60Hz로 복구하여 민첩하고 자연스러운 AI 반응성을 보장한다.

 

TWeakObjectPtr + RemoveAtSwap 연계:

서브시스템이 수백 마리의 AI 컴포넌트를 들고 있을 때, 가비지 컬렉션(GC)을 방해하지 않도록 TWeakObjectPtr를 사용하며, 몬스터가 죽어 소멸하면 RemoveAtSwap으로 O(1)의 속도로 명부에서 삭제한다.

 

 

몬스터가 500마리가 있든 1,000마리가 있든 CPU 사용률은 거의 변하지 않는다. 몬스터가 매 프레임 눈/귀를 감시하는 무거운 'Pull(폴링)' 방식에서, 소리나 시각적 사건이 터졌을 때만 서브시스템이 정보를 밀어 넣는 'Push(이벤트)' 방식으로 아키텍처의 패러다임을 바꾸는 것이 핵심이다.

 

 

4. 추가 내용 : 소리 외의 다양한 형태의 AI 센싱

 

상업용 게임에서는 단순한 소리(청각) 감지 외에도 다양한 형태의 AI 센싱 및 전술적 감지 기능을 복합적으로 활용하게 된다. 이러한 기능들이 글로벌 AI 센싱 서브시스템(Subsystem)과 결합되면, 기존의 무거운 물리 쿼리(매 프레임 라인트레이스나 오버랩)를 돌리지 않고도 이벤트 기반(Event-Driven)으로 초고속 처리할 수 있게 된다.

센싱 종류 실무 게임 내 활용 예시 글로벌 서브시스템 처리 방식
1. 시각 센싱
(Sight)
플레이어 발견, 동료의 시체/혈흔 발견, 횃불/전등 빛 변화 감지 무거운 3D 라인트레이스를 매 틱 안 돌리고, 공간 격자 1차 필터링 ➔ FOV 각도 2차 필터링 ➔ 쓰로틀링 인터벌에만 Raycast 3단계 처리
2. 피격 센싱
(Damage)
저격 피격 시 피격 방향 역추적, 피격 부위 경계 데미지를 입는 즉시 TakeDamage 이벤트에서 공격자의 위치와 피격 벡터를 주변 서브시스템에 리포트
3. 전술/분대 통신
(Squad Alert)
"적 발견! B지점으로 모여라!", 동료의 경계 신호 공유 한 AI가 플레이어를 완전히 알아채면, 서브시스템을 통해 일정 반경 내 분대원 AI들에게 '동기화 이벤트' 즉시 분사
4. 근접/환경 센싱
(Proximity / Touch)
은신 중 플레이어와 어깨가 부딪힘, 좁은 골목 밀착 감지 캡슐 충돌 대신 가상 해시 격자의 초근접 칸(Self Cell)에 플레이어가 진입했을 때 즉시 트리거

 

이처럼 AI에게 전달되는 자극도 여러가지 종류가 있는데, 이를 관리하는 하나의 통합 이벤트 구조체 FAISenseEventData를 만들어 시각, 청각, 피격, 분대 통신을 모두 중계하는 아키텍처로 확장할 수 있다.

 

1) 자극 데이터 및 이벤트 구조체를 정의한다. (AISensingTypes.h)

#pragma once

#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "AISensingTypes.generated.h"

// AI 자극의 종류
UENUM(BlueprintType)
enum class EAIStimulusType : uint8
{
	Sound,       // 청각 (총소리, 발자국 소리)
	Sight,       // 시각 (플레이어 발견, 시체 발견)
	Damage,      // 피격 (어디선가 총에 맞음)
	SquadAlert   // 분대 통신 ("적이다!" 무전 공유)
};

// 글로벌 센싱 서브시스템을 타고 전달될 통일된 자극 데이터 패킷
USTRUCT(BlueprintType)
struct FAISenseEventData
{
	GENERATED_BODY()

	// 발생한 자극의 종류
	UPROPERTY(BlueprintReadWrite, Category = "AISensing")
	EAIStimulusType StimulusType = EAIStimulusType::Sound;

	// 자극을 일으킨 주체 (예: 플레이어, 시체, 폭발물)
	UPROPERTY(BlueprintReadWrite, Category = "AISensing")
	AActor* Instigator = nullptr;

	// 자극이 발생한 위치 (3D 공간 좌표)
	UPROPERTY(BlueprintReadWrite, Category = "AISensing")
	FVector Location = FVector::ZeroVector;

	// 자극의 영향 범위 (센싱 유효 반경)
	UPROPERTY(BlueprintReadWrite, Category = "AISensing")
	float Radius = 1000.0f;

	// 자극의 세기 (소리 크기, 시각적 강도 등)
	UPROPERTY(BlueprintReadWrite, Category = "AISensing")
	float Intensity = 1.0f;

	// 세부 구분을 위한 태그 (예: Sense.Sight.Corpse, Sense.Sound.Gunshot)
	UPROPERTY(BlueprintReadWrite, Category = "AISensing")
	FGameplayTag SenseTag;
};

 

2) 다중 자극 AI 센싱 컴포넌트 (OptimizedSensingComponent.h / cpp)

// OptimizedSensingComponent.h
#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "AISensingTypes.h"
#include "OptimizedSensingComponent.generated.h"

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class UNREALDESIGNPATTERN_API UOptimizedSensingComponent : public UActorComponent
{
	GENERATED_BODY()

public:	
	UOptimizedSensingComponent();

	virtual void BeginPlay() override;
	virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

	// [통합 이벤트 수신기] 서브시스템이 어떤 자극이든 전달하면 반응
	void OnReceiveSenseEvent(const FAISenseEventData& EventData);

	// 시야 각도 (FOV)
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI|Sight")
	float VisionAngle = 90.0f;

	// 소속 분대 ID (동일 분대원끼리 무전 공유용)
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI|Squad")
	int32 SquadID = 1;

private:
	// 시각 체크(Sight)를 위한 정교한 3단계 가스켓 필터
	bool CheckSightSensory(const FAISenseEventData& EventData);
};
// OptimizedSensingComponent.cpp
#include "OptimizedSensingComponent.h"
#include "GameFramework/Actor.h"
#include "Kismet/GameplayStatics.h"

UOptimizedSensingComponent::UOptimizedSensingComponent()
{
	PrimaryComponentTick.bCanEverTick = true;
}

void UOptimizedSensingComponent::BeginPlay()
{
	Super::BeginPlay();
	SetComponentTickInterval(0.2f); // 평소에는 5Hz로 가볍게 스케줄링
}

void UOptimizedSensingComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
	// 거리 기반 Throttling 업데이트...
}

void UOptimizedSensingComponent::OnReceiveSenseEvent(const FAISenseEventData& EventData)
{
	AActor* Owner = GetOwner();
	if (!IsValid(Owner) || EventData.Instigator == Owner) return; // 자기가 일으킨 자극은 무시

	// 거리 제곱 계산으로 1차 필터링
	float DistSq = FVector::DistSquared(Owner->GetActorLocation(), EventData.Location);
	if (DistSq > EventData.Radius * EventData.Radius) return;

	// [자극 종류에 따른 다형적 AI 행동 분기]
	switch (EventData.StimulusType)
	{
	case EAIStimulusType::Sound:
		UE_LOG(LogTemp, Warning, TEXT("[%s] 소음 감지! 조사하러 이동."), *Owner->GetName());
		SetComponentTickInterval(0.0f); // 반응성 위해 즉시 60Hz 복구
		break;

	case EAIStimulusType::Sight:
		// 시각은 거리뿐만 아니라 '시야 각도(FOV)' 및 '가림막(LineTrace)' 추가 체크
		if (CheckSightSensory(EventData))
		{
			if (EventData.SenseTag.MatchesTag(FGameplayTag::RequestGameplayTag(TEXT("Sense.Sight.Corpse"))))
			{
				UE_LOG(LogTemp, Error, TEXT("[%s] 동료의 시체 발견! 전원 경계 상태 발령!"), *Owner->GetName());
			}
			else
			{
				UE_LOG(LogTemp, Error, TEXT("[%s] 플레이어 직접 목격! 전투 진입!"), *Owner->GetName());
			}
			SetComponentTickInterval(0.0f);
		}
		break;

	case EAIStimulusType::Damage:
		UE_LOG(LogTemp, Error, TEXT("[%s] 피격당함! 공격자 방향(%s)으로 즉시 회전 및 은폐!"), *Owner->GetName(), *EventData.Location.ToString());
		SetComponentTickInterval(0.0f);
		break;

	case EAIStimulusType::SquadAlert:
		// 무전 수신: 같은 분대원일 때만 무전 공유 반영
		UE_LOG(LogTemp, Log, TEXT("[%s] 분대 무전 수신! 적 위치 공유받음."), *Owner->GetName());
		SetComponentTickInterval(0.0f);
		break;
	}
}

bool UOptimizedSensingComponent::CheckSightSensory(const FAISenseEventData& EventData)
{
	AActor* Owner = GetOwner();
	FVector Forward = Owner->GetActorForwardVector();
	FVector DirToTarget = (EventData.Location - Owner->GetActorLocation()).GetSafeNormal();

	// 2차 필터링: 시야각(FOV) 내에 있는지 내적(Dot Product) 연산
	float Dot = FVector::DotProduct(Forward, DirToTarget);
	float AngleDeg = FMath::RadiansToDegrees(FMath::Acos(Dot));

	if (AngleDeg <= VisionAngle * 0.5f)
	{
		// 3차 필터링: 시야각 안에 들어온 '그 순간만' 딱 1번 라인트레이스 쏘아 장애물 검사
		FHitResult HitResult;
		FCollisionQueryParams QueryParams;
		QueryParams.AddIgnoredActor(Owner);

		bool bHit = GetWorld()->LineTraceSingleByChannel(
			HitResult,
			Owner->GetActorLocation() + FVector(0, 0, 60.0f), // 눈 높이
			EventData.Location,
			ECC_Visibility,
			QueryParams
		);

		// 장애물에 걸리지 않았거나 목표물에 직접 맞았으면 시각 감지 성공!
		if (!bHit || HitResult.GetActor() == EventData.Instigator)
		{
			return true;
		}
	}
	return false;
}

 

3) 다중 센서 지원 글로벌 서브시스템 (AISensingSubsystem.h / cpp)

서브시스템은 ReportNoiseEvent뿐만 아니라 모든 자극 이벤트를 받아 공간 격자나 전체 AI 명부에 이벤트 브로드캐스팅(Push)을 수행한다.

// AISensingSubsystem.h
#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "OptimizedSensingComponent.h"
#include "AISensingSubsystem.generated.h"

UCLASS()
class UNREALDESIGNPATTERN_API UAISensingSubsystem : public UGameInstanceSubsystem
{
	GENERATED_BODY()

public:
	void RegisterSensingComponent(UOptimizedSensingComponent* Comp);
	void UnregisterSensingComponent(UOptimizedSensingComponent* Comp);

	// [통합 창구 (Facade)] 게임 월드의 모든 시각/청각/피격/무전 자극 리포트
	UFUNCTION(BlueprintCallable, Category = "AI|Sensing")
	void BroadcastSenseEvent(const FAISenseEventData& EventData);

private:
	TArray<TWeakObjectPtr<UOptimizedSensingComponent>> RegisteredAIList;
};

// AISensingSubsystem.cpp
#include "AISensingSubsystem.h"

void UAISensingSubsystem::RegisterSensingComponent(UOptimizedSensingComponent* Comp)
{
	if (Comp) RegisteredAIList.AddUnique(Comp);
}

void UAISensingSubsystem::UnregisterSensingComponent(UOptimizedSensingComponent* Comp)
{
	RegisteredAIList.Remove(Comp);
}

void UAISensingSubsystem::BroadcastSenseEvent(const FAISenseEventData& EventData)
{
	// 전수 조사를 피하고 싶다면 앞서 배운 '공간 해시 격자'를 통해 EventData.Location 주변 AI만 추출
	for (int32 i = RegisteredAIList.Num() - 1; i >= 0; --i)
	{
		if (RegisteredAIList[i].IsValid())
		{
			RegisteredAIList[i]->OnReceiveSenseEvent(EventData);
		}
		else
		{
			RegisteredAIList.RemoveAtSwap(i);
		}
	}
}

 

4) 실제 게임플레이 도중 다양한 이벤트가 발생했을 때 글로벌 서브시스템 호출하기

// 1. 플레이어가 쏜 총에 맞아 동료 몬스터가 사망했을 때 (시체 발견 이벤트를 월드에 방송)
void AEnemyMonster::Die()
{
	if (UAISensingSubsystem* SensingSys = GetGameInstance()->GetSubsystem<UAISensingSubsystem>())
	{
		FAISenseEventData CorpseEvent;
		CorpseEvent.StimulusType = EAIStimulusType::Sight;
		CorpseEvent.Instigator = this;
		CorpseEvent.Location = GetActorLocation();
		CorpseEvent.Radius = 1500.0f; // 시체를 볼 수 있는 반경 15m
		CorpseEvent.SenseTag = FGameplayTag::RequestGameplayTag(TEXT("Sense.Sight.Corpse"));

		// "여기 내 시체가 있다!" 방송 ➔ 지나가던 주변 AI들이 시야 체크 후 반응
		SensingSys->BroadcastSenseEvent(CorpseEvent);
	}
}

// 2. AI 하나가 플레이어를 확정 발견했을 때 (분대원들에게 무전으로 위치 공유)
void AEnemyMonster::AlertSquadMembers()
{
	if (UAISensingSubsystem* SensingSys = GetGameInstance()->GetSubsystem<UAISensingSubsystem>())
	{
		FAISenseEventData SquadEvent;
		CorpseEvent.StimulusType = EAIStimulusType::SquadAlert;
		SquadEvent.Instigator = this;
		SquadEvent.Location = TargetPlayer->GetActorLocation(); // 플레이어 현재 위치
		SquadEvent.Radius = 3000.0f; // 무전이 닿는 반경 30m

		// 주변 분대원들에게 플레이어 위치 즉시 전달!
		SensingSys->BroadcastSenseEvent(SquadEvent);
	}
}

<2. 라이라 파먹기 : Config 폴더 마무리>

 

1. DefaultEditorPerProjectUserSettings.ini

 

역할: 팀원들이 에디터를 처음 열 때 공통으로 적용되는 '에디터 편의성 및 디버그 환경' 설정

[/Script/UnrealEd.LevelEditorPlaySettings]
GameGetsMouseControl=True
RouteGamepadToSecondWindow=True
NetworkEmulationSettings=(bIsNetworkEmulationEnabled=True,EmulationTarget=Server,CurrentProfile="Average",OutPackets=(MinLatency=30,MaxLatency=60,PacketLossPercentage=1),...)

 

GameGetsMouseControl=True: PIE(에디터 내 플레이) 실행 시 클릭을 한 번 더 하지 않아도 마우스 포커스가 즉시 게임 내부로 들어가게 만든다.

 

NetworkEmulationSettings: PIE 테스트 시 지연 시간(30~60ms 렉)과 패킷 손실률(1% Packet Loss)을 가상으로 주입합니다. 로컬 테스트 중에도 실제 온라인 환경처럼 멀티플레이 동기화가 잘 일어나는지 검증하기 위한 에픽의 철저한 테스트 세팅이다.

 

 

[/Script/UnrealEd.ContentBrowserSettings]
FavoritePaths=/LyraGame/Content,/ShooterCore,/ShooterMaps,/ShooterTests,/TopDownArena

콘텐츠 브라우저 즐겨찾기(Favorites): 에디터 좌측 콘텐츠 브라우저에 자주 쓰는 핵심 폴더(메인 게임, 플러그인 폴더들)를 미리 핀으로 고정해 두어 에셋을 찾으러 폴더를 헤매지 않게 돕는다.

 

[/Script/LyraGame.LyraDeveloperSettings]
+CommonEditorMaps=/Game/System/FrontEnd/Maps/L_LyraFrontEnd.L_LyraFrontEnd
+CommonEditorMaps=/ShooterMaps/Maps/L_Expanse.L_Expanse

빠른 맵 전환 툴바: 에디터 상단 툴바에 개발자가 자주 들어가는 테스트 맵(FrontEnd, Gym 맵 등)을 버튼 하나로 바로 열 수 있게 단축 메뉴로 등록.


2. DefaultEditorSettings.ini

 

역할: 에디터 인터페이스의 전역 노출 여부 설정

[/Script/UnrealEd.ContentBrowserSettings]
DisplayPluginFolders=True
DisplayCppFolders=False

 

DisplayCppFolders=False: 콘텐츠 브라우저에 복잡한 C++ 소스코드 폴더(C++ Classes)를 숨겨두어 기획자나 아티스트가 실수로 C++ 폴더를 건드리지 않게 하고, 에셋 관리를 깔끔하게 유지한다.

 

DisplayPluginFolders=True: 라이라의 핵심인 GameFeatures 모듈형 플러그인 Content 폴더는 노출시킨다.

 


3. DefaultEditorKeyBindings.ini

 

역할: 팀원 전체가 공유하는 '에디터 전용 단축키' 설정

 

UserDefinedChords=... BindingContext: PlayWorld, CommandName: StopPlaySession, Key: Escape, Shift: true ...

Shift + Escape: PIE(에디터 플레이) 도중 게임을 즉시 종료하는 단축키를 Shift + Esc로 묶어둔 설정. 개발 중 빠른 테스트 탈출을 돕는다.


4. DefaultInput.ini (가장 중요한 핵심 파일)

 

역할: 디버그 실행 키, 커서 캡처 모드, CommonUI 액션 태그 매핑, 입력 키 변경 프로필 연결

 

① 디버그/치트 실행 바인딩 (+DebugExecBindings)

개발 및 테스트 시 키보드 단축키 하나로 C++ 디버그 명령을 실행한다.

+DebugExecBindings=(Key=Insert,Command="Cheat Teleport")
+DebugExecBindings=(Key=End,Command="CycleAbilitySystemDebug")
+DebugExecBindings=(Key=K,Command="DamageSelfDestruct")
+DebugExecBindings=(Key=NumPadFour,Command="CancelActivatedAbilities")

 

End (GAS 디버거): CycleAbilitySystemDebug를 호출하여 현재 캐릭터에게 붙은 GameplayTag, Active Effects, Attribute(체력/마나 등)를 화면 상단에 실시간 패널로 띄운다.

 

K (Suicide): DamageSelfDestruct 명령으로 즉시 사망 이벤트를 발생시켜 리스폰 로직을 테스트한다.

 

NumPad 4 (어빌리티 취소): 현재 발동 중인 모든 GAS 스킬을 강제로 취소한다.

 

 

② 마우스 커서 및 뷰포트 캡처 통제 ([/Script/Engine.InputSettings])

DefaultViewportMouseCaptureMode=CapturePermanently_IncludingInitialMouseDown
DefaultViewportMouseLockMode=LockOnCapture
DefaultPlayerInputClass=/Script/LyraGame.LyraPlayerInput
DefaultInputComponentClass=/Script/LyraGame.LyraInputComponent

LyraPlayerInput & LyraInputComponent: 엔진 기본 입력 컴포넌트 대신 라이라 커스텀 C++ 클래스를 지정하여 향상된 입력(Enhanced Input)과 키 리매핑(Key Binding) 기능을 확장한다.

 

③ CommonUI UI 액션 바인딩 & 아날로그 커서 ([/Script/CommonUI.CommonUIInputSettings])

+InputActions=(ActionTag=UI.Action.Escape,KeyMappings=((Key=Escape),(Key=Gamepad_Special_Right)))
AnalogCursorSettings=(CursorAcceleration=1500.000000,CursorMaxSpeed=2200.000000,...)

 

UI.Action.Escape: UI 상에서 뒤로 가기 / 닫기 동작을 수행할 UI.Action.Escape 태그에 키보드 Esc와 게임패드 Special_Right(Start/Menu) 버튼을 1:1 매핑해 둔다.

 

AnalogCursorSettings: 게임패드(조이스틱)로 UI 메뉴를 조작할 때, 스틱을 가상 마우스 커서처럼 부드럽게 움직이게 만드는 아날로그 커서 가속도 및 감도 설정이다.

 

 

④ 키 리매핑(Key Rebinding) 프로필 지정 ([/Script/EnhancedInput...])

[/Script/EnhancedInput.EnhancedInputDeveloperSettings]
bEnableUserSettings=True
UserSettingsClass=/Script/LyraGame.LyraInputUserSettings

LyraInputUserSettings: 유저가 인게임 설정 창에서 W, A, S, D나 스킬 키를 자신의 입맛대로 변경했을 때, 해당 키 설정 값을 저장하고 관리하는 커스텀 C++ 세팅 클래스를 활성화한다.


5. DefaultRuntimeOptions.ini

 

역할: 게임 실행 중 C++ 코드를 재컴파일하지 않고 특정 피처(Feature)나 모드를 동적으로 끄고 켜는 '런타임 옵션 스위치'

[/Script/LyraGame.LyraRuntimeOptons]
;bDisableSomething=true

개발 중 테스트 목적이나 라이브 서비스 중 특정 기능에 버그가 생겼을 때, C++ 클래스 내 변수를 .ini 수정만으로 런타임에 켜고 끌 수 있게 통로를 열어두는 클래스이다.


6. DefaultScalability.ini (성능 최적화의 핵심)

 

역할: 그래픽 옵션 단계(낮음/중간/높음/최고/Cine)에 따른 '엔진 렌더링 수치 재정의'

언리얼 엔진의 기본 Scalability 수치를 라이라 프로젝트에 맞춰 커스텀 오버라이드해 둔 파일이다.

; FoliageQuality(식생 품질) 그룹을 끄고 ViewDistanceQuality(조망 거리)로 통합
[FoliageQuality@0]
-foliage.DensityScale=0
-grass.DensityScale=0

[ViewDistanceQuality@0]
foliage.DensityScale=0
grass.DensityScale=0

 

식생(Foliage) 최적화 노하우: 라이라는 옵션 메뉴의 'Foliage' 항목을 독립적으로 돌리지 않고, 시야 거리(View Distance) 옵션 수준에 식생 밀도 스케일을 종속시켜 계산 부하를 줄였다.

[GlobalIlluminationQuality@1]
r.DistanceFieldAO=1 ; Lumen이 꺼지는 '중간(1)' 옵션에서는 DFAO(디스턴스 필드 앰비언트 오클루전)를 대체 활성화

[ReflectionQuality@2]
r.Lumen.Reflections.Allow=1
r.Lumen.Reflections.DownsampleFactor=2 ; '높음(2)' 옵션에서는 Lumen 반사 해상도를 1/2로 다운샘플링하여 성능 확보

 

Lumen 동적 가성비 조절: 사양 옵션에 따라 Lumen을 끌지(0, 1), Lumen을 켜되 해상도를 줄여 성능을 아낄지(2, 3), 최고 화질로 켤지(Cine)를 세밀하게 분기해 두었다.

 

<Config 폴더> 핵심 정리

 

DefaultEngine.ini: C++ 핵심 매니저 교체 및 커스텀 콜리전 채널

DefaultGame.ini: GAS 실패 태그, AssetManager 에셋 자동 스캔, CommonUI 및 오디오

DefaultEditor.ini: BP 생성 시 C++ 상속 강제, UMG 규칙, Localization 수집

DefaultGameplayTags.ini: 입력, 스킬, UI 레이아웃, 메시지 통신의 핵심 태그 레지스트리

남은 6개 .ini: 개발 생산성, 키 바인딩/디버그, 런타임 스위치 및 사양별 스케일러빌리티


*개인 프로젝트 및 개인 공부*

다시 처음부터 프로젝트의 베이스 작업을 시작해보자. 무엇이 중요할지 그동안 깨달은 것들을 집약해서 채워나가려고 한다.

 

(작업내용)


Git 세팅, 기획서 재작성 및 보완, 시퀀스 다이어그램, 게임 루프 구체화, 아이디어 노트 만들기, 기능 및 역할 분담 시트 작성, 작업 목록 및 QA 리스트 작성, 여러 데이터셋 준비, 폴더 미리 준비, 텍스트 구글드라이브 연동하고 기존꺼는 D드라이브로 옮기고 C드라이브에서는 삭제, 안정적인 테스트 환경(ex. bat파일)구축


*오늘의 총평*

프로젝트가 끝나고 팀원들과 함께 프로젝트 리뷰를 진행했고, 개인 프로젝트 회고록도 꼼꼼하게 작성했다. 오랜만에 게임 수학 공부와 언리얼 C++ 디자인 패턴 학습을 진행했고, 개인 학습 프로젝트로 '라이라 스타터 게임 파먹기'를 진행했다.

추가로 개인 프로젝트를 처음부터 진행했다.

 

 

'TIL' 카테고리의 다른 글

07.29 TIL  (0) 2026.07.29
07.28 TIL  (0) 2026.07.28
07.24 TIL  (0) 2026.07.24
07.23 TIL  (0) 2026.07.23
07.22 TIL  (0) 2026.07.22