TIL
06.17 TIL
2026. 6. 17. 19:12

일정표

시간 할 일 비고
08:00~10:00 코드카타, Atanny, 프로그래머스  
10:00~13:00 오늘의 수학, 언리얼 심화반 2강, 프로그래머스  
14:00~18:00 라이라 파먹기  
19:00~21:00 언리얼 따라하기, TIL  
21:00~22:00 운동  
22:00~23:30 개인작업  

*오늘의 코드카타*

 

문제. 최대공약수와 최소공배수

더보기

두 수를 입력받아 두 수의 최대공약수와 최소공배수를 반환하는 함수, solution을 완성해 보세요. 배열의 맨 앞에 최대공약수, 그다음 최소공배수를 넣어 반환하면 됩니다. 예를 들어 두 수 3, 12의 최대공약수는 3, 최소공배수는 12이므로 solution(3, 12)는 [3, 12]를 반환해야 합니다

#include <vector>
#include <algorithm>

using namespace std;

vector<int> solution(int n, int m) {
    vector<int> answer;
    
    int A = max(n,m);
    int B = min(n,m);
    
    for (int i = B; i >= 1; --i) {
        if (A % i == 0 && B % i == 0) {
            answer.emplace_back(i);
            break;
        }
    }
    
    for (int j = A; ; ++j) {
        if (j % A == 0 && j % B == 0) {
            answer.emplace_back(j);
            break;
        }
    }
    
    return answer;
}

(1) for 문 안에서 j 의 범위를 비워놓고 세미 콜론만 남겨 놓아 값을 찾기 전까지 무한으로 반복문이 실행되도록 했다. A와 B는 전역변수로 n,m 중에서 더 큰 값과 작은 값을 분류해 놓기 위해서 알고리즘 헤더의 max, min을 사용했다.

#include <vector>

using namespace std;

int getGCD(int a, int b) {
    if (b == 0) return a;
    return getGCD(b, a % b);
}

vector<int> solution(int n, int m) {
    vector<int> answer;
    answer.reserve(2);
    
    int gcd = getGCD(n, m);
    int lcm = (n * m) / gcd;
    
    answer.emplace_back(gcd);
    answer.emplace_back(lcm);
    
    return answer;
}

(2) 유클리드 호제법

반복문을 1씩 증감시키는 방법은 유용하지만 사실 실행 시간이 오래 걸릴 위험성이 잇다. 최대공약수/최소공배수를 더 빠르게 구하는 방법인 유클리드 호제법을 사용해보자.

최대공약수(GCD, Greatest Common Divisor) : 두 수 a와 b가 있고 a > b 이면, a를 b로 나눈 나머지를 r이라고 하면 GCD(a, b) == GCD(b, r) 이다. 따라서, 나머지가 0이 될 때 나누는 수가 최대공약수이다.

최소공배수(LCM, Least Common Multiple) : 두 수의 곱을 최대공약수로 나누면 바로 최소공배수가 나온다. LCM = (n * m) / GCD


문제. 최대공약수와 최소공배수

더보기

자연수 n이 매개변수로 주어집니다. n을 3진법 상에서 앞뒤로 뒤집은 후, 이를 다시 10진법으로 표현한 수를 return 하도록 solution 함수를 완성해주세요

#include <vector>

using namespace std;

int solution(int n) {
    int answer = 0;
    vector<int> temp;
    
    while (n > 0) {
        temp.emplace_back(n % 3);
        n /= 3;
    }
    
    for (int digit : temp) {
        answer = (answer * 3) + digit;
    }
    
    return answer;
}

10진법 수를 n진법으로 만들고 싶다면 더이상 나머지가 0이 나오지 않을 때 까지 n으로 나눠주면 된다.

그러면 해당 n진법 숫자배열대로 수가 나오게 되는데, 이를 배열에 저장해둔다.

꺼낼때 거꾸로 꺼내면 된다. 이때, 거꾸로 꺼내면서 다시 10진법으로 변환하기 위해서 배열 앞에서부터 읽으면서 기존 누적값에 3을 곱하고 새 숫자를 더해준다. 그러면 자연스럽게 가장 큰 수에 3을 여러번 곱하면서 3진법 수를 10진법으로 간단하게 바꿀 수 있다.


문제. 이상한 문자 만들기

더보기

문자열 s는 한 개 이상의 단어로 구성되어 있습니다. 각 단어는 하나 이상의 공백문자로 구분되어 있습니다. 각 단어의 짝수번째 알파벳은 대문자로, 홀수번째 알파벳은 소문자로 바꾼 문자열을 리턴하는 함수, solution을 완성하세요

#include <string>
#include <cctype>

using namespace std;

string solution(string s) {
    string answer = "";
    
    for (int i = 0; i < s.length(); ++i) 
    {
        if (i % 2 == 0)
        {
            if (islower(s[i]))
            {
                answer += toupper(s[i]);
            }
            else 
            {
                answer += s[i];
            }
                
        }
        else 
        {
            if (isupper(s[i]))
            {
                answer += tolower(s[i]);
            }
            else
            {
                answer += s[i];
            }
        }
        
    }
    
    return answer;
}

처음엔 이런식으로 풀었는데, 이렇게 풀게 되면 공백을 무시하고 for문의 변수인 i를 기준으로 i % 2를 계산하게 되어 공백 이후에 나오는 새로운 단어의 첫 글자가 짝수번째인지 홀수 번째인지 정확히 판별할 수 없게 된다. 게다가 <cctype>헤더의 toupper와 tolower함수는 이미 대문자이거나 이미 소문자인 경우 변환하지 않고 그대로 반환한다. 따라서, 다음과 같이 수정했다.

#include <string>
#include <cctype>

using namespace std;

string solution(string s) {
    string answer = "";
    int word_idx = 0;
    
    for (int i = 0; i < s.length(); ++i) {
        if (s[i] == ' ') {
            answer += ' ';
            word_idx = 0;
        } 
        else 
        {
            if (word_idx % 2 == 0) {
                answer += toupper(s[i]);
            } else {
                answer += tolower(s[i]);
            }
            word_idx++;
        }
    }
    
    return answer;
}

공백을 구분할 수 있도록 별도로 조건문을 만들고, 공백이 나올 때마다 word_idx = 0 으로 만들어주는 것이 포인트.

#include <string>
#include <cctype>

using namespace std;

string solution(string s) {
    int word_idx = 0;

    for (char& c : s) {
        if (c == ' ') {
            word_idx = 0;
        } else {
            c = (word_idx % 2 == 0) ? toupper(c) : tolower(c);
            word_idx++;
        }
    }
    
    return s;
}

참조자와 삼항연산자를 사용하는 방법도 연습했다.


*프로그래머스*

1. [입문] 숫자 찾기

더보기

정수 num과 k가 매개변수로 주어질 때, num을 이루는 숫자 중에 k가 있으면 num의 그 숫자가 있는 자리 수를 return하고 없으면 -1을 return 하도록 solution 함수를 완성해보세요

num에 k가 여러 개 있으면 가장 처음 나타나는 자리를 return 합니다

#include <string>

using namespace std;

int solution(int num, int k) {
    int answer = 0;
    
    string str_num = to_string(num);
    char char_k = k + '0';
    
    auto pos = str_num.find(char_k);
    
    if (pos != string::npos)
    {
        return pos + 1;
    }
    else 
    {
        return -1;
    }
}

(1) 숫자를 문자열로 변환한 뒤에, k 또한 문자(char)로 변환한 뒤에, 문자열 사이에서 k를 찾는다.

k가 없다면 -1를 반환하고, k가 있다면 pos + 1; 을 반환한다. pos는 position의 준말로, 인덱스 값을 의미하므로 +1을 해주어야 한다.

 

 

2. [입문] n의 배수 고르기

더보기

정수 n과 정수 배열 numlist가 매개변수로 주어질 때, numlist에서 n의 배수가 아닌 수들을 제거한 배열을 return하도록 solution 함수를 완성해주세요

#include <string>
#include <vector>

using namespace std;

vector<int> solution(int n, vector<int> numlist) {
    vector<int> answer;
    
    for (int num : numlist)
    {
        if (num % n == 0)
        {
            answer.emplace_back(num);
        }
    }
    return answer;
}

 

#include <vector>
#include <algorithm>

using namespace std;

vector<int> solution(int n, vector<int> numlist) {
    vector<int> answer;
    
    copy_if(numlist.begin(), numlist.end(), back_inserter(answer), 
            [n](int num) { return num % n == 0; });
            
    return answer;
}

알고리즘 헤더의 copy_if와 람다식으로 조건을 설정해서 해당하는 값만 복사해오는 방법. back_inserter를 사용해서 C++ 스럽게 코드를 작성하는 방법이다. 조건을 더 명확하게 확인할 수 있어서 직관적이고 가독성을 올릴 수 있는 방법이다.

 

3. [입문] 자릿수 더하기

더보기

정수 n이 매개변수로 주어질 때 n의 각 자리 숫자의 합을 return하도록 solution 함수를 완성해주세요

#include <string>

using namespace std;

int solution(int n) {
    int answer = 0;
    
    while (n > 0)
    {
        answer += (n % 10);
        n /= 10;
    }
    
    return answer;
}

(설명)

 

4. [입문] OX퀴즈

더보기

