前言

本文整理於 Chromium 軟體工程師 Steve Kobes 的一篇名為「Life of a Pixel」的 PPT。本文閱讀時間約為 5 分鐘。

本文主要目的是介紹瀏覽器內核如何將 Web 頁面內容轉為像素點。這個流程我們通常稱之為「渲染」。接下來的內容主要圍繞著 Web 頁面內容是什麼,像素點是什麼,整個神奇的轉換過程是什麼。

文中提到的代碼是基於 M69 內核版本的。如果某個特性在後面會進行重構的話,我們也會簡單介紹。

內容與像素

頁面內容

這裡提到的頁面內容,主要指的是構成一個網頁或一個 Web 應用前端的全部代碼。這些代碼由文本,標籤,樣式表(用來定義如何渲染標籤),以及腳本(可以動態的修改文本,標籤以及樣式表)。另外,內容還包括圖像,視頻,WebAssembly 等。Web 頁面跟其他軟體不同,它沒有編譯打包的概念。構成 Web 頁面的內容在網路中直接傳輸,瀏覽器內核接收 Web 頁面的內容,並作為渲染的輸入數據。

Blink 內核的一個關鍵的安全機制是:渲染流程只運行在沙箱進程內。

像素

作為流水線的終點(流水線的起點是 Web 頁面內容),我們使用系統提供的圖形庫將像素畫到屏幕上。當今的絕大多數操作系統上使用 OpenGL 作為圖形庫的標準 API。

將來圖形庫會替換為新的 API,例如 Vulkan。

圖形庫提供了底層的圖形原語,如「textures」及「shaders」,通過圖形庫你可以做到「在指定坐標位置繪製一個多邊形到虛擬像素緩衝區中」。不過顯然圖形庫不會瞭解任何 Web 或 HTML 或 CSS 這類內容。

目標

介紹完什麼是內容以及什麼是像素後,我們的目標就很清晰了,它們是:

  1. 將頁面內容渲染成像素 - 將 HTML/CSS/JavaScript 轉為對應的 OpenGL 指令並顯示像素
  2. 構建一個可以高效的可更新的數據結構

更新,指的是最初的渲染數據隨著時間變化而變化。導致渲染數據更新的主要外因包括:JavaScript,用戶輸入,非同步載入,動畫,滾動,縮放。

高效,不但指的是生成的數據結構可以高效的渲染,同樣還意味著這個數據結構可以被腳本語言高效的查詢。

我們將整個流水線生命週期分為幾個階段,不同階段對應著不同的中間輸出。首先我們會介紹整個工作流水線的每個階段,其次回頭來看如何高效更新,同時會介紹相關優化。

渲染流程

解析

HTML 標籤將文檔賦予了層次結構。例如一個 div 標籤中有兩個子 P 標籤,每個 P 標籤中都有自己的文本內容。因此第一步是解析標籤,構建反應這個層次結構的對象模型,也就是文檔對象模型(Document Object Model - 即 DOM)。

DOM 是樹形模型,樹中的節點有父親節點,孩子節點,兄弟節點。DOM 不但是 Blink 內核中的表示頁面的內部數據結構,同樣其 API 會暴露給 JavaScript,用於 JavaScript 中的查詢或修改。JavaScript 引擎(V8)使用一個名為「bindings」的系統,將 DOM 樹簡單封裝後暴露其 DOM web API。

樣式表

構建 DOM 樹後,下一步是處理 CSS 樣式。CSS 選擇器將屬性聲明應用到指定的 DOM 元素上。Web 開發者通過樣式屬性影響 DOM 元素的呈現效果,現在已有數百種樣式屬性。不過,確定一個元素應用了哪些樣式並非易事,元素可以被多個樣式規則命中,而這其中可能會有衝突的樣式規則。

