A Preliminary Study on the Chrome Rendering Process - "Life of a Pixel" Study Notes

For more exciting content, welcome to pay attention to the author's WeChat public account: Code Worker Notes

This article is mainly a study and summary of the "Life of a Pixel" PPT shared by the Google Chrome team in 2020. This sharing mainly introduces the core rendering process of Chrome rendering HTML into screen pixels. Although Chrome's architecture has undergone some iterations in recent years, this article is relatively clear. Every time you read this PPT, you will still have some new gains.

1. Rendering target

The goal of rendering is to render web content into pixels displayed on the screen.

Web content such as the home page of the New York Times below:

The page to be rendered may include the following different types of data:

The pixels to be displayed on the screen come from the rendering instructions and data received by the GPU driver, including textures, shaders, vertext buffers (stored triangle vertex coordinates), etc.:

In addition, because rendering is a continuous running process, in addition to the first rendering, there will be continuous rendering updates in the future.

Therefore, starting from performance, it is required to build a corresponding internal data structure in the rendering process, so that when the content changes, the rendering result can be updated efficiently. The sources of content update mainly include:

  • JavaScript
  • user input
  • asynchronous loading
  • animation
  • Scrolling
  • zoom

2. The basic process of rendering

Conceptually, rendering can be simply abstracted into the following process:

The webpage content data goes through a series of intermediate processing stages (and generates some intermediate result data structures along the way), and finally generates the visible pixels on the screen.

Next, let's analyze these processing stages and corresponding data structures in the rendering process step by step.

1. Parse the DOM tree

This stage is mainly to parse the HTML text and generate a DOM tree:

  • Input: HTML text
  • Output: DOM (Domain Object Model) tree

The DOM tree is both Chrome's internal data structure and an API exposed to JavaScript:

2. Style sheet processing (Style)

Style sheets are used to specify the display style of DOM nodes. The example in the figure below specifies that <p>the text of all nodes is red.

Some common simple styles are as follows:

In addition, the style sheet also supports some more complex declaration methods:

对样式表处理的第一步,是CSSParser模块对样式表文本进行解析,并生成StyleSheetContents对象。

StyleSheetContents对象中记录了所有声明的css规则(StyleRule),每条StyleRule规则都包含了其对应的选择器(CSSSelectorList)和属性值(CSSPropertyValueSet):

然后就是对DOM结点进行样式计算,这一步会计算出DOM树中所有结点的所有显示样式。

可以在JavaScript中使用getComputedStyle(element)得到element的样式计算结果:

3、布局(Layout)

接下来是布局阶段。

布局是要计算出各个DOM结点的坐标和宽高信息。如下图示:

以flow布局为例,flow布局主要分两种:

  • block flow:这种布局比较简单,就是将待布局对象在垂直方向上顺序排列:

  • inline flow:这种布局复杂一些,文本或"inline"元素从左到右排布,超出边界时会折行:

布局器需要根据字体信息对文本的高度和宽度进行测量,其中Shaper模块负责选择glyph并计算其具体放置位置:

有些布局对象的内容可能会超出(overflow)它的边框范围,对于overflow的部分,用户可以设置显示、隐藏或scroll:

其他布局(如flex)的处理流程可能更复杂:

布局树

布局器会根据DOM树构造出一棵布局树,在计算布局时会遍历布局树,计算每个布局对象的坐标和宽高。

DOM树与布局树中的结点并不是一一对应的:有的DOM结点没有对应的布局结点(如display:none的DOM结点),有的布局结点没有对应的DOM结点(如一些专用于布局内部逻辑的特殊结点)。

布局流程举例

以下为一段HTML文本和其对应的DOM树:

其布局树为:

布局计算结果反映在片段树(fragment tree)中:

文本“The”在片段树中的对应结点:

文本“jumps”在片段树中的对应结点:

4、渲染(paint)

接下来,是将布局结果转换成渲染指令(Paint op)。渲染指令指明了需要在什么位置画什么元素(但不包含图片解码)。

  • 输入:布局树上的布局对象(LayoutObject)
  • 输出:一系列渲染指令