덧셈, 뺄셈 수식들이 'X [연산자] Y = Z' 형태로 들어있는 문자열 배열 quiz가 매개변수로 주어집니다. 수식이 옳다면 "O"를 틀리다면 "X"를 순서대로 담은 배열을 return하도록 solution 함수를 완성해주세요.

연산 기호와 숫자 사이는 항상 하나의 공백이 존재합니다. 단 음수를 표시하는 마이너스 기호와 숫자 사이에는 공백이 존재하지 않습니다

X, Y, Z는 각각 0부터 9까지 숫자로 이루어진 정수를 의미하며, 각 숫자의 맨 앞에 마이너스 기호가 하나 있을 수 있고 이는 음수를 의미합니다

[연산자]는 + 와 - 중 하나입니다

X, Y, Z는 0을 제외하고는 0으로 시작하지 않습니다

#include <string>
#include <vector>
#include <sstream>

using namespace std;

vector<string> solution(vector<string> quiz) {
    vector<string> answer;
    
    answer.reserve(quiz.size());
    
    for (const string& q : quiz)
    {
        stringstream ss(q);
        int X, Y, Z;
        char op, eq;
        
        ss >> X >> op >> Y >> eq >> Z;
        
        int result = 0;
        
        if (op == '+')
        {
            result = X + Y;
        }
        else if (op == '-')
        {
            result = X - Y;
        }
        
        if (result == Z)
        {
            answer.emplace_back("O");
        }
        else 
        {
            answer.emplace_back("X");
        }
    }
    
    return answer;
}

stringstream과 여러 변수를 선언하고, 조건식을 조립하는 느낌으로 풀었다. stringstream은 여러번 써먹어도 쓸 때마다 머뭇거리면서 코드를 작성하게 되는데, 암기할 건 제대로 암기하고 여러번 사용하면서 숙달해보자.

 

5. [입문] 문자열안에 문자열

더보기

문자열 str1str2가 매개변수로 주어집니다. str1 안에 str2가 있다면 1을 없다면 2를 return하도록 solution 함수를 완성해주세요

#include <string>

using namespace std;

int solution(string str1, string str2) {
    int answer = 0;
    
    if (str1.find(str2) != string::npos)
    {
        answer = 1;
    }
    else 
    {
        answer = 2;    
    }
    return answer;
}

(1) string::find를 사용한 방법. find는 true, false를 반환하는 것이 아니라 해당 문자 또는 문자열이 시작하는 인덱스 값을 반환한다. string::npos는 찾지 못했을 때 반환하는 특수 상수이다. 따라서, 위와 같이 작성했다. 이때 주의할 점은 string::find 와 std::find를 잘 구분해서 사용해야 한다는 것이다. std::find는 일반적으로 문자 하나만 찾을 때 사용한다.

#include <string>

using namespace std;

int solution(string str1, string str2) {
    return (str1.find(str2) != string::npos) ? 1 : 2;
}

(2) 삼항 연산자를 사용해서 푸는 방법도 연습을 열심히 하자.

 

그럼 이제 좀 더 변형해서, find를 활용한 문자열 parsing을 연습해보자.

C++에는 특정 부분만 추출할 수 있는 substr() 내장 함수가 있다. 여기에 시작 인덱스, 뽑아올 길이를 주면 특정 부분을 추출해낼 수 있다.

#include <iostream>
#include <string>

using namespace std;

int main() {
    string str1 = "Hello, Programmer!;
    string str2 = "Programmer";
    
    size_t pos = str1.find(str2);
    
    if (pos != string::npos) 
    {
        string result = str1.substr(pos, str2.length());
        
        cout << "시작 인덱스: " << pos << "\n";
        cout << "뽑아온 문자열: " << result << "\n";
    }
    else 
    {
        cout << "문자열을 찾을 수 없습니다." << "\n";
    }

    return 0;
}

 

 

6. [입문] 제곱수 판별하기

더보기

어떤 자연수를 제곱했을 때 나오는 정수를 제곱수라고 합니다. 정수 n이 매개변수로 주어질 때, n이 제곱수라면 1을 아니라면 2를 return하도록 solution 함수를 완성해주세요

#include <cmath>

using namespace std;

int solution(int n) {
    int root = sqrt(n);
    
    if (root * root == n) {
        return 1;
    }
    return 2;
}

(1) for 반복문으로 풀면 long long을 굳이 써야하기도 하고, n 이 커질수록 계산이 오래걸린다는 점에서 사용하지 않고 <cmath>헤더의 sqrt() 함수를 사용해서 제곱근을 구하고, 해당 제곱근을 제곱했을 때 n이면 1을 반환하게 했다. 이때 root를 int 자료형으로 선언해서, sqrt(n)이 root 제곱근으로 딱 떨어지지 않으면 어차피 if 조건문에서 조건을 만족하지 못하게 할 수 있다.

using namespace std;

int solution(int n) {
    long long left = 1;
    long long right = n;
    
    while (left <= right)
    {
        long long mid = (left + right) / 2;
        long long square = mid * mid;
        
        if (square == n)
        {
            return 1;
        }
        else if (square < n)
        {
            left = mid + 1;
        }
        else
        {
            right = mid - 1;
        }
    }
    return 2;   
}

(2) 만약 for문도 아니고 sqrt도 안 쓰고 싶다면 왼쪽 끝과 오른쪽 끝에서 시작해서 가운데로 좁혀나가는 이진 탐색법을 사용할 수 있다. 맨 끝 두 수를 잡고 중간값을 구하고, 중간값을 제곱했을 때의 수를 square라고 한다.

만약 square가 n보다 작다면, 왼쪽 끝 값을 mid + 1 으로 세팅해준다.

square가 n보다 크다면, 오른쪽 끝 값을 mid - 1 으로 세팅해준다.

이런식으로 값을 점점 좁혀나가다가, square == n 이되면 1을 반환한다.

 

 

7. [입문] 세균 증식

더보기

어떤 세균은 1시간에 두배만큼 증식한다고 합니다. 처음 세균의 마리수 n과 경과한 시간 t가 매개변수로 주어질 때 t시간 후 세균의 수를 return하도록 solution 함수를 완성해주세요

#include <cmath>

using namespace std;

int solution(int n, int t) {
    return n * pow(2, t);
}

(1) cmath 헤더의 pow를 사용해서 간단하게 풀어보았다.

하지만 이렇게 풀면 문제점이 존재한다.

1. pow 함수는 기본적으로 double 연산을 수행하도록 설계되어있다. 따라서 컴퓨터가 처리하는 데 시간이 더 걸리고, 숫자가 매우 커질 경우 미세한 소수점 오차로 인해 잘못된 정수값이 반환될 위험이 있다.

 

C++를 깊게 다룰 줄 안다면 2를 곱할 때 숨쉬듯이 사용하는 문법이 바로 "비트 시프트" 연산자이다.

using namespace std;

int solution(int n, int t) {
    return n << t;
}

(2) 비트 시프트 연산자 사용하기

이게 대체 뭔소리일까?

컴퓨터는 모든 데이터를 0과 1의 이진수로 저장하는데, << 연산자는 이진수의 자리 자체를 왼쪽으로 미는 역할을 한다. 결국, << 연산자로 한번 왼쪽으로 이진수를 밀면, 2를 곱하는 것과 같게 된다.

그러면 n << t : n의 이진수 비트를 왼쪽으로 t 칸 밀어내라. 즉, n * 2^t 와 동일하다.

매우 자주 사용되는 논리이니 잘 공부해두자.

using namespace std;

int solution(int n, int t) {
    return n << (2 * t);
}

(3) 연습삼아서 4배씩 증식하는 경우를 코드로 작성해보았다.

 

 

8. [입문] 문자열 정렬하기 (2)

더보기

영어 대소문자로 이루어진 문자열 my_string이 매개변수로 주어질 때, my_string을 모두 소문자로 바꾸고 알파벳 순서대로 정렬한 문자열을 return 하도록 solution 함수를 완성해보세요.

#include <string>
#include <algorithm>

using namespace std;

string solution(string my_string) {
    string answer = "";
    
    for (char c : my_string)
    {
        answer += tolower(c);
    }
    
    sort(answer.begin(), answer.end());
    
    return answer;
}

(1) 나의 풀이

 

#include <string>
#include <algorithm>

using namespace std;

string solution(string my_string) {
    
    transform(my_string.begin(), my_string.end(),
             my_string.begin(), ::tolower);
    
    sort(my_string.begin(), my_string.end());
    
    return my_string;
}

(2) transform 함수를 사용한 풀이. my_string의 처음부터 ::tolower 함수를 적용하여 덮어 씌운 뒤, sort로 오름차순 정렬을 하는 방식이다.


*Atanny*

주제 : 언리얼 C++ 입문(복습)

 

1. 언리얼 C++ 빌드 프로세스 이해

 

핵심 개념 

빌드는 컴파일 - 링크 - 실행 파일 생성 순으로 진행된다. 컴파일은 각 .cpp 파일을 오브젝트 파일 (.obj)파일로 변환하는 과정이다. 링크는 여러 오브젝트 파일들을 모아 하나의 실행 파일이나 DLL로 연결하는 과정이다.

 

2. Actor 생명주기와 컴포넌트 구조 이해

 

핵심 개념 

Actor의 생명주기 함수는 Cunstructor(생성자) - BeginPlay - Tick - Endplay 순으로 실행된다.

