일정표
| 시간 | 할 일 | 비고 |
| 08:00~10:00 | 코드카타, Atanny, 프로그래머스 | |
| 10:00~13:00 | 언리얼 3D 게임 만들기 수강 (3-1~3-5) | 따라하면서 진행 |
| 14:00~18:00 | 언리얼 3D 게임 만들기 수강 (3-1~3-5) | 따라하면서 진행 |
| 19:00~21:00 | 언리얼 3D 게임 만들기 수강 (3-1~3-5) | 따라하면서 진행 |
| 21:00~22:00 | 운동 | |
| 22:00~23:30 | 개인 프로젝트 및 개인 공부 | 디자인 패턴(2) |
*오늘의 코드카타*
문제. 문자열 다루기 기본
문자열 s의 길이가 4 혹은 6이고, 숫자로만 구성돼있는지 확인해주는 함수, solution을 완성하세요. 예를 들어 s가 "a234"이면 False를 리턴하고 "1234"라면 True를 리턴하면 됩니다
#include <string>
#include <cctype>
using namespace std;
bool solution(string s) {
bool answer = true;
if (s.length() != 4 && s.length() != 6) {
answer = false;
}
for (char c : s) {
if (!isdigit(c)) {
answer = false;
break;
}
}
return answer;
}
cctype 헤더의 isdigit 함수를 이용해서 숫자가 아니면 answer = false;를 반환하고 즉시 range for문을 종료하도록 했다.
*프로그래머스*
1. [입문] 영어가 싫어요
영어가 싫은 머쓱이는 영어로 표기되어있는 숫자를 수로 바꾸려고 합니다. 문자열 numbers가 매개변수로 주어질 때, numbers를 정수로 바꿔 return 하도록 solution 함수를 완성해 주세요
#include <string>
using namespace std;
long long solution(string numbers) {
long long answer = 0;
int i = 0;
while (i < numbers.length()) {
switch (numbers[i]) {
case 'z': // zero
answer = answer * 10 + 0;
i += 4;
break;
case 'o': // one
answer = answer * 10 + 1;
i += 3;
break;
case 't':
if (numbers[i+1] == 'w') { // two
answer = answer * 10 + 2;
i += 3;
} else { // three
answer = answer * 10 + 3;
i += 5;
}
break;
case 'f':
if (numbers[i+1] == 'o') { // four
answer = answer * 10 + 4;
i += 4;
} else { // five
answer = answer * 10 + 5;
i += 4;
}
break;
case 's':
if (numbers[i+1] == 'i') { // six
answer = answer * 10 + 6;
i += 3;
} else { // seven
answer = answer * 10 + 7;
i += 5;
}
break;
case 'e': // eight
answer = answer * 10 + 8;
i += 5;
break;
case 'n': // nine
answer = answer * 10 + 9;
i += 4;
break;
}
}
return answer;
}
(1) switch case문을 이용한 하드코딩 방식의 풀이
이런식으로 하드 코딩하는 것은 지양해야 하는 일이므로(성능 면에서는 좋지만), 다른 방법을 사용해보자.
#include <string>
#include <vector>
using namespace std;
long long solution(string numbers) {
vector<string> words = {"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"};
for (int i = 0; i < 10; i++) {
size_t pos = 0;
while ((pos = numbers.find(words[i], pos)) != string::npos) {
numbers.replace(pos, words[i].length(), to_string(i));
pos += 1;
}
}
return stoll(numbers);
}
(2) vector에 글자를 넣어두고 find replace 활용하기
position을 size_t pos로 선언해두고, 문자열 안에서 특정 단어를 찾았을 때 그 단어가 시작하는 위치를 기억하고 추적하는 책갈피를 만들어둔다.
numbers.find(words[i], pos)가 실행되면, 문자열에서 해당 영단어가 시작하는 위치를 찾아낸다. 이후 pos에 그 위치 값을 저장해 두는 것이다.
맨 처음 two 를 찾는다고 해보자. two를 찾으면 pos는 0이 되고 two를 2로 바꾼다(문자열 안에서 two -> 2). 그 다음 코드가 pos += 1을 통해 pos를 1로 바꾸게 된다. 만약 twotwo이런식으로 문자열이 있어도 pos가 0에서 1로 증가했기 때문에 인덱스 1번부터 다시 찾게 되므로 이미 바꾼 앞부분은 건너뛰고 뒤에 남아있는 두 번째 two까지 성공적으로 찾아낼 수 있다. 더 이상 단어가 없을 때 루프를 종료해야 하므로 != string::npos 를 사용한다.
2. [입문] 인덱스 바꾸기
문자열 my_string과 정수 num1, num2가 매개변수로 주어질 때, my_string에서 인덱스 num1과 인덱스 num2에 해당하는 문자를 바꾼 문자열을 return 하도록 solution 함수를 완성해보세요
#include <string>
using namespace std;
string solution(string my_string, int num1, int num2) {
string answer = "";
char temp = my_string[num1];
my_string[num1] = my_string[num2];
my_string[num2] = temp;
answer = my_string;
return answer;
}
(1) 순수한(?) 방법으로 먼저 풀어보았다.
#include <string>
#include <algorithm>
using namespace std;
string solution(string my_string, int num1, int num2) {
swap(my_string[num1], my_string[num2]);
return my_string;
}
(2) algorithm 헤더의 swap 을 사용해서 더 간단하게 작성할 수 있다. 코드의 가독성도 개선되고, 실수도 줄일 수 있다.
3. [입문] 한 번만 등장한 문자
문자열 s가 매개변수로 주어집니다. s에서 한 번만 등장하는 문자를 사전 순으로 정렬한 문자열을 return 하도록 solution 함수를 완성해보세요. 한 번만 등장하는 문자가 없을 경우 빈 문자열을 return 합니다.
#include <string>
#include <vector>
using namespace std;
string solution(string s) {
string answer = "";
vector<int> count(26,0);
for (char c : s) {
count[c - 'a']++;
}
for (int i = 0; i < 26; i++) {
if (count[i] == 1) {
answer += (i + 'a');
}
}
return answer;
}
(1) 배열 만들어서 count하고, count == 1인 경우에 answer 문자열에 해당 문자 밀어넣기(소문자여서 가능함)
answer += (i + 'a') 에서, i + 'a'가 계산될 때 char 자료형인 'a'는 계산을 위해 아스키코드 값인 정수로 자동 변환된다. 그래서 i + 'a' 면 우리가 원하는 char 자료형의 아스키코드 값으로 바뀌게 되는 것이고, C++ string 에서는 += 연산자를 가지고 우측에 숫자가 오면 이를 해당 아스키코드의 문자로 취급하여 문자열 끝에 붙이도록 설계되어있다. 따라서, answer는 string 자료형이고 += int 98 꼴의 계산이지만 C++ 에서는 char형으로 묵시적 형변환이 일어난다.
#include <string>
using namespace std;
string solution(string s) {
string answer = "";
for (char c = 'a'; c <= 'z'; c++) {
size_t first_pos = s.find(c);
if (first_pos != string::npos && first_pos == s.rfind(c)) {
answer += c;
}
}
return answer;
}
(2) find, rfind 사용하기
문자열 탐색 함수 find를 이용해보자. a부터 z까지 반복하면서 해당 문자가 처음 등장하는 위치(find)를 찾고, 마지막으로 등장하는 위치(rfind)가 같다면 한 번만 등장한 것으로 간주한다.
#include <string>
#include <algorithm>
using namespace std;
string solution(string s) {
string answer = "";
sort(s.begin(), s.end());
int n = s.length();
for (int i = 0; i < n; i++) {
int count = 1;
while (i+1 < n && s[i] == s[i+1]){
count++;
i++;
}
if (count == 1) {
answer += s[i];
}
}
return answer;
}
(3) sort 사용하기
sort로 문자열을 정렬하면 같은 문자들이 서로 이웃하게 나열된다는 특성이 생긴다. 이를 사용해서 문제를 풀어보자.
반복문을 돌면서 인접한 문자가 같다면 중복을 건너뛰어버리면 된다.
현재 문자와 다음 문자가 같다면(s[i] == s[i+1]) 건너뛰면서 빈도수를 체크(count++)한다. 또한 중복된 문자만큼 인덱스를 미리 뒤로 보낸다(i++;). 이렇게 for + while문을 반복한 후에, count가 1인 s[i]를 answer 문자열에 추가해준다.
4. [입문] 약수 구하기
정수 n이 매개변수로 주어질 때, n의 약수를 오름차순으로 담은 배열을 return하도록 solution 함수를 완성해주세요
#include <string>
#include <vector>
using namespace std;
vector<int> solution(int n) {
vector<int> answer;
for (int i = 1; i <= n; i++) {
if (n % i == 0) {
answer.push_back(i);
}
}
return answer;
}
(1) 기초적인 방식으로 푼 경우
이렇게 하면 n이 점점 커질수록 계산 시간이 커진다는 단점이 있다. 다른 방법을 배운대로 써먹어보자.
#include <vector>
#include <algorithm>
using namespace std;
vector<int> solution(int n) {
vector<int> answer;
for (int i = 1; i*i <= n; i++) {
if (n % i == 0) {
answer.push_back(i);
if (i*i != n) {
answer.push_back(n/i);
}
}
}
sort(answer.begin(), answer.end());
return answer;
}
(2) vector + sort 사용하기
약수 하면 sqrt(n).
그냥 i = 1부터 i*i = n까지만 탐색해보고 i*i != n 일경우에만 n / i 를 추가로 answer 벡터에 밀어넣는다. 이후 sort로 오름차순 정렬해준다.
#include <vector>
#include <set>
using namespace std;
vector<int> solution(int n) {
set<int> s;
for (int i = 1; i*i <= n; i++) {
if (n % i == 0) {
s.insert(i);
s.insert(n/i);
}
}
return vector<int>(s.begin(), s.end());
}
(3) set 자료구조 특성 활용하기
set을 사용하면 굳이 i*i != n 조건을 넣을 필요도 없다. 그동안 vector의 push_back만 많이 사용했으니까 이번엔 set의 insert를 사용해서 값을 넣어보았다. 이후 set에 담긴 값들을 vector<int>(s.begin(), s.end()) 로 정답 배열을 반환하면 된다.
5. [기초] 리스트 자르기
정수 n과 정수 3개가 담긴 리스트 slicer 그리고 정수 여러 개가 담긴 리스트 num_list가 주어집니다. slicer에 담긴 정수를 차례대로 a, b, c라고 할 때, n에 따라 다음과 같이 num_list를 슬라이싱 하려고 합니다
n = 1 : num_list의 0번 인덱스부터 b번 인덱스까지
n = 2 : num_list의 a번 인덱스부터 마지막 인덱스까지
n = 3 : num_list의 a번 인덱스부터 b번 인덱스까지
n = 4 : num_list의 a번 인덱스부터 b번 인덱스까지 c 간격으로 올바르게 슬라이싱한 리스트를 return하도록 solution 함수를 완성해주세요
#include <string>
#include <vector>
using namespace std;
vector<int> solution(int n, vector<int> slicer, vector<int> num_list) {
vector<int> answer;
int a = slicer[0];
int b = slicer[1];
int c = slicer[2];
if (n == 1) {
for (int i = 0; i <= b; ++i) {
answer.push_back(num_list[i]);
}
} else if (n == 2) {
for (int i = a; i < num_list.size(); ++i) {
answer.push_back(num_list[i]);
}
} else if (n == 3) {
for (int i = a; i <= b; ++i) {
answer.push_back(num_list[i]);
}
} else if (n == 4) {
for (int i = a; i <= b; i += c) {
answer.push_back(num_list[i]);
}
}
return answer;
}
(1) if else-if else 문으로 해결하기
#include <vector>
using namespace std;
vector<int> solution(int n, vector<int> slicer, vector<int> num_list) {
vector<int> answer;
int a = slicer[0];
int b = slicer[1];
int c = slicer[2];
switch (n) {
case 1:
for (int i = 0; i <= b; i++) {
answer.push_back(num_list[i]);
}
break;
case 2:
for (int i = a; i < num_list.size(); i++) {
answer.push_back(num_list[i]);
}
break;
case 3:
for (int i = a; i <= b; i++) {
answer.push_back(num_list[i]);
}
break;
case 4:
for (int i = a; i <= b; i+=c) {
answer.push_back(num_list[i]);
}
break;
}
return answer;
}
(2) switch case 문으로 선택하기. 게임 루프에서 메인 메뉴를 만들 때와 같은 방식으로 코드를 작성했다.
6. [기초] 첫 번째로 나오는 음수
정수 리스트 num_list가 주어질 때, 첫 번째로 나오는 음수의 인덱스를 return하도록 solution 함수를 완성해주세요. 음수가 없다면 -1을 return합니다
#include <vector>
using namespace std;
int solution(vector<int> num_list) {
int answer = -1;
for (int i = 0; i < num_list.size(); i++) {
if (num_list[i] < 0) {
answer = i;
break;
}
}
return answer;
}
(1) if문을 사용해서 풀
#include <vector>
#include <algorithm>
using namespace std;
int solution(vector<int> num_list) {
auto it = find_if(num_list.begin(), num_list.end(), [](int n) {
return n < 0;
});
if (it == num_list.end()) {
return -1;
}
return distance(num_list.begin(), it);
}
(2) algorithm 헤더의 find, distance를 사용하고 람다 함수로 조건을 만들어 "음수"를 찾는다. 마지막에는 distance로 인덱스를 구한다.
7. [기초] 배열 만들기 3
정수 배열 arr와 2개의 구간이 담긴 배열 intervals가 주어집니다.
intervals는 항상 [[a1, b1], [a2, b2]]의 꼴로 주어지며 각 구간은 닫힌 구간입니다. 닫힌 구간은 양 끝값과 그 사이의 값을 모두 포함하는 구간을 의미합니다.
이때 배열 arr의 첫 번째 구간에 해당하는 배열과 두 번째 구간에 해당하는 배열을 앞뒤로 붙여 새로운 배열을 만들어 return 하는 solution 함수를 완성해 주세요
#include <string>
#include <vector>
using namespace std;
vector<int> solution(vector<int> arr, vector<vector<int>> intervals) {
vector<int> answer;
for (int i = intervals[0][0]; i <= intervals[0][1]; i++) {
answer.push_back(arr[i]);
}
for (int j = intervals[1][0]; j <= intervals[1][1]; j++) {
answer.push_back(arr[j]);
}
return answer;
}
(1) 단순하게 for문을 두개 이어 붙였다. 배열 안의 배열의 경우 intervals[0][0] 처럼 값을 꺼내 쓸 수 있다. reserve()를 써서 필요한 총 메모리 크기를 미리 계산해서 미리 메모리를 할당하는 방식으로 성능을 조금이라도 더 최적화하는 습관을 들이자.
#include <vector>
using namespace std;
vector<int> solution(vector<int> arr, vector<vector<int>> intervals) {
vector<int> answer;
int a1 = intervals[0][0];
int b1 = intervals[0][1];
int a2 = intervals[1][0];
int b2 = intervals[1][1];
answer.insert(answer.end(), arr.begin() + a1, arr.begin() + b1 + 1);
answer.insert(answer.end(), arr.begin() + a2, arr.begin() + b2 + 1);
return answer;
}
(2) vector 에 내장된 insert 함수와 반복자를 사용하면 반복문을 직접 돌리지 않고도 특정 범위를 한 번에 이어붙일 수 있다.
#include <vector>
#include <algorithm>
using namespace std;
vector<int> solution(vector<int> arr, vector<vector<int>> intervals) {
int a1 = intervals[0][0], b1 = intervals[0][1];
int a2 = intervals[1][0], b2 = intervals[1][1];
int len1 = b1 - a1 + 1;
int len2 = b2 - a2 + 1;
vector<int> answer(len1 + len2);
copy(arr.begin() + a1, arr.begin() + b1 + 1, answer.begin());
copy(arr.begin() + a2, arr.begin() + b2 + 1, answer.begin() + len1);
return answer;
}
(3) <algorithm> 라이브러리의 std::copy를 사용하여 원하는 구간을 통째로 복사해서 붙여넣을 수 있다. 반복문을 직접 작성하지 않아도 되어서 가독성이 더 좋아진다. 다만 이번 문제의 경우는 (2)로 푸는 것이 제일 좋아보인다.
std::copy(시작, 끝, 붙여넣을 위치) 로 구간을 복사할 수 있다. 자주 사용해보고 필요할 때 꺼내쓸 수 있도록 연습하자.
8. [기초] 2의 영역
정수 배열 arr가 주어집니다. 배열 안의 2가 모두 포함된 가장 작은 연속된 부분 배열을 return 하는 solution 함수를 완성해 주세요
단, arr에 2가 없는 경우 [-1]을 return 합니다.
#include <vector>
using namespace std;
vector<int> solution(vector<int> arr) {
int start = -1;
int end = -1;
for (int i = 0; i < arr.size(); i++) {
if (arr[i] == 2) {
if (start == -1) {
start = i;
}
end = i;
}
}
if (start == -1) {
return {-1};
}
vector<int> answer(arr.begin() + start, arr.begin() + end + 1);
return answer;
}
(1) for문과 if 조건식으로 풀기.
#include <vector>
#include <algorithm>
using namespace std;
vector<int> solution(vector<int> arr) {
auto first = find(arr.begin(), arr.end(), 2);
if (first == arr.end()) {
return {-1};
}
auto last = find(arr.rbegin(), arr.rend(), 2);
return vector<int>(first, last.base());
}
(2) find를 사용해서 반환자를 찾은 다음 구하는 방법.
마지막 2를 찾는 과정은 rbegin, rend를 사용해서 뒤에서부터 찾으면 조금이라도 더 효율적이게 만들 수 있다.
5. [기초] 배열 조각하기
정수 배열 arr와 query가 주어집니다. query를 순회하면서 다음 작업을 반복합니다.
짝수 인덱스에서는 arr에서 query[i]번 인덱스를 제외하고 배열의 query[i]번 인덱스 뒷부분을 잘라서 버립니다.
홀수 인덱스에서는 arr에서 query[i]번 인덱스는 제외하고 배열의 query[i]번 인덱스 앞부분을 잘라서 버립니다.
위 작업을 마친 후 남은 arr의 부분 배열을 return 하는 solution 함수를 완성해 주세요.
#include <vector>
using namespace std;
vector<int> solution(vector<int> arr, vector<int> query) {
int start = 0;
int end = arr.size() - 1;
for (int i = 0; i < query.size(); ++i) {
if (i % 2 == 0) {
end = start + query[i];
}
else {
start = start + query[i];
}
}
return vector<int>(arr.begin() + start, arr.begin() + end + 1);
}
(1) 배열 조각이라고 해서 진짜 조각을 하려고 모든 과정을 코드로 작성하려고 하면 안된다. 핵심은 최종적으로 갖다붙이기 위해 남을 배열을 생각해야 한다.
앞에 자르고, 뒤에 자르고, 앞에 자르고, 뒤에 자르고... 하다보면 결국 중간에 배열이 뭉텅이로 남게 될 것이다. 결국 start와 end 인덱스만 알면 그대로 가져오기만 하면 된다는 뜻이다. 진짜 조각한다고 배열을 잘라내버리면 query.size();를 쓸수도 없게 된다.
그리고 for 반복문 쓸 때는 이왕이면 ++i를 사용하자. 전위 연산자를 사용하는 것이 조금이라도 성능이 더 좋다. i++ 후위 연산자를 사용하면 값을 증가시키기 전의 원본을 임시로 한 번 복사하므로(이전 값 복사본) 그런 과정이 굳이 필요 없다면 전위 연산자를 쓰는 것이다. 하지만 반복자(iterator)를 사용하거나 순서를 지켜야 하는 그 외의 경우에는 꼭 전위 연산자와 후위 연산자를 구분해서 쓰자.
*Atanny*
1. 변수와 자료형의 메모리 구조 이해
핵심 요점 :
자료형은 메모리 크기와 데이터 종류를 결정하고, 자료형마다 정해진 바이트 크기가 있다. 자료형은 또한 가능한 연산도 함께 정한다.
2. 조건문과 반복문의 제어 흐름 이해
핵심 요점 :
조건문(if문)은 조건식의 참/거짓 결과에 따라 코드 실행 여부를 결정한다. for 반복문은 누적 계산이 가능하고, while 반복문은 조건식을 먼저 평가한 후 참일 때만 코드 블록을 실행한다.
3. 함수의 역할과 매개변수/반환값 이해
핵심 요점 :
함수는 반복되는 코드를 한 번에 정리하고 재사용하는 도구이다. 매개변수는 함수가 호출될 때 받아들이는 입력값이고, 반환값은 함수가 작업을 완료한 후 돌려주는 결과값이다. 반환값이 없어도 void 함수 형태로 사용할 수 있다.
4. 객체지향 프로그래밍의 4대 특성 이해
핵심 요점 :
캡슐화 : 데이터와 메서드를 하나로 묶고 접근 제어자로 내부 구현을 숨긴다.
상속 : 부모 클래스의 특성을 자식 클래스가 물려받아 코드를 재사용한다.
다형성 : 같은 이름의 메서드가 상황에 따라 다르게 동작한다.
추상화 : 복잡한 세부사항을 감춰서 핵심만 드러낸다.
5. 클래스와 객체의 이해
핵심 요점 :
클래스는 설계도이고 객체는 그 클래스를 기반으로 메모리에 실제로 생성된 인스턴스이다.
멤버 변수는 상태, 멤버 함수는 동작. 즉, 멤버 변수는 객체가 가진 데이터를 저장하고, 멤버 함수는 객체가 할 수 있는 일을 정의한다.
하나의 클래스로 여러 개의 독립적인 객체를 만들 수 있다.
클래스는 설계 단계에서 정의되지만, 객체는 프로그램이 실행될 때 만들어진다.
*수학 공부*
이번 주제 : 그래픽스 셰이더
벡터, 삼각함수, 행렬로 공간 안에서 뼈대를 만들었으면, 셰이더 수학을 가지고 빛과 색을 입혀보자.
게임 프로그래밍에서 RGB(색상 값)은 결국 XYZ(3차원 벡터)와 완전히 똑같은 취급을 받는다. 즉, 공간을 다루던 수학 공식을 색상에도 그대로 적용할 수 있다는 뜻이다. 실무 그래픽스 파이프라인에서 핵심이 되는 수학 원리 3가지를 언리얼 엔진의 셰이더(HLSL / Material) 관점에서 살펴보자.
1. 그래픽스 셰이더 수학
셰이더 수학은 본질적으로 모든 픽셀마다 병렬로 돌아가는 초고속 선형대수학이다.
1) 빛의 기본 : 내적(Dot Product)과 램버트 조명(Diffuse)
3D 공간에서 명암을 계산할 때 내적을 활용해서 계산하게 된다. 이 방식을 램버트 반사율(Lambertian Reflectance)이라고 부른다.
수학적 원리 : 물체 표면이 바라보는 방향(법선 벡터, N)과 빛이 날아오는 방향(조명 벡터, L)을 내적한다.
두 벡터가 평행하면 정면으로 빛을 받는 것이다. 이 때 내적 값은 1.0으로, 가장 밝은 흰색이 된다.
각도가 틀어질 수록 내적 값은 0.0에 가까워진다. 즉, 갈수록 검은색에 가까워진다. 빛을 등지면 음수가 나오므로 max(0, ...)함수를 사용해서 0으로 만든다. 이때 완전한 검은색으로 그림자를 표현한다.
크게 걱정할 건 없는게 언리얼 머티리얼 에디터의 노드들은 내부적으로 HLSL(High Level Shader Language) 코드로 변환되어 GPU에서 실행된다.
2) 반사광 : 반사 벡터와 스페큘러(Specular)
금속 갑옷에 빛이 반사되는 하이라이트를 표현하는 것을 스페큘러(Specular)라고 한다.
수학적 원리 : 빛이 표면에 부딪혀서 튕겨 나가는 반사 벡터(Reflection Vector, R)를 구하고, 반사된 빛이 카메라를 향하는 방향 벡터(View Vector, V)와 일치할수록 밝게 빛나도록 내적을 한 번 더 수행한다.
내적 결과값(0.0~1.0)에 높은 제곱수를 곱하면 0.9 같은 값은 0.001에 근사하게 작아지지만, 1.0은 그대로 1.0을 유지하게 된다. 이 수학적 특성 덕분에 빛의 퍼짐이 극단적으로 좁아지면서 금속 질감을 만들어내는 것이다.
3) 물결과 용암 : UV 좌표와 삼각함수(sin, cos)
3D 공간이라고 해서 셰이더가 3D 좌표만 다루는 것은 아니다. 텍스처를 입히기 위해서 2D 캔버스 좌표계인 UV 좌표(U : 0~1, V : 0~1)을 다루게 된다.
UV 좌표계에 텍스처를 그냥 입히게 되면 멈춰있는 그림일 뿐이지만, 시간(Time)과 삼각함수(sin)를 섞어서 더해주면 움직이는 것처럼 표현할 수 있다.
수학적 원리 :


