Chrome渲染流程初探——《Life of a Pixel》学习笔记
更多精彩内容,欢迎关注作者微信公众号:码工笔记
本文主要是对Google Chrome团队在2020年分享的《Life of a Pixel》PPT 的学习和总结。这篇分享主要介绍的是Chrome将HTML渲染成屏幕像素点的核心渲染流程。虽然Chrome的架构近年发生了一些迭代,但这篇讲的相对比较清楚,每次看这个PPT都还是会有一些新的收获。
一、渲染的目标
渲染的目标是将网页内容渲染成屏幕上显示的像素点。
网页内容例如以下纽约时报的主页:
待渲染的页面中可能包括以下多种不同类型的数据:
而屏幕上要显示的像素点,来源于GPU驱动收到的渲染指令和数据,包括纹理、shader、vertext buffer(存了三角形顶点坐标)等:
除此之外,因为渲染是一个持续运行的流程,除了首次渲染外,后续还会有不断的渲染更新。
所以从性能出发,就要求渲染流程中要构建对应的内部数据结构,使得当内容有变化时能高效地更新渲染结果,其中内容更新的来源主要有:
- JavaScript
- 用户输入
- 异步加载
- 动画
- 滚动(Scrolling)
- 缩放
二、渲染基本流程
从概念上说,渲染可以简单抽象为以下流程:
网页内容数据经过一系列中间处理阶段(并顺带生成了一些中间结果的数据结构),最后生成了屏幕上可见的像素点。
下面,我们来逐步分析渲染流程中的这些处理阶段和相应的数据结构。
1、解析DOM树
这个阶段主要是解析HTML文本,并生成DOM树:
- 输入:HTML文本
- 输出:DOM(Domain Object Model)树
DOM树既是Chrome的内部数据结构,也是暴露给JavaScript的API:
2、样式表处理(Style)
样式表用于指定DOM结点的显示样式。如下图中的例子指定了所有<p>
结点的文字为红色。
一些常见的简单样式如下:
另外,样式表也支持一些比较复杂的声明方式:
对样式表处理的第一步,是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进程
- 显示(双缓冲)