布局对象(LayoutObject)有个Paint方法,可以生成一系列渲染指令(包含了其子结点递归生成的渲染指令):

生成渲染指令时对结点的遍历是以“stacking顺序”来的,而不是以DOM中结点的顺序。如下图中,黄色元素的z-index较大,需要后渲染,虽然它在DOM树中排在绿色元素的前面。

渲染流程总体上可分为多个阶段(简化版),分别渲染各元素的不同部分:

  • background
  • float
  • foreground
  • outline

每个阶段都需要分别对所有“stacking context”进行一次遍历。如下图示,背景是先渲染绿色再渲染蓝色,但绿色元素中的文字需要在蓝色背景渲染完成之后再进行渲染。

下图中的<p>元素需要先画背景(包括背景色和边框),再画前景(文字)。

其中文字的渲染指令内部又记录了各glyph的具体渲染细节信息(后续交给Skia渲染引擎进行渲染)。

5、光栅化(rasterization)

下一步,就是把上面生成的渲染指令列表转换成位图(bitmap)并存到GPU memory 中(并未显示)。此步骤中包含了对图片的解码操作。

  • 输入:渲染指令列表
  • 输出:位图,并存到GPU memory中(并未显示)

若指令中有图片文件,则需要对图片进行解码。

光栅化流程可以使用GPU机制进行加速,如将需要复用的图片以纹理形式存到GPU的memory中,渲染指令中存储对应的texture ID。

光栅化模块调用Skia图形库来生成Open GL指令。

GPU进程

渲染指令是由render进程产生的,而光栅化则是运行在GPU进程中的。所以虽然概念上render进程产生的渲染指令是直接传送给GPU进程:

但从实现上看,渲染指令是通过command buffer传送过去的:

GPU进程在运行时动态链接本地的OpenGL库实现,在Windows平台,还需要将OpenGL指令转换成DirectX指令。

三、渲染更新流程

现在,我们通过以上流程将内容成功渲染到GPU memory中了:

但如果内容发生了变化,渲染结果需要更新时,又会发生什么呢?

例如,渲染进程生成了一些动画帧,某帧耗时比较长,导致它不能在16ms内完成渲染,界面就会发生卡顿。

为提高渲染效率,渲染中的每个阶段都尽量复用之前缓存的中间结果,只有在必要时才重新执行。以下为各渲染阶段重新执行的触发条件。

尽管各渲染阶段都有数据缓存和复用,但如果显示区域中的大部分像素都发生了变化,重绘和光栅化还是会带来很大开销。

如下图中对文本进行scroll时,显示区域中的所有像素都发生了变化:

另外,如果在渲染线程上运行的JavaScript中有一些耗时方法,渲染流程也会被卡住。

要解决上述问题,就需要引入分层和合成器了。

1、分层

渲染线程需要先将页面拆分为不同的图层(layer),将图层信息提交给合成器线程,各图层分别独立进行光栅化。在所有图层光栅化结束后,合成器线程再将结果合并到一起。

注:下图中的impl线程即为合成器线程,因为历史原因叫它impl。

每个图层包含一整棵子树的内容。

如下图中<p>拥有一个独立的图层。

而下图中的<div>和其孩子<p>共同拥有一个图层:

常见的需要独立图层的场景有以下几种:

  • 动画:对图层进行移动
  • 滚动:一个图层移动,一个图层clip它
  • 缩放:对图层进行缩放

用户输入和交互事件(如scroll)也由合成器线程来处理,这能保证在渲染线程忙碌的时候,用户还能有较好的交互体验,而不会被渲染流程卡住:

分层策略

是否为一棵子树拆分出一个独立的图层,主要取决于此子树是否满足一些触发条件(compositing triggers)。

例如,如果某结点设置了transform样式,则它会被提升为一个单独的图层:

或者如果其设置了overflow:scroll属性,则除了将其提升为一个单独的图层外,还需要创建多个相关的特殊图层,包括:main layer、scrolling contets layer、横竖scroll bar、scroll corner layer等。