생성자에서는 Actor의 기본 속성을 초기화하고, BeginPlay에서는 게임 로직을 위한 준비 작업(참조 설정, 시작 값 설정 등)을 하며, Tick은 게임 실행 중 매 프레임마다 반복적으로 호출되어 움직임, 상태 업데이트 등 계속해서 진행되어야 하는 작업들이 이루어진다. 마지막으로 EndPlay는 Actor가 제거될 때 가장 마지막으로 호출되며 메모리 해제, 리소스 정리 같은 정리 작업을 여기에서 처리한다.

 

3. 언리얼 리플렉션 시스템 이해

 

핵심 개념 

리플렉션 시스템은 런타임에 클래스, 프로퍼티, 함수 등의 메타데이터에 접근할 수 있게 해주고,

가비지 컬렉션, 시리얼라이제이션, 네트워크 리플리케이션 등의 기능을 지원한다.

블루프린트와 C++간의 상호운용성을 가능하게 한다.

 

리플렉션 시스템은 명시적 매크로 선언에만 작동한다. 에디터 연동과 데이터 관리(가비지 컬렉션, 에디터 편집, 저장/로드)를 자동화한다.

 

4. 블루프린트와 C++ 연동 방식 이해

 

핵심 개념 

블루프린트와 C++은 "양방향 연동"이 가능하다.

C++은 복잡한 로직과 성능이 필요한 부분을, 블루프린트는 빠른 개발과 시각적 구현에 사용한다. 개발 속도와 성능을 동시에 확보할 수 있도록 유연하게 사용하자.

블루프린트로 만든 이벤트를 C++코드에서 BlueprintImplemetableEvent나 BlueprintNativeEvent 같은 매크로를 활용해서 C++ 코드에서 호출할 수도 있다.


*수학 공부*

주제(1) : 수치 적분 (미적분과 물리 심화)

 

1. 오일러 적분의 한계

위치 += 속도 * dt, 속도 += 가속도 * dt 공식을 오일러 적분이라고 했고 이를 활용해서 C++ 코드까지 구현해보았다. 다만 오일러 적분에는 치명적인 결함이 있는데, 바로 '오차가 누적'된다는 것이다.

 

오일러 적분은 1프레임 동안엔는 속도와 가속도가 변하지 않고 일정할 것이라고 가정한 상태에서 직선으로 뻗어 나가는 방식이다. 하지만, 현실에서는 실시간으로 받는 힘의 방향과 크기가 쉴 새 없이 변한다. 즉, 직선 운동이 아니라 곡선 운동이라는 뜻이다. 따라서, 오일러 적분으로 용수철(훅의 법칙)이나 행성 궤도를 시뮬레이션하면 컴퓨터가 곡선을 직선들로 억지로 쪼개서 계산하는 과정에서 오차가 계속 창조된다. 결과적으로 용수철은 시간이 지날 수록 크게 요동치다가 밖으로 튕겨 나가버리게 된다. 

 

이 결함을 상용 물리 엔진에서 어떻게 보완해냈는지 살펴보자.

 

2. 베를레 적분(Verlet Integration)

베를레 적분은 게임 물리 엔진에서 가장 자주 사용되는 적분법이다. 특징이라면 속도 변수를 아예 사용하지 않는다는 것이다. 대신, 현재 위치이전 프레임의 위치 단 두개만 사용한다.

 

수학적 공식 :

더보기
새로운 위치 = 현재 위치 + (현재 위치 - 과거 위치) + 가속도 * 시간의 제곱)

베를레 적분은 속도 오차가 누적되지 않아 시뮬레이션이 아무리 오래 돌아가도 절대 에너지가 폭발하지 않고 안정성을 유지할 수 있게 해준다.

 

(이용 사례 : 밧줄 노드 시뮬레이션)

void AMyRopeActor::UpdateVerletNode(FVector& CurrentPos, FVector& OldPos, FVector Acceleration, float DeltaTime)
{
    // 관성에 의한 이동량 (현재 위치 - 이전 위치) = 사실상 '속도'의 역할을 함
    FVector Velocity = CurrentPos - OldPos;

    // 다음 위치 계산 (베를레 공식)
    FVector NextPos = CurrentPos + Velocity + (Acceleration * DeltaTime * DeltaTime);

    // 상태 갱신 (현재 위치가 과거 위치가 됨)
    OldPos = CurrentPos;
    CurrentPos = NextPos;
    
    // 이후에 밧줄의 길이를 유지하는 '제약 조건(Constraint)' 코드를 추가하면 됨
}

 

3. 룬게-쿠타 4계 (룬게-쿠타 4차, Runge-Kutta 4th Order, RK4)

이게 무슨 룬 문자인가 싶지만, 그냥 미분방정식을 풀 때 컴퓨터를 이용해 사용하는 대표적인 수치해석 방법이라고 이해하면 된다.

 

수학적 원리 : 현재 상태(1프레임)에서 4번의 기울기(k1, k2, k3, k4)를 구한 뒤, 이에 적절한 가중치를 곱해 평균 기울기를 계산하여 다음 단계의 값을 예측하는 것이다.

4번이나 기울기를 구하는 만큼, 미분방정식을 다루는 방법에서는 정밀도의 끝판왕이라고 생각할 수 있다.

 

다시 말해,

k1 = 현재 위치에서의 가속도

k2 = k1을 바탕으로 0.5프레임 미래의 가속도

k3 = k2를 바탕으로 0.5프레임 미래의 가속도

k4 = k3를 바탕으로 1프레임 끝까지 나아간 곳의 가속도

 

우주 비행(커벌 스페이스 프로그램), 플라이트 시뮬레이터, 하드코어 레이싱 게임 처럼 매우 정밀한 정확도를 요구하는 궤적 시뮬레이션이 필요할 때 사용한다.

 

다만, 매 프레임 계산량이 오일러 적분의 4배나 되기 때문에 무수히 많은 파티클에는 쓰지 못하고 핵심 오브젝트에만 제한적으로 사용해야 한다는 단점이 있다.

 

(이용 사례 : 우주선 궤도 예측 시뮬레이션)

// 가속도를 계산하는 별도의 물리 함수가 있다고 가정 (예: 중력 공식 등)
FVector GetAccelerationAt(FVector Position, FVector Velocity);

void AMySpaceShip::UpdateRK4(float dt)
{
    FVector P = GetActorLocation();
    FVector V = CurrentVelocity;

    // K1: 프레임 시작점의 샘플
    FVector v1 = V;
    FVector a1 = GetAccelerationAt(P, v1);

    // K2: 중간 지점(0.5 dt)의 샘플
    FVector v2 = V + (a1 * 0.5f * dt);
    FVector a2 = GetAccelerationAt(P + (v1 * 0.5f * dt), v2);

    // K3: K2를 바탕으로 한 중간 지점(0.5 dt)의 재샘플링
    FVector v3 = V + (a2 * 0.5f * dt);
    FVector a3 = GetAccelerationAt(P + (v2 * 0.5f * dt), v3);

    // K4: 프레임 끝(1.0 dt)의 샘플
    FVector v4 = V + (a3 * dt);
    FVector a4 = GetAccelerationAt(P + (v3 * dt), v4);

    // 가중 평균을 통한 최종 속도와 위치 적용 (K2와 K3에 2배의 가중치)
    FVector FinalVelocity = V + (dt / 6.0f) * (a1 + 2.0f*a2 + 2.0f*a3 + a4);
    FVector FinalPosition = P + (dt / 6.0f) * (v1 + 2.0f*v2 + 2.0f*v3 + v4);

    SetActorLocation(FinalPosition);
    CurrentVelocity = FinalVelocity;
}

 

 

4. 실무에서 사용하기

오일러 적분 : 캐릭터 이동, 파티클, 단순 탄도학 등의 빠르지만 정밀하지 않아도 되는 가벼운 연산에 사용하면 된다.

베를레 적분 : 헝겊, 머리카락, 밧줄, 랙돌 관절 등 절대 터지거나 폭발하면 안 되는 유연한 물체를 구현할 때 사용하면 된다.

RK4 : 우주선 궤도, 정밀한 물리 연산 등, 연산이 무거워도 현실과 매우 유사하게 구현하고 싶을 때 사용하면 된다.


주제(2) : 기하학적 충돌 감지(SAT 및 고급 기하학)

 

이전에 충돌 파트에서 OBB의 충돌을 다룰 때 SAT(분리축 이론)를 살짝 공부했었다. 이번엔 SAT와 기하학적 충돌 감지를 활용해서 '복잡한 3D 메쉬와 다각형'을 다뤄보자.

 

1. 일반화된 분리축 이론(Generalized SAT for Convex Polygons)

 

(1) 볼록 다각형끼리의 충돌

Convex Polygons. 즉, 임의의 볼록 다각형끼리 부딪힐 때는 충돌을 어떻게 검사할까?

 

수학적 원리 : 두 다각형끼리 충돌하는지 판별하기 위해 검사해야 할 분리축은 바로 "각 다각형의 모든 모서리에 수직인 법선 벡터들"이다.

예를 들어보자. 오각형과 팔각형이 부딪히는지 검사하려면, 오각형의 모서리 5개, 팔각형의 모서리 8개로 총 13개의 수직 축을 뽑아낸다. 그리고 각 다각형의 모든 꼭짓점을 13개 축에 내적해서 단 하나의 축에서라도 최소-최대 값이 겹치지 않으면 충돌하지 않았다고 결론 내리는 것이다.

 

