[Flutter] 이 코드.. 어떻게 픽셀로 옮겨질까? 렌더링 원리 - 3. 페인팅

Broccolism·2022년 1월 3일
5

플러터의 렌더링은 크게 3단계로 나뉜다. 레이아웃(Layout)-페인트(Paint)-컴포지션(Composition) 순서대로 렌더링이 이루어지는데, 이전 글에서는 레이아웃 단계를 다뤘다. 이번에는 페인팅 단계를 중점적으로 살펴보자.

공식 문서에 글로 적힌 내용을 찾지 못해서 관련 영상을 통해 정리한 것이기 때문에 틀린 부분이 있을 수 있습니다. 이번 글 내용의 출처는 Adam Barth, May 5, 2016, "Flutter's Rendering Pipline" [Youtube video], Google 이며 오류를 있다면 댓글/메일 어디로든 알려주시면 감사하겠습니다!

1. 레이아웃 vs. 페인팅

레이아웃 단계에서는 각 위젯이 어떤 위치에, 어떤 크기로 그려질지 정한다. 이 말만 놓고 보면 정해야할건 다 정해진 것 같지만, 우리가 원하는 UI를 화면에 그려주기 위해서는 몇가지 할 일이 더 남아있다. 페인팅 단계에서 어떤 일을 해야 하는지 알아보기 위해 레이아웃 단계와 페인팅 단계를 비교해보자.

차이점

레이아웃 단계페인팅 단계
결정하는 것크기와 위치시각적 모양(visual appearance)
children을 다루는 순서Inflexible -> Flexible트리에 들어가 있는 순서대로

"시각적 모양"을 결정한다니, 크기와 위치 말고도 다른게 남았단건가? 그렇다. 아래에서 살펴볼 '레이어'를 정해주는게 페인팅 단계의 역할이다.

레이아웃 단계에서는 한 위젯 안에 children이 여러개 있을 때, inflexible 위젯 -> flexible 위젯 순서대로 처리했다. 반면, 페인팅 단계에서는 그냥 트리에 들어가있는 순서대로 다룬다. 이렇게 children을 다루는 순서의 차이 때문에 layout 단계와 paint 단계를 별도의 과정으로 나눈 것이라고 한다. 트리를 한번 훑으면서 layout과 paint를 둘 다 하는 시스템과 다른 점이다. 이런 시스템은 이미 방문했던 child를 다시 방문해야 할 때가 있기 때문에 O(N^2)의 시간이 걸릴 수 있는 반면 플러터는 최대 O(N)의 시간을 보장한다.

공통점

레이아웃과 페인팅 단계의 공통점도 있다. 바로 두 단계 모두 한번에 한 방향으로만 정보가 전달되고, 그 덕분에 boundary가 생긴다는 사실이다. Boundary에 대한 내용은 다음 포스팅에서 다룰 예정이다.

2. 페인팅 단계의 주요 이슈: 레이어

우리가 보는 휴대폰 화면은 2D이지만 플러터가 보는 화면은 그렇지 않다. 예를 들어, 아래 화면처럼 자동완성 검색바와 검색 결과, 키보드가 보여지는 화면을 생각해보자.

예시 이미지

검색어를 입력하면 자동으로 자동완성 목록이 생긴다. 이 때 그 목록은 검색 결과 창 위에 쌓여서 검색 결과의 일부를 가린다. 자연스러운 UI다. 여기서 만약 스크롤을 해서 화면 위쪽으로 이동한다면, 키보드가 자동완성 목록 위에 있기 때문에 그 목록의 일부가 가려질 것이다.

이처럼 우리가 접하는 앱 화면에는 깊이가 존재한다. 그 깊이를 표현하기 위해 플러터는 레이어를 생성한다. 어떤 위젯이 어떤 레이어에 존재해야하는지를 결정하는 단계가 바로 페인팅 단계다.

So the tricky thing in painting is basically figuring out in which layer the painting command should go. - Adam Barth, Flutter's Rendering Pipeline[YouTube]

3. 레이어 트리(layer tree)

