유니티/유니티 UGUI 최적화

Unity의 UGUI 최적화 공부하기(Overlay만 다룬다!)(1) - Dirty Flag까지, UGUI의 시스템과 구조를 파악하자.

강목근 2024. 6. 26. 04:28

본 포스트는 알쓸유잡을 보며 공부하고 배운 내용을 기록하는 포스트입니다.

https://www.youtube.com/live/1e2mSCS7o1A?si=BAbKuXCTQBdKMMDb

 

UI 객체도 메시다!

위의 UI를 WireFrame으로 보이게 했을 때의 결과물

2D게임, 3D 게임 구분 없이 UI도 객체로 취급되어 화면에 그려지기 때문에 이를 위한 메시가 필요하다.

실제로 유니티 엔진에서 WireFrame으로 그리게 할 경우 위와 같은 화면을 볼 수 있다.

 

DirectX12나 다른 로우 레벨의 API를 통해 개발을 할 때도 마찬가지로 화면에 2D 이미지를 그리기 위해 이러한 메시를 생성하여 그려줬었다.

DirectX12 프로젝트에서 UI 객체를 그리기 위해 사용한 정점 배열

위 구조를 가지고 UI뿐 아니라 빌보드나 다른 2D 이미지를 월드 상에 배치도 했었기 때문에 3차원 좌표를 사용하고 있다. 

배열 요소의 구성은 점을 나타내는 XMFLOAT3와 uv 좌표를 나타내는 XMFLOAT2를 사용했다.

 

이렇듯 UI를 그릴 때에도 사용되는 이미지를 매핑할 메시가 반드시 존재해야 한다.

 

메시가 존재하는 객체를 화면에 그리니, UI를 그릴 때에도 3D 객체를 그릴 때 발생했던 여러 병목 현상들이 발생할 수 있다. 그래서 관련된 내용을 항상 신경써야 한다. (드로우 콜 최소화, 오버드로우 방지, 셰이더 계산 최적화 등...)

 

메시가 뭔데? (축구선수 아닙니다.)

그래픽스에서 가장 기본적인 요소 중 하나로, 정점과 인덱스, UV좌표 등을 포함한 것을 뜻한다.

 

삼각형과 정점 버퍼, 인덱스 버퍼를 나타낸 그림 (출처: 3D Graphics for Programming(J.Han))

2차원 좌표로 나타낸 그림이다.

각각의 정점을 정점 버퍼에 저장하고, 정점 버퍼에 저장된 번호를 인덱스 버퍼에 저장한다.

인덱스 버퍼에서 순서대로 3개씩 읽어 해당하는 위치에 있는 정점을 사용하면, 삼각형이 그려지게 된다.

 

이렇게 하는 이유는, 정점을 그대로 사용해서 버퍼에 저장하고 전달할 때 동일한 정점이 중복되어 저장되는 일이 발생하기 때문이다.

위의 그림은 간단하게 삼각형 3개만을 나타냈지만, 요즘 같은 시대에 사용하는 복잡한 모델들은 정점의 수가 정말 많은 편에 속한다.

정점이 많으면 많을수록 중복되는 정점 데이터도 많아질 것이고, 그렇게되면 굉장히 비효율적으로 데이터를 저장하는 것이 된다.

 

그래서 번호를 사용해 인덱스 버퍼에 저장하는 것이다.

 

유니티는 오른손 좌표계다. 

그래서 CCW(Counter Clockwise)를 사용한다. 

좌표계에 따라 정점 정렬 방식이 달라진다. (출처: 3D Graphics for Programming(J.Han))

캐시를 통해서 정점을 찾는 과정을 빠르게 하곤 하는데, 캐시 미스일 경우 정점을 꺼내오는 과정을 거치게 된다.

그래서 삼각형마다 처리되는 정점의 개수는 캐시 미스가 발생한 수와 동일한데, 이를 ACMR(Average Cache Miss Ratio)라고 한다.

이를 줄이기 위한 삼각형 재정렬에 대한 알고리즘 연구 등이 존재한다고 하는데, 너무 깊게 들어가는 것 같아서 넘어가겠다.

 

아무튼, 정점을 정렬하는 방식에 따라 삼각형을 만드는 데 사용하는 정점의 순서가 달라지게 된다. 이로 인해 삼각형의 앞면이 어디인지 결정되곤 한다.

DirectX12에서 정점 정보와 동일하게 Input 구조를 만드는 코드이다.

정점에는 단순히 좌표만 있는 게 아니고, 용도에 따라 여러 값이 들어갈 수 있다.

