#composite,  #layer,  #렌더링

Life of Pixel

Life of Pixel

이 글은 Google Chrome 개발자 Steve Kobes의 Life of Pixel영상을 보고 정리한 내용입니다. 사용된 이미지들은 Steve Kobes의 허락 하에 slide show에서 가져왔습니다.


이 글의 목표

  1. 어떤 과정을 통해 HTML파일이 화면에 그려지고, page가 되는지 이해합니다.
  2. composite에 대해 이해합니다.
  3. 왜 layer를 분리하는것이 더 빠른 렌더링을 가능하게 하는지 이해합니다.

간단 요약

sc_-36

  1. HTML content(index.html)를 불러옵니다.
  2. HTML Parser가 index.html을 parsing 하면서 DOM을 생성합니다. (DOM)
  3. style 자원을 만나면 CSS Parser가 parsing 후, 각 DOM 노드에 computedStyle을 적용합니다. (style)
  4. DOM과 computedStyle을 바탕으로 각 노드의 position과 size를 계산합니다. (layout)
  5. layout tree를 바탕으로 layer tree를 생성합니다. (comp.assign)
  6. 노드를 어떻게 그릴지에 대한 작업 순서를 정합니다. (paint)
  7. 작업 순서를 compositor thread (=impl) 에게 전달합니다. (commit)
  8. GPU process에서 paint 단계 때 정한 작업 순서들을 바탕으로 bitmap을 생성합니다. (rasterization)
  9. 이제 bitmap을 OpenGL을 통해 화면에 그립니다.

그림에 나오는 중간에 prepaint, tiling, SKIA는 아래 쪽에서 추가로 설명하겠습니다.

화면에 그려지는 과정

Parsing

sc_-41

HTMLParser는 HTML tag를 읽으면서 DOM Tree를 생성합니다.

조금 더 구체적인 parsing 과정은 다음과 같습니다.

from : https://web.dev/critical-rendering-path-constructing-the-object-model/

DOM

sc_-43-1

DOM은 두 가지를 의미합니다.

  1. HTML Tag를 parsing 해서 만든 C++로 이루어진 트리 형태의 자료 구조 (내부 구현체)
  2. 이 내부 구현체에 Javascript로 접근해서 조작할 수 있도록 만든 API

Style Calculation

sc_-45-1

CSSParser는 CSS코드를 파싱하면서 StyleRule을 생성하고 그것을 StyleSheetContents에 담습니다.

다시 말해서,

.fancy-button {
  background: green;
  border: 3px solid red;
  font-size: 1em;
}

이런 css코드를 파싱해서

sc_-56

이런 StyleRule을 만들어 내는것이라 추측됩니다.

sc_-57

StyleResolver는 StyleRule이 담겨있는 StyleSheetContents를 바탕으로 각 노드(Element)에 대한 ComputedStyle을 구해서 적용합니다.

ComputedStyle은 CSS selector 우선순위까지 다 고려(cascading)해서 최종적으로 노드에 적용될 CSS값들의 모음입니다. dev-tools에서 쉽게 확인 가능합니다.

sc_-58

쉽게 말하자면

CSS코드를 파싱하고, DOM의 각 노드에 대한 ComputedStyle을 구해서 적용합니다.

sc_-59

from : https://developer.chrome.com/blog/inside-browser-part3/#subresource-loading

Layout

이제 DOM을 만들었고 각 노드에 대한 style도 알았으니, 위치와 사이즈 값도 알아낼 수 있습니다.

sc_-61

너무 어려운 Layout 과정

그런데 이 과정은 꽤나 어렵습니다. 고려해야 할 것이 많기 때문입니다.

sc_-62

이렇게 라인이 넘어가는 것도 고려해주어야하고,

sc_-63

font도 고려해주어야 합니다. 이 외에도 overflow나 float속성도 고려해서 위치 값을 계산해야 합니다.

제가 어디서 읽은 바로는 크롬 브라우저 개발자의 대부분이 이 layout계산쪽에서 일을 한다고 합니다. 그 정도로 복잡하고 어려운 일이라고 합니다.

Layout Tree 생성

메인 스레드에서 이 레이아웃을 계산하면서 레이아웃 트리를 생성합니다. 레이아웃 트리의 LayoutObject들은 각각 DOM Tree의 노드와 연결되어 있습니다.

sc_-64

