위와 같은 그림이 있다고 쳐보자. 우리는 가장 위의 행부터 한칸씩 아래로 내려와야 한다.

내려올 때, 같은 열은 밟고 내려올 수 없다. 

 

위의 그림에서 화살표를 보면, 1이 적혀있는 1행 1열에서 시작한다면, 2행에 있는 6, 7, 8 로는 이동할 수 있지만,

2행 1열인 5가 적힌 곳으로는 내려올 수 없다. ( 열이 같기 때문에 )

 

다시, 2행에 있는 8에서부터 이동한다고 해보자. 3행에 있는 4, 3, 2로는 이동할 수 있지만 1로는 이동할 수 없다.

 

이런 규칙을 지닌 채로, 밟은 발판의 값을 모두 더하면서 가장 마지막 행까지 이동하면 된다.

여러 경우의 수중 밟은 발판의 값의 총 합의 최대값을 구하면 된다.

 

1->6->4의 경로로 이동했다면, 밟은 발판의 총 합은 11이 될것이고

2->5->3의 경로로 이동했다면, 밟은 발판의 총 합은 10이 될것이다.

 

위의 그림에서는 5->7->4의 경로로 이동하여 나온 16이라는 값이 밟은 발판의 총 합의 최대치이다.

 

 

문제풀이

 

이 문제는 전형적인 DP문제이다.

 

 

먼저, 현재 발판까지 이동하면서 구한 발판의 값의 총 합을 M이라고 해보자.

 

위의 그림을 보자.

2행에 있는 빨간색 5의 M이 최대값이 되려면, 당연히 이전 행에서 밟은 발판이 최대값이 되어야 한다.

그렇기 때문에, 보라색 5를 밟고 빨간색 5로 이동하는 것이 5의 입장에선 M이 최대가 되는 경로이다.

 

 

빨간색 8의 입장에서도 보자. 이전 행에서 값이 가장 큰 발판은 5이지만, 같은 열이기 떄문에 5->8의 경로는 불가능하다. 그렇기 때문에 5를 제외한 나머지 값중 최대값인 3번 발판을 밟고 8로 이동하게 되면 8번 발판에서 M은 최대가 된다.

 

이렇게, 2행에 있는 발판의 값을 각 발판에서 M의 최대값으로 갱신해보겠다.

 

각 발판의 값은 이렇게 갱신할 수 있다.

이렇게 값을 갱신해준 이유는 각 발판을 밟고 지나갈 수 있는 경로중 최대값에 대해서만 계산하면 되기 때문이다.

값을 갱신하지 않은 위의 상태라고 해보자.

3행 1열에서 M의 최대값을 구하기 위해, 2행 2열을 거쳐오는 경우중 M의 최대값, 2행 3열을 거쳐오는 경우중 M의 최대값, 2행 4열을 거쳐오는 경우중 M의 최대값을 구해야한다. 

 

지금은 문제가 3행밖에 없어서 복잡해보이지 않지만 만약 50행까지 있다고 가정해보면?

 

50행 1열에서 M의 최대값을 구하려면,49행 2열의 M의 최대값, 49행 3열의 M의 최대값, 49행 3열의 M의 최대값을 구해야하고, 49행 2열의 M의 최대값은 48행 1열의 M의 최대값, 48행 3열의 M의 최대값........... 쭉 이렇게 계속 계산해야 한다.

50행이라면 계산해야 하는 경우의 수가 3^49승이 될 것이다.

 

하지만, 위에서 미리 값을 갱신하면서 내려오게 되면, 이전 행에서 M의 최대값을 구하기 위한 연산을 중복해서 할 필요가 없어지기 때문에, 이전 행의 3개 열만 확인하면 된다.

 

i행 j열의 최대값 M을 구하는 경우의 수가 3^(i-1) 에서 3으로 줄어들게 된 것이다.

 

이제, 값을 갱신한 상태로 답을 구해보자.

 

이렇게 값을 갱신항 상태로, 다음 행의 최대값을 구해보자.

위의 행에 있는 4개의 열중 본인의 열과 같은 값을 제외하고 나머지 중에서 가장 큰 값을 찾아서 본인의 값과 더하면 된다.

 

최종적으로 위와 같은 모습으로 값의 갱신이 종료되었다.

아래 4개의 열이 가진 값중 최대값인 16을 답으로 반환하면 끝난다.

 

아래와 같이 주어진 입력에 대해서 한 번 더 테스트해보자.

 

이러한 과정을 거칠 것이고, 결과적으로 답은 가장 마지막 행의 최대값인 23이 될 것이다.

 

 

코드 풀이

 

int Height = land.size();
std::vector<std::vector<int>> DP(Height, std::vector<int>(4, 0));

 

먼저, 입력으로 주어진 2차원 배열의 크기를 이용해서 높이를 구해준 뒤, 입력의 사이즈와 동일한 DP배열을 만들어주었다.

DP[0][0] = land[0][0];
DP[0][1] = land[0][1];
DP[0][2] = land[0][2];
DP[0][3] = land[0][3];

 

이후, DP배열의 1행 값을 초기화해주었다. 

왜냐하면, 이 문제에선 이전 행의 값을 기준으로 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의 최대값으로 갱신되어 있을 것이다.

 

return *std::max_element(DP[Height - 1].begin(), DP[Height - 1].end());

 

답으로는 마지막 행에서 최대값을 반환해주면 끝이다.

 

아래는 코드 전문이다.

더보기
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

int solution(vector<vector<int>> land)
{
    int Height = land.size();
    std::vector<std::vector<int>> DP(Height, std::vector<int>(4, 0));

    DP[0][0] = land[0][0];
    DP[0][1] = land[0][1];
    DP[0][2] = land[0][2];
    DP[0][3] = land[0][3];

    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];
        }
    }

    return *std::max_element(DP[Height - 1].begin(), DP[Height - 1].end());
}

 

지난 게시글에선 예제 프로젝트의 코드를 보면서 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를 입력해주면 된다.

 

이렇게 입력해보겠다.

 

아래 창에 위 사진과 같이 Tag가 상속관계로 생성이 된다.

 

A.B.C.D를 입력했더니 이렇게 생성되었다.

4개 이상의 상속관계로 이루어진 Tag도 만들 수 있는 것 같다.

 

이제 GameplayTag가 사용되고 있는 코드들을 찾아서 사용법을 간단하게 익혀보자.

 

아래 코드는 플레이어의 HP가 변화할 때, 플레이어가 죽었는지 체크하는 코드이다.

위에서 보았던 생성자 코드 내부에서는 DeadTag 변수를 아래와 같이 초기화해주었다.

DeadTag = FGameplayTag::RequestGameplayTag(FName("State.Dead"));

 

아래 코드를 보면, 이 DeadTag를 ASC의 HasmatchingGameplayTag의 인수로 사용하여 플레이어의 현재 GameplayTag가 State.Dead를 포함하고 있는 체크하고 있다.

 

언리얼 엔진의 공식문서를 보면 이처럼 플레이어의 현재 Tag를 확인하는 함수를 여러가지고 제공해주고 있다.

 

가장 상위의 Tag만 일치하는지, 모든 Tag가 일치하는지, 해당 Tag가 존재하는지 등의 방식으로 Tag를 확인할 수 있는 것이다.

 

보니까 플레이어는 하나의 GameplayTag만 보유할 수 있는 것이 아니라, 여러가지를 보유할 수 있는 듯 하다.

당연하겠지만, 동시에 여러개의 버프가 적용되어 있을 수도 있고, 상태이상도 여러개가 적용되어 있을 수 있기 때문이다.

 

아래 코드는 GameplayTag의 변화를 감지하여 Stun이 적용되거나 해제될 때, 특정 함수를 실행하도록 하는 예제 코드이다.

AbilitySystemComponent->
RegisterGameplayTagEvent(
FGameplayTag::RequestGameplayTag(FName("State.Debuff.Stun")), EGameplayTagEventType::NewOrRemoved
).AddUObject(this, &AGDPlayerState::StunTagChanged);

 

보면, ASC에서 RegisterGameplayTagEvent 함수를 호출하고 있다.

RegisterGameplayTagEvent 함수의 인수로 GameplayTag와 EGameplayTagEventType을 넣어주면 델리게이트를 반환해준다. 해당 델리게이트에 AGDPlayerState::StunTagChanged 함수를 bind해주고 있다.

 

EGameplayTagEventType은 어떤 경우에 바인딩된 콜백함수를 호출할 것인지를 지정하는 이넘타입이다.

위의 코드에선 EGameplayTagEventType::NewOrRemoved 를 사용하여 State.Debuff.Stun에 해당하는 GameplayTag가 적용되거나 해제될 때에만 델리게이트에 바인딩된 함수를 호출해주도록 해주었다.

 

EGameplayTagEventType에는 EGameplayTagEventType::AnyCountChange 라는 녀석도 있다.

이 녀석은 검색을 해봐도 정보가 잘 나오지 않아서 내가 찾은 정보가 정확한지는 모르겠지만 일단 찾아본 정보를 얘기해보자면 아래와 같다.

 

만약 플레이어가 A.B, A.C 이렇게 두개의 GameplayTag를 가지고 있다면, 현재 A에 대한 TagCount는 2가 된다.

A.B에 대해선 TagCount가 1이고, A.C에 대해서도 TagCount가 1이다.
여기서 만약 A.D라는 태그가 추가되어 A.B, A.C, A.D 이렇게 3개의 태그를 가지게 되면 A의 TagCount가 3이 된다.