결론은 해당 연산은 점이 많아질수록 기하급수적으로 무거워진다는 특징이 있다. 그래서 물리 엔진에서는 바로 SAT로 계산하지 않고, 주전자를 감싸는 보이지 않는 Convex Hull을 수학적으로 먼저 씌운 뒤에, 점의 개수를 최소한으로 줄인 뒤에 SAT 연산을 돌려서 성능을 최적화한다.

 

(2) SAT + Convex Hull

SAT는 그림자가 겹치는 지를 법선 벡터들을 활용해서 확인하는 알고리즘이지만, 수학적으로는 오직 볼록한 도형에서만 작동한다는 약점이 있다. 예를 들어 별 모양같은 움푹 파인 곳이 있는 다각형에 SAT를 사용하면 충돌하지 않았는데도 그림자가 겹치는 판정 오류가 발생하게 된다.

또한, 실제 게임 속 3D 모델은 볼록한 다각형이라고 하더라도 수만 개의 정점(Vertex)으로 이루어져 있는데, 이 점에 각각 축을 세우기 일일이 내적하면 GPU와 CPU는 그대로 산화하고 말 것이다.

 

따라서 볼록 껍질(Convex Hull)을 씌워서 연산량을 감소시키는 방법을 사용한다. 어떤 울퉁불퉁한 다각형 물체에 볼록 껍질을 씌우게 되면, 움푹 파인 곳들은 팽팽하게 펴지면서 자연스럽게 무시되고, 가장 바깥쪽으로 튀어나온 소수의 뾰족한 정점들만 남게 될 것이다. 이 소수의 뾰족한 점들만 가지고 SAT 연산을 돌리는 것이다.

 

<실무 파이프라인>

 -1. 사전 계산(Baking) : 에디터 단계에서 복잡한 3D 메쉬를 수학 알고리즘(Quickhull 알고리즘 등)을 돌려 수만개의 정점을 50~100개 이하의 단순한 Convex Hull 형태로 압축해서 저장해둔다.

 -2. 연산량 감소 : 인게임에서 두 물체가 부딪힐 때, 원본 메쉬가 아니라 새로 만든 Convex Hull의 정점과 법선 벡터만 가지고 SAT 연산을 돌린다.

 -3. 충돌 판별 : 볼록 껍질끼리 부딪히지 않았다면 그 안쪽에 있는 진짜 메쉬는 볼록 껍질보다 작으므로 당연히 부딪히지 않은 것으로 확정 지을 수 있다.

 

사실 언리얼 엔진에서 Static Mesh 창을 열고 Auto Convex Collision을 생성해 본 적이 있다면 직관적으로 이해할 수 있다. 물리 엔진이 백그라운드에서 Convex Hull 수학을 돌려서 연두색 선으로 내가 지정해 놓은 메쉬를 감싸는 작업을 수행하는 것이다. 


 

2. 레이-트라이앵글 교차 알고리즘(묄러-트럼보어 알고리즘 , Möller–Trumbore Intersection)

 

게임 속 캐릭터나 지형은 수만 개의 삼각형으로 이루어져 있다. Ray를 쐈을 때 해당 Ray가 캐릭터의 어디에 명중했는지 어떻게 정확히 픽셀/버텍스 단위로 알아내기 위해 레이-트라이앵글 교차 알고리즘(묄러-트럼보어 알고리즘)이 사용된다. 

 

과거의 Ray 충돌 판정은 3단계를 거쳤다.

 - 1. 삼각형이 속한 거대한 '평면의 방정식'을 먼저 구한다.

 - 2. 선의 방정식과 연립해서 선이 평면과 닿는 교차점을 구한다.

 - 3. 그 교차점이 삼각형의 내부에 있는지 다시 검사한다.

 

딱봐도 엄청 복잡하고 무거운 연산이라는 것을 알 수 있다.

 

이 문제를 해결한 것이 3D 그래픽스에서는 몰라서는 안되는 알고리즘인 묄러-트럼보어 알고리즘이다.

 

수학적 원리 : 선(Ray)의 방정식과 삼각형의 무게중심 좌표계(Barycentric Coordinates)방정식을 같다고 놓고 연립한 뒤에, 선형대수학의 크래머 공식(Cramer's Rule)을 적용해서 복잡한 행렬식을 벡터의 내적과 외적만으로 정리한 알고리즘이다.

수학 연립 식이나 이런거는 지금 단계에서는 살펴보지 않도록 하고, 나중에 필요할 경우에 찾아서 추가로 학습하기로 하자.

 

아무튼, 이 알고리즘으로 오직 꼭짓점(Vertex) 좌표 3개만 가지고 계산이 가능해져서 메모리를 절약할 수 있게 되었고,

계산을 끝까지 하지 않고도 중간에 애초에 맞지 않을 거는 그냥 맞지 않는 것으로 연산을 조기에 종료(Culling)할 수 있게 되었다.

 -1. 연산 첫 줄에서 선의 방향 벡터와 삼각형의 벡터를 외적한다. 결과값이 0에 가까우면 Ray가 삼각형과 나란히 날아가서 영원히 만나지 않는다는 것으로 판단하고 즉시 false를 뱉어낸다.

 -2. 계산 중간에 무게중심 가중치 u나 v가 0.0보다 작거나 1.0보다 큰 값이 튀어나오게 되면, Ray가 면에 닿긴 했으나 삼각형 밖(허공)을 뚫었다고 판단하고 즉시 연산을 종료한다.

 

우리가 언리얼 엔진에서 다루는 LineTraceSingleByChannel이 이 원리를 사용하고 있다. 언리얼 내부의 물리 코어에서는 묄러-트럼보어 알고리즘이 매 프레임 수만번씩 돌아가면서 외적과 내적을 쉴새없이 계산하고 있는 것이다.


 

3. 무게중심 좌표계(Barycentric Coordinates)

아까 묄러-트럼보어 알고리즘에서 잠깐 만났던 개념이다. Ray가 삼각형의 표면에 정확히 맞았다는 것을 알아냈다고 치자. 그렇다면 맞은 지점이 꼭짓점 3개 중에서 어디에 가장 가까운가를 알아내기 위해서 사용되는 것이 무게중심 좌표계이다.

 

수학적 원리 : 삼각형 내부의 임의의 교차점 P를 세 꼭짓점 A, B, C의 가중치(u, v, w)로 표현하는 방식이다.

원리는 얼핏 보면 단순한 것 같지만, 사실 이 u, v, w 가중치 비율을 알면 상당히 매력적인 기능들을 구현할 수 있게 된다.

예를 들어, 텍스처 블렌딩에 활용하거나, 지형 판별과 같은 기능을 만들어낼 수 있다. 텍스처 블렌딩의 경우 Ray에 맞은 정확한 위치의 UV 좌표를 역산하여 그 자리에만 정확하게 핏자국(Decal)이나 탄흔을 그려낼 수 있게 된다. 지형 판별의 경우 발밑에 있는 삼각형이 흙, 풀, 자갈의 가중치에 따라 섞인 지형이라고 판단되면, 발소리를 흙, 풀, 자갈의 비율로 섞어서(Crossfade) 출력하게 만들 수 있다.

 

(이용 사례 1 : 맞은 위치의 정점 데이터 혼합하기)

#include "Math/UnrealMathUtility.h"

// 삼각형의 세 꼭짓점(A, B, C)과 맞은 점(HitPoint)을 바탕으로 무게중심 좌표(u, v, w)를 구함
FVector ComputeBarycentric(FVector A, FVector B, FVector C, FVector HitPoint)
{
    FVector v0 = B - A;
    FVector v1 = C - A;
    FVector v2 = HitPoint - A;

    // 벡터 내적을 활용해 삼각형 면적의 비율(가중치)을 도출
    float d00 = FVector::DotProduct(v0, v0);
    float d01 = FVector::DotProduct(v0, v1);
    float d11 = FVector::DotProduct(v1, v1);
    float d20 = FVector::DotProduct(v2, v0);
    float d21 = FVector::DotProduct(v2, v1);

    float Denom = d00 * d11 - d01 * d01;

    // 크래머의 공식을 응용한 가중치 v와 w 계산
    float v = (d11 * d20 - d01 * d21) / Denom;
    float w = (d00 * d21 - d01 * d20) / Denom;
    float u = 1.0f - v - w;

    // 편의상 u, v, w를 FVector의 X, Y, Z에 담아서 반환
    return FVector(u, v, w); 
}

void AMyWeapon::SpawnDecalAtHit(FHitResult Hit)
{
    // 1. 물리 엔진이 찾아준 충돌 삼각형의 세 꼭짓점 로드 (가정)
    FVector VertexA, VertexB, VertexC; 
    GetTriangleVertices(Hit, VertexA, VertexB, VertexC);

    // 2. 바리센트릭 좌표(가중치) 도출
    FVector Bary = ComputeBarycentric(VertexA, VertexB, VertexC, Hit.ImpactPoint);

    // 3. 세 꼭짓점이 가지고 있던 UV 좌표를 가중치로 섞어서(Interpolation), 
    // 총 맞은 곳의 정확한 2D 텍스처 좌표를 계산
    FVector2D ExactUV = (UV_A * Bary.X) + (UV_B * Bary.Y) + (UV_C * Bary.Z);

    // 4. 해당 UV 좌표 위치의 텍스처를 찢어진 파편으로 변경하거나 데칼 생성
    ApplyBulletHoleToTexture(ExactUV);
}

 

(이용 사례 2: 혼합 발소리 시스템)

#include "Kismet/GameplayStatics.h"
#include "Components/AudioComponent.h"

void AMyCharacter::PlayBlendedFootstepSound(const FHitResult& Hit)
{
    // 1. 맞은 삼각형의 세 꼭짓점 위치를 가져옴 (물리 엔진의 메쉬 데이터 활용)
    FVector VertexA, VertexB, VertexC;
    GetTriangleVertices(Hit, VertexA, VertexB, VertexC); // 가상의 헬퍼 함수

    // 2. 바리센트릭 좌표 (가중치 u, v, w) 계산
    // 반환된 벡터의 X = A의 가중치, Y = B의 가중치, Z = C의 가중치 (합은 항상 1.0)
    FVector Bary = ComputeBarycentric(VertexA, VertexB, VertexC, Hit.ImpactPoint);

    // 3. 각 꼭짓점에 할당된 지형 정보 (아티스트가 칠해둔 Vertex Color 활용)
    // (이해를 돕기 위해 하드코딩함. 실제로는 메쉬 데이터에서 읽어옴)
    FLinearColor ColorA = FLinearColor(1.0f, 0.0f, 0.0f); // 꼭짓점 A는 100% 흙(R)
    FLinearColor ColorB = FLinearColor(0.0f, 1.0f, 0.0f); // 꼭짓점 B는 100% 풀(G)
    FLinearColor ColorC = FLinearColor(0.0f, 0.0f, 1.0f); // 꼭짓점 C는 100% 돌(B)

    // 4. 수학적 혼합 (Barycentric Blending)
    // 세 꼭짓점의 색상(지형 정보)을 바리센트릭 가중치로 섞어 현재 발밑의 최종 지형 비율 도출
    float FinalDirtWeight  = (ColorA.R * Bary.X) + (ColorB.R * Bary.Y) + (ColorC.R * Bary.Z);
    float FinalGrassWeight = (ColorA.G * Bary.X) + (ColorB.G * Bary.Y) + (ColorC.G * Bary.Z);
    float FinalStoneWeight = (ColorA.B * Bary.X) + (ColorB.B * Bary.Y) + (ColorC.B * Bary.Z);

    // 5. 오디오 시스템에 적용
    // 언리얼 사운드 큐(Sound Cue)에 혼합된 가중치를 넘겨주어 소리를 섞음
    if (UAudioComponent* FootstepAudio = UGameplayStatics::SpawnSoundAtLocation(this, FootstepSoundCue, Hit.ImpactPoint))
    {
        FootstepAudio->SetFloatParameter(FName("DirtVolume"), FinalDirtWeight);
        FootstepAudio->SetFloatParameter(FName("GrassVolume"), FinalGrassWeight);
        FootstepAudio->SetFloatParameter(FName("StoneVolume"), FinalStoneWeight);
        
        // 디버그 로그 예시: "지형 비율 - 흙: 70.0%, 풀: 30.0%, 돌: 0.0%"
        UE_LOG(LogTemp, Log, TEXT("지형 비율 - 흙: %.1f%%, 풀: %.1f%%, 돌: %.1f%%"), 
               FinalDirtWeight * 100.0f, FinalGrassWeight * 100.0f, FinalStoneWeight * 100.0f);
    }
    
    // (추가) 이 비율을 이용해 흙먼지가 날릴지, 풀잎이 흩날릴지 파티클(VFX)도 섞어서 생성할 수 있음
}

 

이처럼, 바리센트릭 혼합 기술은 발소리 뿐만 아니라 많은 곳에서 쓰일 수 있는 굉장히 유용한 스킬이라는 것을 직감적으로 알 수 있다.

 

데칼 변형은 물론이고, 지형 가중치에 따라서 이동 속도 페널티를 부여할 수도 있게 될 것이다.


*Unreal C++*

주제 : 언리얼 게임플레이 프레임워크

 

1. 개요

각 클래스의 "스코프(어디에 존재하는가?)"와 수명(언제 만들어지고 사라지는가?)에 주의하자.

 

1) 다이어그램을 간단히 요약해보자면,

 