우리가 흔히 아는 reflow가 바로 여기서 발생합니다. 즉, reflow는 layout tree를 순회하면서 각 LayoutObject의 위칫값과 사이즈를 다시 계산하는 것을 의미합니다.

예를 들어서, DOM Element의 width/height/top/right 등을 바꾸면 이 layout tree를 순회하면서 LayoutObject의 위칫값과 사이즈를 다시 계산합니다.

Paint

이제 각 노드의 스타일과 위칫값을 알았으니, 화면에 그리는 일만 남았습니다. 하지만! 이름과 달리 paint 단계는 화면에 그리는 일을 하는 단계가 아닙니다.

paint 단계에서는 화면에 무엇을 어떤 순서로 그려야 할지에 대한 정보를 기록하는 일이 이뤄집니다.

sc_-65

PO즉, Paint Operation은 “[100, 200]에 가로가 200px, 세로가 140px인 사각형을 그려!” 라는 작업 내용입니다. 이것들이 쌓여서 DisplayItem에 들어가고, 이 DisplayItem의 목록은 PaintArtifact에 들어갑니다.

DisplayItem을 조금 더 확대해보면 이렇게 생겼습니다.

sc_-68-1

위 설명에 대한 조금 더 쉬운 그림은 다음과 같습니다.

sc_-66

from : https://developer.chrome.com/blog/inside-browser-part3/#subresource-loading

z-index를 고려하라

sc_-67-1

z-index도 잘 고려돼서 그려질 수 있도록 PO가 구성됩니다.

Raster

이제 어떤 순서로 그려야 할지 알았기 때문에, 화면에 그리는 일만 남았다고 생각할 수 있지만, 아직 조금 더 남았습니다.

위에서 구한 정보들(DisplayItem)을 바탕으로 bitmap을 만드는 일을 rasterization이라고 부릅니다.

sc_-69-2

그리고 이 rasterization은 일반적으로 GPU 안에서 이뤄집니다.

sc_-70-2

현재 사용 중인 크롬 브라우저에서 GPU 가속이 사용되고 있는지 확인하려면 chrome://gpu/ 요기로 들어가면 됩니다.

sc_-71-1

Rasterization에 Hardware accelerated가 되어있는 것을 볼 수 있습니다.

raster to screen

이렇게 rasterization 되고 나서 screen에 pixel로 그려지는 작업도 GPU에서 일어납니다.

sc_-73-3

SKIA

다양한 하드웨어 및 소프트웨어 플랫폼에서 작동하는 공통 API를 제공하는 오픈 소스 2D 그래픽 라이브러리입니다. 구글 크롬, 크롬 OS, 안드로이드, Flutter 등 여러 제품의 그래픽 엔진 역할을 합니다. 구글에서 만들었습니다.

OpenGL

그래픽 카드와 통신할 수 있도록 지원해주는 API 또는 표준 규격입니다. 이 API는 GPU에게 직접적으로 명령을 내리는 command로 변환됩니다.

Skia와 OpenGL의 관계

Skia 라이브러리에서 제공하는 API를 사용하면 OpenGL API로 변환됩니다. 즉, Skia는 조금 더 고수준의 API이죠.

쉽게 설명하자면

Paint이후 GPU에서 rasterization이 이뤄지고 화면에 pixel로 그려집니다.

화면에 그린 이후 변화 발생

sc_-74-4

DOM -> style -> layout -> paint -> raster -> GPU -> 화면에 그리기! 까지 왔습니다. 그런데 사용자가 스크롤을 하거나, 줌인/아웃을 하거나 Javascript로 style을 동적으로 바꾸면 브라우저는 이를 어떻게 처리할까요?

Frame

sc_-75-1

초당 60 frame을 그리지 못하면, 화면이 뚝뚝 끊겨 보이는(janky) 현상이 발생합니다.

Invalidation

렌더링이 빠르게 되도록 하는 여러 방법 중 하나는, 변했는지 변하지 않았는지 체크하고 변한 부분만 업데이트하는 것입니다. 예를 들어서

sc_-77-1

DOM노드의 style에 변화가 가해졌으니 다음 프레임때 computedStyle을 다시 구할 필요가 있기 때문에, 표시(mark)해 놓습니다.(SetNeedsStyleRecalc 호출)

마찬가지로 Layout에 변화가 가해졌다면, 다음 프래임 때 layout을 다시 계산하도록 표시해 놓습니다(SetNeedsLayout).

