WebRender가 렌더링 방식을 어떻게 바꿔 웹을 더 부드럽고 일관되게 그리는지, GPU와 게임 엔진 접근법을 중심으로 설명합니다.
Firefox Quantum 출시가 가까워지고 있습니다. 이번 출시에는 Servo에서 가져온 초고속 CSS 엔진을 포함해 많은 성능 개선이 들어갑니다.
하지만 Servo 기술의 또 다른 큰 조각이 있습니다. 아직 Firefox Quantum에는 완전히 들어가 있지 않지만 곧 추가될 예정입니다. 바로 Quantum Render 프로젝트의 일부로 Firefox에 추가되고 있는 WebRender입니다.
WebRender는 매우 빠르기로 알려져 있습니다. 하지만 WebRender의 핵심은 사실 렌더링을 더 빠르게 만드는 데만 있지 않습니다. 더 부드럽게 만드는 데 있습니다.
WebRender를 통해 우리는 디스플레이가 얼마나 크든, 프레임마다 페이지에서 바뀌는 부분이 얼마나 많든, 앱이 매끄럽게 초당 60프레임(FPS) 이상으로 동작하길 원합니다. 그리고 실제로 그렇게 됩니다. Chrome이나 현재의 Firefox에서 15 FPS 정도로 버벅이는 페이지도 WebRender에서는 60 FPS로 동작합니다.
그렇다면 WebRender는 어떻게 그렇게 할까요? 렌더링 엔진이 동작하는 방식을 근본적으로 바꿔서 3D 게임 엔진에 더 가깝게 만듭니다.
이게 무엇을 뜻하는지 살펴봅시다. 하지만 먼저…
Stylo에 관한 글에서 저는 브라우저가 HTML과 CSS에서 화면의 픽셀까지 어떻게 가는지, 그리고 대부분의 브라우저가 이를 다섯 단계로 처리하는 방식을 설명했습니다.
이 다섯 단계를 두 부분으로 나눌 수 있습니다. 첫 번째 절반은 기본적으로 계획을 세웁니다. 이 계획을 만들기 위해 브라우저는 HTML과 CSS를 뷰포트 크기 같은 정보와 결합해서 각 요소가 정확히 어떻게 보여야 하는지—너비, 높이, 색상 등—를 계산합니다. 그 최종 결과가 frame tree 또는 render tree라고 불리는 것입니다.
두 번째 절반인 페인팅과 합성은 렌더러가 하는 일입니다. 렌더러는 그 계획을 받아 화면에 표시할 픽셀로 바꿉니다.
하지만 브라우저는 웹 페이지마다 이 작업을 한 번만 하면 되는 것이 아닙니다. 같은 웹 페이지에 대해 이 작업을 계속 반복해야 합니다. 이 페이지에서 무언가가 바뀔 때마다—예를 들어 div가 펼쳐질 때—브라우저는 이 단계들 중 많은 부분을 다시 거쳐야 합니다.
페이지에서 실제로 거의 아무것도 바뀌지 않는 경우에도—예를 들어 스크롤을 하거나 페이지의 텍스트를 드래그해 강조 표시할 때—브라우저는 화면에 새로운 픽셀을 그리기 위해 적어도 두 번째 부분의 일부는 다시 수행해야 합니다.
스크롤이나 애니메이션 같은 것이 부드럽게 보이려면 초당 60프레임으로 진행되어야 합니다.
아마도 초당 프레임 수(FPS)라는 말을 들어본 적은 있지만 정확히 무엇을 의미하는지는 확신하지 못했을 수도 있습니다. 저는 이것을 플립북에 비유해 생각합니다. 각각은 정지된 그림이지만 엄지로 빠르게 넘기면 페이지들이 움직이는 것처럼 보이는 그림책과 같습니다.
이 플립북의 애니메이션이 부드럽게 보이려면 애니메이션 1초마다 60페이지가 있어야 합니다.
이 플립북의 페이지는 모눈종이로 되어 있습니다. 아주아주 많은 작은 칸이 있고, 각 칸에는 오직 하나의 색만 들어갈 수 있습니다.
렌더러의 일은 이 모눈종이의 칸을 채우는 것입니다. 모눈종이의 모든 칸이 채워지면 한 프레임의 렌더링이 끝난 것입니다.
물론 실제로 컴퓨터 안에 모눈종이가 있는 것은 아닙니다. 대신 컴퓨터에는 프레임 버퍼라고 불리는 메모리 영역이 있습니다. 프레임 버퍼의 각 메모리 주소는 모눈종이의 한 칸과 같습니다… 화면의 한 픽셀에 대응합니다. 브라우저는 각 슬롯을 RGBA(red, green, blue, alpha) 값으로 표현되는 색상 숫자로 채웁니다.
디스플레이가 스스로를 새로 고쳐야 할 때, 이 메모리 영역을 확인합니다.
대부분의 컴퓨터 디스플레이는 초당 60번 새로 고침합니다. 이것이 브라우저가 페이지를 초당 60프레임으로 렌더링하려고 하는 이유입니다. 즉 브라우저는 16.67밀리초 안에 모든 준비 작업—CSS 스타일 계산, 레이아웃, 페인팅—을 마치고 프레임 버퍼의 모든 슬롯을 픽셀 색상으로 채워야 합니다. 두 프레임 사이의 이 시간 구간(16.67 ms)을 frame budget이라고 부릅니다.
가끔 사람들은 dropped frame에 대해 이야기합니다. dropped frame은 시스템이 frame budget 안에 작업을 끝내지 못했을 때를 뜻합니다. 디스플레이는 브라우저가 프레임 버퍼를 다 채우기 전에 새 프레임을 가져오려 합니다. 이 경우 디스플레이는 이전 버전의 프레임을 다시 보여줍니다.
dropped frame은 플립북에서 한 페이지를 찢어낸 것과 비슷합니다. 이전 페이지와 다음 페이지 사이의 전환이 빠져 있기 때문에 애니메이션이 끊기거나 튀는 것처럼 보이게 됩니다.
그래서 우리는 디스플레이가 다시 확인하기 전에 이 모든 픽셀을 프레임 버퍼에 넣고 싶습니다. 브라우저가 역사적으로 이것을 어떻게 해왔는지, 그리고 시간이 지나며 그것이 어떻게 바뀌었는지 살펴봅시다. 그러면 이것을 어떻게 더 빠르게 만들 수 있는지도 볼 수 있습니다.
참고: 페인팅과 합성은 브라우저 렌더링 엔진들이 서로 가장 크게 다른 부분입니다. 단일 플랫폼 브라우저(Edge와 Safari)는 다중 플랫폼 브라우저(Firefox와 Chrome)와 조금 다르게 동작합니다.
가장 초기의 브라우저들에서도 페이지를 더 빨리 렌더링하기 위한 최적화가 일부 있었습니다. 예를 들어 콘텐츠를 스크롤할 때 브라우저는 여전히 보이는 부분을 유지한 채로 옮기고, 빈 공간에만 새로운 픽셀을 그렸습니다.
이렇게 무엇이 바뀌었는지 알아낸 다음 바뀐 요소나 픽셀만 업데이트하는 과정을 invalidation이라고 합니다.
시간이 지나면서 브라우저는 rectangle invalidation 같은 더 많은 invalidation 기법을 적용하기 시작했습니다. rectangle invalidation에서는 화면에서 바뀐 각 부분을 둘러싸는 가장 작은 사각형을 찾습니다. 그런 다음 그 사각형 안쪽만 다시 그립니다.
이것은 페이지에서 바뀌는 것이 많지 않을 때 해야 할 일을 크게 줄여줍니다… 예를 들어 깜빡이는 커서 하나만 있을 때 말입니다.
하지만 페이지의 큰 부분이 바뀔 때는 큰 도움이 되지 않습니다. 그래서 브라우저는 그런 경우를 처리하기 위한 새로운 기법을 내놓았습니다.
레이어를 사용하면 페이지의 큰 부분이 바뀔 때 많은 도움이 될 수 있습니다… 적어도 특정한 경우에는 그렇습니다.
브라우저의 레이어는 Photoshop의 레이어, 혹은 손그림 애니메이션에서 사용되던 오니언 스킨 레이어와 매우 비슷합니다. 기본적으로 페이지의 서로 다른 요소를 서로 다른 레이어에 그린 다음, 그 레이어들을 서로 위에 쌓아 올립니다.
레이어는 오랫동안 브라우저의 일부였지만, 항상 속도를 높이기 위해 사용된 것은 아닙니다. 처음에는 페이지가 올바르게 렌더링되도록 하기 위해 사용되었습니다. 레이어는 stacking contexts라고 불리는 것에 대응했습니다.
예를 들어 반투명 요소가 있다면 그것은 자체 stacking context 안에 있게 됩니다. 즉 그 요소는 아래 색과 자신의 색을 혼합할 수 있도록 자체 레이어를 갖게 됩니다. 이 레이어들은 프레임이 끝나자마자 버려졌습니다. 다음 프레임에서는 모든 레이어를 다시 페인트했습니다.
하지만 이런 레이어 위의 것들은 프레임마다 자주 바뀌지 않았습니다. 예를 들어 전통적인 애니메이션을 생각해 보세요. 전경의 캐릭터가 바뀌더라도 배경은 바뀌지 않습니다. 그 배경 레이어를 그대로 유지하고 재사용하는 편이 훨씬 효율적입니다.
그래서 브라우저가 그렇게 하게 되었습니다. 레이어를 유지하기 시작한 것입니다. 그러면 브라우저는 바뀐 레이어만 다시 페인트하면 됩니다. 어떤 경우에는 레이어 자체도 바뀌지 않았습니다. 단지 위치만 다시 조정하면 되었죠—예를 들어 애니메이션이 화면을 가로질러 움직이거나 무언가가 스크롤될 때처럼요.
이렇게 레이어를 함께 배치하는 과정을 합성(compositing)이라고 합니다. 컴포지터는 다음에서 시작합니다:
먼저 컴포지터는 배경을 목적지 비트맵으로 복사합니다.
그다음 스크롤 가능한 콘텐츠 중 어떤 부분이 보여야 하는지 계산합니다. 그 부분을 목적지 비트맵으로 복사합니다.
이로 인해 메인 스레드가 해야 하는 페인팅 양은 줄어들었습니다. 하지만 메인 스레드는 여전히 합성에 많은 시간을 쓰고 있었습니다. 그리고 메인 스레드에서는 시간을 두고 경쟁하는 일이 아주 많습니다.
이전에도 말했지만 메인 스레드는 일종의 풀스택 개발자와 같습니다. DOM, 레이아웃, JavaScript를 담당합니다. 그리고 페인팅과 합성도 담당했습니다.
메인 스레드가 페인트와 합성에 쓰는 매 밀리초는 JavaScript나 레이아웃에 쓸 수 없는 시간입니다.
하지만 할 일이 많지 않은 채 남아 있던 또 다른 하드웨어가 있었습니다. 그리고 이 하드웨어는 그래픽을 위해 특별히 만들어진 것이었습니다. 바로 GPU였습니다. 게임은 90년대 후반부터 프레임을 빠르게 렌더링하기 위해 GPU를 사용해 왔습니다. 그리고 GPU는 그 이후로 계속 더 크고 더 강력해졌습니다.
그래서 브라우저 개발자들은 작업을 GPU로 옮기기 시작했습니다.
잠재적으로 GPU로 옮길 수 있는 작업은 두 가지였습니다.
페인팅을 GPU로 옮기는 것은 어려울 수 있습니다. 그래서 대체로 다중 플랫폼 브라우저는 페인팅을 CPU에 남겨 두었습니다.
하지만 합성은 GPU가 매우 빠르게 할 수 있는 작업이었고, GPU로 옮기기도 쉬웠습니다.
어떤 브라우저들은 이 병렬성을 더 밀어붙여 CPU에 compositor thread를 추가했습니다. 이것은 GPU에서 일어나는 합성 작업을 관리하는 매니저 역할을 했습니다. 이는 메인 스레드가 무언가를 하고 있을 때(예를 들어 JavaScript를 실행할 때)에도 compositor thread가 스크롤처럼 사용자를 위한 일을 계속 처리할 수 있음을 의미했습니다.
이렇게 하면 모든 합성 작업이 메인 스레드에서 벗어납니다. 하지만 메인 스레드에는 여전히 많은 작업이 남아 있습니다. 레이어를 다시 페인트해야 할 때마다 메인 스레드가 그 일을 하고, 그 레이어를 GPU로 전송해야 합니다.
어떤 브라우저들은 페인팅도 다른 스레드로 옮겼습니다(그리고 오늘날 Firefox도 그 작업을 진행 중입니다). 하지만 이 마지막 작은 작업—페인팅—까지 GPU로 옮기면 더 빨라집니다.
그래서 브라우저는 페인팅도 GPU로 옮기기 시작했습니다.
브라우저는 아직도 이 전환을 진행 중입니다. 어떤 브라우저는 항상 GPU에서 페인트하고, 어떤 브라우저는 특정 플랫폼에서만 그렇게 합니다(예를 들어 Windows에서만, 혹은 모바일 기기에서만).
GPU에서 페인팅하면 몇 가지 이점이 있습니다. CPU가 JavaScript나 레이아웃 같은 일에 온전히 시간을 쓸 수 있게 됩니다. 게다가 GPU는 CPU보다 픽셀을 그리는 데 훨씬 빠르므로 페인팅 자체도 빨라집니다. 또 CPU에서 GPU로 복사해야 하는 데이터 양도 줄어듭니다.
하지만 페인트와 합성을 나누는 이 구분을 유지하는 데에는 둘 다 GPU에 있더라도 여전히 비용이 듭니다. 이 구분은 GPU가 일을 더 빠르게 하도록 만들 수 있는 최적화의 종류도 제한합니다.
바로 여기서 WebRender가 등장합니다. WebRender는 우리가 렌더링하는 방식을 근본적으로 바꾸어 페인트와 합성의 구분을 없앱니다. 이를 통해 오늘날의 웹에서 최고의 사용자 경험을 제공하도록 렌더러 성능을 조정할 수 있고, 앞으로의 웹에서 보게 될 사용 사례도 더 잘 지원할 수 있습니다.
즉 우리는 단지 프레임이 더 빨리 렌더링되기를 원하는 것이 아닙니다… 더 일관되게, 그리고 버벅임 없이 렌더링되기를 원합니다. 그리고 4k 디스플레이나 WebVR 헤드셋처럼 그려야 할 픽셀이 매우 많더라도 여전히 똑같이 부드러운 경험을 원합니다.
위의 최적화들은 특정한 경우 페이지를 더 빠르게 렌더링하는 데 도움이 되었습니다. 페이지에서 바뀌는 것이 많지 않을 때—예를 들어 깜빡이는 커서 하나만 있을 때—브라우저는 가능한 최소한의 작업만 수행합니다.
페이지를 레이어로 나누면서 이런 최선의 경우 시나리오의 수는 더 많아졌습니다. 몇 개의 레이어만 페인트한 뒤 서로 상대적으로 움직이기만 하면 된다면, 페인팅+합성 아키텍처는 잘 동작합니다.
하지만 레이어 사용에는 대가도 있습니다. 레이어는 메모리를 많이 차지하며 실제로는 더 느리게 만들 수도 있습니다. 브라우저는 의미가 있을 때 레이어를 합쳐야 합니다… 하지만 어디에서 그게 의미가 있는지를 판단하기가 어렵습니다.
즉 페이지에서 움직이는 것이 아주 많으면 레이어가 너무 많아질 수 있습니다. 이 레이어들은 메모리를 가득 채우고 컴포지터로 전송하는 데 너무 오래 걸립니다.
다른 경우에는 여러 레이어가 있어야 할 상황에서 하나의 레이어만 생길 수 있습니다. 그러면 그 단일 레이어는 계속 다시 페인트되어 컴포지터로 전송되고, 컴포지터는 아무것도 바꾸지 않은 채 그것을 합성합니다.
이는 얻는 이점 없이 그려야 하는 양을 두 배로 만들고, 각 픽셀을 두 번 건드리게 된다는 뜻입니다. 이런 경우에는 합성 단계를 거치지 않고 페이지를 직접 렌더링하는 편이 더 빨랐을 것입니다.
그리고 레이어가 큰 도움이 되지 않는 경우도 많습니다. 예를 들어 배경색을 애니메이션하면 어차피 레이어 전체를 다시 페인트해야 합니다. 이런 레이어는 소수의 CSS 속성에서만 도움이 됩니다.
프레임의 대부분이 최선의 경우 시나리오에 속해서 frame budget의 아주 작은 부분만 사용한다 하더라도, 여전히 움직임이 끊길 수 있습니다. 체감 가능한 버벅임이 생기려면 몇 개 프레임만 최악의 경우 시나리오에 들어가도 충분합니다.
이런 시나리오를 performance cliffs라고 부릅니다. 앱은 이런 최악의 경우 시나리오 하나(예를 들어 배경색 애니메이션)를 만날 때까지는 멀쩡히 움직이는 것처럼 보이다가, 갑자기 프레임 속도가 절벽 아래로 떨어집니다.
하지만 우리는 이런 성능 절벽을 없앨 수 있습니다.
어떻게 할까요? 3D 게임 엔진의 방식을 따르는 것입니다.
필요한 레이어가 무엇인지 추측하려는 시도를 멈춘다면 어떨까요? 페인팅과 합성 사이의 경계를 없애고, 매 프레임 모든 픽셀을 다시 페인트하는 방식으로 돌아간다면 어떨까요?
이것은 말도 안 되는 생각처럼 들릴 수 있지만, 사실 선례가 있습니다. 현대의 비디오 게임은 모든 픽셀을 다시 페인트하면서도 브라우저보다 더 안정적으로 초당 60프레임을 유지합니다. 그리고 예상과는 다른 방식으로 그렇게 합니다… invalidation rectangle과 레이어를 만들어 그려야 할 양을 줄이는 대신, 그냥 화면 전체를 다시 페인트합니다.
그런 방식으로 웹 페이지를 렌더링하면 훨씬 느리지 않을까요?
CPU에서 페인트한다면 그렇습니다. 하지만 GPU는 이 방식이 가능하도록 설계되어 있습니다.
GPU는 극단적인 병렬성을 위해 만들어졌습니다. 저는 Stylo에 관한 지난 글에서 병렬성에 대해 이야기했습니다. 병렬성이 있으면 기계는 여러 일을 동시에 할 수 있습니다. 한 번에 할 수 있는 일의 수는 가진 코어 수로 제한됩니다.
CPU는 보통 2개에서 8개의 코어를 가집니다. GPU는 보통 적어도 수백 개의 코어를 갖고, 흔히 1,000개가 넘습니다.
하지만 이 코어들은 조금 다르게 동작합니다. CPU 코어처럼 완전히 독립적으로 행동할 수는 없습니다. 대신 보통 하나의 일을 함께 하며, 같은 명령을 서로 다른 데이터 조각에 실행합니다.
이것은 픽셀을 채울 때 정확히 필요한 방식입니다. 각 픽셀은 서로 다른 코어가 채울 수 있습니다. GPU는 한 번에 수백 개의 픽셀을 처리할 수 있기 때문에 CPU보다 픽셀을 훨씬 빠르게 채웁니다… 하지만 그러려면 그 모든 코어가 할 일을 갖고 있도록 해야 합니다.
코어들이 동시에 같은 일을 해야 하기 때문에, GPU는 거쳐 가는 단계 집합이 꽤 엄격하고 API도 꽤 제한적입니다. 이것이 어떻게 동작하는지 살펴봅시다.
먼저 GPU에 무엇을 그릴지 알려줘야 합니다. 즉 도형들을 주고 그것들을 어떻게 채울지 알려줘야 합니다.
이를 위해 그림을 단순한 도형들(보통 삼각형)로 나눕니다. 이 도형들은 3D 공간 안에 있으므로 어떤 도형은 다른 도형 뒤에 있을 수 있습니다. 그런 다음 그 삼각형들의 모든 꼭짓점을 가져와 x, y, z 좌표를 배열에 넣습니다.
그다음 draw call을 보냅니다—GPU에게 그 도형들을 그리라고 지시하는 것입니다.
그 시점부터는 GPU가 맡습니다. 모든 코어가 동시에 같은 일을 합니다. 이들은 다음을 수행합니다.