CSS 解析器構建樣式規則模型,這些樣式規則以多種方式排列,以便進行更高效的查詢。樣式解析從文檔中有效樣式表中獲取全部已解析的樣式規則,結合瀏覽器預設的規則,最終為每一個 DOM 元素計算出每一條樣式屬性的值。最終結果會保存在一個名為 ComputedStyle 對象中,該對象是一個龐大的 map,存儲樣式屬性以及對應的值。通過開發者工具你可以看到任意一個 DOM 元素的計算後的樣式結果。該結果同樣會暴露給 JavaScript,這個機制也是基於 Blink 的 ComputedStyle 對象。

排版

在構建 DOM 樹並完成計算全部樣式後,接下來的步驟是為所有元素決定其顯示位置。

對於塊(block-level)元素,我們計算元素內容區域對應的矩形的坐標。最簡單的場景下,排版按照 DOM 順序將塊元素一塊塊的依次垂直放置。我們稱這個過程為「block flow」。為了得到塊的高度,我們需要計算文本高度,文本高度依賴計算樣式結果的字體,字體決定了何處換行。

一個排版對象排版後可能會得到多個不同的矩形坐標。例如,當出現內容溢出時,排版會計算邊界矩形和完整矩形。如果節點的溢出是可滾動的,排版還會計算滾動邊界,並為滾動條留出空間。最常見的可滾動 DOM 節點就是文檔自己,它也是 DOM 樹的根節點。

複雜一些的排版則包括表格元素或可以將內容分為多列的樣式(flex,dolum-count等),或浮動對象,或某些垂直排版而非水平排版的東亞語言。

排版信息存儲在一個獨立的樹中,該樹與 DOM 樹相關聯(LayoutObject Node)。排版樹中的節點實現了排版演算法。LayoutObject 有對應不同排版需求的不同子類。DOM 節點與排版節點通常是 1:1 的關係,不過也存在一些例外,LayoutObject 沒有 DOM 節點,或是 DOM 節點沒有 LayoutObject。

排版階段位於樣式計算階段之後。首先構建排版樹,其次遍歷排版樹,為每一個節點完善排版信息。

現在,排版階段中,排版對象包括了輸入和輸出,輸入和輸出並未明顯隔離。例如,LayoutObject 獲得它對應的 DOM 元素的 ComputedStyle 對象的所有權。將來我們會進行重構,新的名為 LayoutNG 的排版系統預期可以簡化架構,基於該系統可以構建新的排版演算法。

繪製

瞭解排版後,接下來是繪製時間。

繪製將繪製操作記錄在 display item list 中。繪製操作可以是「在某個坐標用這個顏色繪製一個矩形」。一個排版對象可能有多個 display item,每個 display item 對應了該排版對象不同的可視區域,區域可以是背景,前景,外框等。注意,這個階段只是記錄,該記錄可以回放。這個我們稍後再聊。

注意,繪製元素要依賴元素疊加的正確順序。通過樣式表可以可以控制這個順序(z-index)。另外,一個元素相對另一個元素甚至可以部分在前部分在後。這是由於繪製分為多個階段,每個繪製階段都會遍歷它的子樹。

光柵化

在一個進程中執行 display item list 中記錄的繪製操作的流程稱為光柵化。光柵化出來的點陣圖通常保存在 OpenGL texture object 引用的一塊 GPU 內存中。GPU 還可以通過指令生成點陣圖(加速光柵化)。注意,像素現在還未顯示在屏幕上。

光柵化通過 Skia 庫進行 OpenGL 調用。Skia 提供一個硬體抽象層。

Skia 是由 Google 維護的一個開源代碼。它存在與 Chrome 項目中,但是位於一個獨立的代碼庫。Android OS 產品中也用到了這個圖形庫。Skia 的 GPU 加速實現了自己的繪圖操作緩衝區,該緩衝區在光柵化任務結束的時候刷新。

GPU

由於 renderer 進程是沙箱進程,無法直接進行系統調用,因此,由 Skia 發出的 GL 調用會通過「command buffer」的方式代理到另外一個進程。GPU 進程接收 command buffer,進行實際的 GL 調用。沙箱進程是一個原因,這種隔離方式還可以使內核避免不穩定或不安全的圖形驅動的影響。

