likes
comments
collection
share

Chrome渲染流程初探——《Life of a Pixel》学习笔记

作者站长头像
站长
· 阅读数 5

更多精彩内容,欢迎关注作者微信公众号:码工笔记

本文主要是对Google Chrome团队在2020年分享的《Life of a Pixel》PPT 的学习和总结。这篇分享主要介绍的是Chrome将HTML渲染成屏幕像素点的核心渲染流程。虽然Chrome的架构近年发生了一些迭代,但这篇讲的相对比较清楚,每次看这个PPT都还是会有一些新的收获。

一、渲染的目标

渲染的目标是将网页内容渲染成屏幕上显示的像素点。

网页内容例如以下纽约时报的主页: Chrome渲染流程初探——《Life of a Pixel》学习笔记

待渲染的页面中可能包括以下多种不同类型的数据: Chrome渲染流程初探——《Life of a Pixel》学习笔记

而屏幕上要显示的像素点,来源于GPU驱动收到的渲染指令和数据,包括纹理、shader、vertext buffer(存了三角形顶点坐标)等: Chrome渲染流程初探——《Life of a Pixel》学习笔记

除此之外,因为渲染是一个持续运行的流程,除了首次渲染外,后续还会有不断的渲染更新。 Chrome渲染流程初探——《Life of a Pixel》学习笔记

所以从性能出发,就要求渲染流程中要构建对应的内部数据结构,使得当内容有变化时能高效地更新渲染结果,其中内容更新的来源主要有:

  • JavaScript
  • 用户输入
  • 异步加载
  • 动画
  • 滚动(Scrolling)
  • 缩放

二、渲染基本流程

从概念上说,渲染可以简单抽象为以下流程:

Chrome渲染流程初探——《Life of a Pixel》学习笔记

网页内容数据经过一系列中间处理阶段(并顺带生成了一些中间结果的数据结构),最后生成了屏幕上可见的像素点。

下面,我们来逐步分析渲染流程中的这些处理阶段和相应的数据结构。

1、解析DOM树

这个阶段主要是解析HTML文本,并生成DOM树:

  • 输入:HTML文本
  • 输出:DOM(Domain Object Model)树

Chrome渲染流程初探——《Life of a Pixel》学习笔记

DOM树既是Chrome的内部数据结构,也是暴露给JavaScript的API: Chrome渲染流程初探——《Life of a Pixel》学习笔记

2、样式表处理(Style)

样式表用于指定DOM结点的显示样式。如下图中的例子指定了所有<p>结点的文字为红色。 Chrome渲染流程初探——《Life of a Pixel》学习笔记

一些常见的简单样式如下: Chrome渲染流程初探——《Life of a Pixel》学习笔记

另外,样式表也支持一些比较复杂的声明方式: Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

然后就是对DOM结点进行样式计算,这一步会计算出DOM树中所有结点的所有显示样式。 Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

3、布局(Layout)

接下来是布局阶段。

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

  • block flow:这种布局比较简单,就是将待布局对象在垂直方向上顺序排列: Chrome渲染流程初探——《Life of a Pixel》学习笔记

  • inline flow:这种布局复杂一些,文本或"inline"元素从左到右排布,超出边界时会折行: Chrome渲染流程初探——《Life of a Pixel》学习笔记

布局器需要根据字体信息对文本的高度和宽度进行测量,其中Shaper模块负责选择glyph并计算其具体放置位置: Chrome渲染流程初探——《Life of a Pixel》学习笔记

有些布局对象的内容可能会超出(overflow)它的边框范围,对于overflow的部分,用户可以设置显示、隐藏或scroll: Chrome渲染流程初探——《Life of a Pixel》学习笔记

其他布局(如flex)的处理流程可能更复杂: Chrome渲染流程初探——《Life of a Pixel》学习笔记

布局树

布局器会根据DOM树构造出一棵布局树,在计算布局时会遍历布局树,计算每个布局对象的坐标和宽高。 Chrome渲染流程初探——《Life of a Pixel》学习笔记

DOM树与布局树中的结点并不是一一对应的:有的DOM结点没有对应的布局结点(如display:none的DOM结点),有的布局结点没有对应的DOM结点(如一些专用于布局内部逻辑的特殊结点)。 Chrome渲染流程初探——《Life of a Pixel》学习笔记

布局流程举例

以下为一段HTML文本和其对应的DOM树: Chrome渲染流程初探——《Life of a Pixel》学习笔记

其布局树为: Chrome渲染流程初探——《Life of a Pixel》学习笔记

布局计算结果反映在片段树(fragment tree)中: Chrome渲染流程初探——《Life of a Pixel》学习笔记

文本“The”在片段树中的对应结点: Chrome渲染流程初探——《Life of a Pixel》学习笔记

文本“jumps”在片段树中的对应结点: Chrome渲染流程初探——《Life of a Pixel》学习笔记

4、渲染(paint)

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

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

布局对象(LayoutObject)有个Paint方法,可以生成一系列渲染指令(包含了其子结点递归生成的渲染指令): Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

  • background
  • float
  • foreground
  • outline

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

下图中的<p>元素需要先画背景(包括背景色和边框),再画前景(文字)。 Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

5、光栅化(rasterization)

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

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

GPU进程

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

但从实现上看,渲染指令是通过command buffer传送过去的: Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

三、渲染更新流程

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

为提高渲染效率,渲染中的每个阶段都尽量复用之前缓存的中间结果,只有在必要时才重新执行。以下为各渲染阶段重新执行的触发条件。 Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

另外,如果在渲染线程上运行的JavaScript中有一些耗时方法,渲染流程也会被卡住。 Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

1、分层

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

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

如下图中<p>拥有一个独立的图层。 Chrome渲染流程初探——《Life of a Pixel》学习笔记

而下图中的<div>和其孩子<p>共同拥有一个图层: Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

  • 动画:对图层进行移动
  • 滚动:一个图层移动,一个图层clip它
  • 缩放:对图层进行缩放 Chrome渲染流程初探——《Life of a Pixel》学习笔记

用户输入和交互事件(如scroll)也由合成器线程来处理,这能保证在渲染线程忙碌的时候,用户还能有较好的交互体验,而不会被渲染流程卡住: Chrome渲染流程初探——《Life of a Pixel》学习笔记

分层策略

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

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

目前图层渲染属性树的构造是在渲染流程中prepaint阶段(分层之后,渲染之前): Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

2、提交(Commit)

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

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

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

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

4、图层绘制

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

5、激活(activation)

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

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

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

6、显示

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

四、总体流程

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

Chrome渲染流程初探——《Life of a Pixel》学习笔记

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

五、参考资料