렌더링을 위해 플러터가 만들어내는 마지막 트리다. 레이어 트리에는 어떤 위젯이 어떤 레이어에 있어야하는지를 기록한다. GPU가 화면을 그릴 때 이 트리에서 나온 정보를 사용한다. 그러면 어떻게 레이어 트리를 만들고 활용할까?

예시 이미지

앞에서 인용한 플러터의 렌더링 방식 소개 영상에 나온 예제를 가져왔다. 렌더링하려는 화면이 왼쪽의 초록-노랑-빨강 레이어 순서대로 쌓아서 만들어진 화면이라고 해보자. 영상에서 소개한 예시는 '동영상 플레이어'였다. 영상 플레이어의 UI를 떠올려보자. 재생/정지 버튼과 시간, 볼륨 조절 아이콘 등이 영상 위에 깔린다. 영상을 모두 재생한 다음에는 영상 아래쪽에 있던 까만색 화면이 보이는게 일반적이다. 즉, 예시에서 노란색 레이어에 동영상이 들어간다고 보면 된다.

오른쪽 트리는 이런 구성요소들도 만든 트리다. 트리에 칠해진 색상은 각각 초록, 노랑, 빨강 레이어를 의미한다. 루트 노드는 초록색 레이어에 그려진다. 노드별로 매겨진 번호는 위젯을 렌더링하는 순서다.

렌더링 시 트리를 1번 DFS로 순회하면서 화면을 그린다. 가장 아래에 있는 초록색 레이어를 먼저 그려야 하기 때문에 루트부터 왼쪽 children 노드로 가면서 1, 2, 3번이 매겨졌다. child 노드를 방문할 때 각 child에게 offset 값을 넘겨주면서 어디에 렌더링되어야 하는지를 알려준다.

데이터의 흐름

이제 리프 노드에 도착했으니 parent 노드로 return하기 시작할 차례다. 이 때 parent에게 넘겨주는 정보는 '다음에 나오는 위젯이 어느 레이어에 그려져야 하는지'에 대한 것이다. 앞에서 나왔던 동영상 플레이어 예시를 떠올려보자. 영상 밑에 깔릴 검은색 배경을 가장 먼저 그린 다음, 새 레이어로 이동할 차례가 올 것이다. 그 때가 바로 parent 노드로 리턴하면서 "나 다음에 오는 위젯은 나보다 위에 그려줘!"라고 요청할 때다. 위의 그림상으로는 3번 노드에서 리턴하면서 "이제 나 다음에 오는 위젯은 노란색 레이어에 그려줘!" 라고 요청하는게 되고, 4번 노드를 노란색 레이어에 그려야한다고 표시할 것이다.

이처럼 트리의 아래쪽으로 내려갈 때에는 offset, 위로 올라갈 때는 layer에 대한 정보를 넘겨준다. 이런식으로 정보가 이동하는 방식은 앞에서도 나왔던 방식이다. 바로 페인팅 바로 앞 단계인 레이아웃 단계에서 똑같은 방식으로 정보를 전달했다. 앞에서 말했던 레이아웃 단계와 페인팅 단계의 공통점, '단방향 정보 전달'이 바로 이 방식을 말한 것이었다.

...쟤는 왜 색깔이 2개지?

여기까지 읽으면서 왠지 마음 한구석이 불편한 사람이 있을 수 있다. 두번째로 방문하게 되는 노드는 2번과 5번이 같이 있고, 레이어를 의미하는 색상도 2개가 섞여있기 때문이다! 영상에서 구체적인 예시를 들어주지는 않았지만 이렇게 하나의 구성 요소가 서로 다른 레이어에 그려져야하는 경우도 있다고 한다. "노란색 부분이 미친 영향 때문에 빨간색 부분이 완전히 다른 부분에 그려지게 되는" 경우라고 언급했다.