만약 위에서 RegisterGameplayTagEvent의 첫 번째 인수로 A에 대한 GameplayTag를 전달하였다면 A.D가 추가될 때 TagCount가 변경되는 것이기 때문에 이 때 델리게이트에 바인딩된 함수가 호출된다

 

뭔가 납득이 되는 것 같은 설명이긴 한데, 이 부분에 대해서는 조금 더 정보를 찾아서 교차검증을 해보아야 할 것 같다.

직접 실험해보고 싶지만, 아직 GAS에 대해 뭐 아는게 없어서 실험하기도 애매하다..

 

아래는 델리게이트에 바인딩 된 StunTagChanged함수의 내부이다.

void AGDPlayerState::StunTagChanged(const FGameplayTag CallbackTag, int32 NewCount)
{
	if (NewCount > 0)
	{
		FGameplayTagContainer AbilityTagsToCancel;
		AbilityTagsToCancel.AddTag(FGameplayTag::RequestGameplayTag(FName("Ability")));

		FGameplayTagContainer AbilityTagsToIgnore;
		AbilityTagsToIgnore.AddTag(FGameplayTag::RequestGameplayTag(FName("Ability.NotCanceledByStun")));

		AbilitySystemComponent->CancelAbilities(&AbilityTagsToCancel, &AbilityTagsToIgnore);
	}
}

 

보면 인자로 FGameplayTag와 int32를 받고 있다.

이는 델리게이트와 바인딩하기 위해서 반드시 선언해주어야 하는 파라미터이다.

 

아마 저 int32가 위에서 말했던 TagCount가 아닐까 싶다. 이 함수는 State.Debuff.Stun이 생성되거나 소멸될 때 호출되는 함수이다. 해당 GameplayTag가 소멸되는 순간이라면 TagCount가 0이되기 때문에 파라미터의 NewCount를 활용해서 태그가 생성될 때와 소멸될 때 분기를 나누어 설계하면 되는 것 같다.

 

내부 함수를 보면,Stun 태그가 생성될 때 Ability 태그를 Cancel하고 있다.

Ability태그는 안에 점프, 조준이 포함되어 있다. 기절 상태가 되면 해당 동작을 중지해야 하기 때문에 해당 Tag를 Cancel하는 것 같다.

 

코드를 보면 FGameplayTagContainer라는 구조체가 있다. 저 녀석은 여러개의 태그를 저장하여 여러가지 편의기능을 제공해주는 놈인 것 같다.

 

위의 코드에선 CancelAbilities함수가 FGameplayTagContainer를 인자로 받기 때문에 사용한 것 뿐이지만 실제로는 더 유용한 쓸모가 있을 듯 하다.

(아직은 모르겠다.)

 

일단 여기까지 해서 GameplayTag에 대해 간단하게 알아보았다.

뭔가 하나하나씩 파헤쳐보니 대충 감은 잡히는 것 같은데, 이걸 게임에 적용해보라면 나한테 Stun 태그가 생성된것마냥 멍해질 것 같다. GAS에 대해 어느정도 이론적 지식을 쌓으면 직접 적용해보면서 지식을 체화해야 할 것 같다

 

 

C++ 20부터 추가된 코루틴이라는 기능에 대해 알아보자.

본인도 공부하면서 찾아본 정보를 정리하는거라 다소 정확하지 않을 수 있다.

 

이 코루틴이라는 기능이 다른 언어에는 진작부터 있는 기능이었다고 한다.

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 이라는 구조체가 선언되어 있어야 한다는 것이다.

 

promise_type을 내부에 선언하지 않으면 아래와 같은 컴파일 오류가 발생한다.

 

promise_type과 함께 코루틴 객체 내부를 좀 채워보도록 하겠다.

class MyCoroutine
{
public:
    struct promise_type
    {
        MyCoroutine get_return_object()
        {
            return  MyCoroutine{ std::coroutine_handle<promise_type>::from_promise(*this) };
        }

        std::suspend_always initial_suspend()
        {
            return std::suspend_always{};
        }

        std::suspend_always final_suspend() noexcept
        {
            return std::suspend_always{};
        }

        void unhandled_exception() {}
    };
    
    MyCoroutine(std::coroutine_handle<promise_type> _Handler) : Handler(_Handler) {}
    
    ~MyCoroutine()
    {
        if ((bool)Handler == true)
        {
            Handler.destroy();
        }
    }

public:
    const std::coroutine_handle<promise_type> GetHandler()
    {
        return Handler;
    }

private:
    std::coroutine_handle<promise_type> Handler;
};

 

뭔가 많이 생겼다.

하나씩 확인해보자.

 

먼저, private에 멤벼번수로 std::coroutine_handle<promise_type> 형의 Handler를 선언하였다.

이는 코루틴을 제어하는 핸들러 변수이다. 핸들러가 없어도 코루틴 함수는 실행하는 것에는 문제가 없다고 한다.

다만, 중지된 코루틴 함수를 재개하려면 핸들러가 반드시 있어야 한다고 한다. 그 외에도 핸들러에는 여러가지 기능이 있어서 웬만하면 만드는게 좋을 듯 하다.

 

MyCoroutine 의 생성자를 하나 만들었다.

std::coroutine_handle<promise_type> 타입의 인자가 들어오면 인수를 Handler에 저장하도록 하였다.

 

소멸자에서는 Handler가 할당되었다면 해당 Handler를 해제하도록 하였다.

 

중요한 것은 promise_type 구조체이다.

해당 구조체 안을 보면 4가지의 함수가 존재한다.

 

이 4가지의 함수는 반드시 promise_type안에 정의해야 하는 함수들이다.

 

1. get_return_obejct()

get_return_object() 함수는 코루틴 함수가 최초에 실행될 때, 반환하는 객체에 대한 함수이다.

코루틴 함수를 중간에 재개하는 등 코루틴 함수 외부에서 제어를 하려면 코루틴 객체가 필요하다.

 

그렇기 때문에 코루틴 함수의 반환형은 코루틴 객체로 되어있는데, get_return_object()함수가 정의되어 있지 않다면 반환할 코루틴 객체를 생성할 수 없어 오류가 발생한다.

 

2. initial_suspend()

코루틴 함수가 최초로 호출될 때, 어떻게 작동할지를 제어하는 함수이다.

반환형을 보면 std::suspend_alway{}로 되어있는데, std::suspend_never{} 도 존재한다.

 

std::suspend_alway{}를 반환하게 한다면 코루틴 함수는 최초에 실행될 때 코루틴 객체만 반환하고 함수 내부의 코드를 실행하지 않는다.

 

std::suspend_never{}를 반환하면, 코루틴 함수 내부에서 중지 명령을 찾기 전까지 함수의 코드를 실행한 뒤, 그 이후 코루틴 객체를 반환하게 된다.

 

3.final_suspend()

코루틴 함수는 외부에서 코루틴 핸들의 멤버함수인 done함수를 통해 함수가 끝났는지 끝나지 않았는지 여부를 확인할 수 있다. 

 

만약 final_suspend()에서 std::suspend_never{}를 반환했다면, 함수가 종료되는 순간 그냥 함수를 끝내버리게 된다.

코루틴 함수는 종료되는 순간 코루틴 핸들을 해제해버린다. 즉, 외부에서 핸들러의 done()함수를 실행하는 순간, 해제된 메로리를 참조하기 때문에 프로세스가 펑 하고 터져버린다. 

 

반면, final_suspend()에서 std::suspend_always{}를 반환하게 되면, 코루틴 함수는 종료 직전에 일시중지 상태에 머문다.

그렇기 때문에 코루틴 핸들은 파괴되지 않고, 외부에서는 done()함수를 무사히 실행할 수 있다.

std::suspend_always{}를 반환하여 종료 직전에 일시정지 상태에 머문다고 하더라도, 개발자가 작성해놓은 함수 내부의 코드가 모두 실행되었다면 , done()는 true를 반환한다.

 

4. unhandled_exception()

코루틴 함수 내부에서 의도치 않은 오류가 발생했을 때, 호출하는 함수이다.

예외 처리, 디버깅에 대한 코드를 작성하고 싶다면, 이 함수 내부에 작성하면 된다.

 

이렇게 4가지의 함수는 promise_type의 핵심이기 때문에, 반드기 함수를 구현해주어야 한다.

 

그런데, 위의 코드에는 이 4가지 함수만 구현해놓았지만, 사실은 구현해야 하는 함수가 한 가지 더 있다.

 

5. return_void() , return_value()

구현해야 한다면서 이 함수를 왜 구현해놓지 않았냐면, 설명이 조금 필요하기 때문이었다.

 

이 두개의 함수는 동시에 선언할 수 없고, 코루틴 함수에서 값을 반환하느냐 마느냐에 따라 다르게 선언해야 한다.

코루틴 함수 자체는 기본적으로 코루틴 객체를 반환형으로 하고 있다.

 

하지만, 코루틴 함수가 아예 종료되는 순간 반환하고 싶은 값이 있을 수 있다.

 

그럴 땐, return_value()를 선언하면 된다.

딱히 반환할게 없다면, return_void()를 선언하면 된다.

 

이 게시글에선 두 가지 경우에 대해서 모두 테스트해보도록 하겠다.

먼저, return_void()에 대해서 구현해보겠다.

void return_void() {}

promise_type 내부에 return_void를 선언해주었다. 

 

다음은 CoroutineTest함수를 구현해보겠다.

MyCoroutine CoroutineTest()
{
    std::cout << "Coroutine 1" << std::endl;
    co_await std::suspend_always{};

    std::cout << "Coroutine 2" << std::endl;
}

 