DirectX12 프로젝트를 진행할 때도 각각의 PSO(Pipeline State Object)마다 용도에 맞게 정점 정보에 대한 Input Layout을 만들어 할당했는데, 이 구조와 전달하는 정점 정보가 다르면 의도와 완전히 다르게 렌더링된다. 

(생각보다 로우 레벨로 작업할 때 주변에서도 그렇고 정말 많이 발생했던 버그로, 정점 정보의 순서가 바뀌거나 적절한 크기로 전달하지 않거나 해서 오류가 발생했었다.)

유니티 6 기준, TextMesh Pro의 Sprite 셰이더 코드 중 일부, 정점 셰이더의 입력 데이터

위 사진은 TMP의 Sprite 셰이더 코드인데, 위치 정보인 vertex와 색상 정보 color, 그리고 UV 좌표를 나타내는 texcoord가 정점 데이터인 것을 알 수 있다. (셰이더 프로그래밍을 할 때 반드시 설정해둔 Input Layout과 동일하게 셰이더 코드에서도 구조체로 작성하여 해당 데이터를 받아와야 한다.)

CPU는 이런 정점 데이터를 GPU로 전달해준다. 

당연히 정점이 많으면 많을수록 부하가 크고, 속도에도 영향이 있을 것이다. 

 

최적화가 필요한 이유

UI는 사용자의 상호작용에 따라 시시각각 정점 데이터가 변한다.
화면을 스크롤해서 내리는 경우도 있을 것이고, 버튼을 클릭해서 색상이 변하거나 슬라이드로 화면을 넘길 수도 있을 것이다.

그래서 매 프레임마다 이 정점 데이터를 CPU에서 업데이트하고, CPU 메모리에 존재하는 데이터를 GPU 메모리로 전달하고 하는 과정이 반복되면서 연산 비용이 발생한다.

영상에서 설명해주시는 "오지현" 에반젤리스트님의 말을 들어보면, UI의 병목은 오히려 이런 CPU의 연산 비용 등에서 발생하는 경우가 더 많았다고 한다.

 

Graphic 클래스

UnityEngine의 UI 네임스페이스에 존재하는 Graphic 클래스는 UIBehavior의 자식 클래스이며 ICanvasElement 인터페이스를 상속받는다.

간단하게 클래스 정의 타고가다보면 확인할 수 있다.

 

해당 클래스의 Rebuild 함수를 보자.

 

Graphic 클래스의 Rebuild 함수

캔버스의 업데이트마다 Geometry와 Material을 업데이트하고 있다. 

UpdateGeometry를 타고 들어가볼까?

Graphic 클래스의 UpdateGeometry 함수

useLegacyMeshGeneration에 따라서 실행하는 함수가 다르다.

이름이 굉장히 직관적인데, 그냥 직역하면 '기존에 생성된 메시를 사용한다' 뭐 이런 뜻인 것 같다. (bool 변수이다.)

ctrl + f로 Graphic 클래스에서 변경되는 부분이 있는지 찾아봤는데, 저 코드 말고는 생성될 때 true의 기본 값으로 설정되는 코드밖에는 없다.

 

메시의 Geometry가 변하려면, 뭔가 새롭게 할당하거나 해야 하지 않을까 하는 생각에 하위 클래스를 들어가 찾아봤다.

Image 클래스의 생성자

아하, Image를 새로 넣거나 하면 새로운 메시를 생성하는 것을 알 수 있다.

생각보다 굉장히 코드가 잘 읽힌다. 유니티의 SRP를 통해 파이프라인 단계에서 최적화를 진행하는 것도 어쩌면 금방 익숙해질 수 있지 않을까? 하는 생각이 든다.

 

아무튼, 저 메시를 생성하는 코드를 타고타고 들어가면, OnPopulateMesh라는 함수를 마주할 수 있다.

Graphic 클래스의 OnPopulateMesh 함수

코드를 잘 보면, 변수 r에 GetPixelAdjustedRect()라는 함수를 할당한다.

 

함수의 이름으로 유추해보면, 조정된 사각형의 픽셀을 얻어온다는 뜻으로 보인다.

v라는 변수에 Vector4를 생성해서 각 좌표에 r을 기준으로 값을 저장하는 것을 볼 수 있다.

위 정점을 Triangle로 저장하는 것을 그림으로 보여주기 위해서 그리고 있었는데... 뭔가 이상해서 좀 찾아봤다.

https://forum.unity.com/threads/unity-has-a-clockwise-winding-order.129923/

 

