더보기

* [유니티 그래픽스 최적화] 시리즈는 비엘북스 <유니티 그래픽스 최적화 스타트업>을 읽고 스스로 이해한 바를 정리하는 시간을 갖고자 작성하는 시리즈입니다. 책을 그대로 옮겨쓴 것이 아니고 이해한 대로 재 서술하는 것이기 때문에 틀린 점이 있을 수 있습니다. 정확히 알고 계시는 분은 댓글로 알려주시면 감사하겠습니다 !


 

01. Draw Call

드로우콜은 수많은 병목 중 하나의 원인일 뿐,, 반드시 병목이 드로우콜에서 일어난다고 단정지어서는 안됨.

드로우콜이 강조되는 이유는 대부분 병목의 원인이 드로우콜에 있기 때문. 

 

드로우 콜이란? CPU가 GPU에게 이거 그려! 하고 명령을 호출하는 것.

더 자세하게 ?

한 프레임의 렌더링은 매 오브젝트를 순차적으로 그려주면서 오브젝트를 다 그리면 화면에 보여지게 되는 것.

오브젝트를 화면에 렌더링하기 전에 우선 해당 오브젝트가 렌더링 대상에 포함되는지 체크한다. 현재 프레임 상에서 해당 오브젝트가 카메라의 시야 밖에 있다면 안 그려도 되는 것이므로 렌더링 대상에서 제외한다. 이런 검사 과정을 Culling이라고 한다. 컬링을 거친 오브젝트가 렌더링되려면 CPU로부터 GPU에게 정보가 전달되어야한다. (02에서 설명했던 내용이다)

이렇게 한 프레임 마다 오브젝트를 하나하나 그릴 때 마다 정보들이 CPU에서 GPU로 전달되고 그려진다. 이 과정을 반복해서 렌더링한 후 모든 오브젝트들이 다 그려지면 한 프레임이 끝나고 화면에 출력한다. 이때 CPU가 GPU에게 렌더링하라고 명령을 보내는 것을 Draw Call 이라고 한다.

 

CPU와 GPU가 어떻게 협업하는가

GPU가 Mesh를 렌더링하려면 GPU 메모리에서 데이터를 읽어와야한다. 그러려면 그 전에 GPU 메모리에 데이터가 있어야한다는 의미이다.

그래서 렌더링을 수행하기 전에 데이터 로딩이 이루어지면서 Mesh 정보가 GPU 메모리가 담기는 것.

CPU가 HDD, SDD, SD 카드 등의 스토리지에서 파일을 읽어들이고 데이터를 파싱하여 CPU 메모리에 데이터를 올린다.

그 후 CPU 메모리의 데이터를 GPU 메모리로 복사하는 과정을 거친다. (일반적으로 GPU에서 CPU의 메모리를 바로 접근할 수 없기 때문)

 

이렇게 데이터를 메모리에 전달하는 과정을 매 프레임마다 수행하게 되면 성능 하락이 생길 수 밖에 없다.

따라서 로딩 시점에 메모리에 데이터를 올려두고 씬 전환 시점 같은 때에 데이터를 해제한다. 즉, 게임이 실행되는 동안에는 데이터가 계속 메모리에 상주하게 된다. 텍스쳐, 쉐이더 등 렌더링에 필요한 데이터 모두 GPU 메모리에 존재하고 있어야 한다.

 

Render State와 DP Call

렌더링 루프를 돌면서 어떤 오브젝트를 렌더링해야하는 시점이 오면 GPU에 어떤 텍스쳐, 버텍스, 쉐이더 등을 사용해야할지 전달해줘야하는데 이런 정보들은 한 번의 명령으로 처리되는 것이 아니라 순서대로 일일히 알려줘야한다. 그렇다고 일일히 하나씩 알려주는 것은 비효율적이니 GPU는 해당 정보를 담는 테이블을 가지고 있다. 이런 테이블을 Render State (렌더 상태)라고 부른다. 렌더 상태의 테이블 정보들은 GPU 메모리의 어느 곳에 필요한 데이터가 있는지 데이터의 주소를 저장해둔다. (like 인덱스 파일)

CPU가 렌더 상태를 변경하는 명령을 보내면, GPU는 렌더 상태에다가 오브젝트를 그리기 위한 정보를 저장한다.

CPU는 명령을 보내고 마지막으로 GPU에게 메시를 그리라는 명령을 보낸다. 이 명령을 Draw Primitive Call, DP Call이라고 한다. GPU는 DP Call을 받으면 받아두었던 렌더 상태의 정보들을 기반으로 메시를 렌더링한다.

메시를 렌더링하고 난 뒤, CPU는 이제 또 다른 오브젝트를 렌더링하기 위해 상태 정보를 변경하는 명령을 보낸다. 이 때 바뀔 필요가 없는 상태의 명령은 보내지 않는다. 이렇게 렌더 상태를 변경해주고 마지막으로 GPU가 DP Call을 받으면

또 바뀐 상태대로 렌더링을 하는 것이다. 이런 방식으로 CPU는 필요한 정보를 갱신하는 렌더 상태 변경 명령을 보내고, GPU는 그대로 렌더 상태를 변경하고, CPU가 다시 DP Call을 주면 GPU가 렌더링하는 과정을 반복한다.

 

아까 이거 그려! 하고 명령을 보내는 것을 드로우 콜이라고 했는데, 단순히 그리라는 명령인 DP Call 뿐 아니라 넓은 의미로 상태 변경 명령부터 DP Call까지를 모두 포함한 것을 드로우 콜이라고 한다. 

 

Command Buffer

또 CPU가 GPU에게 명령을 보낸다고 표현했지만 사실 바로 명령을 주지 않고 중간에 한 단계를 거친다. 바로 명령을 보낸다면 GPU가 다른 작업을 수행하고 있다면 CPU가 명령을 주고 할일을 해야되기 때문에 GPU가 하던일을 마칠 때까지 기다려야하는 불상사가 생길 것이다. 그래서 CPU는 명령을 버퍼에 쌓아놓고, GPU는 버퍼에서 명령을 가져가서 할 일을 하는 방식으로 일한다. 저번 편에서 설명한 병렬식 방법이 이것이다! 이렇게 일을 쌓아두는 버퍼를 Command Buffer라과 부른다. 커맨드 버퍼는 FIFO(First In First Out) 방식으로 명령을 처리한다.

 

>>Vulkan과 Metal은 여러개의 커맨드 버퍼를 이용하여 멀티 쓰레드로 처리하기도 한다. 따라서 기존 OpenGL ES보다 드로우콜 부담이 적다.

 

Draw Call은 CPU 바운드의 병목

문제는 CPU가 명령한 것이 GPU에서 사용하는 신호로 변환되어야한다는 것인데, 이는 결국 CPU에게 부담이 된다.

그래픽스 API들은 CPU에서 GPU로 보내는 명령을 공통적인 API로 구성한다. API가 호출되면 드라이버 칩셋에 알맞은 신호를 전달하여 GPU에 맞게 명령을 변형하는 과정을 거친다. 이 과정을 거치기 때문에 CPU가 GPU에게 명령을 보낼 때 오버헤드(어떤 정보 처리를 위한 간접적인 처리 시간)가 발생한다. 그래서 드로우콜은 CPU 바운더리의 오버헤드이다.

 

>>Multithreaded Rendering, 멀티 쓰레드 렌더링

렌더링에 필요한 작업들을 별도의 쓰레드로 분리해서 렌더링 성능을 높이는 것. CPU의 부담을 줄여주는 것임

Edit > Project Settings > Player > Other Settings > Rendering 에서 Multithreaded Rendering을 체크할 수 있다.

다만 모든 디바이스에서 멀티쓰레디드 렌더링이 잘 돌아간다는 보장은 없음.

CPU의 코어가 많지 않은 구형 디바이스는 잘 안돌아갈수있다. 또 iOS는 Metal API로 작동할 때만 멀티쓰레디드 렌더링이 활성화되고, WebGL 플랫폼은 아예 지원되지 않음.

중요한 것은 드로우콜 병목이 아닐 때는 이 옵션이 영향을 주지 못함. 반드시 타깃 디바이스에서 확인해보자~

 