내부를 보면, co_await이라는 처음보는 놈이 존재한다.

이 것은 코루틴 함수의 중지를 요청하는 문법이다.

 

코루틴 함수에서는 co_await, co_yield, co_return 세가지를 사용할 수 있다.

 

co_await는 값의 반환없이 중지하는 경우에 사용하고

co_yield는 값을 반환하며 중지하는 경우에 사용하며

co_return은 코루틴 함수를 종료할 때 사용한다.

 

먼저, co_await으로 테스트해보자.

co_await 뒤에는 std::suspend_always{}와 std::suspend_never{}를 사용할 수 있는데 std::suspend_never{}를 사용하면 중지되지 않고 그냥 함수를 계속 진행하게 된다. 

 

아래는 메인함수이다.

int main()
{
    std::cout << "main 1" << std::endl;
    MyCoroutine Coroutine = CoroutineTest();
    std::cout << "main 2" << std::endl;
}

먼저, main 1을 출력한 이후, CoroutineTest()를 실행하였다.

해당 함수는 코루틴 객체를 반환하기 때문에, 이를 저장할 MyCoroutine형 변수를 선언하여 반환된 객체를 저장하였다.

 

현재는 initial_suspend() 에서 std::suspend_always{}를 반환하도록 하였기 때문에, CoroutineTest()함수를 호출하여도 아무 것도 실행되지 않는다.

 

아래 사진은 그를 증명하는 실행결과이다.

분명 CoroutineTest()를 호출했음에도, 아무것도 실행되지 않았다.

처음 함수가 호출되고 일시정지 상태에 머물러있기 때문이다.

 

이 함수를 재개하기 위해선 코루틴 핸들의 resume함수를 호출해야 한다.

아래와 같이 코드를 작성한 뒤 실행해보겠다.

 

int main()
{
    std::cout << "main 1" << std::endl;
    MyCoroutine Coroutine = CoroutineTest();
    Coroutine.GetHandler().resume();
    std::cout << "main 2" << std::endl;
}

이번엔, Coroutine 1이라는 메시지가 중간에 출력되었다.

 

CoroutineTest함수 코드를 보면, 중간에 co_await을 이용해 중지시켰기 때문에 Coroutine 1만 호출되었다.

이번엔, 다시 아래와 같이 코드를 작성하고 실행해보겠다.

int main()
{
    std::cout << "main 1" << std::endl;
    MyCoroutine Coroutine = CoroutineTest();
    Coroutine.GetHandler().resume();
    std::cout << "main 2" << std::endl;
    Coroutine.GetHandler().resume();
}

이번엔, main2 이후에 Coroutine2가 출력되었다.

Coroutine1이 아닌 Coroutine2가 출력되었다는 것은, CoroutineTest함수가 처음부터 시작된 것이 아니라, 중간부터 시작되었다는 증거이다.

 

만약, 이 이후에 resume을 한 번 더 한다면?

프로세스는 터져버린다. 즉, resume을 할 때 프로세스가 터져버리는 걸 방지하기 위해, 항상 done()함수의 반환값을 확인하며 호출해야 한다. 아래와 같이 방어코드를 함께 작성해야 한다. 

int main()
{
    std::cout << "main 1" << std::endl;

    MyCoroutine Coroutine = CoroutineTest();

    if (Coroutine.GetHandler().done() == false)
    {
        Coroutine.GetHandler().resume();
    }	

    std::cout << "main 2" << std::endl;

    if (Coroutine.GetHandler().done() == false)
    {
        Coroutine.GetHandler().resume();
    }
}

 

코루틴 함수에 대해 설명은 끝났으니, 몇 가지 테스트를 해보자.

 

1. initial_suspend() 에서 std::suspend_never{}를 반환하는 경우

위에서 설명했지만, std::suspend_never{}를 반환하면, 코루틴 함수가 최초에 호출될 때 중지상태에 머물지 않고 co_await, co_yield, co_return을 만날 때까지 계속 실행된다.

 

std::suspend_never{}를 반환하도록 수정한 뒤 아래 코드를 실행해보겠다.

int main()
{
    std::cout << "main 1" << std::endl;

    MyCoroutine Coroutine = CoroutineTest();
    
    std::cout << "main 2" << std::endl;
}

 

아래는 실행 결과이다.

기존에는 resume함수를 호출하지 않으면 Coroutine 1이 출력되지 않았었다. 하지만, std::suspend_never{}를 반환하도록 수정하니 Coroutine 1 이 출력되는 것을 볼 수 있다.

 

 

2. return void가 아닌, return_value를 정의하는 경우

 

이 경우에는 수정을 해야하는 것이 좀 있다.

먼저, 반환할 값을 저장할 멤버변수를 선언해야 한다.

이후, return_value는 값을 그 멤버변수에 저장하도록 구현해야 한다.

 

아래와 같이 말이다.

int A = 0;

void return_value(int value) 
{
    A = value;
}

 

그리고 코루틴 객체 내부에는 return값을 반환해주는 함수를 만들어야 한다.

int GetReturnValue() 
{
    return Handler.promise().A;
}

 

이후 코루틴 함수에서 아래와 같이 return값을 반환하게 되면, promise_type객체 내부의 A에 반환값이 저장된다.

MyCoroutine CoroutineTest()
{
    std::cout << "Coroutine 1" << std::endl;
    co_return (5);

    std::cout << "Coroutine 2" << std::endl;
}

 

외부에서는 아래와 같이 반환값을 받아볼 수 있다.

int main()
{
    std::cout << "main 1" << std::endl;

    MyCoroutine Coroutine = CoroutineTest();
    Coroutine.GetHandler().resume();
    
    if (Coroutine.GetHandler().done() == true)
    {
        std::cout << Coroutine.GetReturnValue() << std::endl;
    }

    std::cout << "main 2" << std::endl;
}

 

여기서 반환값이라 하는 것은 사실 promise의 멤버변수 값을 그냥 받아올 뿐이다.

그렇기 때문에 함수가 co_return을 만나기 전까지는 반환받는 값이 의도와 다를 수 있다.

그러므로 반드시 done()를 체크한 뒤, 반환값을 확인해야 한다.

 

실행 결과는 아래와 같다.

정상적으로 return 값인 5가 출력되고 있다.

 

 

3. co_yield를 사용하는 경우

co_yield는 함수가 일시중지될 때, 값을 반환하기 위해 사용된다.

이를 사용하기 위해선, yield_value함수가 선언되어 있어야 한다.

 

과정 자체는 return_value와 동일하다.

아래와 같이 먼저 yield_value를 정의해주자.

 

int YieldValue = 0;
std::suspend_always yield_value(int _Value)
{
    YieldValue = _Value;
    return std::suspend_always{};
}

 

YieldValue라는 멤버변수에 데이터를 저장해주자.

 

이후, 코루틴 객체에 아래와 같이 YieldValue를 반환하는 함수를 만들자.

int GetYieldValue()
{
    return Handler.promise().YieldValue;
}

 

코루틴 함수는 아래와 같이 수정해보겠다.

MyCoroutine CoroutineTest()
{
    std::cout << "Coroutine 1" << std::endl;
    co_yield (5);

    std::cout << "Coroutine 2" << std::endl;
    co_yield (2);
}

 

이후 아래와 같이 메인함수를 실행해보자.

int main()
{
    std::cout << "main 1" << std::endl;

    MyCoroutine Coroutine = CoroutineTest();
    if (Coroutine.GetHandler().done() == false)
    {
        Coroutine.GetHandler().resume();
        std::cout << Coroutine.GetYieldValue() << std::endl;
    }
    
    if (Coroutine.GetHandler().done() == false)
    {
        Coroutine.GetHandler().resume();
        std::cout << Coroutine.GetYieldValue() << std::endl;
    }

    std::cout << "main 2" << std::endl;
}

 

결과는 아래와 같다.

 

yield에서 반환한 값이 잘 저장되어 있는 것을 볼 수 있다.

 

여기까지가 코루틴의 기초 사용법이었다.

커스텀하는 방법도 다양하고, 활용하는 방법도 다양한 것 같지만, 아직 잘 모르는 것이 많아서 본인도 더 찾아보고 연구해보아야 할 듯 하다.

'C++ > C++' 카테고리의 다른 글

C++ - 얕은 복사 vs 깊은 복사  (1) 2024.04.18
C++ - 가변 인자 템플릿  (0) 2024.04.18
C++ - string_view (읽기 전용 문자열 컨테이너)  (0) 2024.04.11
C++ - SSO (Small String Optimization)  (0) 2024.04.11
C++ - placement new  (0) 2024.04.10

언리얼 엔진에는 GAS라는 기능이 있다고 한다.

게임 내 캐릭터의 스탯, 쿨타임 등의 능력에 관한 기능을 효율적으로 구축할 수 있도록 도와주는 기능이라고 한다.

에픽게임즈에서 개발한 포트나이트에도 사용되고 있고, 이전에 개발했던 파라곤에서도 사용되었었다고 한다.

 

공식 문서에는 위와 같이 설명되어 있다.

 

이 GAS가 정확히 어떤 기능인지, 어떻게 사용하는지, 어디에 활용하는지 등에 대한 궁금증이 생겼고 이 기능을 파헤쳐보고자하는 마음이 생겼다. 그런 이유로 GAS를 분석해보고자 한다.

 

분석에 있어서 아래 깃허브와 공식 문서를 참조하였다.

(해당 깃허브는 에픽게임즈가 제공해주는 공식 예제가 아니라, 감사하게도 제 3자가 기능 설명을 위해 제공해준 예제 프로젝트이다.)

 