변화가 없다면 이전 프레임에서 계산된 결과(DOM Node, LayoutObject 등)를 그대로 사용합니다.

이렇게 변화가 가해졌을 때 다음 프레임에 새로 계산하도록 표시하는 것을 invalidation이라고 부릅니다. (slide에는 granular asynchronous invalidations 라고 적혀있습니다)

repaint

하지만 스크롤이나 애니메이션 같은 경우에는 위의 optimization이 큰 효과를 못봅니다. 너무 많이 변하기 때문입니다. 예를 들어서, 스크롤 같은 경우

sc_-79-1

매 스크롤 이벤트마다 repaint와 rasterization이 계속 발생합니다. 이는 비용이 많이 들어갑니다.

jank

scroll으로 인한 repaint - rasterization 외에도 우리가 신경 써야할 것이 있습니다. 바로 Javascript도 main thread에서 실행된다는 것입니다.

sc_-80-2

(scroll로 인한 repaint - rasterization은 어쩔 수 없는 것인데 왜 신경 써야 하나?..라고 생각하실 수 있습니다. 이는 아래 compositor쪽에 이야기가 다시 나옵니다.)

그래서 아무리 rendering pipeline이 빠르게 진행된다고 하더라도 javascript 코드 실행이 너무 오래 걸리면 jank가 발생합니다.

Compositing

그래서 invalidation 같은 최적화 기능도 있지만, scroll로 인한 repaint + rasterization과 Javascript 코드를 실행하는 비용이 많이 들어서 rendering이 늦어지는 문제를 완화하기 위해 compositing이 나왔습니다.

sc_-82-1

먼저 메인 스레드에서 page를 여러 layer로 나누고 compositor thread에서 이를 합성합니다. 이렇게 layer를 나누면 rasterizing이 각 layer에서 독립적으로 발생합니다.

예시

comisiting-layer-1

BBB layer를 rasterizing해서 만든 bitmap만 transform하면, 매 animation frame마다 전체 페이지를 rasterizing 하지 않아도 됩니다.

compositing-layer-3

그리고 부모가 layer라면 자식들도 그 layer의 subtree가 됩니다.

compositing-layer-4

이렇게 layer를 분리해서 rasterizing 후 생성된 bitmap만 변형하게 된다면, 매 animation frame 마다 전체 페이지를 rasterizing 하지 않아도 되기 때문에 효율적입니다.

threaded input

sc_-86

main thread가 바쁠 때 compositor thread는 browser process로부터 사용자의 스크롤 입력을 받아 bitmap을 transform 합니다.

물론 사용자가 특정 레이어가 아닌 전체 페이지를 스크롤링하면 compositor thread에서 처리하지 않고 main thread로 일을 넘깁니다. 왜냐하면 전체 페이지를 다시 그리는 render pipeline을 거쳐야 하기 때문입니다.

추가로 Javascript에서 scroll event listener를 걸어놓은 경우에는 사용자 입력을 main thread에서 처리하도록 task queue에 넣습니다.

Layer는 어떻게 만들어지는가?

sc_-87-1

Layer는 transform같은 CSS property를 바탕으로 layout object에서 생성됩니다.

sc_-88-1

main thread에서 DOM -> style -> layout 이후에 layer가 만들어지고, 이 단계를 compositing assignments라고 부릅니다.

그리고 paint 단계에서 각 레이어는 자신만의 DisplayItemList를 가지게 됩니다. 즉 “무엇을 어떤 순서로 그릴 것인지”에 대한 정보인 DisplayItemList가 레이어마다 따로따로 설정된다는 의미입니다.

pre-paint

paint전에 pre-paint 단계가 있습니다.

sc_-90

이 단계에서 property tree를 생성합니다. 참고로 property tree는 아래처럼 생겼습니다.

sc_-89

Property tree에 대하여 (Naver D2 글)

“레이아웃 트리와 다음에 설명할 페인트 트리 사이에 한 가지 작업이 더 있다. 레이아웃 트리를 순회하면서 속성 트리(property tree)를 만드는 작업이다. 속성 트리는 clip, transform, opacity 등의 속성 정보만 가진 트리이다. 기존에는 이런 정보를 분리하지 않고 노드마다 가지고 있었다. 그래서 특정 노드의 속성이 변경되면 해당 노드의 하위 노드에도 이 값을 다시 반영하면서 노드를 순회해야 했다. 최신 Chrome에서는 이런 속성만 별도로 관리하고 각 노드에서는 속성 트리의 노드를 참조하는 방식으로 변경되고 있다.”