드로우콜은 GPU의 성능보다 CPU의 성능에 의존적이다. 따라서 드로우 콜로 인한 성능 하락을 줄이려면 (텍스처나 폴리곤 수를 줄이는 것이 아니고) 드로우 콜 횟수를 줄여야한다.

 

 

드로우 콜의 발생 조건

기본적으로 오브젝트를 하나 그릴 때 Mesh 1개, Material 1개라면 드로우콜이 한 번 발생한다. 즉, Batch가 1이 된다.

 

하지만 오브젝트 하나가 메시 여러개로 구성되어있는 경우...메시가 17개라면 드로우콜도 17번 발생한다....

이 메시들이 1개의 메테리얼을 공유하더라도 메시 개수 대로 17번의 드로우 콜이 필요하다.

반대로 하나의 메시에 메테리얼이 여러 개여도 그 개수만큼의 드로우 콜이 필요하다.

 

쉐이더에 의해서도 드로우 콜이 늘어날 수 있다. 쉐이더 내에서는 멀티 패스(Multi Pass)라고, 두 번 이상 렌더링을 거치는 경우가 존재한다. 대표적인 예가 카툰 렌더링이다. 첫번째 패스에서 모델을 렌더링하고, 두번째 패스에서 모델 외곽선을 그려준다. 렌더링을 두 번 해야하므로 드로우 콜이 두 번, Batch도 두 번 ~~

따라서 메시가 하나, 메테리얼이 하나라고해서 반드시 하나의 드로우 콜만 발생하는 것이 아니다. 지레짐작하지 말자.

 

특히 모바일의 경우 드로우콜 횟수가 크면 성능에 많은 영향을 끼칠 수 있으므로 드로우 콜을 신경쓰면서 최대한 줄여야한다. 데스크톱에서는 1000개가 넘어도 가능하지만 모바일에서는 100개도 많은 편이다. 최신 모바일 디바이스는 200개가 넘는 것도 가능하지만...디바이스 마다 다르므로 드로우 콜의 기준을 확정지을 수 없다.

또 드로우콜을 개수로 따져왔지만, 사실 드로우콜 마다도 비용이 각각 다르다. 당연히 상태 변경이 많이 필요한 드로우 콜과 적게 필요한 드로우 콜은 비용 차이가 날 것이다.

 

 

Batch & SetPass

유니티에서는 드로우 콜을 Batch와 SetPass 두 용어로 나누어 표시한다.

Profiler의 Rendering 섹션

Batch

DP Call과 상태 변경들을 합친 넓은 의미의 드로우 콜.

 

만약 Batch가 10번, SetPass가 1번 발생했다면 10번의 드로우 콜 동안 쉐이더의 변경은 없었고, 메시 및 트랜스 폼 정보 등 최소한의 상태 변경만 이뤄졌다는 것을 의미한다.

SetPass도 10번 일어났나면 10번의 드로우 콜 마다 매번 쉐이더의 변경이 이뤄졌고, 경우에 따라 많은 상태 변경이 일어났다는 것을 의미한다. 당연히 이 경우가 성능을 더 많이 잡아먹을 것이다.

 

SetPass Call

SetPass는 쉐이더로 인한 렌더링 패스 횟수를 의미한다.

SetPass에서 알려주는 상태 변경은 쉐이더의 변경 혹은 쉐이더 파라미터들의 변경이 일어나는 경우.

씬 오브젝트를 렌더링하는 과정에서 메테리얼이 바뀌면 그에 따라 쉐이더 및 파라미터들이 바뀌고 SetPass 카운트가 증가한다. 이 때 많은 상태 변경이 일어나야 하기 때문에 SetPass 횟수도 중요하다.

만약 게임이 CPU 바운드이고 GPU에 명령을 보내는 과정, 즉 드로우콜이 병목이라면 SetPass call 횟수를 줄이는게 가장 효율적이다.

서로 다른 메시를 사용한다고 SetPass call이 늘어나는 것은 아님. 다른 메시라도 같은 메테리얼을 쓰면 늘어나지 않음.

10개의 오브젝트가 서로 다른 메시여도 같은 메테리얼을 쓴다면 Batch는 10번 발생해도 SetPass는 1번만 발생한다.

이렇게 SetPass call이 적으면 Batch 구성이 잘 되어있는 것.

 


 

02. Batching

드로우 콜을 줄이는 작업.

유니티에서 배칭을 활용함으로써 드로우 콜을 많이 줄일 수 있기 때문에 거의 필수적으로 사용해야하는 기능.

여러 Batch를 묶어서 하나의 Batch로 만드는 것을 Batching이라고 한다. 굉장히..간단한 단어다.

즉 Batching은 여러 번 드로우 콜 할 상황을 하나의 드로우 콜로 묶는 과정이다. 

 

다른 오브젝트, 메시를 사용하더라도 메테리얼이 같다면 하나의 Batch로 구성할 수 있다.

여러 개의 다른 오브젝트들이지만 메테리얼이 같다면 배칭처리를 통해 한 번에 그리는 것이 가능하다는 얘기이다.

된다면 완전 땡큐인 기능인 것...!

여기서 메테리얼이 같다는 것은 동일한 메테리얼 인스턴스를 의미한다.

같은 텍스쳐, 같은 쉐이더를 이용한 메테리얼이더라도 따로 두개를 만들어 두면 그 두 개는 다른 메테리얼로 인식, 배칭이 되지 않는다. 스크립트에서 메테리얼에 접근할 때도 이런 이유에서 조심해야할 것이 있다. 

GetComponent<Renderer>().material.color = Color.red;

이렇게 메테리얼의 속성을 수정하면 메테리얼이 수정되는 것이 아니라 메테리얼의 복사본이 생성된다.

대신 Renderer.sharedMaterial로 수정하면 복사본이 생성되지 않고 공유된 메테리얼 원본을 수정한다. 공유하고 있던 다른 친구들도 수정된 결과가 적용되니 인지하고 유의해서 사용하자.

 

배칭을 하기 위해서는 하나의 메테리얼을 여러 메시들이 공유해서 사용해야한다.

즉 텍스쳐 하나를 공유해서 사용해야한다는 뜻.

그래서 텍스쳐 하나에다가 여러 개의 텍스쳐를 합쳐서 사용하는 텍스쳐 아틀라스(Texture Atlas) 기법으로 리소스가 제작된다.

 

그러면 모든 메시의 텍스쳐를 1개에다가 때려박으면 되는게 아닌가 ?

안돼요. 해상도 문제를 고려해야한다. 512 해상도의 텍스쳐 16개를 합치면 2048 해상도의 텍스쳐가 필요하다. 구형 디바이스에서는 성능 저하가 발생할 수 있기 때문에 해상도를 고려하면서 작업해야한다.

 

Batching은 Static Batching, Dynamic Batching 두 종류가 있다. 

Edit > Project Settings > Player에서 Static Batching, Dynamic Batching을 체크할 수 있다.

체크하면 조건에 맞는 경우 자동 배칭이 된다. 간단하게 사용할 수 있지만 각 배칭 기법은 특성과 한계가 존재하니 알고 쓰도록 하자.

 

Static Batching

정적인 오브젝트를 위한 배칭 기법. 주로 배경 오브젝트들이 해당.

Static Batching을 적용할 오브젝트라면 인스펙터에서 Static을 체크해줘야 한다. 이걸 켜주면 스태틱 배칭의 대상으로 인정받아 로딩타임에서 자동으로 배칭처리가 될 것이다.

당연히 다이나믹 배칭보다 효율적이다. 런타임에서 수행할 버텍스 연산이 없기 때문이다.

Stats 창에서 Saved by batching, 즉 배칭으로 얼마나 드로우 콜을 절약했는 지 확인할 수 있다.

메테리얼이 1개라고 무조건 1개의 배치로 구성되는 건 아니고 라이트맵, 라이트프로브, 동적라이트 영향 여부 등 다양한 조건에 의해서 배칭이 나뉠 수 있음. 배칭이 왜 나뉘는지는 프레임 디버거(아래 03번에서 배워보자)로 확인할 수 있다.

 