tranek/GASDocumentation: My understanding of Unreal Engine 5's GameplayAbilitySystem plugin with a simple multiplayer sample project. (github.com)

 

GitHub - tranek/GASDocumentation: My understanding of Unreal Engine 5's GameplayAbilitySystem plugin with a simple multiplayer s

My understanding of Unreal Engine 5's GameplayAbilitySystem plugin with a simple multiplayer sample project. - tranek/GASDocumentation

github.com

 

 

먼저, 이 기능을 사용하기 위해 몇가지 프로젝트 세팅을 해야한다.

 

프로젝트 세팅


1. 에디터에서 아래의 플러그인을 활성화 해주어야 한다.

 

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에서 해주고 있었다.

 

아래는 PlayerState에서 함수를 바인딩해주는 코드이다.

너무 길어서 캡쳐하지 않고, 코드블록을 사용하였다.

// Attribute change callbacks
HealthChangedDelegateHandle = 
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetHealthAttribute()).AddUObject(this, &AGDPlayerState::HealthChanged);

MaxHealthChangedDelegateHandle = 
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetMaxHealthAttribute()).AddUObject(this, &AGDPlayerState::MaxHealthChanged);

HealthRegenRateChangedDelegateHandle = 
AbilitySystemComponent->GetGameplayAttributeValueChangeDelegate(AttributeSetBase->GetHealthRegenRateAttribute()).AddUObject(this, &AGDPlayerState::HealthRegenRateChanged);

 

각 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의 흐름을 먼저 파악해야 할 것 같다.

 

그림처럼 5종류의 테트로미노가 있다.

이 테트로미노의 모양에 맞게 배열에서 최대값을 탐색하면 된다.

 

 

이런 이차원 배열이 있다고 해보자.

 위처럼, 빨간색으로 ㅗ 모양으로 배열을 탐색할 수도 있고, 파란색처럼 ㄴ모양으로 탐색할 수도 있다.

노란색처럼 ㅁ모양으로 탐색할 수도 있다.

 

이렇게, 여러 모양에 대해 이차원 배열을 탐색하면서 4개 숫자의 합을 구하고, 그 중 최대값을 구하면 된다.

 

문제 풀이

이 4가지 도형에 대해선 그냥 DFS를 돌며 4칸을 탐색하면 된다.

한 지점에 대해, 이동할 수 있는 4칸에 대해 모두 DFS돌면 위와 같은 모든 도형에 대한 경우의 수를 탐색할 수 있다.

이런 식으로, 특정 좌표를 기준으로 4칸만큼 DFS를 하게 되면 결국 테트로미노 도형과 동일한 형태를 띄게 된다.

 

하지만, 한가지 예외인 도형이 있다.

 

이 모양의 도형은 DFS로 탐색할 수가 없다.

그렇기 때문에 이 도형에 대해서는 예외로 따로 최대값을 구해주었다.

 

즉, 이 문제는

1. 주위의 4칸으로 DFS를 하며 구한 최대값 

2. 보라색 도형의 모양으로 탐색한 최대값

 

두 경우에 대해 최대값을 모두 탐색한 뒤, 둘 중 더 큰 값을 출력하면 되는 문제이다.

 

 

풀이 코드

int Height = 0;
int Width = 0;

int Max = 0;

std::vector<std::vector<int>> Board;
std::vector<std::pair<int, int>> Dir = { {1, 0}, {0, 1}, {-1, 0}, {0, -1} };

 

먼저 위와 같이 변수와 자료구조를 선언하였다.

 

Height 는 입력되는 배열의 높이이고, Width는 입력되는 배열의 길이이다.

Max는 답으로 출력하기 위한 최대값을 저장하는 변수이다.

 

Board는 입력되는 숫자를 저장할 배열이고, Dir은 DFS에서 4방향을 탐색하기 위해 만들어놓은 벡터이다.

 

std::cin >> Height >> Width;

Board.resize(Height, std::vector<int>(Width, 0));

for (int i = 0; i < Height; i++)
{
    for (int j = 0; j < Width; j++)
    {
        std::cin >> Board[i][j];
    }
}

 

이후, 입력을 위와 같이 받았다.

 

for (int i = 0; i < Height; i++)
{
    for (int j = 0; j < Width; j++)
    {
        DFS(j, i, -1, -1, 0, 1);
        GetMaxAnother(j, i);
    }
}

 

그리고, 모든 좌표에 대해 4칸까지 DFS를 하며 최대값을 갱신해주고, 예외인 ㅗ 모양의 도형에 대해서도 최대값을 갱신해주었다.

 

DFS함수 내부를 보자.

void DFS(int _X, int _Y, int _PrevX, int _PrevY, int _Sum, int _Depth)
{
    if (_Depth == 4)
    {
      Max = std::max(Max, _Sum + Board[_Y][_X]);
      return;
    }

    for (int i = 0; i < 4; i++)
    {
        int NextY = _Y + Dir[i].first;
        int NextX = _X + Dir[i].second;

        if (NextX < 0 || NextY < 0 || NextX >= Width || NextY >= Height)
        {
            continue;
        }

        if (_PrevX == NextX && _PrevY == NextY)
        {
            continue;
        }

        DFS(NextX, NextY, _X, _Y, _Sum + Board[_Y][_X], _Depth + 1);
    }
}

 

_X, _Y는 현재 좌표, _PrevX , _PrevY 는 이전 좌표, _Sum은 지나온 좌표에 적힌 숫자들의 합, _Depth는 현재 몇칸까지 탐색했는가 이다.

 

이 문제는 최대 4칸까지의 합만을 요구하기 떄문에 _Depth가 4가 되는 순간 최대값을 갱신하고 재귀함수를 종료하였다.

_PrevX와 _PrevY는 지나온 길을 다시 돌아가지 않기 위한 방문체크용이다.

 

배열을 따로 선언해도 되지만, 함수의 인자를 활용하는 편이 더 편하다고 생각하여 위와 같이 설계하였다.

또한, 배열을 만들지 않음으로써 메모리를 절약할 수도 있다.

 

이후, DFS를 돌며 상하좌우 4방향에 대해 나아가며 탐색하였다.

 

void GetMaxAnother(int _X, int _Y)
{
    if (_Y + 2 < Height)
    {
        int Sum = Board[_Y][_X] + Board[_Y + 1][_X] + Board[_Y + 2][_X];

        if (_X - 1 >= 0)
        {
            Max = std::max(Max, Sum + Board[_Y + 1][_X - 1]);
        }

        if (_X + 1 < Width)
        {
            Max = std::max(Max, Sum + Board[_Y + 1][_X + 1]);
        }
    }

    if (_X + 2 < Width)
    {
        int Sum = Board[_Y][_X] + Board[_Y][_X + 1] + Board[_Y][_X + 2];

        if (_Y - 1 >= 0)
        {
            Max = std::max(Max, Sum + Board[_Y - 1][_X + 1]);
        }

        if (_Y + 1 < Height)
        {
            Max = std::max(Max, Sum + Board[_Y + 1][_X + 1]);
        }
    }
}

 

이 함수는 ㅗ 모양의 도형에 대한 함수이다.

 

 

이렇게 1자 모양으로 3개의 숫자의 합을 구한 뒤, 빨간색 도형에 해당하는 좌표의 값을 더해주어

ㅗ 모양과 ㅜ모양에 대한 합을 탐색하였다.

 

 

이렇게 세로 방향으로도 진행하여 ㅓ 모양과 ㅏ 모양에 대해서도 합을 탐색하였다.

 

std::cout << Max;

return 0;

 

마지막으로 Max를 출력해주면 끝이다.

 

코드 전문

더보기
#include <iostream>
#include <vector>

void Init()
{
   std::ios::sync_with_stdio(false);
   std::cin.tie(nullptr);
   std::cout.tie(nullptr);
}

int Height = 0;
int Width = 0;

int Max = 0;

std::vector<std::vector<int>> Board;
std::vector<std::pair<int, int>> Dir = { {1, 0}, {0, 1}, {-1, 0}, {0, -1} };

void DFS(int _X, int _Y, int _PrevX, int _PrevY, int _Sum, int _Depth)
{
    if (_Depth == 4)
    {
        Max = std::max(Max, _Sum + Board[_Y][_X]);
        return;
    }

    for (int i = 0; i < 4; i++)
    {
        int NextY = _Y + Dir[i].first;
        int NextX = _X + Dir[i].second;

        if (NextX < 0 || NextY < 0 || NextX >= Width || NextY >= Height)
        {
            continue;
        }

        if (_PrevX == NextX && _PrevY == NextY)
        {
            continue;
        }

        DFS(NextX, NextY, _X, _Y, _Sum + Board[_Y][_X], _Depth + 1);
    }
}

void GetMaxAnother(int _X, int _Y)
{
    if (_Y + 2 < Height)
    {
        int Sum = Board[_Y][_X] + Board[_Y + 1][_X] + Board[_Y + 2][_X];

        if (_X - 1 >= 0)
        {
            Max = std::max(Max, Sum + Board[_Y + 1][_X - 1]);
        }

        if (_X + 1 < Width)
        {
            Max = std::max(Max, Sum + Board[_Y + 1][_X + 1]);
        }
    }

    if (_X + 2 < Width)
    {
    	int Sum = Board[_Y][_X] + Board[_Y][_X + 1] + Board[_Y][_X + 2];

    	if (_Y - 1 >= 0)
    	{
    	    Max = std::max(Max, Sum + Board[_Y - 1][_X + 1]);
    	}

    	if (_Y + 1 < Height)
    	{
    	    Max = std::max(Max, Sum + Board[_Y + 1][_X + 1]);
    	}
    }
}