서버만 보는 것 : AGameMode

모두가 복제로 공유하는 것 : AGameState, APlayerState, APawn

본인 클라이언트 + 서버만 보는 것 : APlayerController

로컬 전용 : AHUD, UMG Widget

 

2) 다이어그램의 주요 등장 인물들

 (1) UGameInstance : 게임 세션 전체에 단 하나 존재하며, 레벨이 바뀌어도 죽지 않는다. Subsystem들의 집이다.

 (2) AGameMode : 게임의 규칙. 누가 이기는가, 어떤 Pawn을 사용할 것인가를 결정한다. 서버에만 존재한다.

 (3) AGameState : GameMode가 가진 정보 중 클라이언트도 봐야 하는 것이다. 모두에게 복제된다.

 (4) APlayerController : 사람 플레이어 한 명이 Pawn을 조종할 때 사용한다. 입력/카메라/UI를 다루게 된다.

 (5) AAIController : 사람 대신 AI가 Pawn을 조종할 때 쓰는 Controller이다.

 (6) APlayerState : 플레이어의 영구 상태. 이름/점수/핑/팀처럼 캐릭터가 죽어도 사라지지 않는 정보를 다루게 된다.

 (7) APawn/ACharacter : 월드에서 실제로 보이는 객체. 사람 형태 + Character Movement Component를 가지고 있으면 Character이다.

 (8) AHUD : 화면 위에 2D를 그려주는 옛날 방식이다. 요즘에는 Common UI(UMG 위에 얹힌 플러그인)에 자리를 내줬다.

 (9) APlayerCameraManager : 지금 카메라가 어디에서 무엇을 보고 있는가를 결정한다.

 

2. 4대 핵심 클래스의 역할 분리

게임플레이 프레임워크에서 가장 자주 다루는 네 클래스는 AGameMode, AGameState, APlayerState, UGameInstance 이다.

이 네 클래스의 책임 분담을 잘 파악하고 있어야 한다. 각자 맞는 정보/로직을 넣는 감각이 잡히면 책임 혼재로 인한 설계 문제 대부분이 자연스럽게 사라지기 때문이다.

 

언리얼은 기본적으로 책임을 네 종류로 강제 분리한다.

GameMode : 게임의 심판관으로, 규칙/흐름제어/승부 판정에 책임을 가진다.

GameState: 게임 상황판. 모든 플레이어가 보는 공통 정보의 저장을 책임진다.

PlayerState : 개인 상황판. 각 플레이어만의 정보, 즉 개인 데이터를 책임진다.

GameInstance : 게임 관리자. 전체 생명주기와 레벨 간 데이터를 책임진다.

 

2-1. GameMode

현재 레벨의 "규칙"을 정의하는 클래스로, 게임 규칙을 정하고, 어떤 Pawn 클래스를 사용하는지, 매치는 언제 시작되고 종료하는지를 책임진다. 서버에만 존재한다.

 

(1) UE에는 AGameModeBase가 있고, AGameMode가 있는데, AGameMode는 매치 시스템이 들어간 무거운 버전이라고 생각하면 된다. EMatchState 매치 상태 머신이 포함되어있고, 자동 리스폰 시스템, 관전자 모드 지원 등의 기능이 포함되어있는 형태이다.

class AMyGameMode : public AGameModeBase
{
public:
    // 게임 규칙. 디폴트로만 설정, 런타임에는 바꾸지 않는다.
    UPROPERTY(EditDefaultsOnly, Category = "Rules")
    float MatchDuration = 300.0f;

    UPROPERTY(EditDefaultsOnly, Category = "Rules")
    int32 ScoreToWin = 1000;

    UFUNCTION(BlueprintCallable)
    void StartMatch();

    UFUNCTION(BlueprintCallable)
    void EndMatch(bool bPlayerWon);

private:
    bool bMatchInProgress = false;
    FTimerHandle MatchTimer;
};
void AMyGameMode::StartMatch()
{
    bMatchInProgress = true;

    // GameState에게 정보 업데이트 위임
    if (AMyGameState* GS = GetGameState<AMyGameState>())
    {
        GS->NotifyMatchStarted(MatchDuration);
    }

    // 타임아웃 처리
    GetWorldTimerManager().SetTimer(MatchTimer, this
    {
        EndMatch(false);
    }, MatchDuration, false);
}

void AMyGameMode::EndMatch(bool bPlayerWon)
{
    bMatchInProgress = false;
    UE_LOG(LogTemp, Log, TEXT("매치 종료. 결과: %s"),
        bPlayerWon ? TEXT("승리") : TEXT("패배"));

    if (AMyGameState* GS = GetGameState<AMyGameState>())
    {
        GS->NotifyMatchEnded(bPlayerWon);
    }

    // 다음 단계는 GameInstance가 결정한다 (레벨 전환 권한이 거기 있다)
    if (bPlayerWon)
    {
        if (UGameInstance* GI = GetGameInstance())
        {
            // GI->RequestNextLevel(); 같은 식으로 위임
        }
    }
}