渲染线程在布局阶段完成后,将各布局对象分到不同的图层中(GraphicsLayer),然后对各个不同的图层分别进行渲染。

合成器还能对图层设置一些渲染属性,以下为一些图层渲染属性树的例子:

对某图层设置渲染属性,不会影响别的图层,所以重绘范围也就仅限于有属性改变的图层,从而提高渲染更新的效率。

目前图层渲染属性树的构造是在渲染流程中prepaint阶段(分层之后,渲染之前):

未来,Chrome新架构中,会将合成器放到渲染阶段之后,也即合成器的输入变成了渲染指令,而不是布局对象。

2、提交(Commit)

渲染线程完成Paint阶段后,需要将渲染结果同步地提交给合成器线程,并将图层和属性树拷贝一份用于合成器线程的后续处理。在合成器线程处理完此次提交(Commit)后,渲染线程才能继续处理其他渲染任务。

3、图层分割成瓦片(tiling)

接下来,合成器线程需要将图层分割成比较小的瓦片,并对各瓦片独立进行光栅化(可并行)。

一个图层可能很大,图层中不是所有的paint op都需要立即执行,而且取决于paint op对象离显示区域的远近,需要先光栅化显示区域内及附近的,后处理离显示区域比较远的。

光栅化的主要操作发生在GPU中,结果会存到GPU memory中。

4、图层绘制

瓦片光栅化完成后,光栅化结果已存储到GPU memory中,合成器线程生成 CompositorFrame(其中记录了瓦片光栅化的元信息对象DrawQuad,而DrawQuad中记录了的光栅化结果的纹理信息)对象并将它发送给GPU进程:

5、激活(activation)

合成器线程维护了两套图层数据,一个是当前激活的图层树,一个是等待激活(pending)的图层树。

图层绘制操作的是当前激活的图层树,渲染线程发来的提交(commit)请求修改的是等待激活的图层树。

在当前激活的图层树在进行图层绘制时,如果从渲染线程发来了新的commit,则新commit中的瓦片光栅化操作可以同时进行,互不干扰。

6、显示

运行于GPU进程中viz线程中的display compositor会将多个渲染进程中发来的CompositorFrame进行合并。

然后执行CompositorFrame中的DrawQuad指令,将渲染指令和数据以command buffer的形式发送给gpu线程:

然后再将内容渲染到到GPU双缓冲的back buffer中:

最后,在双缓冲swap时,GPU会展现之前back buffer中存储的已渲染好的像素点,从而将内容显示到屏幕上:

四、总体流程

回顾一下总体流程,一个像素点从开始到结束会经历以下步骤:

  • 渲染进程
    • 渲染线程:
      • DOM(从HTML文本生成DOM树)
      • 样式处理(计算元素的所有显示样式)
      • 布局(生成布局树,根据显示样式计算布局结点的坐标、宽高)
      • 分层(为布局树上的结点分配不同的图层,提高后续渲染更新效率)
      • 渲染前处理(为各图层构造图层属性树,并设置图层属性)
      • 渲染(分阶段,以stacking顺序,为各图层中的布局对象生成渲染指令Paint ops)
    • 合成器线程
      • 提交(将渲染线程生成的图层和属性树数据拷贝一份,提交过程中渲染线程同步等待,提交完成后渲染线程恢复执行)
      • 图层分割成瓦片(将图层分成多个瓦片,并根据瓦片离视区的远近先后进行并行异步光栅化;图层树为等待激活状态)
  • GPU进程
    • 光栅化(调用GL方法把渲染指令转化成位图,包含图片解码;瓦片光栅化结果存储在GPU memory中)
  • 渲染进程
    • 合成器线程
      • 激活(光栅化结束后,图层树变为激活状态)
      • 图层绘制(将瓦片光栅化结果元信息打包成CompositorFrame发送给GPU进程,CompositorFrame中存储了光栅化结果在GPU memory中的texture id)
  • GPU进程
    • 显示(双缓冲)

五、参考资料

Guess you like

Origin juejin.im/post/7254466585469288485