한 칸이 바뀌면 그 캔버스는 통째로 다시 계산된다
Esc

    한 칸이 바뀌면 그 캔버스는 통째로 다시 계산된다

    –UnityUIuGUI최적화

    같은 해상도에 같은 개수의 버튼인데, 어떤 화면은 프레임이 떨어지고 어떤 화면은 멀쩡합니다.

    그림을 얼마나 정교하게 그렸는지의 문제가 아닙니다. 무엇을 한 덩어리로 묶어 두었는지의 문제입니다. 그리고 그 묶음은 대부분 시안에서 정해집니다. 무엇을 무엇 위에 겹쳤는지, 목록 항목의 크기가 내용에 따라 달라지는지, 팝업이 뒤를 다 가리는지 같은 결정들이요.

    캔버스는 한 덩어리로 묶어서 그립니다

    Canvas 는 그 안의 UI 조각들을 모아 하나의 메시로 합치고, 그것을 그리라는 명령을 만듭니다. 유니티 문서의 표현으로는 “combines the meshes representing its UI elements and generates the appropriate rendering commands” 입니다. 프로파일러에서 Canvas.BuildBatch 로 보이는 것이 이 일입니다.

    재려고 할 때 주의할 것이 하나 있습니다. Canvas.BuildBatch 만 보면 안 됩니다. 이 마커는 일을 잡으로 넘기기만 해서, 캔버스에 요소가 몇 개든 거의 같은 시간으로 나옵니다. 실제로 걸린 시간은 Canvas.GeometryJob 과 Canvas.SortJob 에 찍힙니다.

    잘 묶이면 버튼이 스무 개여도 한 번에 그립니다. 안 묶이면 그 수만큼 나눠 그립니다. 그리는 횟수 자체가 왜 비용인지는 드로우 콜을 줄였는데 느려졌다면에 따로 적어 뒀습니다. 여기서는 UI 에서만 생기는 자리들만 봅니다.

    한 칸이 바뀌면 전부 다시 봅니다

    묶는 일은 공짜가 아닙니다. 그리고 유니티는 바뀐 것만 다시 보지 않습니다.

    Whenever any drawable UI element on a given Canvas changes, the Canvas must re-run the batch building process. This process re-analyzes every drawable UI element on the Canvas, regardless of whether it has changed or not.

    한 가지 구분이 필요합니다. 메시를 다시 만드는 쪽은 바뀐 것 하나뿐입니다. Graphic 의 색을 바꾸면 그 Graphic 의 OnPopulateMesh 만 한 번 돕니다. “캔버스 전체를 다시 본다” 는 것은 그 뒤에 엔진이 정렬하고 합치는 쪽 이야기입니다. 그리고 비싼 쪽은 그쪽입니다.

    점수 숫자 하나가 매 프레임 바뀌면, 그 캔버스에 같이 있는 배경과 버튼과 체력바까지 매 프레임 다시 묶습니다. Animator 는 붙어 있는 동안 계속 일한다에서 한 줄로 지나간 것이 이것입니다.

    Canvas 하나 배경 다시 봄 버튼 다시 봄 버튼 다시 봄 체력바 다시 봄 점수 다시 봄 아이콘 다시 봄 매 프레임 바뀝니다 점수 한 칸이 바뀌면 이 캔버스에 있는 여섯 개를 전부 다시 봅니다. 바뀐 것만 보지 않습니다. 유니티 문서에 "regardless of whether it has changed or not" 이라고 적혀 있습니다.
    가만히 있는 다섯 개까지 같이 다시 계산됩니다.
    가만히 있는 Canvas 자주 바뀌는 Canvas 배경 그대로 버튼 그대로 버튼 그대로 체력바 그대로 아이콘 그대로 점수 이것만 다시 봄 캔버스를 나누면 다시 계산하는 범위도 나뉩니다. 대신 캔버스가 갈리면 묶음도 갈려서 그리는 횟수는 늘어납니다. 그 사이를 잡는 일입니다.
    바뀌는 쪽만 다시 봅니다. 다만 그리는 횟수는 하나 늘어납니다.

    떼어 내는 방법은 그 묶음에 Canvas 를 하나 더 다는 것입니다. 안쪽 캔버스는 바깥과 끊어집니다.

    Sub-canvases isolate their children from their parent; a dirty child will not force a parent to rebuild its geometry, and vice versa.

    나누는 기준은 얼마나 자주 바뀌는가 하나입니다. 매 프레임 바뀌는 것, 가끔 바뀌는 것, 열려 있는 동안 한 번도 안 바뀌는 것. 이 셋을 섞어 두지 않으면 됩니다.

    다만 나누면 묶음도 갈립니다. 다시 계산하는 범위는 줄지만 그리는 횟수는 늘어납니다. 문서도 그 사이를 잡는 일이라고 적고 있습니다.

    Performant UI design requires a balance between minimizing the cost of rebuilds and minimizing wasted draw calls.

    그래서 화면 하나를 캔버스 스무 개로 쪼개는 것은 답이 아닙니다. 매 프레임 움직이는 것만 떼어 내면 대개 충분합니다.

    캔버스 경계가 막아 주는 것과 못 막는 것

    여기까지는 “나누면 끊어진다” 입니다. 그러면 무엇까지 끊어지는지가 다음 질문입니다. 부모 Canvas 안에 자식 Canvas 를 넣고 하나씩 바꿔 보면 이렇게 갈립니다.

    무엇을 바꿨나 부모 배치 자식 배치
    부모 Canvas 의 다른 Graphic 색 다시 묶습니다 그대로
    자식 Canvas 안의 Graphic 색 그대로 다시 묶습니다
    자식 Canvas 자체를 이동 그대로 다시 묶습니다
    자식 Canvas 를 감싼 부모 계층을 이동 그대로 다시 묶습니다
    같은 부모 계층의 스케일 그대로 다시 묶습니다
    부모 계층 CanvasGroup 알파 그대로 다시 묶습니다
    자식 Canvas 를 껐다 켜기 다시 묶습니다 다시 묶습니다

    세 줄로 정리됩니다.

    • 캔버스 안의 요소끼리는 서로 막아 줍니다. 부모의 Graphic 을 바꿔도 자식은 그대로고, 자식을 바꿔도 부모는 그대로입니다. 양방향입니다
    • 경계 자체가 움직이면 막을 것이 없습니다. 자식 Canvas 를 옮기든, 자식 Canvas 를 감싼 부모 계층을 옮기든 키우든, 부모 쪽 알파를 바꾸든, 자식 배치는 통째로 다시 만들어집니다. 자식 입장에서는 누가 옮겼는지 구분하지 않습니다
    • 반대로 새는 것은 하나입니다. 자식 Canvas 를 끄고 켜면 부모 배치까지 다시 만들어집니다. 자식이 사라지거나 생기면 부모 배치의 구성이 바뀌기 때문으로 보입니다

    시안 쪽에서 나오는 결론은 이렇습니다.

    • 자주 바뀌는 것을 자식 Canvas 로 떼어 내되, 그 안에 요소를 적게 두세요. 그 캔버스가 한 번 움직이면 안에 든 것이 전부 다시 묶입니다
    • 패널 전체가 슬라이드로 들어오는 연출은 그 안의 캔버스를 매 프레임 다시 묶습니다. 제자리에서 알파만 올리는 연출과 비용이 다릅니다
    • 페이드도 공짜가 아닙니다. 알파가 0 과 1 사이 중간값으로 도는 동안 그 캔버스는 매 프레임 다시 묶입니다. 끝값에 도착해야 쉽니다

    겹쳐 놓은 한 장이 묶음을 가릅니다

    묶이지 않는 가장 흔한 이유는 그림이 다른 곳에서 오는 것입니다. 문서도 “a UI element using a different texture or material” 을 첫 번째로 듭니다.

    그런데 다른 그림을 쓴다고 항상 갈리지는 않습니다. 그것이 어디에 놓였는지가 정합니다.

    An “intermediate layer” is a graphical object with a different material, whose bounding box overlaps two otherwise-batchable objects.

    같이 묶일 수 있었던 두 개 사이에 끼어들고, 그 둘과 겹칠 때만 갈립니다.

    시안에서 할 수 있는 것은 두 가지입니다.

    • 같은 화면에서 같이 뜨는 그림은 같은 아틀라스로 묶어 주세요. 폴더를 그렇게 나눠 주시면 됩니다. 묶는 기준은 텍스처를 합치는 것이 곧 배칭을 합치는 것에 정리해 뒀습니다
    • 다른 아틀라스에서 오는 장식은 아이콘 사이가 아니라 맨 앞이나 맨 뒤로. 반짝임, 리본, 등급 테두리처럼 여러 칸에 걸치는 것들이 여기 해당합니다. 순서만 바꿔도 갈리지 않습니다

    레이아웃 그룹을 겹쳐 쌓으면 맨 위부터 다시 돕니다

    Layout Group 은 칸을 다시 나누는 일을 합니다. 문서 표현으로는 “relatively expensive, as they must recompute the sizes and positions of their child elements each time they are marked dirty” 입니다.

    문제는 다시 계산을 시작하는 자리입니다. uGUI 는 바뀐 요소에서 출발해 부모를 따라 올라가면서, 레이아웃 그룹이 달린 부모가 있는 동안 계속 올라갑니다. 그리고 거기서부터 다시 계산합니다.

    항목 하나의 글자가 길어졌습니다 부모에 레이아웃 그룹이 있는 동안 계속 위로 올라갑니다 Vertical Layout Group (바깥) Horizontal Layout Group (안) 항목 1 그대로 둠 항목 2 글자가 길어짐 항목 3 그대로 둠 다시 계산은 여기서 시작해서 아래를 전부 훑습니다. 그것도 네 번입니다. 가로 크기 계산 → 가로 배치 → 세로 크기 계산 → 세로 배치. 매번 트리를 처음부터 다시 내려갑니다.
    바뀐 것은 항목 하나인데 다시 계산하는 시작점은 가장 바깥 그룹입니다.

    한 번 다시 계산할 때 그 아래를 네 번 훑습니다. 가로 크기를 모아 올리고, 가로로 배치하고, 세로 크기를 모아 올리고, 세로로 배치합니다. 자식 크기를 알아야 부모 크기가 나오기 때문에 아래에서 위로 한 번, 위에서 아래로 한 번씩 갑니다.

    • 목록 항목은 크기를 고정으로. 내용에 따라 높이가 달라지면 항목 하나가 바뀔 때마다 목록 전체가 다시 계산됩니다
    • 그룹을 겹쳐 쌓지 마세요. 가로 그룹 안에 세로 그룹 안에 또 가로 그룹이면, 맨 안쪽 한 칸이 바뀌어도 맨 바깥부터 돕니다
    • 칸을 나누는 규칙 자체는 Layout Group 은 칸을 먼저 나누고 그 안에 자식을 놓는다에 있습니다

    Mask 는 그리기를 두 번 더 합니다

    Mask 는 모양대로 가리기 위해 스텐실이라는 표시를 씁니다. 마스크 모양을 한 번 그려서 “여기까지가 안쪽” 이라고 적고, 안쪽 것들을 그린 뒤에, 적어 둔 표시를 지우는 것을 한 번 더 그립니다. 가리는 것 하나당 그리기가 두 번 늘어납니다.

    겹쳐 쓸 수 있는 한도도 있습니다. uGUI 소스에 깊이가 8 이상이면 경고를 띄우는 코드가 있습니다.

    사각형으로 자르는 것뿐이라면 RectMask2D 쪽이 다릅니다. 컴포넌트 설명에 이렇게 적혀 있습니다.

    • “Does not require stencil buffer / extra draw calls”
    • “Requires fewer draw calls”
    • “Culls elements that are outside the mask area.”

    마지막 줄이 특히 큽니다. 바깥으로 나간 것은 아예 그리지 않습니다. 긴 목록을 스크롤할 때 화면 밖 항목까지 그리는 일이 없어집니다.

    시안에서 자르는 모양이 둥근 사각형이면, 모서리를 마스크로 깎지 말고 항목 그림 자체를 둥글게 뽑아 주시는 편이 싸게 끝납니다. 자르는 방식의 차이는 스크롤 목록은 창문 뒤에서 긴 종이를 움직이는 것이다에 적어 뒀습니다.

    안 보이는 것도 칠합니다

    겹친 층이 많으면 같은 자리의 픽셀을 그 수만큼 칠합니다. 이것을 오버드로우라고 부릅니다. 문서도 모바일에서 이것이 먼저 한계에 닿는다고 적고 있습니다.

    On mobile devices, this high level of overdraw can rapidly exceed the fill-rate capacity of the GPU.

    화면을 옆에서 본 단면 이 자리의 픽셀 하나 배경 한 장 1번째 메뉴 패널 2번째 어둡게 덮는 판 3번째 팝업 4번째 이 픽셀은 네 번 칠해집니다 팝업이 화면을 다 덮는데 뒤 화면을 그대로 두면, 보이지도 않는 것을 매 프레임 칠합니다. 여백을 채우려고 놓은 투명한 Image 도 칠해집니다. 안 보이는 것과 안 그리는 것은 다릅니다.
    겹친 층의 수가 곧 그 픽셀을 칠하는 횟수입니다.
    • 팝업이 뒤를 완전히 가리면 뒤 화면은 꺼 주세요. 반투명으로 살짝 비치는 연출이 아니라면, 뒤에서 계속 칠하고 있을 이유가 없습니다
    • 여백을 채우려고 투명한 Image 를 놓지 마세요. 알파가 0 이어도 칠하는 일은 그대로 일어납니다
    • 클릭만 받으면 되는 빈 영역이라면 그리지 않는 전용 컴포넌트가 있습니다. 눌리는 것들은 전부 같은 다섯 상태를 가진다의 RaycastReceiver 쪽입니다

    클릭을 받는 것의 수도 셉니다

    화면을 누를 때마다 유니티는 클릭을 받겠다고 체크된 것들을 하나씩 훑습니다.

    The Graphic Raycaster is a relatively straightforward implementation that iterates over all Graphic components that have the “Raycast Target” setting set to true.

    그래서 문서의 권고도 단순합니다. “only enable the ‘Raycast Target’ setting on UI components that must receive pointer events”. 배경, 장식, 글자처럼 눌릴 일이 없는 것에서는 빼 두면 됩니다. 어디가 눌리는 영역이 되는지는 12편에 적어 뒀습니다.

    창을 숨기는 방법은 세 가지입니다

    같은 “닫기” 인데 안에서 일어나는 일이 다릅니다. 문서가 말하는 것은 둘입니다.

    • Canvas 컴포넌트만 끄기: 그리지 않습니다. 메시는 메모리에 남고 묶어 둔 것도 그대로입니다. “they will remain resident in memory and their original batching will be preserved”
    • 게임오브젝트 끄기: 들고 있던 메시 데이터를 버립니다. “causes the Canvas to discard its VBO data”

    여기까지만 보면 자주 여닫는 창은 Canvas 만 끄면 될 것 같습니다. 그런데 둘 다 부모 배치를 다시 만듭니다. 앞 절에서 본 그 자리입니다. 자식 Canvas 가 사라지거나 생기면 부모 배치의 구성이 바뀝니다.

    숨기는 방법 부모 배치 여닫는 비용 다시 켤 때
    Canvas 컴포넌트 끄기 다시 묶습니다 안에 든 요소 수에 비례 메시는 그대로
    게임오브젝트 끄기 다시 묶습니다 셋 중 가장 큽니다 자식 메시를 전부 다시 만듭니다
    CanvasGroup 알파를 0 으로 건드리지 않습니다 셋 중 가장 쌉니다 메시는 그대로
    • 자주 열고 닫는 창은 CanvasGroup 의 알파를 0 으로 두는 쪽이 가장 쌉니다. 부모 배치를 건드리지 않습니다. 이때 blocksRaycasts 를 같이 꺼야 안 보이는 패널이 클릭을 먹지 않습니다
    • 한 번 지나가면 끝인 화면은 게임오브젝트를 끕니다. 메모리를 돌려주는 쪽이 낫고, 여는 비용은 어차피 한 번입니다
    • 알파 0 으로 숨긴 것은 여전히 살아 있습니다. 안에서 코루틴이나 Update 가 돌고 있으면 그건 그대로 돕니다

    시안 쪽에서 도와줄 수 있는 것은 이 창이 얼마나 자주 열리는지 한 줄 적어 주는 것입니다. 전투 중에 계속 여닫는 인벤토리인지, 게임 시작에 한 번 뜨는 안내인지에 따라 만드는 방법이 갈립니다.

    시안을 넘길 때 적어 주면 좋은 것

    항목 적어 줄 것
    바뀌는 빈도 매 프레임 바뀌는 것, 가끔 바뀌는 것, 안 바뀌는 것을 표시
    겹침 다른 그림 위로 걸치는 장식이 어디인지
    아틀라스 같은 화면에서 같이 뜨는 그림끼리 폴더를 나눠서
    목록 항목 높이가 내용에 따라 달라지는지, 고정인지
    가림 자르는 모양이 사각형인지 아닌지
    덮음 이 창이 뒤를 완전히 가리는지, 비쳐야 하는지
    통째로 움직임 패널이 슬라이드로 들어오는지, 제자리에서 알파만 오르는지
    페이드 반투명한 중간 상태로 오래 머무는 연출이 있는지
    여닫음 얼마나 자주 열고 닫는 창인지
    클릭 눌릴 일이 없는 장식이 무엇인지

    정리

    • 한 캔버스 안의 요소 하나가 바뀌면 그 캔버스에 있는 것을 전부 다시 묶습니다. 메시를 다시 만드는 것은 바뀐 하나뿐이고, 비싼 쪽은 묶는 쪽입니다
    • 매 프레임 바뀌는 것은 따로 캔버스를 둡니다. 다만 나누면 그리는 횟수가 늘어나니 필요한 만큼만 나눕니다
    • 캔버스 경계는 안에 있는 요소의 변경만 막아 줍니다. 경계 자체를 옮기거나 키우거나, 부모 쪽 알파를 바꾸면 그 안은 통째로 다시 묶입니다
    • 자식 Canvas 를 끄고 켜는 것은 부모 배치까지 다시 만듭니다. 자주 여닫는 창은 CanvasGroup 알파를 0 으로 두고 blocksRaycasts 를 같이 끄는 쪽이 쌉니다
    • 다른 아틀라스에서 온 장식이 묶일 수 있었던 둘 사이에 끼어들고 겹치면 묶음이 갈립니다. 맨 앞이나 맨 뒤로 보내면 됩니다
    • 레이아웃 그룹은 바뀐 자리가 아니라 가장 바깥 그룹부터 다시 계산하고, 그 아래를 네 번 훑습니다
    • Mask 는 그리기가 두 번 늘어납니다. 사각형이면 RectMask2D 가 싸고, 화면 밖 항목은 그리지도 않습니다
    • 겹친 층의 수가 곧 픽셀을 칠하는 횟수입니다. 완전히 가려진 뒤 화면은 꺼 주세요
    • 재실 때는 Canvas.BuildBatch 가 아니라 Canvas.GeometryJob 과 Canvas.SortJob 을 보세요