int main()
{
    Init();

    std::cin >> Height >> Width;

    Board.resize(Height, std::vector<int>(Width, 0));
    
    for (int i = 0; i < Height; i++)
    {
        for (int j = 0; j < Width; j++)
        {
            std::cin >> Board[i][j];
        }
    }

    for (int i = 0; i < Height; i++)
    {
        for (int j = 0; j < Width; j++)
        {
            DFS(j, i, -1, -1, 0, 1);
            GetMaxAnother(j, i);
        }
    }

    std::cout << Max;

    return 0;
}

 지난 게시글에서 세그먼트 트리에 대해 알아보았다.

펜윅트리는 세그먼트 트리에서 조금 더 진보된 형태라고 보면 된다.

 

비트연산을 이용하기 때문에, 처음에 이해하는 것이 다소 까다롭지만 완벽히 이해하고 나면

구현하는 것도 훨씬 간단하고 메모리 사용량도 상대적으로 적기때문에 세그먼트 트리보다 효율적인 알고리즘이다.

 

 

위는 세그먼트 트리의 구조이다.

N개의 숫자에 대해 저장하는 구간합의 최대 개수는 약 4N개이다.

 

반면, 아래 그림은 펜윅 트리이다.

 

위 그림은 펜윅트리에서 각 인덱스에 저장된 구간합의 정보이다.

어떤 기준으로 합이 저장되고, 어떻게 사용하는지는 일단 뒤로 미루고 전체적인 크기를 보자.

 

세그먼트 트리는 구간합에 대한 정보를 트리구조로 저장하기 때문에 최대 4N개의 메모리가 필요했다.

반면, 펜윅트리는 숫자N개에 대한 N개의 배열 하나에 구간합의 정보를 모두 저장하고 있다.

메모리를 1/4로 절약할 수 있는 것이다.

 

메모리를 아끼는 만큼 구간합을 구할 때, 시간복잡도가 더 크지 않을까? 라는 생각을 할 수도 있지만

시간복잡도는 O(logN)으로 동일하다.

또한 펜윅트리는 비트연산을 이용하기 때문에, 같은 시간복잡도여도 재귀함수를 사용하는 세그먼트 트리보다 더 빠르다.

 

즉, 가능하다면 펜윅트리를 사용하는 것이 훨씬 효율적이라는 것이다.

 

다만, 펜윅트리를 모든 상황에서 사용할 수 있는 것은 아니다.

세그먼트 트리 게시글에서 구간 합에 대해서만 언급하였지만, 실제로는 구간의 최소값, 최대값 등을 구하기 위해서도 세그먼트 트리를 활용할 수 있다.

 

반면 펜윅트리는 구간의 합과 같은 몇가지 정보에 대해서만 사용이 가능하다. 기능이 다소 제한적이라는 뜻이다.

이유는 뒤에서 설명하겠지만, 펜윅트리는 합을 구하는 연산을 할 때, 1~K로만 구할 수 있다.

 

세그먼트 트리는 a~b구간에 대해, 그 사이에 있는 여러 구간을 활용하여 a~b의 구간합을 구하지만,

펜윅트리는 1~b 의 구간합에서 1~(a-1)의 구간합을 빼는 방식으로 이루어진다. 

 

즉, 펜윅 트리에서 직접적으로 구할 수 있는 것은 구간합이 아니라 누적합인 것이다.

이러한 이유로 펜윅 트리는 다소 사용할 수 있는 상황이 제한적이기 때문에, 상황에 맞게 잘 사용해야 한다.

 

이제 펜윅트리가 어떻게 작동하는지 확인해보자.

 

 

 

펜윅 트리


 

펜윅 트리에도 세그먼트 트리와 동일하게 Update와 GetSum함수 두가지가 있다.

세그먼트 트리는 초기화 함수도 따로 있었지만, 세그먼트 트리는 Update를 활용해서 초기화를 진행할 수 있기 때문에 초기화 함수가 따로 필요하지는 않다.

 

 

먼저 이 그림을 보자.

 

1의 경우 1로부터 앞쪽으로 1칸의 구간합을 저장하고 있다.

2의 경우 2로부터 앞쪽으로 2칸의 구간합을 저장하고 있다.

3의 경우 3으로부터 앞쪽으로 1칸의 구간합을 저장하고 있다.

4의 경우 4로부터 앞쪽으로 4칸의 구간합을 저장하고 있다.

 

쭉쭉 해보면 8의 경우엔 8로부터 앞쪽으로 8칸의 구간합을 저장하고 있다.

정확한 규칙성은 모른다 하더라도, 일단 2의 지수에 해당하는 칸의 구간합을 저장하고 있다는 것은 쉽게 알 수 있다.

 

규칙성을 찾기 위해 이진수의 형태로 인덱스를 확인해보자.

 

1-> 1

2-> 10

3-> 11

4-> 100

5->101

6->110

7->111

8->1000

 

여기서 최하위 비트에 집중을 해보자.

최하위 비트란, 이진수에서 가장 오른쪽에 있는 1을 의미한다.

위의 표는 각 인덱스를 이진수로 표현했을 때, 최하위 비트가 오른쪽으로 부터 몇 번째에 있는가를 기록한 표이다.

해당 배열의 원소값을 n이라고 했을 때, 2^(n - 1)을 구해보자.

이렇게 값이 변하게 된다.

 

보면 어디서 본 것 같은 숫자가 나온다.

위에서 구해보았던 구간합의 개수 정보이다.

4번 인덱스의 경우 원소값이 4가 되는데, 이 경우 4로부터 앞으로 4칸에 대한 구간합을 저장하겠다는 것이다.

 

8의 경우, 8로부터 앞으로 8칸까지의 구간합을 저장하겠다는 것이다.

 

일단, 이 정보만을 기억해두고 Update과정에 대해 알아보자.

만약, 3번째 숫자를 변경하고 싶다면, 위의 그림에서 주황색으로 표현된 곳처럼 3번 숫자를 포함하고 있는 구간합의 정보를 갱신해야 한다.

 

즉, 3번 숫자가 바뀌게 되면 펜윅트리에선 3, 4, 8 번의 구간합을 수정해야 하는 것이다.

 

3, 4, 8을 이진수로 표현해보자.

3-> 11

4-> 100

8->1000

 

3의 최하위 비트는 1이다.

3에다가 이진수 1을 더해보자.

100이 된다. 11 + 1 -> (3 + 1) -> 4 -> 100

 

이번엔, 4를 보자.

4의 최하위 비트는 3번째 1이고, 이진수로는 100이다.

4에다가 이진수 100을 더해보자.

1000이 된다. (100 + 100) -> 4 + 4 -> 8 -> 1000

 

즉, 변경된 숫자의 인덱스에서 최하위 비트를 더해가며 나오는 값이 변경된 숫자가 포함된 구간의 인덱스가 되는 것이다.

반복문을 돌며, 최하위 비트를 더해가며 인덱스를 구하면서 구간합을 갱신하면 끝이다.

 

다음은, 구간합을 구하는 법에 대해 알아보자.

위에서도 말했듯이 정확히는 누적합이다.

펜윅트리에서 직접적으로 구할 수 있는 구간은 무조건 1부터 시작한다.

K까지의 구간합을 구하게 되면 1~K의 합을 반환하기 때문에 누적합이라고 표현하는 것이 조금 더 적합하다.

N~K 의 구간합을 구하고 싶다면, [1 ~ K] - [1 ~ (N - 1)] 의 방식으로 구간합을 구하게 된다. 

 

구간합을 구하는 방식은 업데이트와 반대이다.

이번엔 최하위 비트를 빼면서 계산하면 된다.

 

예를 들어, 7까지의 누적합을 구한다고 해보자.

이진수로 표현하면 7은 111이다.

최하위 비트는 첫번째 1이고, 이진수로는 1이다.

 

111에서 1을 빼면, 110이 된다.

110은 십진수로 6이다.

 

이번엔 6에서 다시 최하위 비트를 빼보자.

110의 최하위 비트는 2번째 1이고, 이진수로는 10이다.

110 에서 10을 빼면, 100 -> 4가 나온다.

 

다음은 4에서 다시 최하위 비트를 빼보자.

100의 최하위 비트는 세번째 1이고, 이진수로는 100이기 때문에

4에서 최하위 비트를 빼면 0이 나온다. 

 

즉, 이제 더이상 더할 것이 없다는 것이다.

 

이 결과대로라면 펜윅트리의 7번 인덱스 + 6번 인덱스 + 4번 인덱스의 값이 1~7의 누적합이 되는 것이다.

 

그림에서 보면, 7번 인덱스 + 6번 인덱스 + 4번 인덱스의 값이 [1] ~ [7]까지의 합인 것을 확인할 수 있다.

 

이처럼, 펜윅트리는 최하위 비트를 활용하여 트리를 갱신하고 누적합을 구하게 된다.

개념 자체는 다소 복잡하지만 코드로 구현해보면 매우 간단하다는 것을 알 수 있다.

 

이제, Update와 GetSum을 알아보았으니 초기화 과정도 알아보자.

위에서 말했듯이 초기화는 Update를 이용해서 구현한다고 하였다.

최초에 배열은 이렇게 비어있다.

모든 값이 현재 0으로 저장되어 있는 것이다.

 

숫자가 위와 같이 주어진다면, 이 것을 변경되는 값이라고 가정하고 Update를 진행하는 것이다.

 

