왜냐하면, 이 문제에선 이전 행의 값을 기준으로 DP배열을 갱신할 것인데 1행은 이전의 행이 없기 때문에 입력된 값을 그대로 넣어준 것이다.
for (int i = 1; i < Height; i++)
{
for (int j = 0; j < 4; j++)
{
int Max = 0;
for (int k = 0; k < 4; k++)
{
if (j == k)
{
continue;
}
if (Max < DP[i - 1][k])
{
Max = DP[i - 1][k];
}
}
DP[i][j] = Max + land[i][j];
}
}
위의 코드는 DP배열을 갱신하는 코드이다.
가장 바깥 반복문에서는 행의 크기만큼 반복문을 돌아주고 있다.
1행부터 가장 마지막 행까지 DP배열을 갱신하는 것이다.
내부에는 2중반복문이 존재한다.
먼저,(i, j)에서 발판 값의 총 합의 최대값을 구하려면, (i-1)행에 있는 4개의 발판을 검사해야 한다.
그 중 최대값을 가져와서 i행 j열의 DP배열에 대입해주었다.
이 과정을 i행에 있는 4개의 발판에 대해 모두 반복해주었다.
이 반복문이 끝나면, DP배열은 모두 각 발판에 도달하는 경우중 M의 최대값으로 갱신되어 있을 것이다.
지난 게시글에선 예제 프로젝트의 코드를 보면서 Attribute가 무엇인지 간단하게 알아보았다. 다른 기능에 대한 설명에서도 계속 보이는 Attribute라는 것을 먼저 알아야 다른 것들을 이해할 수 있을 것 같아서 Attribute를 먼저 알아보았다.
이번엔 먼저 GamePlayTag에 대해 알아보자.
GamePlayTag는 해당 객체의 상태를 나타내는 태그인 것 같다.
예를 들어, 플레이어가 기절상태에 걸렸다고 해보자.
일반적으로 enum으로 state를 변경하거나 bool값으로 스턴 여부를 파악하게 된다.
반면, GamePlayTag에선 문자열을 이용해서 이런 상태이상 여부를 파악한다고 한다.
공식 문서에는 위와 같이 설명되어 있다. 사실 저 글만 보면 무슨 기능인지 당최 이해할 수가 없다.
위 글은 예제 프로젝트의 깃허브에 적힌 설명이다. 아래는 번역해본 내용이다.
.
FGamePlayTags는 부모.자식.손자의 형태로 구성된 계층적인 이름이다. 그것은 GameplayTagManager에 의해 등록이 된다. 이 태그들은 오브젝트의 상태를 묘사하거나 분류하는 것에 있어 매우 유용하다. 예를 들어, 캐릭터가 기절 상태에 돌입하였다면 우리는 State.Debuff.Stun 이라는 GameplayTag를 기절시간동안 부여할 수 있다.
당신은 GameplayTag를 사용하여 enums나 booleans를 대체하고, 오브젝트가 특정 GameplayTag를 가지고 있는지 없는지를 boolean으로 체크하고 있는 모습을 발견할 수 있을 것이다.
위 코드는 예제 프로젝트에서 플레이어, 적 유닛 등 게임 내의 캐릭터들이 공통으로 상속받을 AGDCharacterBase클래스의 생성자 코드의 일부이다.
FGameplayTag라는 구조체를 사용하여 Tag를 반환받고 있다. RequestGameplayTag의 인수를 보면 A.B.C의 형태로 3개의 단어가 연결된 문자열을 사용하고 있다.
가장 앞의 단어는 가장 포괄적인 상태를 의미하고, 두 번 째 단어에서 상태를 조금 더 구체화하고, 세 번째 단어에서 완전히 구체화하여 상태를 구분하도록 하는 것 같다.
예를 들면, State.Debuff.stun 이렇게 사용하여 (상태. 디버프. 기절) 이렇게 표현할 수도 있고
State.Buff. Invincible 이렇게 사용하여 (상태. 버프. 피해면역) 이렇게 표현할 수도 있다.
그런데 위 코드에서 보면 State.Dead 이렇게 두개의 단어만 사용하기도 하는 걸 보니, 꼭 3개의 단어로 이루어져야 하는 건 아닌 것 같다. 아마 4개 이상의 단어도 될 것 같다. ( 나중에 실험해봐야지 )
저 GameplayTag를 표현하는 단어집합은 에디터에서 추가해줄 수 있다.
1. 에디터의 프로젝트 세팅에서 추가
에디터의 프로젝트 세팅을 들어가면 Project -> GameplayTag 카테고리가 있다.
내부를 보면 Add new gameplay Tag source도 있고 manage Gameplay Tags도 있다.
먼저, Add new gameplay Tag source 를 눌러보자.
위와 같은 창이 뜨는데, Name 에 GameplayTag들을 저장할 ini파일의 이름을 설정해주면 된다.
이후, Manage Gameplay Tags를 눌러보자.
이런 창이 뜬다.
왼쪽 상단의 초록색 십자표시 눌러보자.
이렇게 창이 확장된다.
source를 눌러보면 아까 만든 ini 파일의 이름이 보일 것이다.
이제 source를 저 ini파일로 바꾸고 Name칸에 원하는 GameplayTag를 입력해주면 된다.
아마 저 int32가 위에서 말했던 TagCount가 아닐까 싶다. 이 함수는 State.Debuff.Stun이 생성되거나 소멸될 때 호출되는 함수이다. 해당 GameplayTag가 소멸되는 순간이라면 TagCount가 0이되기 때문에 파라미터의 NewCount를 활용해서 태그가 생성될 때와 소멸될 때 분기를 나누어 설계하면 되는 것 같다.
내부 함수를 보면,Stun 태그가 생성될 때 Ability 태그를 Cancel하고 있다.
Ability태그는 안에 점프, 조준이 포함되어 있다. 기절 상태가 되면 해당 동작을 중지해야 하기 때문에 해당 Tag를 Cancel하는 것 같다.
코드를 보면 FGameplayTagContainer라는 구조체가 있다. 저 녀석은 여러개의 태그를 저장하여 여러가지 편의기능을 제공해주는 놈인 것 같다.
위의 코드에선 CancelAbilities함수가 FGameplayTagContainer를 인자로 받기 때문에 사용한 것 뿐이지만 실제로는 더 유용한 쓸모가 있을 듯 하다.
(아직은 모르겠다.)
일단 여기까지 해서 GameplayTag에 대해 간단하게 알아보았다.
뭔가 하나하나씩 파헤쳐보니 대충 감은 잡히는 것 같은데, 이걸 게임에 적용해보라면 나한테 Stun 태그가 생성된것마냥 멍해질 것 같다. GAS에 대해 어느정도 이론적 지식을 쌓으면 직접 적용해보면서 지식을 체화해야 할 것 같다
C++은 C++ 20이 되어서야 추가되었고, 사용법 또한 C++ 답게 다소 복잡하다고 한다.
(사실 다른 언어에서 사용을 안해봐서 얼마나 복잡한지는 모름)
코루틴이 먼저 무엇인지 알아보자.
일반적인 함수는 위와 같은 방식으로 실행된다.
A함수에서 B함수를 호출하게 되면, B함수가 실행되고, B함수는 내부에서 return문을 만나거나 함수의 끝에 도달하게 되면 함수를 종료하고, 다시 A함수로 돌아가 남은 코드를 실행하게 된다.
반면, 코루틴 함수는 아래 그림과 같다.
중간에 중단했다가, 나중에 그 위치부터 다시 진행하고, 다시 중단했다가, 다시 진행하며 A함수와 B함수를 왔다갔다 할 수 있다.
일반적인 함수는 중간에 return하며 스레드가 해당 함수를 탈출한 뒤, 다시 함수를 호출하면 그 함수의 처음부터 실행하게 된다. 반면, 코루틴 함수는 스레드가 함수를 탈출한 위치를 저장해뒀다가 코루틴 함수를 재개하면 함수가 탈출했던 위치부터 다시 함수를 실행하게 된다.
사실, 이 기능이 어떻게 활용되는 지는 아직 잘 모른다. 일단 이 기능이 어떻게 구현되고, 어떻게 사용되는지를 먼저 알아보고 나서 활용방법에 대해서는 따로 정리된 게시글을 작성할 생각이다.
먼저, 코루틴 함수를 사용하기 위해선 코루틴 객체가 필요하다. 코루틴 함수를 중단하고 재개하는 과정은 코루틴 객체를 통해 이루어진다. 코루틴 함수를 최초에 호출하면 해당 함수는 코루틴 객체를 반환하게 되고, 반환된 객체를 이용하여 원하는 시기에 코루틴 함수를 재개하는 것이다.
먼저 코루틴을 사용하기 위한 틀을 만들어보겠다.
참고로 코루틴을 사용하기 위해선 C++ 20이상이어야 하고 coroutine 헤더파일을 포함해야 한다.
#include <coroutine>
class MyCoroutine
{
public:
private:
};
MyCoroutine CoroutineTest
{
}
int main()
{
return 0;
}
위와 같이 틀을 만들어 보았다.
코루틴 객체인 MyCoroutine 클래스를 만들었고, 테스트를 위한 코루틴 함수인 CoroutineTest 함수도 선언해주었다.
이제 코루틴 객체를 채워보자. 코루틴은 일종의 프레임워크이다. 정해진 규칙이 있고, 그 규칙에 맞게 설계해야 한다.
코루틴 객체를 만들기 위한 규칙 중 중요한 규칙은 내부에 반드시 promise_type 이라는 구조체가 선언되어 있어야 한다는 것이다.
2. 프로젝트의 build.cs에 있는 PrivateDependencyModuleNames에 해당 모듈을 추가해주어야 한다.
3. 게임이 실행되기 전 UAbilitySystemGlobals::Get().InitGlobalData() 함수를 반드시 호출해주어야 한다.
언리얼엔진 5.3 이후부터는 해당 함수를 자동으로 호출해준다고 한다.
하지만, 4.24 ~ 5.2의 버전에서는 이 함수를 자동으로 호출해주지 않기 때문에 직접 호출해주어야 한다고 한다.
이 함수는 GAS를 초기화 하는 함수이며, GAS가 직접적으로 사용되기 전에 호출해주어야 한다고 한다.
위의 깃허브 소유자는 UAssetManager의 StartInitialLoading() 에서 해당 함수를 호출해주고 있다.
GAS
일단, 게시글을 일겅보니
ASC(AbilitySystemComponent)와 Attribute, AttributeSet이라는 용어가 보였다.
ASC는 UActorComponent를 상속받은 컴포넌트 클래스이며, GAS를 사용하고 싶은 Actor는 반드시 이 컴포넌트를 소유해야 한다고 한다. 플레이어가 죽고 되살아나는 과정에서 Attribute의 지속성이 필요한 경우에는 ASC는 직접 소유하기보다는 PlayerState가 소유하는 것이 더 적합하다고 한다.
중간에 위와 같은 글이 있다.
ASC가 PlayerState에 위치한다면, NetUpdateFrequency를 증가시켜야 한다는 것 같다. 기본값이 너무 낮게 설정되어 있어서 Attribute나 GameplayTags의 업데이트가 지연될 수 있다고 한다. 또한, Adaptive NetworkUpdate Frequency 를 활성화 하라고 하는데, 찾아보니 값이 변하지 않았는데도 업데이트 하는 경우를 방지하여 CPU의 사이클을 효과적으로 운영하는 방식이라고 한다.
Attribute란 해당 플레이어가 갖는 속성을 의미한다고 한다. HP, MP, Level 등의 스텟정보를 의미하는 것 같다. 위의 깃허브 프로젝트의 내부 코드를 보니 Gold와 같은 부분에도 Attribute를 사용하고 있었다. 아마 수치화 될 수 있는 모든 부분에서 사용이 가능한 듯 하다. ( 그냥 예측이긴 하지만 커뮤니티에 등록된 친구의 수, 길드원의 수와 같은 것들도 Attribute로 표현할 수 있을 것 같다. )
AttributeSet이란, 저런 속성들의 집합이다. Hp, Mp, Level 등의 속성을 한 곳에 모아서 관리하는 클래스가 AtrributeSet인 것 같다.
만약, ASC의 OwnerActor가 AvaterActor (Pawn, Character)와 다르다면 (Player State가 소유하고 있다면) IAbilitySystemInterface 인터페이스를 두 곳에서 모두 상속받아야 한다고 한다.
이 인터페이스에는 UAbilitySystemComponent* GetAbilitySystemComponent() const 라는 순수가상함수가 선언되어 있는데, 이를 정의해야만 OwnerActor와 AvaterACtor사이에서 상호작용 할 수 있다고 한다.
만약 AvaterActor가 ASC를 소유하고 있다면, 상속받을 필요가 없는 것 같다.
예제 프로젝트의 코드를 보면 PlayerState클래스에서 ASC와 AttributeSet을 모두 생성해주고 있다.
ASC의 SetReplicationMode를 보면 3가지 종류가 있었다.
Full은 GamePlayEffect를 모든 클라이언트에게 리플리케이트 한다.
Mixed는 gamePlayEffect를 오직 소유한 클라이언트에게만 리플리케이트 하고 GamePlayTag과 gamePlayCues는 모두에게 리플리케이트 한다.
Minimal은 GamePlayEffect를 아무한테도 리플리케이트 하지 않고, GamePlayTags와 gamePlayCues를 모두에게 리플리케이트 한다.
네트워크에 관한 옵션인데, 모드에 따라 정확히 어떻게 달라지는 건지 잘 모르겠다. GamePlayEffect, GamePlayTags, GamePlayCue에 대해서 좀 알아야지 정확히 어떤 기능인지 이해할 수 있을 것 같다.
아래는 예제 프로젝트에서 AttributeSet 상속받아 만든 클래스이다.
UAttributeSet을 보니, Attribute들을 관리하는 여러가지 함수들이 있다.
우리는 여기에 Hp, Mp, Exp등의 Attribute를 추가해야 하기 때문에, 이를 상속받아서 사용해야 한다.
내부를 보면 아래와 같이 멤버변수들이 선언되어 있다.
Health는 현재 체력이고 MaxHealth는 최대 체력인 듯 하다.
HealthRegenRate는 시간에 따라 Health가 회복되는 수치인 것 같다.
보면, UPROPERTY에 여러가지 인자들이 포함되어있다.
BluePrintReadOnly는 블루프린트에서 해당 변수의 데이터를 읽을 수는 있으나 수정할 수는 없다는 뜻이다.
Category는 에디터에서 해당 변수를 분류할 그룹의 이름을 지정하는 것이다.
ReplicatedUsing은 이 변수를 네트워크를 통해 리플리케이트 함과 동시에, 해당 변수의 데이터가 수정될 때 호출될 함수를 콜백 방식으로 등록하는 것 같다.
Replicated와 ReplicatedUsing 두 가지가 있는데, Replicated는 콜백함수없이 리플리케이트만 하는 것이고, ReplicatedUsing은 리플리케이트함과 동시에 호출될 콜백함수를 지정하는 것이다.
해당 함수들의 정의를 보면 아래와 같다.
GAMEPLAYATTRIBUTE_REPNOTIFY를 호출해주고 있다.
변경된 값을 네트워크를 통해 업데이트해주는 매크로라고 한다.
매크로 내부를 보니, Attribute의 값이 변경될 때 델리게이트에 저장된 함수를 호출해주고 있는 것을 보았다.
이런 식으로, 델리게이트에 바인딩되어있는 함수가 있다면 어트리뷰트의 값이 변경될 때 그 함수를 호출하도록 되어있다.
예제 프로젝트에서는 그 함수를 어디서 바인딩 하나 찾아봤더니, PlayerState에서 해주고 있었다.
각 Attribute에 바인딩된 함수 내부를 보니, HP가 0이하라면 사망 처리를 하거나, UI WIdget을 업데이트 하는 등의 기능을 하고있었다.
즉, 변수의 값이 업데이트 되면, UPROPERTY에서 설정해둔 콜백함수인 OnRep_(속성) 을 호출하게 되고 OnRep_(속성) 함수 내부에선 GAMEPLAYATTRIBUTE_REPNOTIFY 매크로를 호출하여 네트워크를 통해 업데이트된 값을 리플리케이트 해주게 된다. 리플리케이트 함과 동시에, 각 Attribute의 델리게이트에 바인딩된 함수가 있다면 해당 함수를 호출해주고 있는데, UI를 업데이트 하거나 플레이어의 사망처리를 하거나 특정 이펙트를 띄우는 등 원하는 함수를 바인딩하면 자동으로 호출되는 것이다.
또한, AttributeSet에서는 위의 함수도 오버라이딩하여 해당 매크로를 실행해주어야 한다고 한다.
해당 매크로에 대해 찾아보았는데, GAS에서는 Attribute를 예측을 통해 미리 업데이트하는 것 같다
그래서, 서버를 통해 Attribute가 변경되었다는 알림을 받았는데 클라이언트에서는 이미 변경된 Attribute의 값이 저장되어있을 수 있다고 한다.
그래서 클라이언트에 저장된 Attribute의 값이 변경되지 않을 수 있는데, 이 때도 NOTIFY를 실행할 것인가 말 것인가를 설정하는 것이라고 한다.
실제로 값이 변경될 때만 NOTIFY를 실행할 것인가, 아니면 값이 변경되었다는 신호가 올 때마다 NOTIFY를 실행할 것인가 를 결정하는 것이다.
멤버변수의 선언을 볼 때, 보면 ATTRIBUTE_ACCESSORS라는 매크로도 있다.
보니까 이 매크로를 등록하면 해당 Attribute에 대한 여러가지 함수가 생기는 것 같다.
실험을 해보니, 해당 매크로가 작성되어 있을 땐, GetHealth() SetHealth() InitHealth() 등의 Attribute를 관리하는 함수가 정상적으로 호출되었지만, 해당 매크로에 주석을 쳐보니 인텔리센스에서 해당 함수를 감지하지 못하는 상황이 발생하였다.
GAS에 의해 관리될 Attribute라면 해당 매크로를 반드시 작성해주어야 하는 듯 하다.
GAS의 기초가 되는 Attribute, AttributeSet이 무엇인지 이해하기 위해 이것저것 둘러보았는데, 사실 아직도 정확히 어떤 구조로 돌아가는지는 잘 이해가 안된다. 기본적으로 네트워크를 고려하여 설계된 프레임워크인듯 한데, 본인이 언리얼엔진의 네트워크 구조 자체에 이해가 아직 부족하기 때문에 더욱 이해가 힘든 것도 있는 것 같다. 꾸준히 네트워크에 대한 공부를 하면서 분석해야 GAS를 더 완벽히 이해할 수 있을 것 같다.
지금 본인의 지식 상태에선 하나하나 이해하려고 하기보다는 전체적인 구조를 파악해보면서 GAS의 흐름을 먼저 파악해야 할 것 같다.
c++ 17부터는 문자열을 효율적으로 사용하기 위해 std::string_view라는 클래스가 추가되었다.
string_view가 무엇인지 알아보자.
일단, string_view를 알려면 std::string의 복사에 대해 알아보아야 한다.
void Function(std::string _Str)
{
std::cout << _Str << "\n";
}
int main()
{
std::string Test = "ABCD";
Function(Test);
}
위와 같이, Function을 호출하면 어떻게 될까?
당연히 ABCD는 정상적으로 콘솔 창에 출력이 된다.
하지만, 인자로 std::string을 넘기는 과정에서 복사가 이루어진다.
문자열이 짧을 땐 큰 체감이 안될지 몰라도, 문자열이 길어지면 복사 자체도 오래걸리고 SSO의 최적화를 받지 못하기 때문에 임시객체에서 동적할당까지 이루어져서 성능의 저하가 심각해진다.
이러한 복사를 줄이기 위해, 우리는 보통 아래와 같이 참조형을 사용하게 된다.
void Function(std::string& _Str)
{
std::cout << _Str << "\n";
}
int main()
{
std::string Test = "ABCD";
Function(Test);
}
하지만, 여기에도 문제가 생긴다.
바로 리터럴 문자열을 인자로 받을 때이다.
리터럴 문자열은 다른 변수들처럼 스택이나 힙에 저장되지 않고, 데이터영역에서도 문자열을 관리하는 특수한 영역에서 관리된다. 리터럴 문자열은 최적화를 위해 한 번 생성되면 데이터 영역에 저장해뒀다가 나중에 또 그 문자열이 사용될 때 해당 문자열을 참조만 하는 방식으로 관리되고 있다. 그렇기 때문에 리터럴 문자열은 외부에서 함부로 수정해서는 안된다. 그래서 const를 항상 함께 사용해야 하는데, 위의 방식은 const가 붙어있지 않아 리터럴 문자열을 사용할 수가 없다.
void Function(const std::string& _Str)
{
std::cout << _Str << "\n";
}
int main()
{
std::string Test = "ABCD";
Function(Test);
}
그럼 이렇게 const를 붙혀준다면?
리터럴 문자열을 사용할 수는 있지만 여전히 한가지 문제가 남아있다.
위와 같이 사용하면 리터럴 문자열에 대해서는 복사가 여전히 진행되어 버리는 것이다.
참조를 하기 위해선 기본적으로 자료형이 같아야 한다. 하지만, 리터럴 문자열은 const char* 타입이고, Function의 파라미터는 std::string 이다. 이 때문에, 인자로 들어온 리터럴 문자열은 생성자의 인자로 취급되고 임시 객체를 생성하게 된다. 이후, 임시 객체는 문자열을 복사하게 된다. 문자열의 길이가 길다면 동적으로 메모리를 할당하는 작업 또한 실행할 것이다. 그리고 파라미터의 _Str은 해당 임시객체를 참조하게 된다.
불필요한 문자열의 복사를 막으려고 const 참조를 사용했는데, 막지 못하는 상황이 되어버리는 것이다.
이를 방지하기 위해 사용하는 것이 std::string_view 클래스이다.
std::string_view는 내부적으로 문자열에 대한 포인터를 담고 있다. 생성자로 리터럴 문자열이 들어온다면, 해당 문자열의 주소값만을 내부에 보관하게 되기 때문에 문자열의 복사가 발생하지 않는다.
그러므로, 아래와 같이 std::string_view를 활용하면 성능 향상을 노려볼 수 있다.
void Function(std::string_view _Str)
{
std::cout << _Str << "\n";
}
int main()
{
std::string Test = "ABCD";
Function(Test);
}
본인은 string과 string_view의 차이에 대해 4가지의 경우로 시간을 직접 재보았다.
1. 함수의 파라미터는 const std::string& 으로 받고, 리터럴 문자열을 인수로 사용하는 경우
(500만번 반복문 기준 5.06초 소요)
2. 함수의 파라미터는 const std::string& 으로 받고, std::string을 인수로 사용하는 경우
(500만번 반복문 기준 0.017초 소요)
3. 함수의 파라미터는 std::string_view 으로 받고, 리터럴 문자열을 인수로 사용하는 경우
(500만번 반복문 기준 0.28초 소요)
4. 함수의 파라미터는 std::string_view 으로 받고, std::string을 인수로 사용하는 경우
(500만번 반복문 기준 0.15초 소요)
이중 가장 빠른 것은 2번이었다. 그냥 압도적으로 빠르다. 500만번의 반복문을 돌리면 평균적으로 0.017초 정도 소요되었다.
2번의 경우 자료형이 완전히 일치하기 때문에, 임시객체를 생성하지도 않고 그냥 참조만 하게 된다.
그렇기 때문에, string 자체를 인자로 보낼 것이 확실한 상황이라면 const std::string&를 사용하는 것이 가장 효율적인 것 같다.
다음으로 빠른 것은 4번인데, 사실 3번보다 4번이 왜 더 빠른지는 잘 모르겠다. 내 예상이지만, 리터럴 문자열은 주소를 찾아가는 과정이 필요하지만, std::string의 경우 주소값을 내부에 보관하고 있어서 바로 복사가 가능하기 때문이 아닐까 싶다.
중요한 것은 리터럴 문자열을 사용할 때, const std::string& 을 사용하는 것과 std::string_view를 사용하는 것의 차이이다.
속도 차이가 무려 18배나 난다. 어마어마하게 차이나는 것이다.
리터럴 문자열을 자주 사용할 것 같다면, 반드시 std::string_view를 사용하여 최적화를 노려보도록 하자..
스택영역의 경우 컴파일 과정에서 메모리의 주소를 어느정도 예측할 수 있기 때문에 계산된 주소를 이용해 바로바로 접근이 가능하기 때문에 힙영역보다 빠르다고 한다.
이러한 문제점으로 인한 성능 저하를 최소화하기 위해, 적어도 길이가 짦은 문자열 만큼은 동적으로 메모리를 할당하지 말고 스택영역에 저장하자는 것이 SSO이다.
일정 길이 이하의 문자열은 Stack영역에 저장하여, 오버헤드를 최소화 하겠다는 것이다.
실제로 시간을 재보았다.
std::chrono::system_clock::time_point Start = std::chrono::system_clock::now();
for (int i = 0; i < 5000000; i++)
{
std::string Test = "AAAAAAAAAAAAAAA"; //15자
}
std::chrono::system_clock::time_point End = std::chrono::system_clock::now();
std::chrono::duration<float> Time = End - Start;
std::cout << Time << "\n";
1 ~ 15 글자에 대해 스트링을 생성할 때엔 평균적으로 2.7초가 소요되었다. (당연히 소요 시간은 환경에 따라 다를 수 있다.)
std::chrono::system_clock::time_point Start = std::chrono::system_clock::now();
for (int i = 0; i < 5000000; i++)
{
std::string Test = "AAAAAAAAAAAAAAAA"; //16자
}
std::chrono::system_clock::time_point End = std::chrono::system_clock::now();
std::chrono::duration<float> Time = End - Start;
std::cout << Time << "\n";
반면, A를 딱 하나만 더 붙혀서 16자로 만들어서 string을 생성해보니 평균 4.8초가 소요되었다.
퍼센트로 보면 80%의 시간이 더 걸리는 셈이다.
급격하게 속도가 느려지는 것을 보니 16자 이상부터는 동적할당을 이용해 문자열을 관리하는 것 같다.
하지만, 이렇게 문자열을 담는 스택배열을 생성하게 되면 아무래도 메모리적으로는 효율적이지 못할 수 있을 것 같다는 생각이 들지만, 내부에선 union을 사용하여 동일한 메모리 영역을 문자열 길이에 따라 다르게 사용함으로써 효율적으로 관리하고 있다고 한다.
퐁 조명 모델이란, 빛을 계산할 때 픽셀 단위로 법선과 광원을 고려하여 빛을 계산하는 모델이다.
퐁 조명 모델에선 빛을 4가지 종류로 분류한 뒤, 4가지 빛의 합으로 색을 결정하게 된다.
4가지 빛의 종류는 아래와 같다.
1.Diffuse Light (난반사광)
2.Specular Light (정반사광)
3.Ambient Light (환경광)
4.Emissive Light (자체발산광)
이 4가지 빛을 항목별로 모두 계산한 뒤, 더해주면 최종적으로 물체에 적용될 빛이 된다.
이 빛을 물체의 기본 색상과 곱해주면 물체에 명암이 생기고 재질을 느낄 수 있게 된다.
그렇다면, 하나씩 어떻게 계산하는지 알아보자.
1. Diffuse Light
Diffuse Light란, 물체의 표면에서 빛이 반사하는 것을 계산하는 것이다.
먼저 그림을 하나 보자.
좌측은 Diffuse Light만 적용된 상태이고, 우측은 Specular Light도 적용된 상태이다.
Diffuse Light를 적용하게 되면, 물체의 명암을 구분할 수 있게 된다.
반면, 좌측 그림와 우측 그림의 느낌을 한 번 비교해보면 좌측은 다소 거친 느낌이 들지만 우측은 다소 매끄러운 느낌이 난다. 즉, Specular Light란 이처럼 물체가 얼마나 매끈한지를 표현해주는 빛인 것이다.
정확히는 매끈함을 표현하기 위함이라기보단 물체 표면에서 눈을 향해 반사되는 빛을 계산하는 것이다.
매끈한 재질일수록 눈을 향해 반사되는 빛이 더 많아지게 된다.
이제 Diffuse Light를 더 자세히 알아보자.
물체의 표면이 위 그림과 같다고 가정해보자.
물체의 표면은 재질에 따라 정도의 차이는 있겠지만, 대부분 울퉁불퉁하게 되어있다.
이 물체에 조명을 비춰보자.
이런 식으로, 물체의 모든 지점에 빛이 닿게될 것이다.
그리고 이 빛은 재질의 표면으로부터 반사될 것이다.
반사되는 방향은 표면에 따라 달라지게 된다.
이 반사되는 방향의 기준이 있을까? 물론 있다.
바로 법선 벡터이다.
법선 벡터란, 어떠한 지점에 접하는 선분과 수직인 벡터이다.
그림으로 표현하면 이와 같다. 접선에 대해 수직이면서, 물체의 바깥쪽을 향하는 벡터를 법선벡터라고 한다.
빛은 이 법선벡터를 기준으로 반사된다.
물체로부터 표면의 한 점에 빛이 들어올 때, 빛과 법선벡터 사이의 각도를 입사각이라고 하며
이 빛이 물체로부터 반사될 때, 그 반사되는 빛의 방향과 법선벡터 사이의 각도를 반사각이라고 한다.
이 입사각과 반사각은 항상 동일한 각도를 유지하며 빛이 산란하게 된다
그런데, 우리가 생각해보면, 빛이 가장 강한 시기는 언제일까?
길을 걸을 때, 생각해보자. 태양이 머리 위에서 수직으로 내리 쬐는 12시쯤이 더울까
비스듬하게 빛을 쬐는 오후 무렵이 더울까?
당연히 12시쯤이 훨씬 더울것이다.
그렇다면 이렇게 표현할 수 있다.
법선벡터와 빛의 방향이 유사할수록 더 빛이 더 강하다.
-> 입사각이 작을수록 빛이 강하다.
-> Cos(Theta)가 클수록 빛이 강하다.
이 그림에서 보면, 표면의 노멀벡터와 빛이 들어오는 방향 벡터를 알고있다면 입사각을 구할 수 있다.
어떻게? 벡터의 내적을 활용하면 된다.
두 벡터를 법선벡터 N과 빛이 들어오는 방향 벡터 L로 표현해보자.
이렇게 된다.
이 때, 두 벡터를 내적을 하기 위해, L의 방향을 뒤집어주자.
이 상태에서, N과 L을 노멀라이즈(정규화)하여 단위 벡터로 만들어 준 뒤, 내적하게 되면 Cos(Theta)를 구할 수 있다.
이 Cos(Theta)의 값은 최대가 1이다. 최소는 -1이 될 것이다.
하지만, cos(Theta)가 0보다 작은 값이 나올 땐, 90 <= Theta <= 270 일 때이다.
그림으로 본다면, 파란색 벡터와 같이 반대편에서 빛이 비추는 상황인 것이다.
이 때, 저 빛이 의미가 있을까? 우리는 물체 반대편에서 비추는 빛은 눈으로 볼 수가 없다.
그렇기 때문에, 아래와 같은 공식을 이용하여 최소값을 0으로 바꿔주면 된다. 음수인 값은 그냥 모두 0으로 처리하면 된다.
max(Cos(theta), 0.0f); (clamp를 사용하여도 된다.)
이렇게 입사각의 Cos(theta)를 구했다면, 그 값이 Diffuse Light이다.
2. Specular Light
Specular Light는 어떻게 구할까?
Specular Light는 먼저 우리의 눈을 기준으로 들어오는 빛을 계산하게 된다.
빛은 위 그림처럼 물체의 표면에서 동일한 각도로 반사되어 나간다.
그 때, 반사되어 나오는 빛이 눈에 직선으로 꽂힐수록 빛은 강하게 느껴질 것이다.
즉, 이전엔 법선벡터와 빛이 물체를 향하는 방향벡터의 각도를 구했다면,
이번엔 눈이 물체를 보는 방향벡터와 반사벡터 사이의 각도를 구해야 한다.
눈이 그림처럼 위치하고 있다면, Theta_2 의 값을 구해야하는 것이다.
그렇다면 어떻게 구하는지 먼저 크게 한 번 살펴보자.
위 그림에서 초록색 벡터를 보자.
저 벡터를 만약에 구할 수 있다면?
빨간색 벡터와 초록색 벡터*2를 통해, 파란색 벡터를 구할 수 있게 된다.
이 아이디어를 가지고 초록색 벡터를 먼저 구해보자.
먼저, 빨간색 벡터의 방향을 반대로 뒤집어 주었다.
그리고, 법선벡터와 빨간색 벡터를 모두 정규화하여 길이를 1로 만들어주었다.
이후, 빨간색 벡터와 법선벡터를 내적하면, 보라색 선분의 길이를 구할 수 있다.
법선벡터 * 보라색 선분의 길이 = 보라색 선분의 벡터
이 그림의 보라색 벡터를 위의 과정을 통해 구할 수 있다.
이제, 빨간색 벡터와 보라색 벡터를 알았으니 초록색 벡터를 구할 수 있다.
초록색 벡터 = 빨간색 벡터 - 보라색 벡터
초록색 벡터를 구했으니, 위의 그림에서 하늘색 벡터를 구하는 것도 어렵지 않다.
하늘색 벡터 = 빨간색 벡터 + (-초록색 벡터) * 2 가 된다.
이렇게 반사 벡터를 구했다.
이제 반사벡터와 눈의 방향 벡터 사이의 각도만 구하면 된다.
먼저, 지금은 이 상황일 것이다.
먼저, 눈의 벡터의 방향을 반대로 뒤집어주자.
이제, 하늘색 벡터와 눈의 방향 벡터를 정규화 한 뒤, 내적하면 Cos(Theta_2)를 구할 수 있다.
이 값이 Specular Light가 된다.
그림처럼 울퉁불퉁한 재질은 근접한 지점이어도 빛이 사방으로 튀기 때문에, 눈으로 들어가는 빛이 거의 없다.
반면 매끈한 재질의 경우, 눈이 바라보고 있는 곳 주위의 빛을 거의 다 눈으로 받아들이게 된다.
이 그림에서 보면, 특정 부위에 하얗게 빛이 몰려있는 것을 볼 수 있는데, 위의 그림에서 설명한 것 처럼 눈으로 보고있는 곳 주위의 빛이 모두 눈을 가깝게 향하기 때문이다.
재질의 표면이 거친 경우에는 아주 가까이에 있는 지점이더라도 눈과 아주 먼 방향으로 빛이 반사될 수도 있다.
3. Ambient Light
Ambient Light는 광원에 의해 직접적으로 받는 빛이 아니라, 주변 물체에 의해 받는 빛이다.
예를 들어보면, 초록색 유리구슬을 빛이 비추는 곳에 놓게 되면, 그 주위가 초록색으로 물드는 것을 볼 수 있다.
빨간색 구슬을 두면 주위가 빨간색으로 물들게 될 것이다. 이런 빛을 의미하는 것이 Ambient Light이다.
그런데, 이 환경광은 모든 물체의 색을 다 고려하고 하나하나 계산하는 것이 여간 복잡한 일이 아니다.
그래서 일반적으로 그냥 상수로 설정해두어 화면의 전체적인 밝기를 조금씩 올리는 정도로만 해결한다.
개발자가 0.1이라고 하면 0.1인거고 0.2라고 하면 0.2인것이다.
물론 이 환경광을 디테일하게 만들기 위해, 글로벌 일루미네이션, 엠비언트 오클루전 등의 기법들이 존재하긴 한다.
하지만 그것은 심화기술이기 때문에 이 글에서는 따로 다루지는 않겠다.
4. Emissive Light
이 자체발산광 또한, Embient Light와 똑같다. 물체 자체에서 발산하는 빛이기 때문에, 밝은 물체를 표현하고 싶다면 해당 물체에 높은 수치의 Emissive Light를 설정하면 되고 어두운 물체를 표현하고 싶다면 낮은 수치의 Emissive Light를 설정하면 된다.
Ambient Light는 화면에 있는 모든 물체에 대해 적용되는 빛이지만, Emissive Light는 본인에게만 적용되는 빛이라는 차이가 있다.
이렇게 4가지 빛을 모두 구했다면, 그 빛을 모두 더해준 뒤 물체의 색상과 곱해주면 끝이다.
빛이 계산되기 전에 물체의 기본 색상을 Diffuse Color이라고 한다. (Albedo Color라고 하기도 한다.)
Diffuse Color * (Diffuse Light + Specular Light + Ambient Light + Emissive Light) 이 값이 픽셀의 색상이 되는 것이다.
모든 픽셀에 대해 이 연산을 적용하게 되면, 화면에는 재질 표현과 명암의 구분이 잘 되어있는 물체를 렌더링하게 된다.
이 과정을 퐁 쉐이딩 (퐁 조명 모델) 이라고 한다.
여기서, 이 퐁 조명 모델을 살짝 변형한 하프 람베르트 모델, 블린 퐁 조명 모델 등이 있는데 이와 관련해서는