로딩타임에서 배칭처리를 하기 때문에 처음부터 씬에 존재해야 스태틱 배칭에 껴준다. 나중에 추가되는 정적인 오브젝트들은 자동으로 스태틱 배칭이 되지 않고

StaticBatchingUtility.Combine()

로 런타임 상에 추가된 정적인 오브젝트들도 배칭처리를 받을 수 있게 해줘야한다.

But 스태틱 배칭에 껴주기 위해 데이터를 수집하고 메시를 재생성해야하기 때문에 많은 시간이 필요하므로 되도록 자제하도록 하자.

 

>> 예제 프로그램을 위해 추가적인 드로우 콜 요인 제거하기

  • 카메라의 Clear Flags를 Solid Color로.
  • Allow HDR과 Allow MSAA를 끈다
  • Directional Light의 Shadow Type을 No Shadow로 설정.

주의할 점

메모리가 추가로 필요하다!

다른 메시들을 메테리얼이 같다는 이유로 한 번에 그리는 것이다. 따라서 배칭처리를 하면 오브젝트들을 합쳐서 내부적으로 하나의 메시로 만들어 놓는데, 1개의 메시만 사용하더라도 여러 개의 메시를 합친, 거대한 메시를 만들기 위한 추가 메모리가 필요한 것이다. 이렇게 새로 만들어낸 메시를 GPU가 가져가서 그대로 화면에 렌더링하므로 드로우 콜은 1번에 처리될 수 있던 것.

추가적으로 메모리를 희생하더라도 드로우 콜을 줄일 수 있기 때문에 런타임 성능을 높일 수 있다.

But 얘 때문에 메모리가 문제된다면 줄여야지.

 

>>배경 맵 모듈화

씬을 하나의 커다란 메시로 만드는 것 보다 모듈화해서 조립하듯이 제작하는 것이 성능면에서 더 좋다.

하나의 커다란 메시로 만든다면 메시 일부만 보여도 전체 메시의 폴리곤을 처리하기 때문 !

스태틱 배칭 처리가 되더라도 원래의 게임오브젝트 기준으로 컬링이 이뤄진다.

근데 야외 맵은 한 화면에 배경 오브젝트가 그렇게 많지않고 반복되는 모듈이 적어서 조금 애매하다..

또 모듈의 단위를 너무 작게나누면 오히려 비효율적.

 

 

Dynamic Batching

Static과 반대로, Static이 체크되지 않은 동적인 오브젝트들 중 동일한 메테리얼을 사용하고 특정 조건을 만족하는 오브젝트들을 대상으로 배칭처리를 하는 기능. 역시 Dynamic Batching을 체크해주면 별로의 추가 작업이 필요하진 않고 알아서 해준다.

하지만 제약사항이 많다 ! 런타임상에 배칭처리를 해야되기 때문에 어쩔수없다

매 프레임 씬에서 동적인 오브젝트들의 버텍스를 모아서 합쳐주는 과정을 거친다. 모은 버텍스들을 다이나믹 배칭에 쓰이는 버텍스 버퍼와 인덱스 버퍼에 담으면 GPU가 이것을 가져가서 렌더링한다. 결과적으로 매번 데이터 구축과 갱신이 발생하기 때문에 매 프레임마다 오버헤드가 발생한다. 일반적으로 렌더링 할 때는 버텍스 쉐이더에서 월드스페이스로 변환하는 과정에서 GPU에서 고속연산이 이뤄지는데, 다이나믹 배칭을 위해서는 오브젝트의 버텍스를 월드스페이스로 변환하는 연산이 CPU에서 이뤄진다. 따라서 이 연산과정이 드로우 콜보다 시간이 오래걸리면 오히려 효율이 떨어지는 것이다. 배칭 오버헤드와 드로우 콜 시간을 비교하여 더 빠른 쪽으로 하는 것이 맞다.

만약 특정 오브젝트의 배칭 오버헤드가 더 커서 배칭을 쓰지 않도록 하고 싶다면 쉐이더 태그에서 DisableBatching 플래그를 True로 설정해주면 된다. 아래는 쉐이더 코드에서 쉐이더 태그를 다는 예시.