Commit

paint가 완료되면, 이제 이렇게 만든 레이어들을 하나의 프레임으로 만들기 위해서 레이어들과 property tree를 compositor thread에게 넘겨줘야 합니다. 이 단계를 commit이라 부릅니다.

sc_-91

tiling

paint 이후에 layer의 paint operation을 bitmap으로 만드는 작업인 rasterization을 합니다. 그런데 layer가 너무 큰 경우는 어떨까요?

사용자에게 보이는 view port보다 엄~청 큰 layer의 경우, 이 layer를 rasterizing 하는 것은 너무 비용이 큽니다. 그래서 compositor thread에서 이 layer는 작은 tile 들로 나눠집니다. 이 tile 들은 render process 안에 있는 여러 raster thread에서 비동기적으로 rasterzied 됩니다.

그런데 아래쪽에서는 rasterization은 Skia를 통해 GPU에서 일어난다고 하는데, 아마도 render process의 raster thread가 GPU Process의 SKIA를 사용해서 rasterization 한다는 의미 같습니다.

sc_-93

Layer 그리기

layer의 모든 tile 들이 rasterizing되면 compositor thread는 각 tile에 대한 DrawQuad를 생성합니다. DrawQuad는 tile을 rasterizing 한 bitmap을 참조하고 있고, tile을 스크린의 어느 위치에 그려야 하는지에 대한 instruction를 가지고 있습니다. 이때 이 위칫값은 property tree를 고려해서 계산됩니다.

sc_-94

이렇게 만든 DrawQuad를 묶어서 CompositorFrame 객체에 넣습니다. 그리고 이 CompositorFrame은 GPU Process에게 넘겨집니다.

지금까지 우리는 renderer process 안에서 main thread + compositor thread + raster thread를 활용해 DOM -> style -> layout -> layer -> pre-paint -> paint -> commit -> tiling 과정을 통해 최종적으로 CompositorFrame을 만들었습니다.

남은 일은 이 CompositorFrame(=animation frame)을 화면에 그리기만 하면 됩니다.

Display(viz)

GPU Process는 CompositorFrame을 받아서 SKIA API를 사용해 OpenGL(혹은 Vulkan) API를 부르고, OpenGL은 그래픽카드를 사용해 화면에 tile을 그립니다.

sc_-96-1

정리

sc_-98

  1. 브라우저는 web content를 받습니다.
  2. DOM Tree를 만듭니다.
  3. style을 계산합니다(resolve styles).
  4. layout을 계산합니다.
  5. layer를 만듭니다.
  6. property tree를 만듭니다.
  7. layer를 paint합니다.
  8. layer + DisplayItemList(paint operations) + property tree를 compositor thread로 commit(복사/붙여넣기)합니다.
  9. layer를 여러 작은 조각(tile)로 나눕니다.
  10. SKIA library를 사용해 tile을 rasterizing 합니다.
  11. DrawQuads를 생성합니다.
  12. Skia와 OpenGL를 통해 DrawQuads를 실제 스크린에 그립니다. (pixel화)

질문

external CSS도 HTML parser를 block 하나요?

sc_-48

from : https://web.dev/preload-scanner

네, blocking 합니다. 관련글

In this case, the parser runs into a <link> element for an external CSS file, which blocks the browser from parsing the rest of the document—or even rendering any of it—until the CSS is downloaded and parsed.

inline style 도 parser를 block 하나요?

네 block합니다! 관련 글

Similar to inlining code in a <script> tag, inline critical styles required for the first paint inside a <style> block at the head of the HTML page

style자원을 가져오고 parsing할때까지 왜 HTML Parser는 멈춰있나요?

flash of unstyled content (FOUC) 문제 때문에 그렇습니다.

sc_-49

from : https://web.dev/preload-scanner

그러니까, style.css파일을 다운받고 있는데, HTML Parser가 다 파싱하고 rendering까지 끝나면 스타일이 적용되지 않은 사이트가 사용자에게 보일것이고, 후에 style.css파일 다운로드가 끝나고 파싱하고 적용하면 그때 스타일이 적용된 사이트가 보일것입니다.

이는 사용자에게 번쩍! 하는 느낌을 주기 때문에 별로 좋지 않습니다. 그래서 style.css파일을 다운받고 파싱이 끝날때까지 HTML Parser는 기다립니다.

참고