1번 인덱스의 숫자가 현재 1이다. 이를 숫자가 0->1로 변경되었다고 가정해보자.

그렇다면, 1번 숫자가 포함된 구간합을 저장하고 있는 펜윅트리의 인덱스를 모두 갱신해야 한다.

위에서 설명했던 최하위 비트를 활용하여 갱신하게 되면 아래와 같아진다. 

 

 

2번 숫자에 대해서도 갱신해보자.

3번 숫자에 대해서도 갱신하면?

 

이런 식으로 펜윅 트리가 계속 갱신된다.

즉, 맨 처음의 숫자가 모두 0이었다가 주어진 숫자로 변경되었다고 가정하고 모든 숫자에 대해 Update를 진행하게 되면

규칙대로 구간합을 저장하게 되는 것이다.

 

최종적으로는 이렇게 펜윅 트리가 완성된다.

이제 코드로 구현해보자.

 

 

 

코드 구현


std::vector<int> Nums;
std::vector<int> FenWickTree;

 

먼저 숫자를 저장할 벡터와 구간합을 저장할 펜윅트리 두 가지를 선언해주었다.

Nums.resize(NumSize + 1);
FenWickTree.resize(NumSize + 1);

 

이후, resize를 해주었다. 세그먼트 트리와 동일하게 펜윅트리도 인덱스 0번을 사용하지 않고 1번부터 사용한다.

왜냐하면 0의 이진수는 0이기 때문에 최하위 비트를 활용한 연산이 불가능하기 때문이다.

 

void Update(int _Index, int _Value)
{
    while (_Index < Nums.size())
    {
        FenWickTree[_Index] += _Value;
        _Index += (_Index & -_Index);
    }
}

 

그리고, 초기화를 하기 위해 Update함수를 먼저 정의하였다.

_Index는 변경된 숫자의 인덱스이고, _Value는 변화량이다.

1->3으로 바뀌었다면 _Value는 3이 아니라 2이다.

5->2로 바뀌었다면 _Value는 2가 아니라 3이다.

 

반복문 내부를 보면 &연산자를 이용해 비트연산을 진행하고 있다.

비트 연산에 익숙하지 않은 사람은 해당 연산이 뭘하는건지 잘 모를 수 있다.

 

먼저, 숫자 15를 비트로 표현해보자.

int자료형이라면 실제로는 32자리가 되겠지만, 편의를 위해 8자리만 사용해 설명하도록 하겠다.

 

15를 비트로 표현하면, 0000 1111이 된다.

-15는 어떻게 표현할까?

 

결론부터 말하자면, -15는 1111 0001이다.

 

어떤 비트의 부호를 바꾸고 싶다면, 1와 0을 모두 뒤집은 뒤 1을 더해주면 된다.

 

0000 1111 의 0과 1을 모두 뒤집게 되면 1111 0000 이 된다.

여기서 1을 더해주면? 1111 0001 이 된다.

더보기

여기서, -15와 15는 2의 보수라고 표현하기도 한다.

 

왜냐하면 1111 0001과  0000 1111을 수학적으로 더하면, 1 0000 0000 이 된다. 절대값은 같지만 부호가 다른 두 수를 더하면 최대 비트를 초과하면서 가장 작은 2의 제곱수가 나오게 되는데, 수학에서 이 것을 2의 보수라고 한다.

 

하지만, 컴퓨터 공학에선 1111 0001 과 0000 1111을 더한다고 1 0000 0000 이 나오지는 않는다.

 

1111 0001은 -15이고, 0000 1111은 15이다. 두 수를 더하면 0이 나온다.

0의 비트는 0000 0000 이다. 

 

즉, 1111 0001 과 0000 1111을 더하면 0000 0000이 나온다는 뜻인데, 이는 컴퓨터 공학에서 비트 연산이 수학적인 이진수 덧셈과는 다르다는 것을 알려주는 부분이다. 

 

그럼에도 2의 보수라는 단어를 사용하는 이유는 그냥 수학에서 2의 보수라고 하니까 관습적으로 사용하는 것 같다.

 

15의 비트 -> 0000 1111

-15의 비트 -> 1111 1001 

 

두 비트의 &연산을 하게 되면 두 비트 모두 1인 비트는 1로, 다른 비트는 0으로 표현하여 반환하게 된다.

즉 15 & -15 를 하게 되면 0000 0001 이라는 비트가 반환된다.

최하위 비트를 구할 수 있는 것이다.

 

한 번 더 확인하기 위해 이번엔 19와 -19를 &연산 해보자.

 

20의 비트 -> 0001 0100

-20의 비트 -> 1110 1100

 

20 & -20 -> 0000 0100

이 번에도 최하위 비트를 반환하였다.

 

이 결과를 보면 Num과 -Num을 &연산한 결과는 Num의 최하위 비트라는 것을 알 수 있다.

이를 이용해, 인덱스에 최하위 비트를 더해주면서 최대 인덱스를 초과하기 전까지 펜윅 트리를 갱신하는 것이 Update 함수이다.

 

다음은 누적합을 구하는 GetSumFromZero 함수이다.

본인은 GetSumFromZero와 GetSegSum함수를 분리해놓았는데

GetSumFromZero는 누적합을 구하는 함수이고, GetSegSum함수는 원하는 구간의 구간합을 구하는 함수로 만들었다.

 

int GetSegSum(int _Start, int _End)
{
    return GetSumFromZero(_End) - GetSumFromZero(_Start - 1);
}

 

구간합은 위에서 설명했듯이, 두 누적합의 차를 반환하면 된다.

 

int GetSumFromZero(int _Index)
{
	int Sum = 0;

	while (_Index > 0)
	{
		Sum += FenWickTree[_Index];
		_Index &= (_Index - 1);
	}

	return Sum;
}

 

누적합을 구하는 함수는 위와 같다.

누적합을 구할 때에는 최하위 비트를 빼면서 값을 더한다고 하였다.

그런데, while문 내부를 보면 위에서 보았던 양수와 음수의 &연산을 통해 최하위 비트를 구하고 있지 않다.

 

물론 위에서 설명한 방식으로 해도 된다. 다만, 한 가지 방식이 더 있기 때문에 보여주기 위해 사용하였다.

먼저, 최하위 비트를 뺀다는 것은 다른 비트는 그대로 냅두고 최하위 비트만 0으로 만들겠다는 것이다.

 

1110 0101 이라면 1110 0100으로 만들고, 0000 1000이라면 0000 0000으로 만드는 것이다.

 

Index를 7이라고 가정해보자. 7은 0000 0111이 된다.

여기서 1을 빼면, 0000 0110이 된다.

 

두 수를 &연산하면, 0000 0110이 나온다. 최하위 비트를 뺀 값이 나왔다.

 

이번엔, Index를 20이라고 가정해보자.

20은 0001 0100이 된다.

여기서 1을 빼면, 0001 0011이 된다.

 

두 수를 &연산하면 0001 0000이 된다. 

 

결과를 보면 _Index 와 _Index - 1 을 &연산하면, _Index에서 최하위 비트를 뺀 값이 나온다는 것을 알 수 있다.

더보기

비트의 마지막 수가 1이라면, 1을 뺐을 때 마지막 수가 0이된다.

측, 최하위 비트가 1에서 0으로 바뀌는 것이다.

 

그러므로 기존의 값과 &연산을 하게 되면 마지막 값만 0으로 바뀐 채로 나머지 비트는 유지된다.

 

반면, 비트의 마지막 수가 0이라면, 1을 뺐을 때 최하위 비트의 값이 1->0으로 바뀐다.

 

0100에서 1을 빼보자. 0011이 된다.

0010에서 1을 빼면, 0001이 된다.

1000에서 1을 빼면, 0111이 된다.

1011 0100에서 1을 빼면, 1011 0011이 된다.

 

즉 1을 빼면, 가장 오른쪽 비트부터 최하위 비트까지 0과 1이 반전된다.

그러므로, Num 과 Num - 1을 &연산하면 최하위 비트는 서로 다르기에 0이 되고, 나머지 값은 모두 그대로 보전이 된다.

 

(최하위 비트 뒤의 값은 서로 달라 &연산을 하면 0이 되지만, 최하위 비트의 특성을 생각해보면 원래 0이었기 때문에 값에 변함이 없다. 최하위 비트 앞의 값들은 변하지 않았기 때문에 &연산을 하여도 값이 변하지 않는다.)

 

코드를 모두 정리하면 아래와 같다.

std::vector<int> Nums;
std::vector<int> FenWickTree;

int GetSumFromZero(int _Index)
{
    int Sum = 0;

    while (_Index > 0)
    {
        Sum += FenWickTree[_Index];
        _Index &= (_Index - 1);
    }

    return Sum;
}

int GetSegSum(int _Start, int _End)
{
     return GetSumFromZero(_End) - GetSumFromZero(_Start - 1);
}

void Update(int _Index, int _Value)
{
    while (_Index < Nums.size())
    {
        FenWickTree[_Index] += _Value;
        _Index += (_Index & -_Index);
    }
}

int main()
{
    //숫자는 모두 입력받았다고 가정하자.
    Nums.resize(NumSize + 1);
    FenWickTree.resize(NumSize + 1);
    
    //초기화
    for (int i = 1; i <= NumSize; i++)
    {
        Update(i, Nums[i]);
    }
}

 

 

위와 같은 값에 대해 테스트 해보자.

 

먼저 초기화 함수를 테스트해보자.

 

초기화가 잘 된 것을 확인할 수 있다.

 