이 마지막 단계는 여러 방식으로 수행될 수 있습니다. GPU에 그 방법을 알려주기 위해 pixel shader라고 불리는 프로그램을 GPU에 제공합니다. pixel shading은 GPU에서 프로그래밍할 수 있는 몇 안 되는 부분 중 하나입니다.
어떤 pixel shader는 단순합니다. 예를 들어 도형이 단일 색상이라면, shader 프로그램은 그 도형의 각 픽셀에 대해 그 색을 반환하기만 하면 됩니다.
다른 경우에는 더 복잡합니다. 예를 들어 배경 이미지가 있을 때입니다. 이미지의 어느 부분이 각 픽셀에 대응하는지 계산해야 합니다. 이것은 화가가 이미지를 확대하거나 축소하는 방식과 비슷하게 할 수 있습니다… 이미지 위에 각 픽셀에 대응하는 격자를 올려두는 것입니다. 그런 다음 어떤 칸이 그 픽셀에 대응하는지 알게 되면, 그 칸 안의 색들을 샘플링해서 최종 색이 무엇이어야 하는지 계산합니다. 이것을 texture mapping이라고 부르는데, 이미지(텍스처라고 부름)를 픽셀에 매핑하기 때문입니다.
GPU는 각 픽셀에서 여러분의 pixel shader 프로그램을 호출합니다. 서로 다른 코어가 서로 다른 픽셀을 동시에 병렬로 처리하지만, 모두 같은 pixel shader 프로그램을 사용해야 합니다. GPU에게 도형을 그리라고 지시할 때 어떤 pixel shader를 사용할지도 함께 알려줍니다.
거의 모든 웹 페이지에서 페이지의 서로 다른 부분은 서로 다른 pixel shader를 사용해야 합니다.
shader는 draw call 안의 모든 도형에 적용되기 때문에, 보통 draw call을 여러 그룹으로 나누어야 합니다. 이를 batch라고 합니다. 모든 코어를 가능한 한 바쁘게 유지하려면, 많은 도형을 담은 적은 수의 batch를 만드는 것이 좋습니다.
이것이 GPU가 수백 개, 수천 개의 코어에 작업을 분배하는 방식입니다. 매 프레임 모든 것을 렌더링하는 발상을 우리가 고려할 수 있는 것은 바로 이 극단적인 병렬성 덕분입니다. 하지만 이 극단적인 병렬성이 있더라도 여전히 해야 할 일은 많습니다. 여전히 이 작업을 영리하게 처리해야 합니다. 여기서 WebRender가 등장합니다…
페이지를 렌더링할 때 브라우저가 거치는 단계들을 다시 봅시다. 여기서는 두 가지가 달라집니다.
display list는 고수준의 그리기 지시 집합입니다. 구체적인 그래픽 API에 얽매이지 않으면서 무엇을 그려야 하는지 알려줍니다.
새롭게 그려야 할 것이 생길 때마다 메인 스레드는 그 display list를 RenderBackend에 전달합니다. 이것은 CPU에서 실행되는 WebRender 코드입니다.
RenderBackend의 일은 이 고수준 그리기 지시 목록을 GPU가 필요로 하는 draw call로 바꾸는 것이며, 이것들은 더 빠르게 실행되도록 batch로 묶입니다.
그다음 RenderBackend는 이 batch들을 compositor thread에 넘기고, compositor thread가 그것들을 GPU에 전달합니다.
RenderBackend는 GPU에 전달하는 draw call이 가능한 한 빠르게 실행되기를 원합니다. 이를 위해 몇 가지 서로 다른 기법을 사용합니다.
시간을 아끼는 가장 좋은 방법은 애초에 일을 하지 않는 것입니다.
먼저 RenderBackend는 display item 목록을 줄입니다. 실제로 화면에 나타날 display item이 무엇인지 계산합니다. 이를 위해 각 스크롤 박스에서 얼마나 아래로 스크롤되었는지 같은 정보를 확인합니다.
도형의 일부라도 상자 안에 있으면 포함됩니다. 하지만 도형이 페이지에 전혀 나타나지 않을 것이라면 제거됩니다. 이 과정을 early culling이라고 합니다.
이제 우리는 사용할 도형만 포함하는 트리를 갖게 되었습니다. 이 트리는 앞서 이야기한 stacking context들에 따라 구성됩니다.
CSS 필터와 stacking context 같은 효과는 일을 조금 복잡하게 만듭니다. 예를 들어 opacity가 0.5인 요소가 있고 그 안에 자식들이 있다고 해봅시다. 각 자식이 투명하다고 생각할 수도 있지만… 실제로는 전체 그룹이 투명한 것입니다.
이 때문에 먼저 그룹을 텍스처에 렌더링해야 하며, 이때 각 상자는 완전한 불투명도로 그려집니다. 그런 다음 부모 안에 배치할 때 전체 텍스처의 opacity를 바꿀 수 있습니다.
이 stacking context들은 중첩될 수 있습니다… 그 부모 자체가 또 다른 stacking context의 일부일 수 있습니다. 그러면 또 다른 중간 텍스처에 렌더링되어야 하고, 이런 식으로 이어집니다.
이 텍스처를 위한 공간을 만드는 것은 비용이 큽니다. 가능한 한 많은 것들을 같은 중간 텍스처로 묶고 싶습니다.
GPU가 이를 도울 수 있도록 우리는 render task tree를 만듭니다. 이를 통해 어떤 텍스처가 다른 텍스처보다 먼저 생성되어야 하는지 알 수 있습니다. 다른 것에 의존하지 않는 텍스처는 첫 번째 패스에서 만들 수 있으므로 같은 중간 텍스처로 묶을 수 있습니다.
그래서 위의 예에서는 먼저 박스 그림자의 한쪽 모서리를 출력하는 패스를 수행합니다. (실제로는 이보다 조금 더 복잡하지만, 핵심은 이렇습니다.)
두 번째 패스에서는 이 모서리를 박스 주변으로 반사시켜 박스들에 그림자를 배치할 수 있습니다. 그런 다음 그룹을 완전한 불투명도로 렌더링합니다.
그다음 필요한 일은 이 텍스처의 opacity를 바꾸고, 화면으로 출력될 최종 텍스처 안의 올바른 위치에 배치하는 것뿐입니다.
이 render task tree를 쌓아 올리면 우리가 사용할 수 있는 오프스크린 render target의 최소 개수를 계산할 수 있습니다. 앞서 말했듯 이런 render target 텍스처를 위한 공간을 만드는 것은 비용이 크기 때문에 이는 좋은 일입니다.
또한 이것은 batch를 함께 묶는 데도 도움이 됩니다.
앞서 이야기했듯, 많은 도형을 담은 적은 수의 batch를 만들어야 합니다.
batch를 만드는 방식에 신경 쓰면 속도를 크게 높일 수 있습니다. 가능한 한 많은 도형이 같은 batch에 들어가도록 해야 합니다. 여기에는 몇 가지 이유가 있습니다.
첫째, CPU가 GPU에게 draw call을 하라고 지시할 때마다 CPU는 많은 일을 해야 합니다. GPU를 설정하고, shader 프로그램을 업로드하고, 다양한 하드웨어 버그를 검사하는 등의 일을 해야 합니다. 이 작업은 누적되며, CPU가 이것을 하는 동안 GPU는 놀고 있을 수도 있습니다.
둘째, 상태를 바꾸는 데 비용이 듭니다. 예를 들어 batch 사이에서 shader 프로그램을 바꿔야 한다고 해봅시다. 일반적인 GPU에서는 현재 shader 작업이 모든 코어에서 끝날 때까지 기다려야 합니다. 이것을 draining the pipeline이라고 합니다. 파이프라인이 비워질 때까지 다른 코어들은 놀고 있게 됩니다.
이 때문에 가능한 한 많이 batch해야 합니다. 일반적인 데스크톱 PC에서는 프레임당 draw call을 100개 이하로 유지하고, 각 call에는 수천 개의 vertex가 들어가도록 하는 것이 좋습니다. 그래야 병렬성을 가장 잘 활용할 수 있습니다.
우리는 render task tree의 각 패스를 보고 무엇을 함께 batch할 수 있는지 계산합니다.
현재로서는 서로 다른 종류의 primitive마다 서로 다른 shader가 필요합니다. 예를 들어 border shader, text shader, image shader가 있습니다.
우리는 이 shader들 중 많은 것을 결합할 수 있다고 생각하며, 그렇게 되면 더 큰 batch를 만들 수 있게 됩니다. 하지만 현재 상태도 이미 꽤 잘 batch되어 있습니다.
이제 거의 GPU로 보낼 준비가 되었습니다. 하지만 제거할 수 있는 작업이 조금 더 있습니다.
대부분의 웹 페이지에는 서로 겹치는 도형이 많습니다. 예를 들어 텍스트 필드는 div(배경 포함) 위에 있고, 그 div는 body(또 다른 배경 포함) 위에 있습니다.
GPU가 어떤 픽셀의 색을 계산할 때 각 도형 안에서 그 픽셀의 색을 계산할 수 있습니다. 하지만 실제로 보이는 것은 맨 위 레이어뿐입니다. 이것을 overdraw라고 하며 GPU 시간을 낭비합니다.
그래서 한 가지 방법은 맨 위 도형을 먼저 렌더링하는 것입니다. 다음 도형을 처리할 때 같은 픽셀에 도달하면, 이미 그 픽셀에 값이 있는지 확인합니다. 값이 있다면 그 일을 하지 않습니다.
하지만 여기에는 작은 문제가 있습니다. 도형이 반투명할 때마다 두 도형의 색을 혼합해야 합니다. 그리고 이것이 올바르게 보이려면 뒤에서 앞으로 일어나야 합니다.
그래서 우리는 작업을 두 개의 패스로 나눕니다. 먼저 불투명 패스를 수행합니다. 앞에서 뒤로 진행하면서 모든 불투명 도형을 렌더링합니다. 다른 것 뒤에 있는 픽셀은 건너뜁니다.
그다음 반투명 도형을 처리합니다. 이것들은 뒤에서 앞으로 렌더링됩니다. 반투명 픽셀이 불투명 픽셀 위에 떨어지면 그 불투명 픽셀과 혼합됩니다. 불투명 도형 뒤에 떨어질 픽셀이라면 계산되지 않습니다.
이처럼 작업을 불투명 패스와 알파 패스로 나누고, 필요 없는 픽셀 계산을 건너뛰는 과정을 Z-culling이라고 합니다.
단순한 최적화처럼 보일 수 있지만, 이것은 우리에게 매우 큰 이점을 가져왔습니다. 일반적인 웹 페이지에서 우리가 실제로 건드려야 하는 픽셀 수를 크게 줄여 주었고, 현재는 더 많은 작업을 불투명 패스로 옮기는 방법을 찾고 있습니다.
이 시점에서 우리는 프레임을 준비했습니다. 제거할 수 있는 작업은 최대한 제거했습니다.
이제 GPU를 설정하고 우리의 batch를 렌더링할 준비가 되었습니다.
CPU는 여전히 일부 페인팅 작업을 해야 합니다. 예를 들어 텍스트 블록에서 사용되는 문자들(이를 glyph라고 부릅니다)은 아직 CPU에서 렌더링합니다. 이것을 GPU에서 하는 것도 가능하지만, 컴퓨터가 다른 애플리케이션에서 렌더링한 glyph와 픽셀 단위로 정확히 일치시키기가 어렵습니다. 그래서 사람들은 GPU로 렌더링된 글꼴을 보면 이질감을 느낄 수 있습니다. 우리는 Pathfinder 프로젝트로 glyph 같은 것을 GPU로 옮기는 실험을 하고 있습니다.
현재로서는 이런 것들은 CPU에서 비트맵으로 페인트됩니다. 그런 다음 GPU의 texture cache라고 불리는 곳에 업로드됩니다. 이 캐시는 대개 바뀌지 않기 때문에 프레임 사이에도 유지됩니다.
이런 페인팅 작업이 CPU에 남아 있더라도, 우리는 여전히 그것을 지금보다 더 빠르게 만들 수 있습니다. 예를 들어 글꼴의 문자들을 페인트할 때 우리는 서로 다른 문자들을 모든 코어에 나누어 배분합니다. 이것은 Stylo가 스타일 계산을 병렬화할 때 사용하는 것과 같은 기법인 work stealing을 통해 이뤄집니다.
우리는 초기 Firefox Quantum 출시 이후 몇 번의 릴리스 뒤인 2018년에 Quantum Render의 일부로 WebRender를 Firefox에 적용하게 되기를 기대하고 있습니다. 그러면 오늘날의 페이지들이 더 부드럽게 동작하게 될 것입니다. 또한 화면의 픽셀 수가 늘어날수록 렌더링 성능이 더 중요해지기 때문에, 새롭게 확산되는 고해상도 4K 디스플레이에도 Firefox가 대비할 수 있게 됩니다.
하지만 WebRender는 Firefox에만 유용한 것이 아닙니다. WebVR 작업에도 매우 중요합니다. WebVR에서는 4K 해상도에서 각 눈마다 서로 다른 프레임을 90 FPS로 렌더링해야 하기 때문입니다.
WebRender의 초기 버전은 현재 Firefox에서 플래그 뒤에 숨겨진 상태로 사용할 수 있습니다. 통합 작업이 아직 진행 중이기 때문에 현재 성능은 완료 후만큼 좋지는 않습니다. WebRender 개발 상황을 계속 따라가고 싶다면 GitHub 저장소를 보거나, Quantum Render 프로젝트 전체에 대한 주간 업데이트를 위해 Twitter의 Firefox Nightly를 팔로우할 수 있습니다.
Lin은 Mozilla의 Advanced Development에서 Rust와 WebAssembly를 중심으로 일하고 있습니다.