void AMyGameMode::CheckVictoryCondition()
{
    // 모든 PlayerState를 순회하며 점수 확인
    for (APlayerState* PS : GameState->PlayerArray)
    {
        AMyPlayerState* MyPS = Cast<AMyPlayerState>(PS);
        if (MyPS && MyPS->CurrentScore >= ScoreToWin)
        {
            EndMatch(true);
            return;
        }
    }
}

GameMode를 구현할 때 핵심은 "결정만 하고 정보 갱신은 위임"한다는 것이다. GameMode가 정보(변수)를 직접 들고 있으면 클라이언트가 보지 못한다. 따라서 처음부터 GameState로 보낸다. 

 

2-2. GameState

GameMode가 가진 정보 중 클라이언트도 봐야 하는 것을 담는 클래스이다. 매치 시간, 라운드 번호, 모든 플레이어의 PlayerState 리스트 같은 공유 상태가 여기에 포함된다.

UCLASS()
class AMyGameState : public AGameStateBase
{
    GENERATED_BODY()

public:
    // 서버에서만 수정, 클라이언트는 읽기 전용
    UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_RemainingTime)
    float RemainingTime = 0.0f;

    // GameMode가 호출하는 통신 진입점
    UFUNCTION()
    void NotifyMatchStarted(float Duration);

    // UI 친화 함수들
    UFUNCTION(BlueprintPure)
    FString GetFormattedTime() const;

    UFUNCTION(BlueprintPure)
    FLinearColor GetTimeColor() const;

protected:
    // 서버에서 RemainingTime이 갱신되면 클라에서 자동 호출됨
    UFUNCTION()
    void OnRep_RemainingTime();

private:
    float InitialDuration = 0.0f;
    FTimerHandle CountdownHandle;
};
void AMyGameState::NotifyMatchStarted(float Duration)
{
    if (GetLocalRole() != ROLE_Authority) return; // 서버만 실행

    InitialDuration = Duration;
    RemainingTime   = Duration;

    UE_LOG(LogTemp, Log, TEXT("GameState: 매치 시작 (%.0f초)"), Duration);

    // 1초마다 RemainingTime 감소. 자동으로 클라에 복제됨
    GetWorldTimerManager().SetTimer(
        CountdownHandle,
        this
        {
            RemainingTime = FMath::Max(RemainingTime - 1.f, 0.f);
            if (RemainingTime <= 0.f)
            {
                GetWorldTimerManager().ClearTimer(CountdownHandle);
            }
        },
        1.0f, true
    );
}

void AMyGameState::OnRep_RemainingTime()
{
    UE_LOG(LogTemp, Verbose, TEXT("클라: 남은 시간 갱신 %.0f"), RemainingTime);

    // UI 업데이트 신호로 사용
    OnTimeUpdated.Broadcast(RemainingTime);
}

// UI 친화적 시간 포맷팅
FString AMyGameState::GetFormattedTime() const
{
    int32 Minutes = RemainingTime / 60;
    int32 Seconds = (int32)RemainingTime % 60;
    return FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);
}

// 시간에 따른 색상 변화
FLinearColor AMyGameState::GetTimeColor() const
{
    float Ratio = RemainingTime / InitialDuration;
    if (Ratio > 0.5f) return FLinearColor::Green;   // 여유
    if (Ratio > 0.2f) return FLinearColor::Yellow;  // 주의
    return FLinearColor::Red;                       // 위험
}

GameState는 정보의 제공자이다. 전광판처럼 정보만 표시한다고 생각하면된다. 데이터를 보여줄 뿐, 직접 결정하지 않는다. UI와 클라가 참조만 하는 읽기 전용 클래스이다.

 

이제 GameMode와 GameState를 합쳐서 생각해보자.

 

GameMode와 GameState는 짝이 맞아야 한다. 매치 상태 머신을 쓰려면 둘다 AGameMode, AGameState로 맞춰주고, 쓰지 않을 거라면 둘다 AGameModeBase, AGameStateBase로 사용한다.

 

GameMode에서 GameState로 가는 사용 흐름을 다시 한번 상기하자.

GameMode가 상태를 계산해서 GameState에 알려준다. GameState는 그 상태를 모든 클라이언트에 자동으로 복제한다. UI나 애니메이션은 GameState를 보고 표시만 한다.

 

 

2-3. PlayerState

플레이어 한 명의 영속 상태를 담는 객체이다. 이름/점수/핑/팀처럼 캐릭터가 죽어도 사라지면 안 되는 정보를 책임진다. 

 

(1) 생명주기의 특징 : 레벨 전환에서 살아남음.

        Level_Menu  →  Level_Game1  →  Level_Game2
            ↓             ↓               ↓
GameMode    파괴       새로 생성        새로 생성
GameState   파괴       새로 생성        새로 생성
PlayerState 유지       유지             유지            (살아 있음)

레벨 전환시 같은 인스턴스가 옮겨가는 것이 아니라, 이전 World의 데이터가 새로운 PlayerState 인스턴스로 복사(CopyProperties)되는 방식이다. 결과적으로 끊김 없이 이어지는 것처럼 보일 뿐이지 실제로는 새로운 객체이다.

 

(2) 소유권 구조

GameMode (서버에만 존재)
    ↓
PlayerController (각 유저마다 1개. 서버+클라 공존)
    ↓
PlayerState (서버+모든 클라에 복제)
PlayerController  ↔  PlayerState
        ↓
       Pawn

PlayerController와 PlayerState는 짝지어진다. Pawn은 그 둘이 조종하는 별개의 몸체가 된다. 자신의 PlayerState를 가져오고 싶으면 PlayerController에서 가져와야 한다.

// 잘못된 접근: GameState 배열에서 임의 인덱스로 잡는 것
AMyPlayerState* PS = GetWorld()->GetGameState()->PlayerArray[0];

// 올바른 방법 1: PlayerController에서 자기 PlayerState 가져오기
AMyPlayerState* PS = MyController->GetPlayerState<AMyPlayerState>();

// 올바른 방법 2: 클라에서 변경이 필요하면 서버에 RPC로 요청
MyPlayerState->Server_AddScore(50);

 

UCLASS()
class AMyPlayerState : public APlayerState
{
    GENERATED_BODY()

public:
    UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_Score)
    int32 CurrentScore = 0;

    UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_Lives)
    int32 Lives = 3;

    // 서버 전용 RPC. 클라가 점수를 직접 수정하지 못하게 강제한다.
    UFUNCTION(Server, Reliable)
    void Server_AddScore(int32 Points);

    UFUNCTION(Server, Reliable)
    void Server_TakeDamage(int32 Amount);

    UFUNCTION(BlueprintPure)
    bool IsGameOver() const { return Lives <= 0; }

    // UI 업데이트용 멀티캐스트 델리게이트
    DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnScoreChanged, int32, Old, int32, New);
    UPROPERTY(BlueprintAssignable)
    FOnScoreChanged OnScoreChanged;

protected:
    UFUNCTION()
    void OnRep_Score();

    UFUNCTION()
    void OnRep_Lives();

private:
    int32 HighScore = 0;
};
void AMyPlayerState::Server_AddScore_Implementation(int32 Points)
{
    if (Points <= 0) return;

    int32 Old = CurrentScore;
    CurrentScore += Points;

    if (CurrentScore > HighScore)
    {
        HighScore = CurrentScore;
    }

    // 점수 변경 브로드캐스트 (서버 → 클라)
    OnScoreChanged.Broadcast(Old, CurrentScore);

    // GameMode에 승리 조건 검사 요청
    if (AMyGameMode* GM = GetWorld()->GetAuthGameMode<AMyGameMode>())
    {
        GM->CheckVictoryCondition();
    }
}

void AMyPlayerState::OnRep_Score()
{
    // 클라이언트에서 자동 호출됨 (Replication 이벤트)
    UE_LOG(LogTemp, Verbose, TEXT("클라: 점수 갱신 %d"), CurrentScore);
    OnScoreChanged.Broadcast(CurrentScore, CurrentScore);
}

void AMyPlayerState::Server_TakeDamage_Implementation(int32 Amount)
{
    Lives = FMath::Max(Lives - Amount, 0);
    OnRep_Lives();
}

void AMyPlayerState::OnRep_Lives()
{
    UE_LOG(LogTemp, Verbose, TEXT("클라: 남은 목숨 %d"), Lives);
}

 

 

2-4. GameInstance

게임 세션 시작부터 종료까지 살아 있는 유일한 객체이다. 레벨이 바뀌어도 죽지 않는다.

 

(1) 주요 특징

게임 전체 생명주기 : 시작부터 종료까지 살아있다.

레벨 간 데이터 유지 : 레벨이 바뀌어도 절대 사라지지 않는다.

레벨 전환 권한 : 레벨을 바꾸는 코드는 GameInstance에 모이는 것이 자연스럽다.

UCLASS()
class UMyGameInstance : public UGameInstance
{
public:
    virtual void Init() override;

    // 영속 데이터 (레벨 바뀌어도 유지)
    UPROPERTY(BlueprintReadWrite)
    FString PlayerName = TEXT("Player");

    UPROPERTY(BlueprintReadWrite)
    int32 TotalScore = 0;       // 전체 누적 점수

    UPROPERTY(BlueprintReadWrite)
    int32 CompletedLevels = 0;

    UFUNCTION(BlueprintCallable)
    void LoadGameLevel(int32 LevelIndex);

