일정표
| 시간 | 할 일 | 비고 |
| 08:00~10:00 | 코드카타, Atanny, 코드프로그래머스 | |
| 10:00~12:00 | TA | |
| 12:00~13:00 | 오늘의 수학 | |
| 14:00~18:00 | 언리얼 3-3~3-5 수강 | |
| 19:00~21:00 | 언리얼 4-1~4-4 수강 | |
| 21:00~22:00 | 운동 | |
| 22:00~23:00 | 개인 공부 |
*오늘의 코드카타*
문제. 행렬의 덧셈
행렬의 덧셈은 행과 열의 크기가 같은 두 행렬의 같은 행, 같은 열의 값을 서로 더한 결과가 됩니다. 2개의 행렬 arr1과 arr2를 입력받아, 행렬 덧셈의 결과를 반환하는 함수, solution을 완성해주세요
#include <vector>
using namespace std;
vector<vector<int>> solution(vector<vector<int>> arr1, vector<vector<int>> arr2) {
vector<vector<int>> answer(arr1.size(),
vector<int>(arr1[0].size(), 0));
for (int i = 0; i < arr1.size(); ++i) {
for (int j = 0; j < arr1[0].size(); ++j) {
answer[i][j] = arr1[i][j] + arr2[i][j];
}
}
return answer;
}
C+++의 2차원 vector는 사실 배열을 원소로 가지는 배열이다. arr1은 전체 행렬을 의미하고, arr1[0]은 그 행렬의 첫 번째 행(가로줄)을 의미하게 된다. 따라서, arr1[0].size()는 첫 번째 행에 들어있는 숫자의 개수, 즉 열의 개수를 뜻한다.
*프로그래머스*
1. [기초] n번째 원소부터
정수 리스트 num_list와 정수 n이 주어질 때, n 번째 원소부터 마지막 원소까지의 모든 원소를 담은 리스트를 return하도록 solution 함수를 완성해주세요
#include <vector>
using namespace std;
vector<int> solution(vector<int> num_list, int n) {
vector<int> answer;
for (int i = n - 1; i < num_list.size(); ++i) {
answer.emplace_back(num_list[i]);
}
return answer;
}
(1) 기초 방법으로 풀기
#include <vector>
using namespace std;
vector<int> solution(vector<int> num_list, int n) {
return vector<int>(num_list.begin() + n - 1, num_list.end());
}
(2) 반복자(Iterator)로 풀기.
#include <vector>
using namespace std;
vector<int> solution(vector<int> num_list, int n) {
vector<int> answer;
answer.assign(num_list.begin() + n - 1, num_list.end());
return answer;
}
(3) assign으로 배열 덮어 씌우기
2. [기초] 순서 바꾸기
정수 리스트 num_list와 정수 n이 주어질 때, num_list를 n 번째 원소 이후의 원소들과 n 번째까지의 원소들로 나눠 n 번째 원소 이후의 원소들을 n 번째까지의 원소들 앞에 붙인 리스트를 return하도록 solution 함수를 완성해주세요
#include <vector>
using namespace std;
vector<int> solution(vector<int> num_list, int n) {
vector<int> answer;
answer.reserve(num_list.size());
for (int i = n; i < num_list.size(); ++i) {
answer.emplace_back(num_list[i]);
}
for (int j = 0; j < n; ++j) {
answer.emplace_back(num_list[j]);
}
return answer;
}
(1) 기본 풀이 방식
#include <vector>
using namespace std;
vector<int> solution(vector<int> num_list, int n) {
vector<int> answer;
answer.reserve(num_list.size());
answer.insert(answer.end(), num_list.begin() + n, num_list.end());
answer.insert(answer.end(), num_list.begin(), num_list.begin() + n);
return answer;
}
(2) insert 사용하기
#include <vector>
#include <algorithm>
using namespace std;
vector<int> solution(vector<int> num_list, int n) {
rotate(num_list.begin(), num_list.begin() + n, num_list.end());
return num_list;
}
(3) rotate로 벡터 회전시키기
알고리즘 헤더에는 std::rotate라는 벡터를 기준점을 잡아 회전시킬 수 있는 함수가 있다. 새로운 벡터를 만들 필요 없이 전달받은 벡터를 직접 회전시켜서 반환하는 것이다.
rotate(시작점, 새로운 첫번째가 될 위치, 끝점 지정) 해서 배열을 회전시킨다. 방향은 그대로 회전시킨다고 생각하면 된다. (원점 기준 대칭 회전 아님)
3. [기초] 왼쪽 오른쪽
문자열 리스트 str_list에는 "u", "d", "l", "r" 네 개의 문자열이 여러 개 저장되어 있습니다. str_list에서 "l"과 "r" 중 먼저 나오는 문자열이 "l"이라면 해당 문자열을 기준으로 왼쪽에 있는 문자열들을 순서대로 담은 리스트를, 먼저 나오는 문자열이 "r"이라면 해당 문자열을 기준으로 오른쪽에 있는 문자열들을 순서대로 담은 리스트를 return하도록 solution 함수를 완성해주세요. "l"이나 "r"이 없다면 빈 리스트를 return합니다
#include <string>
#include <vector>
using namespace std;
vector<string> solution(vector<string> str_list) {
vector<string> answer;
for (int i = 0; i < str_list.size(); ++i) {
if (str_list[i] == "l") {
for (int j = 0; j < i; ++j) {
answer.emplace_back(str_list[j]);
}
break;
}
else if (str_list[i] == "r") {
for (int j = i + 1; j < str_list.size(); ++j) {
answer.emplace_back(str_list[j]);
}
break;
}
}
return answer;
}
(1) for if 사용한 기본 풀이
#include <string>
#include <vector>
using namespace std;
vector<string> solution(vector<string> str_list) {
for (int i = 0; i < str_list.size(); ++i) {
if (str_list[i] == "l") {
return vector<string>(str_list.begin(), str_list.begin() + i);
}
else if (str_list[i] == "r") {
return vector<string>(str_list.begin() + i + 1, str_list.end());
}
}
return vector<string>();
}
(2) vector 의 반복자를 활용해서 풀기. 반복자를 사용해서 원하는 구간만 잘라내어 새로운 벡터를 생성할 수 있다.
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
vector<string> solution(vector<string> str_list) {
auto it = find_if(str_list.begin(), str_list.end(),
[](const string& s)
{ return s == "l" || s == "r"; });
if (it != str_list.end()) {
if (*it == "l") {
return vector<string>(str_list.begin(), it);
}
else {
return vector<string>(it + 1, str_list.end());
}
}
return vector<string>();
}
(3) find 사용한 풀이
find를 사용하면 l을 찾고 만약 없으면 r을 찾는데 이때 결국 2번 순회하게 된다. 굉장히 비효율적인 방식이 되어버린다.
따라서, find_if()를 사용한다. 배열을 한 번만 순회하면서 l 이나 r 중 먼저 나오는 것을 찾기 위해서 <algorithm> 헤더의 find_if() 와 람다 표현식을 결합해서 문제를 풀어보았다.
4. [기초] n번째 원소까지
정수 리스트 num_list와 정수 n이 주어질 때, num_list의 첫 번째 원소부터 n 번째 원소까지의 모든 원소를 담은 리스트를 return하도록 solution 함수를 완성해주세요
#include <vector>
using namespace std;
vector<int> solution(vector<int> num_list, int n) {
vector<int> answer(num_list.begin(), num_list.begin() + n);
return answer;
}
(1) 가장 기초적인 방법. 굳이 for 반복문을 사용해서 하나씩 집어넣지 않았다.
#include <algorithm>
#include <vector>
using namespace std;
vector<int> solution(vector<int> num_list, int n) {
vector<int> answer;
copy_n(num_list.cbegin(), n, back_inserter(answer));
return answer;
}
(2) 알고리즘 헤더의 copy_n()을 사용하는 방법.
copy_n : 이름 그대로 시작점부터 n개를 복사해라라는 의미를 가진다. for 문의 0~n-1 까지 순회하는 과정을 하나로 압축한 것과 같다.
num_list.cbegin() : 복사를 시작할 위치를 나타낸다. begin()을 사용해도 되지만, 'const'의 의미를 담은 cbegin()을 사용한다. 이렇게 되면 읽기 전용으로 처음부터 순회하겠다는 의도를 전달하면서 안정성을 높일 수 있다.
n : n번째를 가져다가 붙이라는 의미이다.
back_inserter(answer) : 복사한 데이터를 어디에 담을 것인지 정한다. <vector> 헤더 또는 <iterator> 헤더를 인클루드하면 사용할 수 있다. answer는 빈 벡터이기 때문에 그냥 값을 넣을 수 없는 상태이다. 따라서 back_inserter를 씌워줌으로써 복사된 원소가 들어올 때마다 자동으로 answer.push_back을 호출하여 공간을 늘리면서 값을 뒤에 추가하는 것이다.
5. [기초] n개 간격의 원소들
정수 리스트 num_list와 정수 n이 주어질 때, num_list의 첫 번째 원소부터 마지막 원소까지 n개 간격으로 저장되어있는 원소들을 차례로 담은 리스트를 return하도록 solution 함수를 완성해주세요
#include <vector>
using namespace std;
vector<int> solution(vector<int> num_list, int n) {
vector<int> answer;
for (int i = 0; i < num_list.size(); i+=n) {
answer.emplace_back(num_list[i]);
}
return answer;
}
(1) 기본 풀이
#include <vector>
#include <ranges> // C++20 Ranges 라이브러리
using namespace std;
vector<int> solution(vector<int> num_list, int n) {
// num_list를 n 간격(stride)으로 순회하는 뷰(view)를 생성
auto strided_view = num_list | views::stride(n);
// 뷰의 결과를 vector로 변환하여 반환
return vector<int>(strided_view.begin(), strided_view.end());
}
(2) C++20 이상에서 사용할 수 있는 <ranges> 라이브러리의 views::stride 사용하기
코딩테스트에서는 사용할 수 없지만, 'n 간격으로 가져온다' 를 직관적으로 구현한 함수다.
*Atanny*
1. 생성자와 소멸자, 접근 제어자의 이해
핵심 개념 :
생성자와 소멸자는 객체의 생명주기를 관리하는 특별한 함수이다. 생성자는 객체가 만들어질 때 자동으로 호출되어 초기화 작업을 담당하고, 소멸자는 객체가 메모리에서 제거될 때(프로그램이 종료되거나 객체의 범위를 벗어날 때) 자동으로 호출되어 정리 작업을 한다.
접근 제어자(public, private, protected)는 클래스의 멤버에 누가 접근할 수 있는지를 결정하는 규칙이다. public이면 누구나 접근 가능하고, private면 클래스 내부에서만 접근할 수 있다.
소멸자는 클래스 이름 앞에 ~(틸드) 기호를 붙여 선언한다. 소멸자는 오버로딩이 불가능하며(한 클래스에 하나만 존재한다는 뜻), 매개변수를 가질 수 없다. 또한 명시적으로 정의하지 않아도 컴파일러가 기본 소멸자를 자동으로 생성하여 객체 소멸 시 호출한다.
2. 포인터와 동적 메모리 관리
핵심 개념 :
포인터는 메모리 주소를 저장하는 변수이다. 변수가 어디에 저장되어 있는지를 가리키는 화살표 같은 역할을 한다.
동적 메모리는 프로그램이 실행되는 동안에 필요에 따라 할당하고 해제하는 메모리 공간이다. 필요한 만큼만 사용할 수 있다는 장점이 있다.
포인터 다루기 :
포인터를 다룰 때에는 &(주소 연산자)와 *(역참조 연산자) 두 가지를 반드시 구분해야 한다.
&를 붙이면 주소값을 알 수 있게 되며, *를 붙이면 해당 주소에 저장되어있는 값을 알 수 있게 된다.
포인터를 통한 간접 접근으로 원본 ㄴ변수의 값을 직접 수정할 수 있다.
int* ptr; : ptr은 '정수 주소를 가리키는 포인터' 라는 뜻이다.
*ptr : ptr이 '가리키는 곳의 값' 이라는 뜻이다.
동적 메모리 할당과 연결 :
new 키워드로 메모리를 할당하면, 그 메모리의 주소가 포인터에 저장된다. 나중에 delete로 명시적으로 해제할 수 있다. 포인터와 메모리는 함께 움직이며 프로그램에 유연성을 준다.
3. 함수와 연산자 오버로딩 이해
핵심 개념 :
함수 오버로딩은 같은 이름의 함수를 여러 개 만들 수 있으나 매개변수의 개수나 타입을 다르게 정의하는 것이다. 컴퓨터는 함수를 호출할 때 전달하는 매개변수를 보고 자동으로 올바른 함수를 찾아 실행한다(컴파일 타임에 결정됨)
연산자 오버로딩의 경우도 비슷하다. +, -, * 같은 기본 연산자들을 사용자가 정의한 새로운 타입(클래스)에 맞게 다시 정의하는 것이다. 예를 들어 vector 크래스가 있으면 두 벡터를 + 기호로 더할 수 있도록 만드는 식이다.
오버로딩 : 매개변수가 다르지만 같은 이름으로 여러버전이 존재함. 컴파일 타임에 결정된다.
오버라이딩 : 상속 관계에서, 부모 함수를 자식이 재정의하는 것을 말한다. 런타임에 결정된다.
연산자 오버로딩 :
#include <iostream>
class Vector2D {
private:
double x;
double y;
public:
// 생성자: 초기값을 설정
Vector2D(double x = 0.0, double y = 0.0) : x(x), y(y) {}
// 연산자 오버로딩 (operator+)
// 현재 객체(this)와 더해질 다른 객체(other)의 x, y 값을 각각 더해 새로운 벡터를 반환
Vector2D operator+(const Vector2D& other) const {
return Vector2D(this->x + other.x, this->y + other.y);
}
// 결과를 확인하기 위한 출력 함수
void print() const {
std::cout << "(" << x << ", " << y << ")" << std::endl;
}
};
int main() {
// 두 개의 벡터 생성
Vector2D v1(3.0, 4.0);
Vector2D v2(1.5, 2.5);
// 3. 오버로딩된 + 연산자 사용
// 내부적으로 v1.operator+(v2) 호출
Vector2D v3 = v1 + v2;
std::cout << "v1: "; v1.print();
std::cout << "v2: "; v2.print();
std::cout << "v1 + v2 = v3: "; v3.print();
return 0;
}
4. 템플릿과 제네릭 프로그래밍의 이해
핵심 개념 :
템플릿은 자료형을 마치 변수처럼 다루어서 같은 로직을 여러 타입에 대해 한 번에 작성할 수 있게 해주는 기능(컴파일 시간에 타입에 따라 코드를 자동으로 생성하는 기능)이다. 예를 들어 정수 배열의 합을 구하는 함수와 실수 배열의 합을 구하는 함수를 따로 만드는 대신, 타입 자체를 매개변수로 받아서 하나의 함수로 처리하는 것이다. 이렇게 타입을 일반화해서 프로그래밍하는 방식을 제네릭 프로그래밍이라고 한다. 템플릿은 타입 안정성을 유지하면서 동시에 코드 재사용성을 높이는 기법이다.
클래스 템플릿 :
클래스 템플릿은 인스턴스 생성 시 타입 매개변수를 명시적으로 지정해야 한다. 여러 개의 타입 매개변수를 가질 수 있고, 타입에 독립적인 컨테이너를 만들 수 있다. 클래스 템플릿은 함수 템플릿과 달리 타입 추론이 자동으로 되지 않는다. (C++ 17 이후에는 타입 추론이 자동으로 가능)
*수학 공부*
이번 주제 : 충돌 및 물리 수학
게임에서 캐릭터가 벽을 뚫고 지나가지 않게 하거나, 스킬/탄약이 적에게 맞았는지 판별하는 것은 물리 엔진의 가장 기본적인 역할이다. 충돌 판별의 핵심은 "복잡한 3D 모델을 수학적으로 얼마나 단순하게 감쌀 것인가"에 달려있다.
1. AABB(Axis-Aligned Bounding Box)
AABB는 '축에 정렬된 경계 상자'라는 뜻으로, 상자가 회전하지 않고 항상 X, Y, Z 축과 나란하게 고정되어있다. 또한 AABB는 수학적으로 딱 두개의 점, Min과 Max만으로도 정의된다.
이러한 특징 덕분에 충돌 판별 공식이 매우 단순해진다. 두 상자의 X, Y, Z축이 모두 겹쳐 있으면 Overlap, 충돌 판정을 내릴 수 있게 되는 것이다.
(사용 예시)
struct FAABB {
FVector Min;
FVector Max;
};
bool CheckCollisionAABB(const FAABB& BoxA, const FAABB& BoxB)
{
// 3차원 축(X, Y, Z)이 모두 겹쳐야만 충돌로 인정
bool bOverlapX = (BoxA.Max.X > BoxB.Min.X) && (BoxA.Min.X < BoxB.Max.X);
bool bOverlapY = (BoxA.Max.Y > BoxB.Min.Y) && (BoxA.Min.Y < BoxB.Max.Y);
bool bOverlapZ = (BoxA.Max.Z > BoxB.Min.Z) && (BoxA.Min.Z < BoxB.Max.Z);
return bOverlapX && bOverlapY && bOverlapZ;
}
장점 : 덧셈과 대소 비교밖에 없어서 연산이 매우 빠르다.
단점 : 상자가 회전할 수 없으므로, 대각선으로 된 물체나 복잡한 모양의 물체를 정밀하게 덮을 수 없다.
2. 구형 충돌(Bounding Sphere)
캐릭터의 폭발 범위나 채집 사거리를 판별할 때 주로 사용하는 방법. 중심점(Center)과 반지름(Radius)만으로 이루어진 구형 충돌을 사용한다.
두 점 사이의 거리를 구할 때 피타고라스 정리를 쓰면 제곱근 연산이 되어 매우 무거운 연산을 하게 되므로, 충돌 수학에서는 반지름의 합도 제곱하여 사용하는 최적화 기법을 필수적으로 사용한다.