SubShader {
	Tags { "RenderType" = "Opaque" "DisableBatching" = "True" }
    ...

 

제약 사항

오버헤드가 발생하므로 제약 사항이 많다. 그래서 스태틱 배칭보다 잘 쓰이지 않는다.

 

  • Skinned Mesh에는 적용이 불가하다. Skinned Mesh는 움직이는 캐릭터에 Skinning을 수행하는 기능인데, GPU나 SIMD에서 고속으로 연산을 수행해야한다. 다이나믹 배칭으로 묶어버리면 CPU 연산 효율이 떨어지기 때문에 Skinned Mesh는 다이나믹 배칭의 영향을 받지 않는다. 캐릭터 여러개가 렌더링 되야한다면 각각 별도의 드로우콜로 렌더링 되어야 하는 것이 맞다.
  • 버텍스가 너무 많은 메시는 다이나믹 배칭의 대상에서 제외된다. 다이나믹 배칭은 버텍스를 모아서 합치는 과정이기 때문에 너무 많은 버텍스를 수집하게 되면 오히려 오버헤드가 드로우 콜의 비용보다 높아질 수 있다.포지션, 노말, UV를 사용하는 모델이라면 300이하의 버텍스를 가진 모델만 다이나믹 배칭이 적용가능한데, 사실상....요즘엔 모바일에서도 300이상의 버텍스를 가진 모델이 많으므로 적용하기가 쉽지 않다.

>>Skinning, 스키닝

메시를 애니메이션 포즈에 맞게 메시의 버텍스들을 보정해주면서 변형해주는 과정.

스키닝이 필요한 애니메이션을 가진 모델들은 Skinned Mesh Renderer라는 특별한 메시 렌더러를 컴포넌트로 가진다.

렌더링 전에 스키닝 연산이 이뤄지면서 버텍스 위치의 재계산이 일어난다. 따라서 스키닝 되는 메시의 폴리곤이 많을수록 렌더링도, 스키닝 연산도 부담이 생긴다. 스키닝 연산은 CPU에서 이뤄지므로 결과적으로 버텍스가 많은 스키닝 메시는 스키닝 처리에서 CPU의 부담을 유발할 수 있다. GPU를 통해 연산하고 싶다면 Edit > Project Settings > Player > Other Settings > GPU Skinning을 체크하면 된다. GPU가 더 빨라보이지만, CPU 스키닝 연산은 SIMD(Single Instruction Multiple Data)라는 아키텍처를 통해 고속연산이 가능하기 때문에 CPU, GPU 중 어디에 병목이 있는지 확인하고 병목이 없는 쪽에서 스키닝을 연산해서 병목이 있는 바운드의 부담을 덜어주는 것이 좋을 것이다. 대부분 게임은 GPU 자원이 많이 필요하므로 CPU에서 스키닝 연산을 하는 편이다.

GPU 스키닝은 OpenGL ES2에서는 지원 되지 않고, DirectX 11과 OpenGL ES3에서 유효하다. 모바일 디바이스 Vulkan, Metal 에서는 동작하지않는다.

 

 

Mesh.CombineMeshes

수동으로 배칭을 처리하는 방법. 처음부터 하나의 메시로 되어있는게 아니라 런타임동안 파츠가 조합되어 오브젝트가 만들어져야 하는 경우라면 이것을 이용해 드로우콜을 줄일 수 있다.

메테리얼을 공유하는 애들끼리 묶어두고 자식들의 메시를 수집한 뒤 하나의 메시로 합쳐주는 스크립트를 작성해서 부모가 되는 오브젝트에 추가해준다.

 

>>CombineMeshesSample.cs

using UnityEngine;

public class CombineMeshesSample : MonoBehaviour
{
    void Start()
    {
        MeshFilter[] meshFilters = GetComponentsInChildren<MeshFilter>();
        MeshRenderer[] meshRenderers = GetComponentsInChildren<MeshRenderer>();

        if (CheckSameMaterial(meshRenderers) == true)
        {
            CombineInstance[] combine = new CombineInstance[meshFilters.Length];
            int i = 0;
            while (i < meshFilters.Length)
            {
                combine[i].mesh = meshFilters[i].sharedMesh;
                combine[i].transform = meshFilters[i].transform.localToWorldMatrix;
                meshFilters[i].gameObject.SetActive(false);
                i++;
            }
            MeshFilter meshfilter = gameObject.AddComponent<MeshFilter>();
            MeshRenderer meshRenderer = gameObject.AddComponent<MeshRenderer>();
            meshRenderer.sharedMaterial = meshRenderers[0].sharedMaterial;
            meshfilter.mesh = new Mesh();
            meshfilter.mesh.CombineMeshes(combine);
            transform.gameObject.SetActive(true);
        }
    }
    
    bool CheckSameMaterial(MeshRenderer[] meshRenderers)
    {
        Material mtrl = meshRenderers[0].sharedMaterial;

        for (int i = 1; i < meshRenderers.Length; ++i)
        {
            if (mtrl != meshRenderers[i].sharedMaterial)
                return false;
        }
        return true;
    }
}

 

 

2D Sprite Batching

2D Sprite도 Batching이 이뤄질 수 있다.

3D 보다 버텍스가 적기 때문에 배칭이 훨씬 효율적으로 가능하다.

Static이나 Dynamic Batching처럼 체크하지 않아도 자동으로 배칭이 이뤄진다.

텍스처 아틀라스와 같은 기법으로 스프라이트들을 하나의 이미지에 모아 넣는 스프라이트 시트로 많이 제작된다.

 

Sprite Atlas

스프라이트들을 하나의 아틀라스로 합치면 Batch를 줄일 수 있다. 당연

Edit > Project Settings > Editor > Sprite Packer의 Mode를 Alway Enabled로 설정한다. 그리고 Project > Create > Sprite Atlas를 선택해서 스프라이트 아틀라스 에셋을 생성한다.

 

Sprite Atlas Inspector

 

Inspector에서 설정이 가능하다. Objects for Packing에서 원하는 스프라이트를 추가할 수 있다. 폴더나 여러개의 스프라이트를 통째로 끌어다 놓는 것도 가능하다. 하나씩 하지 말자..

그 후 Pack Preview 버튼을 누르면 하나의 텍스쳐로 패킹된 결과를 확인할 수 있다.

 

 

GPU Instancing, GPU 인스턴싱

Static, Dynamic Batching 외에도 GPU Instancing이라는 기법을 활용해서 드로우 콜을 줄일 수 있다.

인스턴싱이란 동일한 메시의 복사본을 만드는 것이다. 한번의 드로우 콜로 오브젝트의 여러 복사본을 렌더링한다는 점은 배칭과 같다. 인스턴싱은 배칭에 비해 런타임 오버헤드가 적다. 배칭은 CPU에서 지오메트리 정보들을 연산해 합친 메시를 새로 만들어내는 과정을 거치고 GPU가 이를 가져다 렌더링하는 방식이었는데, GPU 인스턴싱은 메시를 새로 만들어 내지 않고 인스턴싱 되는 오브젝트들의 트랜스폼 정보를 별도의 버퍼에 담는다. GPU는 이 버퍼와 원본 메시를 가져다 여러 오브젝트들을 한번에 처리해서 렌더링한다. 인스턴싱 처리를 GPU에서 처리하기 때문에 GPU에서 메시를 재구성하는 오버헤드나 메모리 이슈로부터 자유롭다. 오버헤드로 인한 제약이 적어서 원본 메시의 버텍스 개수와 상관없이 런타임에서 동적인 오브젝트들을 배칭처리할 수 있다. 다이나믹 배칭보다 제약이 적고 부담도 없다 !

 

Standard Shader를 쓰는 메테리얼의 마라미터 중 Enable GPU Instancing을 체크하면 이 메테리얼을 쓰는 오브젝트들은 유니티가 자동으로 인스턴싱 처리를 해준다. 동일한 메시끼리만 한 번의 드로우콜로 처리가 가능하다.

 

역시 Skinned Mesh Renderer를 쓰는 스키닝 메시에는 사용할 수 없다.

GPU에서 처리하는 것이므로 디바이스의 스펙에 의존적이다. 지원하는 디바이스에서만 사용할 수 있다.

모바일 기기에서는 OpenGL ES 3.0 이상이나 Vulkan, Metal을 이용하는 경우에만 사용 가능하다.

 


 

03. Frame Debugger

드로우 콜은 Stat 창이나 Profiler로 확인할 수 있었지만 상세하게는 알 수 없었다. 그럴 때 프레임 디버거를 쓴다.

프레임이 어떻게 렌더링되는지 직관적으로 확인할 수 있다. 게임 수행 중 프레임을 캡쳐해서 드로우콜을 순서대로 확인할 수 있고, 각 드로우콜 과정에서 어떤 메시가 렌더링되고 쉐이더의 속성은 무엇인지도 확인할 수 있다. 배칭 처리 중에 드로우콜이 나뉜다면 왜 나뉘는지도 확인할 수 있다.

 

 

04. Culling

 

 

 

* [유니티 그래픽스 최적화] 시리즈는 비엘북스 <유니티 그래픽스 최적화 스타트업>을 읽고 스스로 이해한 바를 정리하는 시간을 갖고자 작성하는 시리즈입니다. 책을 그대로 옮겨쓴 것이 아니고 이해한 대로 재 서술하는 것이기 때문에 틀린 점이 있을 수 있습니다. 정확히 알고 계시는 분은 댓글로 알려주시면 감사하겠습니다 !

 


01. 병목의 이해

성능 최적화란? 적은 자원을 사용하더라도 연산 효율이 높아지도록 최적의 성능을 이끌어 내는 것.

최적화를 위해 할 수 있는 노력들은...

 

  • Mesh의 Vertex 줄이기
  • 텍스처 크기 줄이기
  • 가벼운 Shader 사용
  • Draw Call 줄이기
  • 게임 로직 최적화
  • 물리 연산 줄이기

등등이 있는데 그 전에 선행되어야 할 것이 병목을 탐지하는 것.

 

Bottleneck, 병목

전체 프로세스가 갑자기 느려지거나 막혀서 정지하는 원인이나 그 장소.

병목현상이 발생했다 ! == 전체 성능이나 용량이 어떤 하나의 구성요소 때문에 제한 받는 일이 생겼다 !

특정 로직 수행이 오래걸리면 그 친구 때문에 전체 성능이 떨어지게 되는 것.

따라서 최적화를 하려면 병목 현상이 누구 때문에 일어나는지 찾아야 함.

 

프로파일링

병목 지점을 찾는 과정. 이걸 도와주는 툴은 프로파일러.

프로파일링을 통해 원인을 찾고 걔를 먼저 최적화해주고 다시 확인하면서 최적화 진행.

 

 

FPS vs Frame Time

Frame Time은 한 프레임을 처리하는데 걸리는 시간.

프로파일링 할 때는 프레임타임으로 하는 것이 좋다.

일반적으로 프레임 타임은 ms 단위로 측정.

 

30FPS

= 1초에 30프레임

= 1프레임에 1/30초

이건 초단위의 경우. ms = 1/1000초니까

FPS를 ms로 환산하면 ms = 1000/FPS.

반대로 FPS = 1000/ms.

 

>>Display FPS

유니티 에디터 좌측 상단의 Status를 누르면 FPS와 프레임 타임을 확인할 수 있음.

but 실제 타깃 디바이스에서 성능을 확인하고 싶다면 프로파일러를 연결하거나 FPS를 출력하는 스크립트를 짜서 직접 출력해야함.

 

아래 주소에서 DisplayFPS 샘플을 다운받을 수 있다.

https://github.com/ozlael/FPSCheckerSample

 

구간 측정

성능을 측정할 때 포인트는 각 프레임을 렌더링하는데 시간이 얼마나 걸리는지다.

FPS로만 측정하면 성능의 최종 결과만 확인할 수 있고, 어떤 구간에서 얼마나 시간이 걸렸는지는 모른다.

하지만 최적화를 하려면 어디에서 얼마나 시간이 걸렸는지를 알아야하기 때문에 프레임 타임으로 측정하는 것이 옳다.

 

선형적인 측정

FPS로 성능을 측정하고 비교할 때, FPS 값은 비선형적인 변화를 보이기 때문에 성능 저하가 일정하게 일어나고 있는지 판단하기 어렵다.

예를 들어, 90FPS였던 게임에서 오브젝트를 추가할 때마다 FPS가 떨어지는 것을 측정했는데 그 변화가

90.0 -> 45.0 -> 30.0 -> 22.5 -> 18.0 이었다고 가정하자.

오브젝트 하나를 추가할 때마다 일정하게 성능이 변화해야하는데, 일정하게 변화한 것처럼 보이지는 않다.

 

이것을 프레임 타임으로 환산해보면

1000/90.0 = 11.1ms,

1000/45.0 = 22.2ms,

1000/30.0 = 33.3ms,

1000/22.5 = 44.4ms,

1000/18.0 = 55.5ms.

두둥. ms로 보니 사실 일정하게 변화한 것이 맞았다.

그래서 FPS가 비선형적, ms가 선형적 측정.

프레임 타임으로 측정해야 변화량을 제대로 측정할 수 있다는 것이 결론이다.

 

측정 시나리오

데이터를 측정할 때는 여러 번 측정하여 평균값을 내고, 여러 씬, 여러 상황에서 다양하게 측정하는 것이 좋다.

씬마다 오브젝트 개수나 그래픽 설정이 달라 병목 원인이 다를 수 있기 때문이다.

디바이스에서 Randering Path가 Deferred로 설정되어 있는 경우, 기본적으로 대역폭에 요구되는 비용이 크기 때문에 씬이 간단하더라도 FPS가 잘 안나올 수 있다. 또 후처리 효과들도 기본 비용이 크다.

 

 

Target Frame Rate

강제로 최고 FPS를 설정해서 throttling 상황이 발생하지 않도록 하는 것이다.

Throttling은 주로 모바일 디바이스에서 자주 발생하는데, 기기의 발열이 심해져서 일정 수준 이상이 되면 발열을 낮추기 위해 자동으로 성능을 낮추는 기능이다. 쓰로틀링 상태가 되면 연산속도가 느려지고 렌더링 시간도 늘어나서 결국 FPS가 떨어지게 된다.

그래서 최고 FPS를 제한해두고 그 위로 성능이 나올 수 있음에도 발열을 막기 위해 렌더링 시간의 여유를 두고 한 프레임 수행을 마치고 남은 시간동안 프로세서가 잠시 쉴 수 있도록 하는 것이다. 프로파일링을 할 때는 FPS가 60까지 나오는 지 확인해야하기 때문에 60으로 설정해야하는 것이 맞다.

 

애플리케이션이 시작될 때

Application.targetFrameRate = 40;

하고 설정하면 40FPS로 강제 고정시킬 수 있다.

 

VSync, 수직동기화

Vertical Synchronization. 모니터 주파수와 렌더링 퍼포먼스를 맞춰 Tearing 현상을 방지하는 역할.

만약 targetFrameRate를 높게 설정해 뒀는데도 60FPS를 넘지 못한다면 외부적인 영향 때문일 수 있는데, Vsync의 영향일 가능성이 높다.

 

Tearing

모니터는 화면 갱신 주파수에 한계가 있는데 그를 넘는 신호를 입력하면 화면이 물결치거나 찢어지는 듯한 현상을 볼 수 있다. 이 현상을 Tearing이라고 하는데, 더블버퍼링을 할 때 Back Buffer가 아직 렌더링 되고 있는 동안 Front Buffer와의 전환이 이루어져 지금 렌더링 중이던 화면과 이전 화면이 섞여서 나타나는 현상이다.

 

이 Tearing을 방지하기 위한 것이 VSync이다.

VSync를 활성화하면 디스플레이하는 모니터의 주파수에 맞게 렌더링 퍼포먼스를 조절해줘서 마치 targetFrameRate가 설정된 효과를 보인다. 성능 측정 시에는 당연히 VSync를 꺼줘야 문제를 확인할 수 있을 것.

 

Edit > Project Settings > Quality > Other > V Sync Count 에서 설정해준다.

V Sync Setting

 

CPU 바운드 vs GPU 바운드

게임은 CPU, GPU 모두 연산이 들어가기 때문에 둘 모두에게서 병목이 발생할 수 있다.

병목이 CPU에 몰려있으면 CPU 바운드, GPU에 몰려있으면 GPU 바운드.

병목이 어느 바운드인지 인지하는 것이 중요.

CPU랑 GPU가 직렬적으로 작동하면 어디 병목인지 상관없이 무조건 일처리를 줄이면 전체 시간이 줄어들 것이지만

얘네는 병렬적으로 작동하기 때문에 어디 병목인지 알아서 처리해줘야 전체 시간이 줄어들 것이다.

 

프레임이 시작하고 나서 CPU가 뭔가 수행하다가 렌더링해야하는 상황이 오면 GPU를 불러 명령을 주고 (GPU가 처리를 끝낼 때까지 기다리는 것이 아니고) 자기는 또 하던 일을 한다. 그러다가 또 렌더링할게 있으면 명령을 휙 던지고 하던일을 한다...그리고 GPU는 받은 명령을 순서대로 수행한다.

그러다가 CPU가 할일을 모두 마쳤는데 GPU가 아직 일하는 중이라면 끝날 때까지 기다린다. GPU까지 일이 끝나면 비로소 한 프레임이 끝나고 화면에 출력한다. 이런 상황이면 GPU 때문에 처리 시간이 늦어졌으니 GPU 바운드인 것이고 아무리 CPU 연산을 줄여도 어차피 GPU를 기다려야하기 때문에 최종 성능에 영향을 주지 못한다.

반대로 GPU가 명령이 별로 없어서 일찍 끝나고 CPU가 연산을 계속 하고 있는 상황이라면 CPU 바운드이기 때문에 GPU의 일감을 줄여봤자 성능에 영향을 주지 못한다.

 

결과적으로 병목이 어느 바운드인지 먼저 알아야 해결을 할 수 있다는 얘기.

 

 

02. 병목의 측정

유니티는 프로파일러를 제공해준다!

빠르고 간단하게 CPU 바운드, GPU 바운드를 확인해 볼 수 있다.

Window > Analysis > Profiler 또는 Crtl + 7 을 눌러 창을 열 수 있다.

Profiler

 

프로파일러를 연결하려면 Development 빌드로 해야하는데 이 때는 프로파일러 정보 등 각종 오버헤드가 포함되기 때문에 당연히 오버헤드가 더 많이 발생되므로 실제 배포용 빌드랑은 동일한 성능이 나오지는 않는다. 또 플랫폼, 디바이스마다 병목 지점이 다를 수 있으므로 실제 타깃 디바이스에서 확인해보는 것이 좋다.

하단에 Timeline 드롭박스 메뉴가 있는 것을 볼 수 있는데, 거기서  Hierarchy를 선택하면 어떤 항목들이 성능을 얼마나 잡아먹고 있는지 볼 수 있다.

GPU 성능이 좋지 않은 구형 디바이스일수록 Graphics.PresentAndSync가 성능 비용을 많이 잡아먹어 가장 상단에 위치할 것이다. GPU 바운더리에서 병목이 있다는 것은 알 수 있으나, 그 중 어디가 문제인지는 유니티 내장 프로파일러만으로는 알아내기 힘들다. 이 부분은 추가적인 테스트가 따로 필요함. 

 

Setting for Android

>> Player Settings

Other Setting > Color Space를 Linear -> Gamma로 바꿔준다.

why? OpenGL ES 버전 호환성을 위해서.. Linear로 설정하면 OpenGL ES 2.0을 지원하지 못한대

Identification > Package Name을 변경해준다. 안드로이드 패키지의 이름을 설정해주는 건데 보통 [com.제작자이름.프로젝트명]  형식으로 쓴다.

>> Build Settings

Development Build, Autoconnect Profiler 두 개를 체크해준다.

안드로이드 폰과 PC를 USB로 연결하고 동일한 네트워크 상으로 설정한 후 빌드를 진행하면 된다.

 

빌드가 완료되면 폰에 apk가 자동으로 설치되고 게임이 실행된다. 제대로 Development Build를 체크했다면 화면 오른쪽 하단에 Development Build라고 뜰 것이다. 이게 떠야 프로파일러에 연결이 가능하고 PC에서 프로파일링이 가능하다.

 

 

03. GPU 병목 탐지

프로파일러 창의 Add Profiler 드롭박스 메뉴에서 GPU를 선택해서 추가하면 어떤 부분이 GPU를 잡아먹는지 확인할 수 있다.

대부분의 PC, 콘솔에서는 가능하지만 모바일 디바이스에서는 지원하지 않는다.

안드로이드 - Vulkan 지원가능, Open GL ES의 경우 NVIDIA, Intel GPU일 때만 지원가능.

 

외부 툴을 이용해서 GPU를 프로파일링하고 싶다면

스냅드래곤 칩셋을 사용하는 디바이스라면 Aualcomm에서 제공하는 전용 툴로 GPU 프로파일링이 가능하다.

iOS의 경우 XCode에서 제공하는 GPU Frame Debugger를 통해 가능하다.

 

Fillrate, 필레이트

그래픽 카드가 1초에 스크린에 렌더링할 수 있는 픽셀 수. 게임 렌더링에서는 더 많은 뜻을 내포하고 있다.

픽셀 처리에 대한 부담을 모두 포함한 개념이 필레이트.

필레이트 = 화면 픽셀 수 * 프래그먼트 쉐이더 복잡도 * 오버드로우

픽셀이 많거나, 프래그먼트 쉐이더가 너무 복잡하면 렌더링 파이프라인의 Rasterizer 부분에서 병목이 발생한다. 이때를 필레이트가 병목이 되었다고 표현한다. 필레이트에서 병목이 발생한 거니까 필레이트 바운드라고도 하지.

 

GPU 병목 중 높은 확률로 필레이트가 원인인 경우가 많다.

디스플레이 해상도를 변경했을 때 성능이 눈에 띄게 좋아진다면 해상도(렌더링 해야하는 픽셀 수)가 병목 원인이므로 이때는 필레이트 병목을 강하게 의심할 수 있다. 

 

>>해상도 설정

Edit > Project Settings > Player 에서 Resolution Scaling Mode를 Fixed DPI로 변경해주면 특정 해상도가 아닌 DPI(Dots Per Inch)에 맞춰서 해상도가 자동 조정된다. DPI가 낮을수록 해상도가 낮게 조절된다.

특정 해상도로 고정해놓고 싶다면 Screen.SetResolution()으로 설정할 수 있다.

 

 

Overdraw, 오버드로우

화면에 렌더링되는 하나의 픽셀이 여러 번 덧그려지는 현상.

하나의 오브젝트를 렌더링했는데 그 위에 다른 오브젝트를 겹쳐서 그릴 때 픽셀을 여러번 그리게 되므로 발생하는 건데,

Z 버퍼 검사를 하면 뒤에 나올 친구는 안 그리도록 걸러줘서 오버드로우를 방지할 수 있다.

오버드로우를 막기 위해 유니티는 렌더링 전에 오브젝트들을 카메라와의 거리 순으로 정렬하고 그 순서대로 렌더링한다. 앞에 나오는 친구부터 그리니까 그 뒤 오브젝트를 그릴 때 겹칠부분은 아예 안그리게 되므로 오버드로우가 발생할 확률이 낮아진다.

 

투명 오브젝트

투명한 오브젝트는 반대로 정렬해야한다. 뒤에 있는 오브젝트여도 가려지지 않고 보여야하므로 뒤에 있는 애들부터 렌더링한다. 투명 오브젝트는...모든 픽셀에서 오버드로우가 발생할 수 밖에 없다.

오버드로우 뿐 아니라 프레임 버퍼를 읽어오는 과정에서도 병목이 발생할 수 있다.

또 투명 오브젝트를 그리려면 지금 그리는 픽셀 컬러와 기존의 프레임 버퍼에 있는(이미 그려진) 픽셀의 컬러를 블렌딩해야하므로 프레임 버퍼의 색을 읽어와야하는데, 이 과정에서 병목이 발생할 수 있다고한다.

 

파티클

파티클도 오버드로우가 발생하기 쉽다. 파티클의 밀도가 높을 수록 당연히 오버드로우가 많아지겠지.

씬 뷰의 좌측 상단 Draw모드를  Overdraw로 설정하면 시각적으로 확인할 수 있다. 픽셀의 색이 밝을 수록 오버드로우가 많이 일어나는 픽셀이다.

 

 

포스트 프로세싱

아까 필레이트에 영향을 미치는 요소로 프래그먼트 쉐이더의 복잡도도 한 몫했었다.

프래그먼트 쉐이더가 무거워지는 이유 중 하나는 포스트 프로세싱이다. 포스트 프로세싱이 병목의 원인이라면 가장 쉬운 방법은 역시 해상도를 줄이는 것이다.

 

포스트 프로세싱도 종류별로 성능 비용이 다르다. Color Grading은 모바일에서도 부담이 없고, Bloom까지도 요즘 모바일의 성능이 좋아지면서 사용되고 있다. DOF는 모바일에 적용하기에는 비용이 크기 때문에 인게임에서는 사용하기 힘들고 컷씬이나 로비등에서 사용한다. DOF는 프래그먼트 쉐이더에 부담을 줄 뿐 아니라 드로우콜이 늘어나는 요인이 되기도 한다. 

 

 

Upscaling Sampling, 업스케일링 샘플링

해상도를 줄이는것이 가장 쉬운 방법이지만, 해상도를 줄이면 유저가 알아채기 쉽다.

이를 보완하기 위해 UI의 해상도는 그대로 유지하고, 3D 씬만 해상도를 낮추는 일종의 트릭을 쓰는 것이 업스케일링 샘플링이다. 왜 이렇게 길고 어려운이름인지는 모르겠다.

 

구현 순서는 다음과 같다.

  • 저해상도 렌더 텍스쳐(Render Texture)를 생성한다
  • 3D 씬을 렌더 텍서쳐에 렌더링한다
  • 렌더 텍스쳐를 업스케일링해서 현재의 백 버퍼에 렌더링한다
  • 오버레이(Overlay) UI를 렌더링한다

핵심은 전체 해상도를 줄이는 것이 아니라는 것. 현재 디스플레이 되는 해상도는 그대로 두고, 저해상도의 렌더 타깃(현재 렌더링을 수행하고 있는, 타깃이 되는 버퍼)으로 렌더 텍스쳐를 생성해서 렌더링 하는 것이다. 그러면 결과적으로 3D 씬은 저해상도로 렌더링되고, UI는 원래 해상도로 렌더링된다. 

 

 

폴리곤

폴리곤 수가 많으면(=버텍스 수가 많으면) Geometry 부분에서 병목이 생길 것이다. 버텍스 수가 많다는 것은 그만큼 Vertex Shader를 수행해야 하기 때문. 폴리곤 수를 줄여봤을 때 성능이 눈에 띄게 좋아진다면 여기서 병목이 생긴다는 것을 알 수 있다.

 

LOD, Level of Detail

디테일에 단계를 나누는 것. 가까운 친구는 자세히 그려야되니까 디테일을 높게하고, 카메라에서 먼 친구는 대충 그려도 되니까 디테일을 줄이는 것이다. 카메라에서 먼 친구는 적은 폴리곤을 사용하도록 해서 그림으로서 버텍스 수를 줄이는 것. LOD 그룹을 나눠서 모델을 지정하고 해당 LOD레벨에 지정된 모델이 렌더링 되도록 하는 것이다.

 

LOD를 적용한 오브젝트에는 LOD Group 컴포넌트가 추가된다.

여기서 화면에서 오브젝트가 차지하는 비율에 따라 어떤 레벨의 LOD가 적용되는지 확인하고 카메라나 아래 LOD 영역을 클릭해서 조정할 수 있다.

LOD 레벨이 클 수록(숫자가 클 수록) 적은 폴리곤을 가진 모델이 렌더링된다.

LOD Group

 

Edit > Project Setting > Quality > Other에서 LOD Bias를 조절할 수 있다.

LOD Bias는 LOD 레벨이 결정되는 화면 내 차지 비율에 반영되는 수치이다.

아래 사진을 비교해보면 성능이 좋을수록 LOD Bias 수치가 크다.

1보다 작은 값으로 설정하면 높은 레벨의 LOD 메시가 렌더링 된다. 

Quality : Very Low
Quality : Ultra

 

Texture, 텍스쳐

텍스쳐도 당연히 병목의 원인이 될 수 있다. 전체 텍스쳐의 해상도를 조절해보면서 텍스쳐가 병목 원인인지 아닌지 확인할 수 있다. 텍스쳐 해상도는 Edit > Project Settings > Quality > Rendering에서 Texture Quality를 조절할 수 있다. Full Res가 가장 높은 해상도, 아래로 갈수록 낮은 해상도이다. 텍스쳐로 인한 메모리 대역폭이 병목의 원인이라면 메모리의 부담을 줄여야하는데 이에 대해서는 '08. 텍스쳐'에서..!

 

* [유니티 그래픽스 최적화] 시리즈는 비엘북스 <유니티 그래픽스 최적화 스타트업>을 읽고 스스로 이해한 바를 정리하는 시간을 갖고자 작성하는 시리즈입니다. 책을 그대로 옮겨쓴 것이 아니고 이해한 대로 재 서술하는 것이기 때문에 틀린 점이 있을 수 있습니다. 정확히 알고 계시는 분은 댓글로 알려주시면 감사하겠습니다 !

 


01. GPU의 의미

그래픽 처리를 위한 시스템은 GPU를 중심으로 구성된 칩셋에서 이뤄짐.

CPU가 렌더링 명령을 내리면 GPU가 수행.

 

GPU 메모리, VRAM

Video Random Access Memory.

CPU가 메모리에서 데이터를 읽어오듯 GPU도 그래픽 카드에 GPU 메모리가 있다! 그것이 VRAM.

텍스처, 메시 데이터 등 렌더링에 필요한 데이터들, 렌더링 결과를 저장하는 버퍼들이 포함됨.

렌더링할 때 여기에 저장된 데이터를 참고해서 그래픽 처리.

 

02. 게임 루프

유저가 게임을 실행하면

로딩(초기화, 리소스 생성) -> 게임 중(매 프레임 렌더링) -> 게임 종료(리소스 해제)

위와 같은 일련의 과정이 게임 루프.

만약 10프레임이라면, 1초동안 10개의 장면, 한 장면당 0.1초를 쓰게 됨.

 

매 프레임마다 

물리연산, 입력신호, AI, 게임 로직처리, 애니메이션 모션 처리, 네트워크 처리, 오디오 처리...

등등 매우 다양한 연산을 거쳐야 한다. 그리고 최종 형태를 결정하고 그것을 렌더링하는 것까지. 이것을 반복.

 

크게 과정을 나눠보면

 

    Initialization         _ Awake, OnEnable, Start 호출

          ↓

       Update            _ Physics, Input Events, Game logic 처리

         

       Render            _ Scene, GUI rendering

          ↓ 

Decommissioning

 

* 렌더링은 업데이트가 모두 끝난 후! 

 

아래는 유니티 매뉴얼에 나와있는 flowchart.

Unity 매뉴얼 : Script Lifecycle Flowchart

 

03. 렌더링 루프

렌더링 과정을 살펴보면

업데이트가 모두 끝나고 오브젝트의 위치나 모습등이 결정되면 이제 그것들을 렌더링 해줘야하는데 이것도 순서가 있다. 모든 오브젝트를 렌더링하면 프레임이 끝나고 비로소 화면에 출력.

 

04. 렌더링 파이프라인

== 오브젝트를 2D로 그리는 과정.

오브젝트를 한번에 뿅! 그리는게 아니라 일련의 과정을 거치면서 그려진다는 얘기.

 

오브젝트를 렌더링하기 위해서는

  • 형태를 나타내는 Mesh 정보
  • Albedo, Normal, Specular 등의 텍스처 정보
  • 조명을 어떻게 처리할지 결정하는 Shader 정보
  • Transform (위치, 회전, 스케일) 정보

등의 많은 정보가 필요하다. 이 정보들은 하나의 오브젝트가 렌더링되는 과정에서 쓰인다.

 

여기서는 파이프라이는 크게 세 파트로 나누어 설명한다.

Application, Geometry, Rasterizer

 

Application

애플리케이션 상에서 처리되는 단계. 오브젝트 렌더링 이전에 업데이트에서 데이터를 처리하는 단계.

CPU에서 연산하는 거라(100% CPU만 쓰는 것은 아님) 엄밀히 말하면 렌더링 파이프라인에 속하지 않지만 렌더링 이전에 필요한 연산을 처리하는 것으로 반드시 거쳐야하는 단계이기 때문에 크게 보면 파이프라인에 껴줄 수 있음.

이 단계에서 수행되는 연산들은 최종적으로 렌더링 파이프라인 성능에 영향을 주기 때문!

 

현재 프레임에서 렌더링 가능한 오브젝트들을 컬링을 통해 선별 --> 오브젝트가 줄어든만큼 GPU 부담 적어짐

 

 

Geometry

버텍스, 폴리곤 처리. 오브젝트의 버텍스를 화면상에 배치.

 

 

Vertex Transform

렌더링을 수행할 시점이 되면 GPU는 GPU 메모리에 저장되어있는 버텍스 정보들을 가져옴.

이 버텍스 정보들은 메시 모델에 대한 위치 데이터만 가지고 있음 (얘가 어디 배치될지 모르니까)

이를 로컬 스페이스(local space)에 존재한다고 표현한다.

모델 그 혼자만의 공간을 로컬 스페이스라고 생각하면 된다.

 

얘를 3D 공간 상에 배치하려면 오브젝트의 Transform 정보에 따라 위치를 이동시켜줘야한다. Transform 정보를 반영해주면 Local에서 World space로 데뷔한다. 이렇게 World space로 변환시켜주는 것을 World Transform이라고 한다.

 

이제 얘는 월드 공간에 배치되었다!

근데 또 카메라 스페이스로 변환시켜주어야한다. 우리는 얘를 카메라로 보고 있기 때문.

카메라 역시 고유의 위치, 방향을 가지고 있는데 (스케일은 없다) 카메라의 공간은 Camera Space 혹은 View Space라고 한다. 그러면 역시 View Space로 변환시켜주는 것은 View Transform이라고 부르겠지.

 

자 이제 얘는 카메라 앞에도 설 수 있게 되었다.

그치만 우리는 3D가 아닌 2D에 렌더링해야한다. 그래서 최종적으로 이 3D 공간을 2D 상의 위치에 매칭시켜야한다.

매칭시켜주는 과정을 Projection, 투영이라고 한다.

 

Perspective Projection, 원근 투영

카메라에서 빔 프로젝트를 쏜 것처럼 뷰 프러스텀이 생긴다. 이 경우 원근법이 적용된다. 같은 크기의 물체라도 카메라와의 거리에 따라 크기가 다르게 보여진다. 원리는 그림을 통해 설명해야할 것 같으므로,,,게임수학을 복습할 때 포스팅해야겠다.

 

Orthographic Projection, 직교 투영

뷰 프러스텀이 직육면체 형태로, 원근법이 적용되지 않은 투영이다. 카메라와의 거리가 무시되고 원래의 크기대로 보여진다.

 

Vertex Shader

트랜스폼 변환들은 버텍스 쉐이더에서 이루어진다.

Shader라고 해서 조명 처리를 할 것 같지만 .. Vertex Shader에서는 트랜스폼 처리를 한다.

월드-뷰-프로젝션 트랜스폼은 버텍스 쉐이더에서 행렬 연산을 통해 수행된다. 이 행렬연산도 게임수학..에서..

메쉬의 버텍스에다가 이 행렬을 곱해줘서 버텍스를 알맞은 위치에 배치시켜주는 일을 Vertex Shader가 하는 것.

위치 뿐 아니라 Normal, Color도 버텍스 쉐이더에서 결정된다. 일반 조명은 픽셀별로 처리되는데 만약 버텍스 별로 처리되는 조명이라면 버텍스 쉐이더에서 처리를 하고 이때 버텍스의 컬러에 조명 결과를 반영한다.

 

Geometry 생성

버텍스 쉐이더를 거치고 나면 이제 버텍스들은 서로 연결되어 형태를 이룰 것인데 그래픽스에서는 이 Mesh의 형태를 Geometry라고 부른다. Geometry의 생성은 버텍스 쉐이더를 거치면 다음 단계에서 자동으로 이루어지는 것이다.

그래픽스 API 버전에 따라 최신버전에서는 지오메트리 생성단계에서 지오메트리 쉐이더, 헐 쉐이더, 도메인 쉐이더 등을 거쳐 테셀레이션을 수행할 수 있다. 테셀레이션은 원래 있던 버텍스에서 추가로 버텍스를 생성해서 메쉬를 더 잘게 쪼개 표현을 더욱 부드럽고 세밀하게 할 수 있는 기법이다. 이것도 게임수학에서 더 자세히 ㅎㅎ

 

 

Rasterizer

메쉬가 화면에 매칭되는 픽셀을 결정하고 최종적으로 색을 입히는 과정. 이 때 메쉬의 폴리곤에 속한 영역을 픽셀로 매칭시키는 과정을 Rasterization이라고 함.

버텍스 쉐이더를 거쳐서 지오메트리가 생성되었으므로 이제 화면에 어떤 픽셀에 그려져야하는지 결정된 것이다.

 

Z Buffer (=Depth Buffer)

픽셀은 크게 두가지의 버퍼에 정보를 저장한다. 픽셀의 최종 색상 정보는 Color Buffer, 카메라로부터의 거리인 깊이값은 Z Buffer에 저장한다. 픽셀을 렌더링 할 때마다 이 Z Buffer를 통해 깊이 판정을 수행한다. 이것을 Z 테스트라고 한다.

이 부분은 조금 자세하게 써져있어서 게임수학으로 미루지 않고 간단히 요약해본다.

Z Buffer에 저장되어있는 픽셀의 깊이 값과 지금 출력하고자하는 픽셀의 깊이 값을 비교해서 지금 출력하고자 하는 픽셀이 더 앞에 있다면 (깊이값이 작은 것이 앞에 있는 것) 출력하고, 뒤에 있다면 출력하지 않는 것이다. (일반적으로 뒤에 있으면 가려져서 보이지 않으니까 괜히 렌더링하지 않는 것이다. 계획이 있다면 반대로 작동시킬 수 있다)

Z 테스트를 수행하면 가려지는 것을 알 수 있으므로 요즘의 그래픽 칩셋들은 Fragment Shader 이전에 Z 테스트를 수행해서 미리 가려지는 픽셀들을 걸러내어 비용을 절약한다.

 

Fragment Shader(=Pixel Shader)

픽셀들의 최종 색을 계산.

텍스처로부터 색상을 읽어오고 그림자도 적용. 픽셀별 조명 처리를 한다면 조명 연산을 반영한 색상을 입힘.

 

Blending

투명도가 있는 오브젝트라면 픽셀 렌더링 시 알파 블렌딩을 거친다.

쉐이더에서 결정된 Alpha 값을 이용하여 해당 위치의 기존 컬러 버퍼 값과 지금 출력하고자 하는 픽셀의 컬러 버퍼 값을 섞어서 최종 색상을 결정한다.

 

05. 정리

오브젝트들은 앞의 일련의 과정들을 거쳐 화면에 렌더링 된다.

화면에 바로 렌더링 되는 것은 아니고, 버퍼에 차곡차곡 쌓여서 그려지는 것이다.

 

Double Buffering

버퍼를 두개 사용하는 것이다.

버퍼 1, 2가 있으면 1이 화면에 나타나서 시선을 끄는 동안 버퍼 2에 프레임을 렌더링한다. 버퍼 2의 렌더링이 끝나면 얘를 보여주고 버퍼 1은 렌더링을 시작한다. 이렇게 번갈아 보여주면서 다음 프레임을 렌더링할 시간을 버는 것이다. 이때 지금 출력되고 있는 버퍼가 Front Buffer, 뒤에서 렌더링 중인 버퍼가 Back Buffer이다.

 

렌더링 루프를 쭉 정리해보자면...

  • 프레임이 시작되고 렌더링 이전에 업데이트가 이루어짐. 물리, 입력, 로직, 애니메이션 등 연산을 수행하고 컬링 연산으로 불필요한 렌더링 부하를 방지한다.
  • 오브젝트를 렌더링한다. 오브젝트마다 드로우 콜이 발생하며 GPU 파이프라인에 따라 버퍼에 순차적으로 렌더링된다.
  • 포스트 프로세싱을 처리한다. 얘도 버퍼에 렌더링을 하는 것으로 한번 이상의 드로우 콜이 발생한다. 포스트 프로세싱에서는 특정 메쉬를 그리는 것이 아니라 화면을 덮는 사각형을 그리기 때문에 대부분의 처리가 버텍스 쉐이더가 아닌 프래그먼트 쉐이더에서 이루어진다.
  • 모든 요소가 렌더링이 되면 화면에 버퍼들이 교체되면서 디스플레이된다.
  • 디스플레이가 되고 나면 한 프레임이 끝나고 ,, 이제 다음 프레임으로 넘어가서 업데이트부터 다시 반복한다.

 

* [유니티 그래픽스 최적화] 시리즈는 비엘북스 <유니티 그래픽스 최적화 스타트업>을 읽고 스스로 이해한 바를 정리하는 시간을 갖고자 작성하는 시리즈입니다. 책을 그대로 옮겨쓴 것이 아니고 이해한 대로 재 서술하는 것이기 때문에 틀린 점이 있을 수 있습니다. 정확히 알고 계시는 분은 댓글로 알려주시면 감사하겠습니다 !

 


01. 최적화 개요

 

FPS

Frame Per Second. 초당 프레임. 초당 몇 프레임을 렌더링 할 것이냐

초당 프레임을 많이 보여줄수록 부드러운 움직임.

대부분 60FPS (대부분의 디스플레이 장치도 60~70까지만 지원하기 때문에 그 이상으로 해봤자 의미가 없다)

--> 게임은 인터랙티브. 제한된 성능에서 높은 FPS로 렌더링해야하기 때문에 최적화 필요.

 

그래픽스 API

렌더링은 GPU에서 처리하는데 기기마다 GPU는 제각각임.

하나하나 맞춰서 개발할 수 없기 때문에 그래픽스 API가 존재한다 (ex. DirectX, OpenGL)

그래픽스 API는 다양한 GPU의 드라이버를 이용하는 라이브러리를 만들고 API로 제공해서 OS나 GPU 상관없이 렌더링 소프트웨어(ex.3ds Max)를 개발할 수 있도록 함.

But 그래픽스 API도 OS에 따라 ...

  • DirectX _ MS
  • OpenGL _ MS Windows, 매킨토시, 리눅스
  • OpenGL ES _ 안드로이드, iOS / WebGL을 통해 데스크톱의 웹브라우저에서도 쓰임
  • Metal _ iOS에서 쓰는 차세대 그래픽 API래
  • Vulkan _ 안드로이드에서 쓰는 차세대 그래픽 API

유니티같은 상용엔진은 알아서 각 플랫폼에 맞는 그래픽 API를 사용해 렌더링해줌.

그치만 최적화를 하려면 그래픽스 API가 어떻게 렌더링을 처리하는지 그 과정을 알아야겠지 ~~

 

오버헤드

Overhead. 어떠한 처리를 하기 위해 들어가는 시간, 메모리 등. 오버헤드가 늘어나면 성능에 좋지 않다.

 

드로우콜

Draw Call. 오버헤드 중 대표적인 예시. CPU가 GPU에게 뭘 그리라고 명령하는 것.

 

 

 

 

+ Recent posts