    UFUNCTION(BlueprintCallable)
    void LoadNextLevel();

    UFUNCTION(BlueprintCallable)
    void ReportLevelCompleted(int32 LevelIndex, int32 Score);

private:
    int32 CurrentLevelIndex = 0;
    bool  bIsChangingLevel = false;
};
void UMyGameInstance::LoadGameLevel(int32 LevelIndex)
{
    if (bIsChangingLevel) return; // 중복 호출 방지

    UE_LOG(LogTemp, Warning, TEXT("레벨 %d 로딩 시작"), LevelIndex);

    bIsChangingLevel = true;
    CurrentLevelIndex = LevelIndex;

    // 현재 상태 백업: 살아남아야 할 데이터를 GameInstance로 옮긴다
    if (APlayerController* PC = GetWorld()->GetFirstPlayerController())
    {
        if (AMyPlayerState* PS = PC->GetPlayerState<AMyPlayerState>())
        {
            TotalScore += PS->GetCurrentScore();
        }
    }

    // 실제 레벨 로딩
    FString LevelName = FString::Printf(TEXT("Level_%d"), LevelIndex);
    UGameplayStatics::OpenLevel(GetWorld(), FName(*LevelName));
}

void UMyGameInstance::ReportLevelCompleted(int32 LevelIndex, int32 Score)
{
    UE_LOG(LogTemp, Warning, TEXT("레벨 %d 완료. 점수: %d"), LevelIndex, Score);

    TotalScore += Score;
    CompletedLevels = FMath::Max(CompletedLevels, LevelIndex);

    // 2초 뒤 다음 레벨로
    FTimerHandle Timer;
    GetWorldTimerManager().SetTimer(Timer, this
    {
        LoadNextLevel();
    }, 2.0f, false);
}

void UMyGameInstance::LoadNextLevel()
{
    LoadGameLevel(CurrentLevelIndex + 1);
}

*주의*

GameInstance는 전역 데이터를 다루다보니 모든 걸 박아넣는 경우가 종종 있다. 이는 전역 데이터 남용 안티패턴이므로, 가능한 적절한 스코프의 Subsystem(UEngineSubsystem / UGameInstanceSubsystem / UWorldSubSystem / ULocalPlayerSubsystem)으로 쪼갠다. 즉, 데이터의 실제 수명에 맞춰 가장 좁은 스코프를 고른다.

 

 

GameMode, GameState, PlayerState, GameInstance를 살펴봤는데, 생존 범위를 기준으로 비교한 표는 다음과 같다.

역할 생존 범위 책임
GameMode 레벨 단위 규칙·승패·플로우
GameState 레벨 단위 현재 진행 데이터
PlayerState 세션 단위 개인 정보
GameInstance 게임 전체 데이터 영속 + 레벨 전환 제어

 

3. AController / APlayerController / AAIController

 

(1) AController는 Pawn을 possess/unpossess할 수 있는 능력을 정의한 부모 클래스이다. 직접 인스턴스화하기보다는 APlayerController 아니면 AAIController 둘 중 하나의 자식 클래스를 사용하게 된다.

 

(2) APlayerController는 플레이어 한 명의 의지를 대변한다. 입력을 받고 Pawn을 조종하고 카메라를 결정하고 UI를 띄우는 역할을 한다.

자기 클라이언트와 서버에 둘 다 존재하며, 다른 플레이어의 PlayerController는 보이지 않는다. 

 

PlayerController의 책임:

입력 바인딩 (SetupInputComponent, UE 5 버전부터는 Enhanced Input 기본)

Pawn 점유 (Possess, Unpossess)

카메라 시점 (PlayerCameraManager)

자기 PlayerState, HUD, LocalPlayer 소유

 

(3) AAIController는 PlayerController의 AI 버전으로, 사람이 아니라 AI 로직이 Pawn을 조종한다.

여러 적이 같은 클래스의 Pawn을 쓰더라도 각각 자기 AIController 인스턴스를 가진다.

 

AIController의 책임:

Behavior Tree, Blackboard 호스팅

AI Perception 컴포넌트 부착(시야, 청각 등)

AI Pawn possess

 

 

4. APawn / ACharacter / 특수 Pawn

월드에서 시각적/물리적으로 존재하는 모든 액터의 부모는 APawn이다. Controller가 possess 할 수 있다.

 

(1) APawn은 가장 일반적인 형태로, MovementCOmponent로 이동을 처리한다.

 

(2) ACharacter는 Pawn의 특수 형태로, 이족 보행 캐릭터 전용이다. UCharacterMovementComponent가 기본적으로 포함되어있고, UCapsuleComponent 콜리전, USkeletalMeshComponent 메시가 미리 세팅되어있다. 사람 캐릭터는 거의 대부분 얘를 상속한다.

 

(3) 특수 Pawn의 종류에는 ADefaultPawn, ASpectatorPawn 등이 있다.

ADefaultPawn : 자유 비행 카메라용으로 사용한다. 에디터/디버깅에서 자주 쓰이게 된다.

ASpectatorPawn : 매치 관전 모드용으로 사용한다. 충돌 없이 자유롭게 이동 가능하다.

 

"Pawn의 책임"

체력/무기 같은 단기 게임플레이 스탯은 Pawn에 둘 수 있지만, 영구 통계는 PlayerState로 보낸다. Pawn에 모든 게임플레이 로직을 넣으면 거대한 클래스 안티패턴이 된다. 책임은 UActorComponent로 분리하자(UCombatComponent, UInventoryComponent 등)

 

5. UWorld / ULevel

 

(1) UWorld는 지금까지 다룬 모든 클래스가 실제로 존재하는 런타임 컨테이너이다. Mode, State, Controller, Pawn 전부 어떤 World 안에서 살아간다. 한 게임에 여러 World가 동시에 존재할 수도 있다. 

 

(2) ULevel은 디스크에 저장된 한 장의 .umap 파일이다. World는 그 레벨들을 런타임에 모아 돌리는 컨테이너이고, 한 World는 메인 레벨 한 장 위에 여러 서브 레벨을 동적으로 로드/언로드 할 수 있다. UE 5 버전 이후부터는 World Partition이 사실상 표준이므로, 그리드로 자동 분할된 셀들이 거리 기반으로 알아서 들고 나가게 된다.

 

항상 UObject::GetWorld()로 본인이 속한 컨텍스트의 World를 얻게 하자.

 

6. AHUD

화면에 2D 그래픽을 그리는 객체로, 옛날 스타일의 즉시 모드 그리기(Canvas)이다.

PlayerController 한 명당 하나가 적용된다. 로컬 전용이라 네트워크 복제가 없다.

 

현대 UE의 경우 본격적인 UI는 거의 다 UMG(UUserWidget) 기반으로 옮겨갔고, 그 위에 다시 Common UI 플러그인을 얹는 것이 UE 5 버전 이후의 사실상 표준이 되었다. 따라서 AHUD는 디버그 라인/원 그리기, 크로스 헤어 같은 단순 오버레이용으로 주로 남아 있는 상태이다.

 

(1) UMG : 위젯 트리 / 바인딩 / 블루프린트 노출까지 담당하는 토대이다. Common UI도 UMG 위에서 돈다.

(2) Common UI : 입력 라우팅(키보드/게임패드/터치 통합), 입력 모드 추상화, 위젯 활성/비활성 스택 관리 같은 것들을 미리 만들어둔 레이어이다.

 

단순한 1인칭 데모나 메뉴 두 장짜리 프로젝트라면 UMG만으로도 충분하다.

다만 AAA/콘솔 멀티플랫폼 게임과 같이 입력 모드 전환, 모달/팝업 스택, 패드/마우스 동시 지원이 들어오는 순간부터 Common UI 도입이 거의 강제된다.

 

7. APlayerCameraManager

PlayerController가 소유하고, "지금 화면을 어디서 어떻게 보여줄 것인가"를 결정한다. 카메라 모드 전환, 카메라 셰이크, FOV 변경 등이 여기에서 일어난다.

Pawn에 부착된 UCameraComponent가 기본 시점을 제공하고, PlayerCameraManager는 보통 현재 ViewTarget의 CameraComponent를 따라간다.

 

8. Framework 로딩의 순서

 

(1) 게임 시작부터 첫 레벨까지의 로딩 순서

 

(1-1 : 엔진 부팅)

1. UEngine 초기화
2. UGameInstance 생성 → Init() 호출   (가장 먼저)
   - 이 시점: World 없음, GameMode 없음
   - 가능: 글로벌 설정, Subsystem 초기화, SaveGame 로드
   - 불가: GetWorld(), UI 생성, GameMode 접근

 

(1-2 : 첫 레벨 로딩)

3. World 생성 (Persistent Level 포함)
4. GameMode(Base) 생성 → InitGame() 호출
5. GameState(Base) 생성 (GameMode 내부에서 스폰)
6. PlayerController 생성
7. PlayerState 생성 (Controller 소유)
8. Pawn 생성 → Controller가 Possess()

9. 모든 BeginPlay() 호출 (서로 간 호출 순서는 보장되지 않음)
   - 대상: GameMode, GameState, PlayerController,
           PlayerState, Pawn, 기타 모든 Actor
   - 의존성 있는 접근은 주의

 

(2) 레벨 전환 시의 메커니즘

 

(2-1 : 현재 레벨 정리)