Unity has a Clockwise winding order?

I've been beating my head over this, and I can't find it anywhere in the documentation or forums. :( Can someone please confirm for me that Unity...

forum.unity.com

이전에 유니티를 오른손 좌표계라고 설명했었다.

그리고 오른손 좌표계는 CCW 방식이다.

 

그런데.. 유니티의 화면 좌표계는 좌측 하단이 0, 0이다.

그럼 위 정점을 순서대로 화면에 배치하면 다음 그림과 같이 된다.

이거 완전히 CW 아닌가?

이건 내가 예상한 결과가 아니다. DirectX12 프로젝트를 진행할 땐 좌측 상단이 0, 0이었기 때문에 처음 그림은 위와 달랐다.

위 그림을 그리기 전, 좌측 상단을 원점으로 생각했을 때 그림

사실 좌측 상단을 0, 0으로 두면 CCW 방식과 동일하게 보인다.

왜 이런 건지 도저히 이유를 모르겠어서 열심히 찾아봤으나... 뭔가 확실한 답을 얻진 못했다.

이유를 아는 사람이 있다면 알려주면 정말 감사하겠다...

 

위 토론 글을 보면, 누구는 CW라 주장하고 누구는 CCW라 주장한다.

조금 어지럽긴 하지만, 일단 위 코드의 순서로 삼각형을 만들어 메시를 생성한다는 것만 알아두고 넘어가자.

 

Canvas (cpp 클래스)

메시를 구성하여 렌더링 명령을 생성하는 클래스이다. (GPU 명령도 얘가 전달한다고 한다.)

렌더링 주체가 캔버스가 되고, 그 하위에 존재하는 이미지나 다른 여러 객체가 업데이트 될 때마다 캔버스가 가지고 있는 데이터를 갱신한다.

캔버스가 리배칭이 필요한 Geometry를 포함하면, dirty flag 처리를 하여 아까 봤던 코드의 bool 값인 m_VertsDirty와 같은 값에 전달되어 처리하게 된다.

결국 모든 드로우 콜은 캔버스 단위로 일어난다고 보면 된다.

 

캔버스 클래스는 유니티 엔진 내부의 코드로 라이센스가 없으면 볼 수 없다.

나중에 취업하고 기회가 되면 반드시 뜯어보겠다...

 

3D 객체는 조건에 따라 최적화하는 방법이 어느정도 정론이 있는데, UI의 경우는 정론이 없다고 한다.

최대한 "정적인 UI"와 "동적인 UI"를 캔버스 단위로 나눠라... 인데, 드로우 콜이 캔버스 단위로 일어나니 캔버스를 무작정 나눌 수도 없는 노릇이고, 그렇다고 하나의 캔버스에 다 때려박아서 동적인 UI의 업데이트 때문에 다른 정적인 UI까지 업데이트 시키는 비효율적인 작업을 하게 할 수도 없는 것이다. 

 

Nested Canvas (개념)

한 캔버스가 다른 캔버스의 자식으로 배치되는 구조를 말한다.

 

알쓸유잡 강의 캡처, 에반젤리스트님 죄송합니다,, 코드에 접근할 수 있는 권한이 없어서... ㅠㅠ (출처: https://www.youtube.com/live/1e2mSCS7o1A?si=Nx1sEARnX-Os6dm4)

 

캔버스의 RenderOverlays 함수인데, 잘 보면 NestedCanvasList가 코드에 보인다.

이게 이제 자식 캔버스 리스트라고 보면 되는데, m_NestedCanvases의 반복자를 받아와서 그 반복자를 가지고 순차적으로 자식 캔버스를 찾아가 같은 함수를 재귀호출하고 있다.

캔버스마다 메시를 따로 가지고 있기 때문에, 이런 식으로 캔버스를 구성하는 것을 권장한다.

 

모든 캔버스가 루트 캔버스일 필요는 없다. 하나의 루트 캔버스 아래에서, 동적인 것들과 정적인 것들을 나눠서 각각의 자식 캔버스에 할당해두면 최적화를 할 수 있다는 것이다.

 

Dirty Flag

Dirty Flag는 필요할 때까지 그 일을 미뤄두고, Flag가 오면 그 일을 수행하는 것을 뜻한다.

Graphic 클래스의 SetAllDirty 함수

주석이 달려있는데, 기존 스프라이트가 동일한 텍스처의 동일한 사이즈면 레이아웃을 업데이트할 필요가 없다는 뜻이다.

의미없는 반복을 최대한 피하기 위해서 이렇게 한 것으로 보인다.