(사용 예시)
struct FBoundingSphere {
FVector Center;
float Radius;
};
bool CheckCollisionSphere(const FBoundingSphere& SphereA, const FBoundingSphere& SphereB)
{
// 중심점 사이의 벡터
FVector Dir = SphereA.Center - SphereB.Center;
// sqrt()를 쓰지 않는 거리 제곱 연산
float DistSquared = Dir.SizeSquared();
// 두 반지름을 합친 값의 제곱
float RadiusSumSquared = FMath::Square(SphereA.Radius + SphereB.Radius);
// 거리의 제곱과 반지름 합의 제곱을 비교
return DistSquared <= RadiusSumSquared;
}
3. 레이캐스트(Raycast / Line Trace)
주로 사격을 할 때 또는 마우스로 3D 공간의 물체를 클릭할 때 사용하는 수학 기법. 눈에 보이지 않는 무한한 선을 쏴서 그 선이 특정 평면이나 경계 상자(AABB or Sphere)와 교차하는지 수학적으로 판별한다.
수학적 선의 방정식 : P = P(0) + tD
해당 선의 방정식과 구형/상자의 방정식을 수학적으로 연립해서 교차점 t가 존재하는지, 그리고 정확히 어느 좌표(X, Y, Z)에서 부딪혔는지 알아내야 한다.
(사용 예시)
void AMyCharacter::FireHitscanWeapon()
{
FVector StartLocation = GetMesh()->GetSocketLocation("Muzzle");
FVector ForwardVector = GetMesh()->GetSocketRotation("Muzzle").Vector();
// 사거리 10,000 유닛
FVector EndLocation = StartLocation + (ForwardVector * 10000.0f);
FHitResult HitResult;
FCollisionQueryParams CollisionParams;
CollisionParams.AddIgnoredActor(this); // 나 자신은 쏘지 않도록 예외 처리
// 물리 엔진을 호출해 선(Ray)을 쏘고 교차점 판별 수학 연산을 GPU/CPU 내부에서 수행
bool bHit = GetWorld()->LineTraceSingleByChannel(
HitResult, StartLocation, EndLocation, ECC_Visibility, CollisionParams
);
if (bHit)
{
// 수학적으로 교차한 정확한 점(HitResult.ImpactPoint)에 이펙트 생성
UE_LOG(LogTemp, Warning, TEXT("충돌 좌표: %s"), *HitResult.ImpactPoint.ToString());
}
}
언리얼 엔진에서는 교차 수학을 LineTraceSingleByChannel 이라는 함수로 이미 구현해두었다. 나 자신은 쏘지 않도록 예외처리해야하고, StartLocation과 ForwardVector 를 사용해서 사거리를 구현할 수 있다.
4. OBB(Oriented Bounding Box, 방향성 경계 상자)
AABB의 문제점을 해결하기 위해서 물체의 회전에 맞춰 상자도 돌아가는 OBB가 등장하게 되었다. AABB는 최소점과 최대점 2개로 정의할 수 있었ㅎ으나 OBB는 회전을 기억해야 하므로 수학적 구조가 복잡해질 수밖에 없다. 즉, 상자 하나가 자기 자신만의 '로컬 공간 행렬'을 들고 다닌다고 생각하면 된다.
>> OBB를 수학적으로 정의하기 : 중심점(C), 방향 벡터(U, V, W), 절반 크기(e1, e2, e3 // Half-extent)
중심점 : 상자의 정중앙 좌표(월드 좌표계)
방향 벡터 : 상자의 로컬 X, Y, Z 축을 나타내는 3개의 정규화된 단위 벡터
절반 크기(Half-extent) : 중심점에서 각 축 방향으로 상자가 얼마나 뻗어 있는지 나타내는 Half-extents 길이.
>> OBB 충돌의 핵심 : 분리축 이론(SAT, Separating Axis Theorem)
OBB끼리 부딪혔는지 판별할 때에는 AABB처럼 단순히 좌표의 대소 비교만으로는 불가능하다. 여기서 게임 물리 엔진에서 자주 사용되는 수학 정리 중 하나인 분리축 이론이 등장하게 된다.
SAT의 원리 : "두 볼록한 도형 사이에 빛을 비췄을 때, 그립자가 겹치지 않는 각도가 단 하나라도 존재한다면 두 도형은 절대 충돌하지 않은 것이다."
이 그림자가 생기는 바닥 선을 수학에서는 분리축이라고 한다.
수학적 판별법 : 프로그래머는 수학적으로 이미 증명이 완료된 15개의 축만 검사하면 된다.
1) 상자 A의 로컬 X, Y, Z 축 (3개 축)
2) 상자 B의 로컬 X, Y, Z 축 (3개 축)
3) 상자 A의 축들과 상자 B의 축들을 서로 외적(Cross Product)해서 얻은 수직 축들 (3 * 3 = 9개 축)
15개의 축에 두 상자의 중심점과 크기를 내적하여 그림자를 구한다. 만약 15개의 축 중 단 하나라도 그림자가 겹치지 않는다면 즉시 False(충돌하지 않음)를 반환하고 연산을 종료한다. 반대로 15개 모두 그림자가 겹치면 True(충돌한 것)다.
(실무 C++ 개념 코드)
// 가상의 OBB 구조체
struct FOBB {
FVector Center;
FVector Axis[3]; // X, Y, Z 로컬 방향 벡터
float Extents[3]; // 절반 크기
};
// 특정 축(TestAxis)을 기준으로 두 OBB가 겹치는지 검사하는 핵심 함수
bool IsOverlapOnAxis(const FOBB& BoxA, const FOBB& BoxB, FVector TestAxis)
{
// 1. 두 상자의 중심점 사이의 거리를 검사 축에 투영(내적)
FVector DistanceVec = BoxB.Center - BoxA.Center;
float DistanceProjection = FMath::Abs(FVector::DotProduct(DistanceVec, TestAxis));
// 2. 상자 A의 절반 크기들을 검사 축에 투영한 그림자 길이 합산
float ShadowA = BoxA.Extents[0] * FMath::Abs(FVector::DotProduct(BoxA.Axis[0], TestAxis)) +
BoxA.Extents[1] * FMath::Abs(FVector::DotProduct(BoxA.Axis[1], TestAxis)) +
BoxA.Extents[2] * FMath::Abs(FVector::DotProduct(BoxA.Axis[2], TestAxis));
// 3. 상자 B의 절반 크기들을 검사 축에 투영한 그림자 길이 합산
float ShadowB = BoxB.Extents[0] * FMath::Abs(FVector::DotProduct(BoxB.Axis[0], TestAxis)) +
BoxB.Extents[1] * FMath::Abs(FVector::DotProduct(BoxB.Axis[1], TestAxis)) +
BoxB.Extents[2] * FMath::Abs(FVector::DotProduct(BoxB.Axis[2], TestAxis));
// 4. 중심점 사이의 거리(투영됨)가 두 그림자의 합보다 크면 분리된 것 (충돌 X)
if (DistanceProjection > (ShadowA + ShadowB))
{
return false; // 분리축 발견
}
return true; // 이 축에서는 그림자가 겹침
}
언리얼 엔진의 UBoxComponent가 회전할 수 있는 이유가 바로 내부적으로 OBB 수학(PhysX/Chaos 엔진의 SAT 알고리즘)을 사용하고 있기 때문이다.
다만, OBB연산은 볼 수 있다시피 생각보다 연산이 많이 들어가서 AABB나 구형 충돌에 비해 CPU 연산비용이 더 많이 소모된다.
따라서, 보통 넓은 구형 충돌로 1차 필터링을 하고 거기서 겹친 물체들로만 2차로 OBB 정밀 검사를 수행하는 식으로 최적화를 한다.
5. 언리얼 엔진 C++ 적용해보기
(1) 근접 전투의 다중 충돌 판별(Sweep)
검을 휘두르는 공격을 했다고 하면, 단순히 점이나 얇은 선만 사용하게 되었을 때 칼날의 궤적 사이에 있는 적들이 맞지 않고 무시되는 버그가 발생할 수 있다. 이를 해결하기 위해 구형이나 상자형으로 충돌체를 만들어서 시작 위치에서 끝 위치까지 밀어내면서(Sweep) 그 공간 안에 겹치는 모든 적을 한 번에 수학적으로 검출한다.
void AMyCharacter::PerformMeleeAttack()
{
FVector StartPos = WeaponMesh->GetSocketLocation("BladeStart");
FVector EndPos = WeaponMesh->GetSocketLocation("BladeEnd");
// 칼날을 감싸는 반경 50cm짜리 가상의 구(Sphere)를 수학적으로 생성
FCollisionShape AttackShape = FCollisionShape::MakeSphere(50.0f);
// 타격된 모든 결과를 담을 배열
TArray<FHitResult> HitResults;
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(this); // 내 캐릭터는 타격 대상에서 제외
// 물리 엔진을 통해 Start부터 End까지 구를 밀어내며 겹치는 모든 대상을 검출
bool bHit = GetWorld()->SweepMultiByChannel(
HitResult,
StartPos,
EndPos,
FQuat::Identity, // 구형이므로 회전은 무시
ECC_GameTraceChannel1, // 적 피격 채널
AttackShape,
QueryParams
);
if (bHit)
{
for (const FHitResult& Hit : HitResults)
{
// OBB나 AABB 등 각 적의 히트박스에 정확히 부딪힌 대상들에게 데미지 적용
ApplyDamageTo(Hit.GetActor());
}
}
}
언리얼에서는 구형 충돌 수학을 기반으로 한 SweepMultiByChannel 함수를 이용해서 궤적 충돌을 계산한다!!
(2) 히트스캔 무기와 지형 관통(Raycast + Normal)
흔히 말하는 월샷은 어떻게 구현할까? 총알을 발사하면, 단일 충돌이 아니라 무한한 선의 방정식을 쏴서 궤적상의 모든 교차점을 구한 뒤, 맞은 표면의 재질을 수학적으로 검사하는 것이다.
void AMySniperRifle::FirePiercingShot()
{
FVector CameraPos = PlayerCamera->GetComponentLocation();
FVector AimDirection = PlayerCamera->GetForwardVector();
FVector EndPos = CameraPos + (AimDirection * 15000.0f); // 150m 사거리
TArray<FHitResult> HitResults;
FCollisionQueryParams QueryParams(TEXT("SniperTrace"), true, GetOwner());
// 물리 재질(나무, 철판 등) 정보를 반환하도록 강제 설정
QueryParams.bReturnPhysicalMaterial = true;
// 광선을 쏴서 궤적 상의 모든 물체를 관통하며 수집
GetWorld()->LineTraceMultiByChannel(HitResults, CameraPos, EndPos, ECC_Visibility, QueryParams);
float CurrentDamage = 100.0f; // 초기 데미지
for (const FHitResult& Hit : HitResults)
{
// 부딪힌 표면의 재질 확인
UPhysicalMaterial* HitMaterial = Hit.PhysMaterial.Get();
if (HitMaterial && HitMaterial->SurfaceType == SurfaceType_Metal)
{
// 철판에 맞았다면 총알이 막힘 (루프 종료)
SpawnSparkEffect(Hit.ImpactPoint, Hit.ImpactNormal);
break;
}
else if (HitMaterial && HitMaterial->SurfaceType == SurfaceType_Wood)
{
// 나무에 맞았다면 관통하지만 데미지가 30% 감소함
CurrentDamage *= 0.7f;
SpawnWoodSplinterEffect(Hit.ImpactPoint, Hit.ImpactNormal);
continue; // 뚫고 계속 날아감
}
// 적에게 맞았다면 현재 남은 데미지를 적용
if (AActor* HitActor = Hit.GetActor())
{
DealDamage(HitActor, CurrentDamage);
}
}
}
(3) 수류탄 도탄과 반사(Reflection Vector)
수류탄을 벽에 튕기게 던지는 뱅크 샷은 어떻게 구현할까? 또는, 레이저 퍼즐 게임에서 거울에 레이저를 반사시킬 때 궤적을 어떻게 나타낼까?
답은 법선 벡터와 내적, 그리고 충돌 함수들을 결합하여 사용한다.
void AMyLaserTrap::FireBouncingLaser(FVector StartPos, FVector Direction, int32 MaxBounces)
{
FVector CurrentStart = StartPos;
FVector CurrentDir = Direction.GetSafeNormal();
for (int32 i = 0; i < MaxBounces; ++i)
{
FVector EndPos = CurrentStart + (CurrentDir * 5000.0f);
FHitResult Hit;
if (GetWorld()->LineTraceSingleByChannel(Hit, CurrentStart, EndPos, ECC_Visibility))
{
// 레이저를 발사 위치부터 부딪힌 곳까지 화면에 그림
DrawDebugLine(GetWorld(), CurrentStart, Hit.ImpactPoint, FColor::Red, false, 2.0f);
// 도탄 수학(Reflection) 적용
// 수학 공식: R = V - 2(V \cdot N)N
// 언리얼에서는 아래 함수 하나로 제공
FVector ReflectionDir = FMath::GetReflectionVector(CurrentDir, Hit.ImpactNormal);
// 반사된 위치와 방향을 다음 루프의 시작점으로 갱신
CurrentStart = Hit.ImpactPoint + (ReflectionDir * 1.0f); // 표면에 끼지 않게 살짝 띄움
CurrentDir = ReflectionDir;
}
else
{
// 아무것도 맞지 않았다면 끝까지 레이저를 그리고 종료
DrawDebugLine(GetWorld(), CurrentStart, EndPos, FColor::Red, false, 2.0f);
break;
}
}
}
언리얼 엔진에서는 FMath::GetReflectionVector(CurrentDir, Hit.ImpactNormal); 반사 방향(벡터)을 구하는 함수를 제공하고 있다.
*TA*
주제 : Technical Artist 이해하기
1. Technical Artist란 무엇인가
TA는 개발자와 아티스트 사이를 연결하는 역할이다. 아티스트가 만든 리소스가 엔진 안에서 제대로 사용되도록 돕고, 개발자가 만든 기능이 시각적으로 잘 표현되도록 연결한다.
<역할 예시> -> 절반은 엔진 개발, 절반은 아트 직군
리소스 파이프라인 정리 : 파일 이름 규칙, 폴더 구조, Import 설정 관리
머티리얼 구조 설계 : Parent Material, Material Instance 구조 정리
반복 작업 자동화: 에셋 이름 정리, 폴더 자동 분류, 검사 도구 제작
애니메이션 연결 : Animation Notify, 공격 타이밍, 피격 반응 연결
VFX 연결 : Niagara 이펙트 호출 위치와 타이밍 관리
엔진 기술 연구, 최적화 기준 수립 : Texture 크기, Material 복잡도, 파티클 수, Draw Call 관리
협업 기준 정리 : 개발자와 아티스트가 같은 기준으로 작업하도록 지원
2. 클라이언트 개발자에게 TA 기초가 필요한 이유
클라이언트 개발자는 보통 기능을 구현한다. 실제 게임 화면에서 보이는 여러 기능들이 있는데, 공격 모션, 무기 궤적 이펙트, 피격 이펙트, 사운드, 카메라 흔들림, 머티리얼 변화가 함께 연결되어야 플레이어가 타격감과 몰입감을 느끼게 된다.
3. 좋은 문제 설명 예시
TA 기초를 알면 문제를 더 구체적으로 설명할 수 있다.
이펙트가 이상하다 -> 공격 Notify 타이밍은 맞는데 Niagara System 이 Socket 위치와 어긋나있다.
머티리얼 색이 이상하다 -> Lightning 환경과 Roughness 값 때문에 반사가 강하게 보이고 있다.
애니메이션이 이상하다 -> Root Motion과 캐릭터 이동 로직이 충돌하고 있다.
이런식으로 어느 시스템을 확인해야 하는지 좁혀갈 수 있게 된다. 어디가 맘에 안드는지 명확하게 이야기할 수 있게 된다는 뜻이다.
4. 게임 화면을 구성하는 기본 요소
Mesh : 게임 오브젝트의 형태를 만드는 3D 구조.
ㄴ Static Mesh : 뼈대 구조가 없는 정적인 오브젝트.
ㄴ Skeletal Mesh : Bone과 Skeleton 구조를 가진 오브젝트.
Texture : Mesh 표면에 적용되는 이미지 데이터. 단순히 색을 입히는 이미지가 아니라, 표면의 여러 정보를 담는다.
ㄴ Base Color(기본 색상), Normal Map(표면의 미세한 굴곡 표현), Roughness Map(표면의 거칠기 표현), Metalic Map(금속성 표현), Ambient Occlusion(틈새나 접촉부의 어두움 표현)
Material : 오브젝트 표면이 어떻게 보일지 결정한다. 같은 Mesh라도 Materiaal에 따라 플라스틱, 금속, 유리, 돌, 피부처럼 보이게 할 수 있다.
ㄴ Base Color(표면 색상), Roughness(표면의 거칠기), Metallic(금속성), Normal(표면 굴곡), Emissive(스스로 빛나는 표현), Opacity(투명도)
Lighting : 장면의 빛. 분위기와 시선 유도에 영향을 준다.
ㄴ 분위기 형성 : 따뜻함, 차가움, 긴장감
ㄴ 시선 유도 : 중요한 오브젝트를 강조
ㄴ 공간감 형성 : 그림자를 통해 깊이감 제공
ㄴ 플레이 경험 보조 : 위험 지역, 안전 지역, 목표 지점 강조
Animation : 캐릭터나 오브젝트의 움직임을 표현. 게임에서 Animation은 단순 재생 영상이 아니라 입력, 상태, 판정, 피드백과 연결된다. 예를 들어, 공격 애니메이션에서 중요한 요소를 알아보자.
ㄴ 공격 시작 타이밍, 무기 이동 타이밍, 판정 타이밍, 이펙트 타이밍, 후속 행동 타이밍
VFX : 게임에서 발생하는 시각 효과. 언리얼에서는 주로 Niagara를 사용한다 (Cascade는 구버전. 동적 변화 적용이 안됨)
ㄴ 공격 궤적 : 공격 방향과 범위 전달
ㄴ 피격 이펙트 : 적중 여부 전달
ㄴ 폭발 : 강한 충격 표현
ㄴ 먼지/불꽃 : 상황의 질감 표현
ㄴ 마법 효과 : 스킬의 성격 표현
ㄴ 위험 범위 표시 : 플레이어에게 회피 정보 전달
Blueprint / Game Logic : Blueprint와 C++은 게임 로직을 담당한다. 하지만 시각 리소스와 분리되어 있지는 않다. 예를 들어보자.
ㄴ Animation : Montage 재생
ㄴ VFX : Niagara Effect 생성
ㄴ Material : Material Parameter 변경
ㄴ Sound : 공격음, 피격음 재생
ㄴ Camera : Camera Shake 호출
ㄴ UI : 체력바, 스킬 아이콘 표시
ㄴ Mesh : 무기 교체, 캐릭터 외형 변경
5. 화면 구성 요소 분해하기
"포트폴리오 작성" 관점, "공부를 카테고리별로 잘 하고 있는지" 체크하는 데에도 효과적이다.
ㄴ Mesh : 화면의 주요 3D 오브젝트는 무엇인가?
ㄴ Texture : 표면 색상, 패턴, 디테일은 어디에서 보이는가?
ㄴ Material : 금속, 돌, 천, 피부, 유리처럼 보이는 표면은 어디인가?
ㄴ Lighting : 빛은 어느 방향에서 오는가? 그림자는 어떤 역할을 하는가?
ㄴ Animation : 움직임이 예상되는 오브젝트는 무엇인가?
ㄴ VFX : 불꽃, 먼지, 충격파, 마법, 잔상 효과가 있는가?
ㄴ UI : 플레이어에게 정보를 전달하는 요소는 무엇인가?
ㄴ Game Logic : 이 화면은 어떤 이벤트나 상태에서 만들어졌을까?
6. 게임 액션 리소스 연결 구조 분석 (실습)
| 1 | 플레이어가 Attack 입력을 누른다 |
| 2 | Character Blueprint에서 공격 상태로 전환한다 |
| 3 | Attack Animation Montage를 재생한다 |
| 4 | Anim Notify 시점에 공격 판정을 실행한다 |
| 5 | Weapon Socket 위치에서 Slash VFX를 호출한다 |
| 6 | Trace 또는 Collision으로 적중 여부를 확인한다 |
| 7 | 적중 시 Hit VFX와 Sound를 호출한다 |
| 8 | Enemy에게 Damage를 적용한다 |
| 9 | Enemy Hit Reaction Animation을 재생한다 |
| 10 | 필요 시 Camera Shake 또는 Material Flash를 호출한다 |
이렇게 하나의 게임 화면을 보고 단계별로 생각할 수 있어야 한다. (의사 코드를 만드는 과정)
7. 실제 실습 (오버워치 분석해보기)
1. 선택한 게임과 액션
- 오버워치, 아나의 생체 수류탄 투척
2. 시작 이벤트
- E 버튼을 입력
3. 필요한 리소스
- Mesh: 캐릭터 스켈레톤 메쉬, 총 스켈레톤 메쉬(노리쇠), 수류탄 스태틱 메쉬, 적 스켈레톤 메쉬, 지형지물 스태틱 메쉬
- Animation: 수류탄 투척 애니메이션, 연습용 봇 피격시 애니메이션, 이동과 투척을 동시에 하기 위한 Anim Montage
- VFX: 수류탄 투척 궤적 VFX, 수류탄 폭발 시 VFX, 수류탄 피격시 상태 이상 표시
- Material: 총의 금속 재질, 연습용 봇 금속 재질, 상태 이상 표현(아군 및 적군)을 위한 동적 머티리얼 인스턴스,
- Sound: 수류탄 투척 시 효과음, 수류탄 폭발 시 효과음, 수류탄 피격시 효과음
- Camera: 카메라 시점 고정
- UI: 애니메이션 동작 시간 동안에는 노란색으로 팝업되며 추가 입력 불가, 애니메이션 끝난 후 쿨타임 계산
쿨타임 계산 동안에는 회색으로 변경되며 시계방향으로 돌아가며 쿨타임 시각화
애니메이션 동작 시간 동안에는 다른 스킬 UI가 빨간색으로 바뀌며 사용 불가 상태 시각화
적 적중시 화면 중앙 크로스헤어에 표시되는 Hit Marker
4. 연결 흐름
1) 플레이어가 E키(또는 할당된 스킬 입력키)를 입력한다.
2) Character Blueprint에서 스킬 쿨타임 및 사용 가능 여부를 체크하고 수류탄 투척 상태로 전환한다
3) 수류탄 투척 Anim Montage를 재생한다.
4) Anim Notify가 트리거되고, Notify 호출 시점의 Hand Socket 위치를 기준으로 수류탄 액터를 생성한다
5) 수류탄이 포물선 궤적으로 비행하고, 궤적을 따라 Trail VFX를 남긴다.
6) 수류탄이 캐릭터나 지형의 콜리전 채널과 Overlap 또는 Block 되면 폭발 이벤트를 발생시킨다.
7) 이벤트 발생 시에 Hit/Explosion VFX와 Sound를 호출한다.
8) 타겟의 Tag를 확인하여 Enemy에게 Damage와 상태 이상을 적용하고, 아군에게는 치유 및 힐 증폭 버프를 적용한다.
9) Enemy의 상태 이상을 확인할 수 있도록 상태 이상 VFX 및 Hit Reaction Animation을 재생한다.
5. 역할 분리
- 개발자 담당:
입력 바인딩 및 캐릭터 상태 관리, 쿨타임 시스템 구현하기
수류탄 C++ 클래스 작성 및 UProjectileMovementComponent 세팅하기(수류탄 포물선 궤적)
충돌 판정 로직 작성 및 범위 내 아군 및 적군 식별 로직 구현하기
- 아티스트 담당:
수류탄 3D 메쉬 및 텍스처 제작
1인칭/3인칭 관점의 수류탄 투척 애니메이션 제작 (Anim Montage)
Niagara system을 활용한 궤적 및 폭발 VFX
스킬 아이콘, 쿨타임 타이머 등 UMG에 들어갈 에셋 제작
- TA 관점에서 확인할 부분:
동기화 : 애니메이션 + 투사체 생성 로직이 정확한 타이밍에 맞물리도록 Anim Notify 조절
최적화 : Niagara 파티클의 Culling 설정, 오버드로우 최소화하기
머티리얼 및 렌더링 : 동적 머티리얼 인스턴스의 파라미터 세팅하기, 데칼 프로젝션 설정
콜리전 : 충돌 채널 분리 및 세팅
6. 정리
1) Anim Notify로 애니메이션 몽타주와 C++ 함수를 호출을 정확하게 동기화해야
플레이어가 훌륭한 조작감을 경험할 수 있을 것.
2) 수류탄이 폭발하는 지점을 기준으로 반경 내의 적 또는 아군 캐릭터에게 각각 다른 로직으로
상태 이상 or 효과가 적용되어야 함
*오늘의 총평*
언리얼 3D 강의를 들으며 각 아이템별 상호작용 로직, Item Random Spawn, GameMode와 GameState 설정 등을 C++을 통해서 구현했다.
TA 직군에 대해서 이해하고 실제 어떤 일을 하는지 가볍게 살펴보았다. 실제 게임의 완성도를 담당한다고 생각되었다. 팀프로젝트를 하면서 많이 연습해봐야겠다.
공부 양이나 밀도가 모자란 것은 아니지만, C++ 기본기를 채우기 위한 코딩테스트 연습이 조금 소홀해진 것 같다. 쉬운 문제로 개수를 많이 채우다보니 그런 것 같은데, 어려운 문제를 여러 각도에서 살펴보면서 가독성과 성능을 최적화 할 수 있는 연습을 하는 방향으로 하는 게 좋을 것 같다.