將來我們會進行重構,將光柵化移到 GPU 進程中,該優化會改善性能,今後還會支持 Vulkan

動畫

以上是整個流水線的圖示,內容通過這個流水線變為存儲在內存中的像素。不過,渲染並不總是靜態的,頁面的滾動,縮放,動畫,動態載入,JavaScript 等都會導致頁面變化。而如果完整的運行整個流水線則開銷巨大。渲染器不斷生成動畫幀。如果每秒幀數低於 60 的話,滾動/動畫則會發生卡頓。

流水線中的每個階段都有特定的刷新職責。

合成

合成做了兩件事情:

  • 將頁面分成不同的圖層(主線程)
  • 在另外一個線程中將這些圖層進行合併(合成線程)

我們常見的動畫,滾動,縮放等都用到了圖層的變化與合成。合成線程可以處理用戶輸入消息。這種架構的優勢在於,即使在主線程繁忙的場景(例如運行 JavaScript),用戶滾動頁面仍然不會覺得卡頓。

圖層同樣也是樹狀結構。作用於一個排版對象上的特定的樣式屬性會導致該排版對象生成一個圖層。如果一個排版對象沒有直接對應一個圖層的話,該排版對象將被繪製到其最近的擁有圖層的祖先節點的圖層內。

加入合成處理後,我們的流水線圖更新如下:

目前,構建圖層樹這個階段位於繪製階段之前,而且每個圖層獨立繪製。將來,構建圖層會放在繪製後進行。

上傳

繪製完成後,上傳會更新合成器線程上拷貝的一份圖層樹,這樣可以保證位於合成器線程上的圖層樹拷貝與位於主線程的原始圖層樹的狀態相同。

分塊(tiling)

之前我們提到過光柵化階段位於繪製階段之後,該階段會將繪製操作變成點陣圖。圖層有可能非常大,意味著光柵化一個巨大的圖層,它的開銷也十分巨大,另外,我們也沒有必要去光柵化一個圖層中不可見的部分。因此,合成器線程會將圖層切割為多個分塊(tile)。

分塊是光柵化的工作單元。一個專用光柵化線程池用於分塊的光柵化。根據一個分塊距離視窗的遠近決定該分塊的優先順序。(一個層會有多個分塊去對應不同的解析度)。

繪畫

一旦全部分塊光柵化完畢,合成器線程生成「繪畫矩形」。繪畫矩形指的是在屏幕的指定位置上繪製一個分塊的指令,該指令同時還會考慮到圖層樹的各種變換。每個繪畫矩形引用了對應的分塊的內存,該內存存儲了光柵化後的輸出(記住,目前屏幕上還沒有任何像素)。繪畫矩形被封裝到一個合成器幀對象,該對象會提交到瀏覽器進程。

注意,合成器進程有兩份圖層樹的拷貝,這樣光柵化和繪製就可以同時進行。當前提交的圖層樹用來做分塊的光柵化,前一次提交的圖層樹用來做繪製。這個機制稱為激活。

顯示

在一個名為「viz」(visuals 的簡稱)的服務中,瀏覽器進程運行一個名為顯示合成器的組件。顯示合成器聚合了來自全部 renderer 進程提交的合成器幀對象,其中還包括了 WebContents 以外的瀏覽器 UI 的合成器幀對象。顯示合成器調用 GL 指定繪製繪畫矩形資源,還記得光柵化過程中調用 GL 指令的方式麼?顯示合成器也用類似方式訪問 GL 進程。

大多數平臺上,顯示合成器的輸出都是雙緩衝的,備份緩衝用於繪畫矩形,再交換到前臺用於顯示。

最終,像素呈現到屏幕上了。

最新最好的內核技術文章,請搜索並關注公眾號"U4內核技術"或"u4core"。

推薦閱讀:

相關文章