이럴 때에는 리페인트 바운더리(repaint boundary)가 생긴다. 내용이 길어질 수 있기 때문에 자세한 내용은 다음 포스팅에서 다룰 예정이다. 간단히 요약하자면 리페인트 바운더리를 만들어서 아래와 같은 트리로 바꿔서 사용한다. 그러니까 원래는 초록-노랑-빨강 레이어를 그리기 위해서는 첫번째 트리만 있으면 되는데, 두번째 트리처럼 노란색 레이어에 있는 무언가로 인해 하나의 노드가 서로 다른 레이어에 그려져야 할 때면 보라색, 파란색이 있는 마지막 트리와 같이 새로운 구조를 만들어낸다.

요약

왜 만들었지?

라는 의문이 들수도 있다. 화면을 여러 레이어로 나눈 이유는 페인팅 다음 과정을 위해서다. 플러터의 렌더링 프레임워크를 보면 페인팅 다음 단계로 Composition 단계가 있는걸 볼 수 있다. 이 composite 단계를 빠르게 하기 위해 레이어 트리를 만든 것이다.

가장 대표적인 예시로는 스크롤링 (scrolling)이 있다. 플러터 입장에서 스크롤링은 꽤나 무거운 연산이 필요한 작업이다. 정말 naive하게 구현한다고 하면, 매 프레임마다 각 픽셀 하나하나의 색상을 바꾸는 방식이 있을 것이다. 이걸 실제로 썼다가는....

효과적으로 스크롤링을 구현하기 위해, 스크롤 되는 각 항목을 서로 다른 레이어에 집어넣는 방식을 사용한다. 예를 들어 ScrollView에게 주어진 children이 총 10개라면 레이어가 최대 10개 만들어진다. 이 때 '최대'라고 표현한 이유는 각 레이어를 만드는 타이밍 때문이다. 스크롤뷰가 있더라도 유저가 스크롤하지 않았다면 처음에 보여지는 아이템 몇개만 렌더링하면 된다.

따라서 플러터는 각 아이템이 화면에 보여져야하는 그 순간 해당 아이템을 위한 새 레이어를 만든다. 그리고 아이템을 집어넣는다. 만약 이미 레이어를 만든 아이템이라면 더이상 할 일이 없다. 그냥 위치를 조금 수정해주기만 하면 된다. (이렇게 얘기하는걸 보면, 새 레이어를 만드는 작업보다 이미 만들어진 레이어를 수정하는게 훨씬 가벼운 일이라는걸 추측해볼 수 있다.)

4. Composite

페인팅 단계에서 각 위젯이 어떤 레이어에 갈지 정한 다음에는, 컴퓨터가 알아들을 수 있도록 위젯에 대한 정보의 형태를 바꾼다. 영상에서 잠깐 언급한 내용을 종합해보면 각 레이어별 정보를 벡터로 저장하는 것 같다. 픽셀 단위로는 변환하지 않는다고 한다. 생각해보면 납득이 가는 얘기다. 한 픽셀별 정보를 들고있는 것보다 벡터화 된 정보를 갖고 있는게 훨씬 가벼울 것이다.

영상에서는 "레이어에 대한 정보"를 "painting commands to execute" 라고 표현한다. 사실 이 painting command가 정확히 무엇을 의미하는지 찾지 못해서 포스팅이 좀 미뤄진 것도 있다. (추측하기로는 GPU의 인풋으로 들어가는 명령어가 아닐까하는 생각이 든다.) 이외에도 픽셀과 벡터화 등 그래픽스 관련 깊은 내용이 군데군데 나와서 composition 이후의 과정은 정확하게 이해하지는 못했다.

Compositing은 기본적으로 "texturized" 벡터를 화면에다가 순서대로 뿌려주는 것이라고 한다. 그래픽스 관련 지식이 없다보니 어떤 뜻인지 잘 모르겠지만 플러터 엔진에서 이 texturize 관련 기술은 여전히 발전의 여지가 있는 부분이다. 지금은 휴리스틱하게 언제 texturize하고 언제 하지 말아야할지를 구분해내고 있다고 한다.

References

profile
설계를 좋아합니다. 코드도 적고 그림도 그리고 글도 씁니다. 넓고 얕은 경험을 쌓고 있습니다.

0개의 댓글