다음은 GetSegSum함수를 테스트해보자.

 

매우 잘 구해지는 것을 확인할 수 있다.

만약, 1~100까지의 숫자가 있을 때 그 사이에 있는 구간의 합을 구하고 싶다고 해보자.

예를 들면, 66~80의 합을 구하고 싶다거나 31~99의 합을 구하고 싶을 수도 있다.

이렇게 숫자가 간단하면 등차수열 공식을 이용하여 빠르게 구하면 된다.

 

그런데 숫자가 , 1, 6, 11, 12 ,18, 23,91 이런 식으로 규칙성 없이 주어진다면?

이 땐, 직접 더해볼 수 밖에 없다.

 

하지만, 더하는 것이 단 1번이라면 몰라도 여러 구간의 합을 계속 구해야 한다면?

불규칙적인 숫자가 100만개가 주어졌다고 해보자.

 

여기서, 랜덤한 구간의 합을 구하는 명령을 1만번정도 실행해야 한다고 하면, 구간의 길이만큼의 반복문을 1만번 실행해야 한다.

 

만약, 구간이 1~1000000 인 입력만 계속 들어온다면?

100억번의 반복문을 돌아야 한다 .

 

정말 끔찍한 연산량이다.. 이러한 시간복잡도를 줄이고 효율적으로 구간의 합을 구하기 위한 알고리즘이 세그먼트 트리이다.

 

 

세그먼트 트리


 

세그먼트 트리는 구간의 합을 미리 저장해놓고, 저장해놓은 값을 기반으로 빠르게 구간의 합을 탐색하는 알고리즘이다.

이름이 세그먼트 트리인 만큼 트리구조를 이용하여 구간의 합을 저장한다.

 

먼저, 아래의 배열과 같은 숫자가 주어졌다고 해보자.

 

여기서, 세그먼트 트리는 아래와 같이 구간을 나눠 합을 미리 저장하게 된다.

 

루트 노드엔 모든 숫자의 합을 저장하게 되고, 그 중 절반을 나눠 왼쪽 노드와 오른쪽 노드에 저장하게 된다.

말단까지 가면, 숫자 1개만큼의 합을 가진 노드가 위치하게 된다.

 

이렇게 저장한 뒤, 찾고자 하는 구간을 입력받으면 해당 구간에 대해 탐색하게 된다.

탐색 과정은 아래와 같다.

이렇게, 구간을 쪼개서 세그먼트 트리 내부에 저장된 구간의 합을 탐색하게 된다.

 

원리 자체는 이게 끝이다. 간단하다. 숫자 범위가 좁아서 연산 횟수가 별 차이가 안나는 것 처럼 보이지만,

 

실제로는 O(LogN)의 시간복잡도를 보유하기 때문에 선형으로 구간의 합을 구할 때보다 압도적으로 빠른 속력을 보인다.

다만, 구간의 합을 나눠서 미리 저장하는 만큼 메모리 사용량이 다소 많아지게 된다.

 

일반적으로 세그먼트 트리는 배열을 이용하기 때문에, 몇개의 구간합을 보유하는지에 대한 예측이 필요하다.

(reserve, resize를 사용하기 위해)

 

일반적으로 N개의 숫자가 주어지면, 4 *N으로 reserve, resize를 진행하게 된다.

그 이유는 아래와 같다. (수학적 증명이 필요없다면, 스킵해도 된다.)

 

더보기

N개의 숫자에 대해 아래의 등식이 성립한다고 가정해보자.

$$ 2^n <= N < 2^ {n + 1} $$

이 경우, 절반씩 나누어지는 세그먼트 트리의 특성을 생각하며 등비수열의 합 공식을 사용하면 구간합의 개수 M에 대해 아래와 같은 등식이 성립하게 된다.

$$ 2^ {n +1} + 1 <= M < 2^ {n + 2} $$

 

두 등식을 합쳐보면, 아래와 같은 등식으로 다시 표현할 수 있다.

$$2^n <= N < 2^{n + 1}< 2^{n + 1} + 1 <= M < 2^{n + 2}$$

 

마지막 항인 2^ (n + 2)는 4 * 2^n으로 풀어쓸 수 있기 때문에, 다시 아래와 같이 표현해보자.

$$2^n <= N < 2^ {n + 1} < 2^ {n + 1} + 1 <= M <  4 * 2^n $$

 

위에서 정의한 N의 범위를 생각해보면, 아래와 같은 등식까지 유도할 수 있다.

$$2^n <= N < 2^{n+1} < 2^{n+1} + 1 <= M < 4 * 2^n <= 4N$$

 

결과적으로 구간합의 개수 M에 대해 아래와 같은 관계가 성립하게 된다.

$$ M < 4N $$

 

세그먼트 트리는 일반적으로 3가지의 함수를 구현하게 된다.

 

1. 세그먼트 트리 초기화

2. 구간합 탐색

3. 숫자 변경에 대한 트리 업데이트

 

1번은 세그먼트 트리를 사용하기 위래 트리에 구간합을 저장하는 것이며,

2번은 원하는 구간을 입력하여 구간합을 구하는 함수이다.

 

3번은 무슨 말인지 잘 이해가 안될 수 있으니 설명해보겠다.

 

최초에 1,2,3,4,5 가 주어진다면 세그먼트 트리는 이 숫자를 기반으로 구간합을 저장할 것이다.

 

그런데, 중간에 세번째 숫자를 6으로 바꾸게 된다면?

1,2,6,4,5로 숫자가 변경되면 세그먼트 트리에 저장된 구간합을 수정할 필요가 있다.

 

3번은 이렇게 숫자를 중간에 변경할 경우 세그먼트 트리에 저장된 구간합을 갱신하는 함수를 의미한다.

 

 

이제 코드를 구현해 보겠다.

 

 

 

코드 구현


 

위와 같은 숫자 배열에 대해 구간합을 구한다고 가정하도록 하겠다.

std::vector<int> Nums;
std::vector<int> SegmentTree;

int main()
{
	int NumSize = 8;

	Nums.resize(NumSize + 1);
	SegmentTree.resize(4 * NumSize);

	Nums[1] = 1;
	Nums[2] = 3;
	Nums[3] = 4;
	Nums[4] = 6;
	Nums[5] = 8;
	Nums[6] = 11;
	Nums[7] = 13;
	Nums[8] = 16;

	return 0;
}

 

숫자의 개수를 저장하고, 이를 기반으로 숫자를 담을 배열과 세그먼트 트리의 사이즈를 resize하였다.

주의할 점은 0번 인덱스가 아닌 1번 인덱스부터 사용한다는 것이다.

 

배열을 이용해 트리를 구현하게 되면, 인덱스의 곱을 통해 부모자식관계를 맺게 되는데 0번 인덱스부터 시작하면 모든 인덱스가 0이 되어 버린다.

 

이제, SegmentTree를 초기화 하는 함수를 세워보자.

먼저 아래 그림을 보자.

 

현재 우리가 아는 정보는 말단 노드에 단일 숫자들이 저장될 것이라는 것이다.

그렇다면, 밑에서부터 올라오며 합을 기록해야 할텐데 문제는 말단 노드의 인덱스를 알지 못한다는 것이다.

 

처음부터 아래에서 위로 올라가며 구간합을 더하는 것은 불가능하기 때문에, 재귀함수를 이용해서 말단 노드까지 내려간 다음 말단 노드에서 다시 합을 해주며 위로 올라올 것이다.

 

int InitTree(int _Start, int _End, int _CurIndex)
{
    if (_Start == _End)
    {
        SegmentTree[_CurIndex] = Nums[_Start];
        return SegmentTree[_CurIndex];
    }

    int Mid = (_Start + _End) / 2;

    int Left = InitTree(_Start, Mid, _CurIndex * 2);
    int Right = InitTree(Mid + 1, _End, _CurIndex * 2 + 1);
    SegmentTree[_CurIndex] = Left + Right;

    return SegmentTree[_CurIndex];
}

 

보자. 파라미터의 _Start는 합을 구하고자 하는 구간의 시작지점이고 _End는 합을 구하고자 하는 구간의 끝지점이다.

_CurIndex는 SegmentTree의 인덱스이다.

 

아래쯤을 보면 Mid를 기준으로 Left와 Right를 나눈 뒤, 두 값을 더해 SegmentTree에 저장해주고 있다.

Left와 Right를 구하는 과정에서 구간을 계속 절반으로 쪼개고 있는데, 계속 쪼개다 보면 구간의 시작과 끝이 같아지는 지점이 온다.

 

이 때가 바로 말단노드인 것이다. _Start == End가 되는 지점에 Nums의 [_Start]를 SegmentTree에 저장해준 뒤 해당 값을 반환해주고 있다.

 

이 때부터, 위로 올라오며 구간합을 갱신하게 된다.

 

처음에는 InitTree(1, 8, 1)을 호출할 것이다.

_Start와 _End는 모든 숫자를 포함하는 구간으로 설정하고 _CurIndex는 1번 인덱스로 설정할 것이다.

이렇게 되면 루트노드부터 내려오며 모든 구간에 대한 구간합을 갱신하게 된다.

 모두 끝나면 위 그림과 같은 값이 배열에 들어가 있을 것이다.

내부의 값을 보면 동일하게 들어있는 것을 확인할 수 있다.

SegmentTree의 초기화는 끝났다.

 

이제 구간합을 탐색하는 함수를 만들어보자.