1. 모든 Actor의 EndPlay() 호출
2. GameMode / GameState 파괴
3. 일반 Actor 파괴
4. PlayerState 데이터 보존  (여기서 데이터가 새 인스턴스로 옮길 준비를 한다)

 

(2-2 : 새 레벨 로딩)

5. 새 World 생성
6. PlayerState의 데이터가 새 인스턴스로 복사 (CopyProperties)
7. 새 GameMode / GameState 생성
8. PlayerController가 새 PlayerState에 재연결
9. 새 Pawn 생성
10. 모든 BeginPlay() 호출

 

*주의사항*

PlayerState는 CopyProperties 진행 중에 일시적으로 접근이 불가능하다.

GameMode / GameState는 항상 레벨 단위로 다시 생성된다. >> 세션 전체 연속성은 GameInstance만 보장한다.

레벨 전환 중간에 PlayaerState나 World 데이터에 접근하면 크래시 위험이 있다.

 

(3) Framwork 로딩 순서를 알고 있어야 하는 이유

 

각 단계에서 할 수 있는 것과 없는 것을 구분할 수 있게 된다.

 

(3-1 : UGameInstance::Init() 에서 가능한 것)

// 가능
- 글로벌 설정 로드
- Subsystem 초기화
- SaveGame 데이터 로드

// 금지
- GetWorld() 호출 (아직 World가 없다)
- GameMode / PlayerController 접근
- UI 생성

 

(3-2 : GameMode 생성자에서 가능한 것)

// 가능
- 기본 클래스 변수 설정
- 게임 룰 변수 초기화

// 금지
- GameState 접근 (아직 없다)
- PlayerController 접근

 

(3-3 : GameMode::PostInitializeComponents() 에서 가능한 것)

// 이 시점부터 GameState 접근 가능
// PlayerController / PlayerState는 아직 없다

if (AMyGameState* GS = GetGameState<AMyGameState>())
{
    if (GS->HasBegunPlay())
    {
        // 안전하게 호출 가능
    }
}

 

(4) 시점별 안전성 표

더보기
타이밍 가능한 작업 금지 사항
UGameInstance::Init() 글로벌 설정, SaveGame, 네트워크 초기화 GetWorld(), GameMode 접근
GameMode 생성자 클래스 설정, 룰 초기화 GameState, PlayerController 접근
PostInitializeComponents() GameState 접근 PlayerController, PlayerState 접근
BeginPlay() 게임 로직 시작 (호출 순서 비보장) 의존 객체 직접 접근 주의
PostLogin() 모든 플레이어 데이터 확실 준비 거의 없음 (가장 안전)

(5) 생명주기 함수 안전성 정리

더보기
위험도 함수 설명
높음 생성자 의존 객체 아직 없음
중간 PostInitializeComponents GameState 정도는 존재
주의 BeginPlay 호출 순서 비보장. 의존성 접근 주의
안전 PostLogin PlayerController / PlayerState 모두 준비
안전 StartPlay 모든 BeginPlay 이후. 완전 준비 상태

(6) 최종 정리

더보기
클래스 어디에 존재? 무엇을 담아야 하나
UGameInstance 세션 전체 (1개) 레벨 이동에도 살아남을 데이터, Subsystem 호스트
UWorld / ULevel 현재 레벨 모든 액터·컴포넌트의 런타임 컨테이너(World) + 디스크 .umap(Level)
AGameMode 서버 only 게임 규칙, 스폰 결정, 매치 진행
AGameState 모든 인스턴스 (복제) 클라가 봐야 하는 매치 정보, PlayerArray
APlayerController 본인 + 서버 입력, UI, 카메라, Pawn 조종
AAIController 서버 (대개) AI 로직, Behavior Tree, Perception
APlayerState 모든 인스턴스 (복제) 영구 플레이어 상태 (이름, 점수, 팀)
APawn / ACharacter 모든 인스턴스 (복제) 월드에서의 물리적 존재, 단기 스탯
AHUD 로컬 only 즉시 모드 2D 그리기 (대개 디버그용)
APlayerCameraManager 본인 + 서버 카메라 결정 (ViewTarget, 셰이크, FOV)

 

9. 설계 사고법

 

데이터와 로직을 어디에 둘 것인가? 가 설계의 핵심이다.

 

(1) 데이터를 어느 클래스에 둘 것인가?

 

1. 생명주기, 2. 수정 권한, 3. 참조 대상, 순으로 질문을 던져서 결정하자.

 

 

(2) 데이터의 위치는 어디인가?

 

질문을 순서대로 던지다보면 답이 나온다. 

더보기
  1. 레벨이 바뀌어도 살아야 하는가? : UGameInstance 또는 그 Subsystem으로
  2. 클라이언트가 볼 수 있어야 하는가? : 아니라면 AGameMode, 볼 수 있어야 하면 AGameState 또는 APlayerState
  3. 한 플레이어 단위인가, 매치 전체 단위인가? : 플레이어면 APlayerState, 매치 단위이면 AGameState
  4. 죽으면 잃어도 되는가? : 잃어도 되면 APawn, 안되면 APlayerState
  5. 보여주기만 하는 것인가? : AHUD 또는 UUserWidget(UMG)

 

(3) 로직 위치 결정하기

더보기
  1. 입력 처리 및 UI 띄우기 : APlayerController
  2. 게임 규칙, 승패 판정 : AGameMode
  3. 캐릭터 물리/이동/애니메이션 : ACharacter + MovementComponent
  4. 가로지르는 기능(체력 및 인벤토리 등) : UActorComponent
  5. 전역 도구(저장시스템 및 통계 등) : 적절한 Subsystem 으로

 

(4) 싱글에서 멀티로의 전환

 

이 과정을 거쳐 오면 올바르게 분리가 되어있을 것이고, 싱글플레이어 코드를 멀티플레이어로 확장할 때 한 줄만 추가하면 된다.

// 싱글플레이어 코드
void AMyPlayerState::AddScore(int32 Points)
{
    Score += Points;
    OnScoreChanged.Broadcast(Score);
}

// 멀티플레이어. 권한 체크 한 줄만 추가
void AMyPlayerState::AddScore(int32 Points)
{
    if (HasAuthority())
    {
        Score += Points;
        OnScoreChanged.Broadcast(Score);
    }
}

즉, 싱글플레이부터 제대로 책임을 나눠놓으면, 멀티플레이로의 확장이 굉장히 쉬워진다.


*Lyra Starter Game 파먹기*

제미나이의 도움을 받아서 Tree 형태의 그림으로 전체 C++ 클래스 아키텍처를 살펴보았다.

 

라이라 스타터 게임 Source 폴더를 보면 크게 두가지로 나누어져 있는데, LyraGame과 LyraEditor 두 가지 모듈로 나누어져 있다.

 

LyraGame은 런타임에 사용하는 모듈로, 유저가 실제로 게임을 플레이할 때 돌아가는 핵심 C++ 로직(캐릭터 이동, 사격, UI 등)이 들어있다.

LyraEditor는 개발자 도구로, 언리얼 에디터 내에서 기획자나 아티스트가 편하게 작업할 수 있도록 만든 커스텀 툴, 버튼, 데이터 검증 로직 등이 들어있다.

 

이렇게 분리하는 이유는, 유저에게 배포할 게임 파일을 구울 때 에디터용 코드는 절대 포함되지 않도록 분리하여 게임 용량을 최적화하고 해킹의 위험을 줄이기 위해서이다.

 

그리고 목록에 *.Target.cs 형태의 파일들이 있는 것을 확인할 수 있는데, 이는 멀티플레이어 및 크로스 플랫폼을 염두에 두고 설계되었기 때문이다.

 

LyraClient / LyraServer : 멀티플레이 게임을 만들 때에는 Client / Dedicated Server 프로그램을 따로 만들어 빌드해야 한다. 이를 구분하는 설정.

LyraGameSteam / LyraGameEOS : 스팀 업적 및 친구 초대 기능을 쓸 것인지, 에픽 온라인 서비스를 쓸 것인지 플랫폼에 따라 코드를 다르게 묶어 빌드하기 위한 설정.

 

LyraGame 안의 소스코드를 살펴보다보니까 생각보다 별 내용이 없어서 당황했는데, 알고보니 라이라는 핵심 소스에서 기능을 구현하기보다는 Plugins / GameFeatures 라는 별도의 플러그인 폴더에 기능을 분리해두고, 새로운 게임 모드나 무기를 추가할 때 기존 코드를 건드리지 않고 새로운 플러그인만 뗐다 붙였다 하도록 만들어 놓았다. 굉장히 세련된 방식이라 폴더를 천천히 살펴보면서 언리얼 C++ 아키텍처를 만들 때 참고하기로 했다.


*오늘의 총평*

전반적으로 충분한 학습량으로 공부를 해서 만족스러운 날이었다.

그 외에는 언리얼 C++ 작업을 위해서 전체적인 구조를 그리는 연습을 했고, 블루프린트 사용법도 충분히 연습을 하기 위해서 공식 계정 유튜브 영상을 따라하면서 지형을 만들어보았다.


 

'TIL' 카테고리의 다른 글

06.19 TIL  (0) 2026.06.19
06.18 TIL  (0) 2026.06.18
06.12 TIL  (0) 2026.06.12
06.11 TIL  (0) 2026.06.11
06.10 TIL  (0) 2026.06.10