float2 DistortedUV = OriginalUV;
DistortedUV.y += sin(Time * 5.0f + OriginalUV.x * 10.0f) * 0.05f;
float4 FinalColor = Texture2DSample(MyTexture, MySampler, DistortedUV);
return FinalColor;
이런 식으로 수학 공식을 샇용해서 폭포수가 흘러내리거나 아지랑이가 피어오르는 시각 효과를 만들어낼 수 있다.
2. C++에서 다루는 그래픽스 셰이더
1) 현실적 분업
셰이더 수학과 HLSL 코드는 그래픽스 프로그래머나 테크니컬 아티스트(TA)가 전담한다. 일반 게임플레이 프로그래머는 셰이더를 직접 짤 일은 거의 없다. 다만 중소규모의 게임 프로젝트나 인디 게임 개발 환경같은 경우 프로그래머가 TA 역할까지 겸하는 경우가 많다.
2) 파라미터 제어 (C++ 과 Shader)
셰이더 자체는 TA가 만들더라도, 게임 내 상황에 따라 셰이더가 변하게 만드려면 결국 C++ 코드에서 파라미터를 셰이더에 쏴줘야 한다. 즉, 셰이더 내부에서 0.0~1.0 값이 빛의 세기로 어떻게 작용하는지 수학적 지식이 기본 바탕이 되어야 아티스트와 원활하게 소통할 수 있다.
3) 화면 공간 연출(포스트 프로세싱, Post-Processing)
화면 공간 연출, 예를 들어 화면 가장자리에 붉은 빛이 감돌게 한다거나, 저격 스코프 화면 밖이 왜곡되는 연출은 특정 오브젝트가 아니라 화면 전체에 셰이더를 덮어씌우는 포스트 프로세스 영역이다. 이러한 연출들은 렌더링 파이프라인의 가장 마지막 단계에서 UV 좌표계와 삼각함수를 섞어서 화면 픽셀을 일그러뜨리는 방식으로 작동한다. C++에서 이 시스템을 껐다 켰다 하거나, FOV 데이터와 연동시키는 것을 C++ 코드로 할 수 있어야 한다.
4) 성능 최적화와 병목 탐지
게임 프레임이 갑자기 떨어질 때, 알고보니 특정 몬스터나 특정 오브젝트의 셰이더가 문제인 경우가 많다. 셰이더 수학을 알고 있으면 머티리얼 안에 sin이나 pow같은 무거운 연산이 너무 많다거나, Dot 계산이 픽셀마다 돌아가고 있어서 GPU 과부하가 온다고 설명을 할 수가 있다.
이런 경우 보통 어떻게 해결할 수 있는지 추가적으로 알아보자.
(1) 연산 주파수 낮추기(Frequency Shift: Per-Pixel >> Per-Frame)
가장 흔하게 발생하는 셰이더 병목의 원인. 모니터 해상도가 FHD(1920x1080)라면 200만개의 픽셀이 있고, 셰이더에서 sin(Time)연산을 수행한다면 GPU는 매 프레임마다 200만 번의 sin 계산을 해야 한다.
이럴 때, 시간에 따라 변하는 값은 픽셀마다 다를 필요가 없다. 무거운 sin 연산은 C++(CPU)에서 매 프레임 한 번만 계싼하고, 그 결과값을 셰이더로 넘겨주는 방식(파라미터 제어 방식)으로 연산의 부하를 GPU에서 CPU로 옮긴다.
#include "Materials/MaterialInstanceDynamic.h"
#include "Math/UnrealMathUtility.h"
// 매 프레임마다 호출되는 언리얼 C++의 Tick 함수
void AWarningLight::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
RunningTime += DeltaTime;
// CPU에서 무거운 삼각함수 연산을 '단 한 번만' 수행한다.
// sin(시간)을 이용해 0.0 ~ 1.0 사이의 깜빡임 강도를 도출
float FlashIntensity = (FMath::Sin(RunningTime * FlashSpeed) + 1.0f) * 0.5f;
//이미 계산이 끝난 '상수(가벼운 숫자)'를 셰이더로 전송
if (DynamicMatInstance)
{
// 셰이더 안에는 무거운 연산 없이, 이 "Intensity" 값만 받아서 색상에 곱하기만 함
DynamicMatInstance->SetScalarParameterValue(FName("Intensity"), FlashIntensity);
}
}
(2) 룩업 테이블(Look-Up Table, LUT)
셰이더 내부나 C++ 물리 연산에서 복잡한 연산이 너무 많이 쓰이는 경우 연산처리장치가 한계에 달하면서 프레임이 급격하게 떨어질 수 있다.
컴퓨터 구조상 복잡한 계산을 하는 것보다 메모리에 적힌 답안지를 읽어오는 것이 훨씬 빠를 때가 많다. 따라서 복잡한 수학 공식의 결과값을 미리 1D 텍스처(배열 형식, TArray)로 구워 두고(Baking), 실시간 계산 대신 인덱스를 통한 검색만 수행하도록 구조를 바꾸는 것으로 해결할 수 있다.
// 최적화 전: 실시간으로 무거운 거듭제곱과 제곱근을 계산 (병목 발생)
float CalculateHeavyDamage(float Distance)
{
// 실시간으로 pow와 sqrt가 실행됨 (매우 무거움)
return FMath::Pow(BaseDamage, 1.5f) / FMath::Sqrt(Distance);
}
==<비교>==
// 최적화 후: 룩업 테이블(LUT) 활용
TArray<float> DamageLUT; // 게임 시작 전(BeginPlay)에 미리 계산해둔 1000개의 데미지 배열
float CalculateOptimizedDamage(float Distance)
{
// 거리를 배열의 인덱스(0 ~ 999)로 변환 (아주 가벼운 정수 연산)
int32 Index = FMath::Clamp(FMath::RoundToInt(Distance), 0, 999);
// 무거운 수학 계산 없이, 이미 계산된 답안지에서 값을 꺼내옴
return DamageLUT[Index];
}
(3) 제곱근 제거와 빠른 거리 판별(Distance Squared)
타겟이 움직이는 상황을 생각해보면, 게임에서는 타겟과의 거리를 판별하는 연산이 매 프레임 수천 번씩 일어나게 된다. 두 점 사이의 거리를 구하는 벡터의 크기 공식에는 피타고라스 정리에 의한 제곱근 연산이 들어가는데, 사실 이 제곱근 연산은 컴퓨터에게 매우 무거운 연산이다.
따라서 거리를 정확히 알아야 하는 게 아니라 만약 '특정 반경 안에 있는지 비교'만 하는 상황이라면, 거리의 제곱근을 구하지 않고 기준 반경을 제곱해서 비교하면 연산 비용을 줄일 수 있다.
// 최적화 전
FVector Dir = TargetPos - MyPos;
float Distance = Dir.Size(); // 내부적으로 sqrt() 발생
if (Distance < 500.0f)
{
Attack();
}
// 최적화 후
FVector Dir = TargetPos - MyPos;
float DistSquared = Dir.SizeSquared(); // sqrt()를 수행하지 않고 x*x + y*y + z*z 까지만 수행
if (DistSquared < 250000.0f) // 500.0f 의 제곱값인 250,000과 비교
{
Attack();
}
*오늘의 총평*
게임 개발을 위한 수학 공부 리스트를 업데이트하고, 언리얼 C++ 공부 리스트도 업데이트 했다. 오늘은 언리얼 3D 개발 강의를 수강하면서 따라하는 것에 집중했다. Rider 사용하다보니까 VS보다 언리얼 사용할 때에는 훨씬 편한 것 같다.
좀 더 개선해야 할 건 코딩테스트 준비를 위해서 프로그래머스 활용을 더 적극적으로 해야 할 필요가 있을 것 같다.