int GetSum(int _NumStart, int _NumEnd, int _SegStart, int _SegEnd, int _CurIndex)
{
    if (_SegStart > _NumEnd || _SegEnd < _NumStart)
    {
        return 0;
    }
    else if(_SegStart <= _NumStart && _SegEnd >= _NumEnd)
    {
        return SegmentTree[_CurIndex];
    }

    int _NumMid = (_NumStart + _NumEnd) / 2;

    return GetSum(_NumStart, _NumMid, _SegStart, _SegEnd, _CurIndex * 2) + GetSum(_NumMid + 1, _NumEnd, _SegStart, _SegEnd, _CurIndex * 2 + 1);
}

 

_NumStart와 _NumEnd는 노드에 기록된 구간이고, _SegStart와 _SegEnd는 합을 구하고자 하는 구간이다.

 

먼저, 두 가지의 예외처리를 주었다.

 

이렇게, _NumStart ~ _NumEnd 와 _SegStart~_SegEnd가 전혀 겹치지 않을 땐 0을 반환하도록 하였다.

 

위와 같이, 인자로 받은 _SegStart와 _SegEnd가 _NumStart와 _NumEnd를 품고있는 상황이 된다면

그대로 SegmentTree[_CurIndex]를 반환하도록 하였다.

 

이렇게, 두 가지 예외처리를 설정한 뒤 재귀호출을 하였다.

재귀호출은 InitTree와 유사하게 Mid를 둔 뒤, 양 방향으로 나누어 탐색하였다.

 

_SegStart, _SegEnd는 그대로 두고 _NumStart, _NumEnd만 쪼개가며 탐색을 하였다.

과정을 하나하나 보며 어떻게 진행되나 보자.

 

이건, 루트노드의 왼쪽 자식 노드를 탐색하는 과정이다.

위에서 _SegStart, _SegEnd와 _NumStart, _NumEnd 범위가 아예 겹치지 않는 경우와 그 안에 포함되는 경우 두 가지에 대해 재귀호출 종료 조건을 달았다.

 

하얀색 배경으로 칠해진 노드는 범위가 겹치지 않아 0을 반환하고 있는 노드이며

검은색 배경으로 칠해진 노드는 _NumStart와 _NumEnd를 포함하고 있어 SegmentTree[_CurIndex]를 반환하는 경우이다.

주황색 노드는 _NumStart와 _NumEnd가 일부분만 겹쳐있는 노드이다.

 

2~6에 대한 구간합을 구하기 위해 재귀호출을 했더니, 왼쪽 자식 노드에서는 2~4의 구간합을 반환하고 있다.

 

오른쪽 자식 노드에 대해서도 보자.

동일하게 진행되지만, 오른쪽 노드는 말단노드에 도착하기 전에 예외처리가 되어, 말단 노드까지 가기 전에 답을 찾아냈다.

오른쪽 노드에선 5~6의 구간합을 반환하고 있다.

 

마지막으로 루트 노드에서 왼쪽 자식노드에서 구한 구간합 (2~4)와 오른쪽 자식 노드에서 구한 구간합(5~6)을 더한 (2 ~6)의 구간 합을 반환하게 되면, 찾고자 했던 2~6의 구간합을 구할 수 있게 된다.

 

이번엔, 중간에 숫자가 변경되는 경우 구간합을 갱신하는 Update함수를 만들어보겠다.

void Update(int _NumStart, int _NumEnd, int _NumIndex, int _AddValue, int _CurIndex)
{
	if (_NumIndex < _NumStart || _NumIndex > _NumEnd)
	{
		return;
	}

	SegmentTree[_CurIndex] += _AddValue;

	if (_NumStart == _NumEnd)
	{
		return;
	}

	int Mid = (_NumStart + _NumEnd) / 2;

	Update(_NumStart, Mid, _NumIndex, _AddValue, _CurIndex * 2);
	Update(Mid + 1, _NumEnd, _NumIndex, _AddValue, _CurIndex * 2 + 1);

}

 

이번에도 거의 유사하다.

_NumStart, _NumEnd는 노드가 저장하고 있는 구간 합의 구간이며, _NumIndex는 바뀐 숫자의 인덱스이다.

여기서 주의할 점은 _Addvalue이다.

 

만약, Nums[5] =3 에서 Nums[5] = 10 으로 바뀌었다면, _AddValue는 7이 된다.

Nums[5] = 10에서 Nums[5] = 3이 되었다면, _Addvalue는 -7이 된다.

 

이 함수는 숫자의 변화량만큼 구간합에 그대로 더해주는 방식이기 때문에 _Addvalue에는 바뀐 숫자를 넣는 것이 아니라 기존의 숫자와의 차이를 넣어주어야 한다.

 

_CurIndex는 SegmentTree의 인덱스이다.

 

먼저, _NumIndex를 _NumStart, _NumEnd가 포함하지 않는 경우에는 바로 Return 해주었다.

포함되었다면, SegmentTree에 _AddValue를 더해주어 구간합을 갱신해준다.

 

_NumStart와 _NumEnd가 같다면 (말단 노드라면) 이대로 끝내면 되고,

말단 노드가 아니라면 자식 노드에 대해서도 검사해야 한다.

 

자식노드에 대해 왼쪽 오른쪽 Update함수를 호출해주면 된다.

여기서, Update함수를 왼쪽과 오른쪽에 대해 둘 다 호출하고 있는데, _NumIndex는 둘 중 하나에밖에 속하지 못하기 때문에 둘 다 호출할 필요는 없다.

 

조건문을 달아서 한 쪽만 호출하게 할 수도 있지만, 어차피 양쪽의 함수를 호출해도 한쪽은 가장 위의 조건문 때문에 바로 return된다.

 

조건문을 다는 것보다 간단하기 때문에 위처럼 양쪽 노드의 update를 모두 호출하고 있다.

본인이 조건문을 달아서 한 쪽만 호출하게 하고 싶다면 그렇게 하면 된다.

 

이제 기능은 모두 구현하였다.

테스트 한 번 해보자.

 

InitTree는 위에서 확인하였으니 GetSum과 Update에 대해 확인해보자.

 

 

2~5의 구간합은 21이다.

 

3~8의 구간합은 58이다.

 

1~3의 구간합은 8이다.

 

잘 작동하는 것을 확인할 수 있다.

 

다음은 Update이다.

 

이렇게 4번 인덱스를 10으로 바꿔보겠다.

 

이렇게 갱신을 한 뒤 SegmentTree의 값을 확인해보자.

제대로 갱신된 것을 확인할 수 있다.

 

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를 사용하여 최적화를 노려보도록 하자..

 

 

 

 

 

 

 

'C++ > C++' 카테고리의 다른 글

C++ - 가변 인자 템플릿  (0) 2024.04.18
C++ - 코루틴 (coroutine)  (0) 2024.04.14
C++ - SSO (Small String Optimization)  (0) 2024.04.11
C++ - placement new  (0) 2024.04.10
C++ - malloc/free , new/delete  (0) 2024.04.10

 

C++에서 문자열에 대해 알아보다 SSO라는 것을 알게 되었다.

SSO란, std::string 에서 길이가 짧은 문자열에 대한 최적화 방식이라고 한다.

 

기본적으로 string은 동적 메모리를 기반으로 문자열을 저장하게 된다.

동적 메모리를 기반으로 하게 되면 몇 가지 문제점이 생긴다.

 

1. 메모리의 할당과 해제 과정에서 오버헤드가 발생할 수 있다.

2. 힙영역의 데이터는 스택영역보다 데이터 처리 속도가 느리다. (특히 복사에서 두드러진다고 한다.)

더보기

힙 영역보다 왜 스택 영역이 빠른가에 대해서 좀 찾아봤는데, 이유야 여러가지가 있겠지만

스택영역의 경우 컴파일 과정에서 메모리의 주소를 어느정도 예측할 수 있기 때문에 계산된 주소를 이용해 바로바로 접근이 가능하기 때문에 힙영역보다 빠르다고 한다. 

 

이러한 문제점으로 인한 성능 저하를 최소화하기 위해, 적어도 길이가 짦은 문자열 만큼은 동적으로 메모리를 할당하지 말고 스택영역에 저장하자는 것이 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을 사용하여 동일한 메모리 영역을 문자열 길이에 따라 다르게 사용함으로써 효율적으로 관리하고 있다고 한다.

 

 

 

'C++ > C++' 카테고리의 다른 글

C++ - 가변 인자 템플릿  (0) 2024.04.18
C++ - 코루틴 (coroutine)  (0) 2024.04.14
C++ - string_view (읽기 전용 문자열 컨테이너)  (0) 2024.04.11
C++ - placement new  (0) 2024.04.10
C++ - malloc/free , new/delete  (0) 2024.04.10

 

컴퓨터 그래픽스에선 빛을 계산하기 위해 사용하는 모델이 몇가지 있다.

이번 게시글에선 그중 가장 대표적인 퐁 조명 모델에 대해 설명하겠다.

 

퐁 조명 모델이란, 빛을 계산할 때 픽셀 단위로 법선과 광원을 고려하여 빛을 계산하는 모델이다.

퐁 조명 모델에선 빛을 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) 이 값이 픽셀의 색상이 되는 것이다.

모든 픽셀에 대해 이 연산을 적용하게 되면, 화면에는 재질 표현과 명암의 구분이 잘 되어있는 물체를 렌더링하게 된다.

이 과정을 퐁 쉐이딩 (퐁 조명 모델) 이라고 한다.

 

여기서, 이 퐁 조명 모델을 살짝 변형한 하프 람베르트 모델, 블린 퐁 조명 모델 등이 있는데 이와 관련해서는

추후 게시물을 작성해보도록 하겠다.

 

+ Recent posts