介绍目前Chromium中渲染流水线中的主要阶段及其工作。

简易渲染流程

  1. HTML/JS/CSS - 解析token,生成DOM树与样式表
  2. layout - 排版与元素布局计算
  3. Paint - 图层绘制(生成绘制指令)
  4. Composite - 图层合成
  5. Draw - 屏幕绘制

不同元素操作下渲染流水线执行步骤的差异:

  • 使用JS修改DOM元素位置与样式时,产生回流(reflow),重新执行所有步骤
  • 修改元素颜色,位置不变时,产生重绘(redraw),从第3步开始执行
  • 滚动操作或使用CSS transform与opacity属性执行动画时,直接进入第4步执行(元素处于自身合成器层条件下)

为什么会产生这些差异?下面来看看其中的详细过程自然就会明白

完整渲染流水线

输入:<html>...</html> 输出: 屏幕上的像素

  1. Parse - 数据解析
  2. Layout - 排版布局
  3. Paint - 绘制处理
  4. Composite - 图层合成
  5. Draw - 屏幕绘制

此流程是老版的流程,chromium项目在发展过程中已经对其进行了一些改造和优化

数据解析阶段 - Parse

Node => RenderObject

一开始通过网络得到的HTML,JS与CSS文件会由浏览器进程传给渲染进程,在渲染进程中进行初步处理。

处理HTML

  1. 首先解析html中的token,生成对象模型。HTMLDocumentParser
  2. 生成一颗完整的DOM树。HTMLTreeBuilder

    同一document中可以包含多个DOM树,custom element元素具有一颗shadow tree。在shadow tree slot中嵌入的节点时会被FlatTreeTraversal向下遍历时所找到。FlatTreeTraversal

处理CSS

  1. 解析CSS,处理选择器与声明。CSSParser
  2. 生成样式表内容,包含多种样式规则。StyleSheetContents & StyleRule
  3. Style将document中解析后的样式规则(StyleSheetContents)与由浏览器提供的默认样式结合,重新计算。 Document::UpdateStyle & StyleResolver::StyleForElement
  4. 在为每个DOM元素计算最终的样式属性后,将结果保存在一个大对象ComputedStyle中。ComputedStyleModel
    • 可以在Chrome开发者工具中观察任何元素的computed style,也有暴露的JS接口: getComputedStyle(element),由blink提供

排版布局阶段 - Layout

RenderObject => LayoutObject

完成生成DOM树与样式计算后,需要处理的是元素的可视几何属性。

布局处理

几何属性

对于一个块级(block-level)元素,会计算它内容区域所占据的矩形坐标与尺寸。

流动方向

最简单的情况下,所有块级元素按照DOM的顺序依次顺着竖直方向排列,称为block flow。而text node和像<span>这样的行内元素会生成inline box,一般情况下是在盒内由左向右的方向,不过RTL的语言,如阿拉伯语和希伯来语,它们的行内流动顺序是相反的。

字体字形

根据computed style中的font属性与文本,传入文本整形引擎HarfBuzz中来计算每个字形的尺寸和布局。字体整形时必须考虑其印刷特征:字距调整(kerning)与连写(ligatures)。HarfBuzzShaper

包围矩形

对于一个简单元素可能会计算多种边界矩形,比如在出现overflow的情况下,会计算border box rect和layout overflow rect,若节点的overflow是可滚动的,则layout同样会计算滚动的边界并保留滚动条的空间。最常见的可滚动DOM节点就是document自身,即树的根节点。

复杂布局

某些元素可能具有较复杂的布局,比如table元素或由周围内容包围的浮动元素。注意DOM结构与ComputedStyle值是如何传给布局算法的:每个流水线阶段都会利用前一个阶段的结果。

  • <table>
  • float: left
  • column-count: 3
  • display: flex
  • writing-mode: vertical-lr

布局对象与布局树

Layout在一颗与DOM相关联的另一颗树上(布局树)进行操作,布局树中的节点(LayoutObject)均实现了布局算法,LayoutObject有不同的子类,取决于期望的布局行为。 LayoutObject

在之前的样式更新阶段中会构建布局树,在布局阶段会遍历布局树,在每个LayoutObject上执行布局,计算可视的几何属性。 StyleEngine::RebuildLayoutTree & LocalFrameView::UpdateLayout

构建布局树

  • 通常一个DOM节点会得到一个LayoutObject,有时也存在没有节点的LayoutObject或没有LayoutObject的节点。甚至会出现包含多个LayoutObject的节点,如在一个块级子元素前后的带文本的行内元素

    <div> // [no LayoutObject]
    
    <div> block </div> // LayoutBlock DIV
    <span> inline </span> // LayoutBlock (anonymous)[no DOM node] -> LayoutInline SPAN
    
  • 最终,布局树会基于FlatTreeTraversal进行构建,该过程同样会对shadow DOM进行遍历。

LayoutNG布局引擎

在之前的chromium中,所有布局对象都包含布局阶段的所有输入输出,没有清晰划分它们的界限。

新的布局系统LayoutNG解决了这个问题,改善了它们的结构。在NG中,布局的输出是一个不可变的NGLayoutResult对象。

目前的布局树中同时包含旧版和NG的布局对象,随着时间演变,NG会支持越来越多的布局类型。

绘制处理阶段 - Paint

LayoutObjects => PaintLayer

在得到元素的样式和布局几何属性后就该绘制它们了,但这个”绘制”并不是指输出到屏幕上。

记录绘制操作

Paint会使用一个列表存储需要绘制的DisplayItem对象,DisplayItem对象中记录了要画出该元素需要执行的绘制操作,比如使用哪种颜色,在什么位置画一个矩形

每个布局对象根据它的可视情况可能会包含多个DisplayItem,比如背景、前景、边框等。

绘制顺序

Paint过程会使用层叠顺序而不是DOM元素的顺序

<div class="foo"></div>
<div class="bar"></div>
.foo { z-index: 2 };
.bar { z-index: 1 };

绘制阶段

  • 在每个绘制阶段(paint phase)中会分别去遍历层叠上下文。
  • 简化的绘制阶段流程: backgrounds => floats => foregrounds => outlines

光栅化

  • DisplayItem中记录的绘制操作会在光栅化阶段中被执行。 cc::DisplayItemList
  • 在光栅化后所得到的位图中,每一个栅格都存储着经过颜色与透明度编码后像素比特值。 cc::ResourcePool::InUsePoolResource
  • 光栅化过程还会对嵌入页面的图片资源进行解码处理。绘制操作会引用图片的压缩数据(JPEG, PNG等),光栅化器会调用对应的解码器去处理他们。

使用位图数据

  • 光栅化后产生的位图会保存在内存中,一般是OpenGL纹理对象所引用的GPU内存。 GpuRasterBacking
  • GPU也可以执行绘制操作并生成位图(光栅化加速)。

调用OpenGL

  • 光栅化过程中会使用Skia这个库来执行OpenGL调用。 GrGLGpu::draw
  • Skia提供一个硬件抽象层,并且可以理解像路径与贝塞尔曲线等复杂对象。
  • 另一篇的最后简单介绍了下Skia,不过那里介绍的是软件渲染的过程,Skia会自己生成Bitmap,而这里是硬件加速的应用。
  • Skia在GPU加速过程中会构建自身的绘制操作缓冲区,当光栅化任务结束时会被清空。

光栅化与Skia绘制相关方法

GPU进程绘制

command buffer

  • 由于渲染进程是在沙盒中,无法直接进行系统级接口的调用,因此实际上从Skia发出的GL调用是由command buffer代理,传入其他进程。 gpu::CommandBuffer
  • GPU进程接收到command buffer后,通过函数指针获取数据执行真正的GL调用。GLApi::glDrawElementsFn
  • 隔离GPU进程中GL操作可以避免不稳定或不安全的图形驱动操作

执行OpenGL调用

  • GPU进程中的GL函数指针由与系统共享的OpenGL动态查找来初始化
  • 在Windows上则使用ANGLE,该库会将OpenGL翻译成DirectX,即微软的Window系统所使用的图形API

图层合成阶段 - Composite

PaintLayer => GraphicsLayer

现在我们的元素图像已经处理成位图并存放在了内存中,那么接下来还有什么操作呢?

由于渲染并不是静态的,页面上可能存在交互操作、属性改变或执行的动画,这些情况下由于图形本身内容未发生改变,并且执行整个渲染流水线是比较耗时的,此时会进入合成阶段(composite)。

  • 一个页面可以分解成多个可以独立光栅化的层(layers),在另一个线程来合并这些层。
  • 一般在主线程(main thread)中构建层,并提交(commit)到合成器线程(impl thread)中绘制层。此处的impl由于历史原因,起的名称不是那么的“好”(“impl”=Composite thread)

图层变换

常见的动画与交互操作都对应着图层的某些变换:

  • 某些动画: 图层移动
  • 滚动: 一个图层移动,另一个图层剪裁,剪裁出当前viewport中的内容
  • 捏合手势:图层缩放

处理输入的操作

以滚动操作为例

  • 浏览器进程检测到滚动操作后会将其发送给渲染进程
  • 渲染进程接收到后使用合成器线程处理滚动操作,执行图层的移动与剪裁,更新页面

注意,在这个过程中,页面内容的刷新并没有重新进行layout与paint等过程,而直接通过合成器进行了处理。

图层提升 - layer promotion

一个普通的布局树生层图层的过程:

LayoutView -> PaintLayer -> GraphicsLayer
|_LayoutBlockFlow
  |_LayoutBlockFlow
    |-LayoutText
    |_LayoutBlockFlow

其中PaintLayer是合成前的”候补层”,参与GraphicsLayer与最终图像的绘制。默认情况下布局树中的元素与其父元素共处同一图层。

现在给其中一个块级元素添加CSS Animation动画,该过程就会变成:

LayoutView                         -> PaintLayer -> GraphicsLayer(cc::Layer) : 一颗图层树(layer tree)
|-LayoutBlockFlow                     |               |
  |-LayoutBlockFlow[animaiton:...] -> |-PaintLayer -> |-GraphicsLayer(cc::Layer)
    |-LayoutText
    |-LayoutBlockFlow

可以看出在添加动画的元素上多生成了一层,这是因为某些指定的样式属性会触发层的生成,为LayoutObject单独生成一个Layer,即被提升的层

图层提升可以将变化的层直接体现在之后的合并过程中,提高图层合成的效率,无需执行前置步骤。

图层滚动 - scrolling layer

LayoutBlockFlow[overflow:scroll] -> PaintLayer[CompositedLayerMapping]

滚动容器会创建一个特殊图层的集合(CompositedLayerMapping):

main layer
|-scroll layer // 在这一层执行剪裁
  |- scrolling content layer
|-scrollbar container layer
  |-horz. scrollbar layer
  |-vert. scrollbar layer
  |-scroll corner layer

图层树的构建 - layer tree

层级合成更新(compositing update)目前成为了主线程中的一个新的生命周期阶段。

  1. Parse - 数据解析
  2. Layout - 排版布局
  3. Composite update - 合成更新
  4. Paint - 绘制处理
  5. Draw - 屏幕绘制

在该阶段会在paint前根据布局树生成图层树,在之后的paint阶段分别绘制每个层级。

PaintLayerCompositor::UpdateIfNeeded

属性树 - proporty tree

合成器可以在绘制图层时应用多种属性(transform, clip, effect, scroll),这些属性会存储在他们自己的树中。

这个属性树的生成发生在paint阶段前,通过遍历图层树来获取(PrePaintTreeWalk::Walk & PaintPropertyTreeBuilder ),那么流水线就变成了:

  1. Parse - 数据解析
  2. Layout - 排版布局
  3. Composite update - 合成更新
  4. PrePaint - 绘制准备
  5. Paint - 绘制处理
  6. Draw - 屏幕绘制

将层提交给合成器线程 - commit

paint结束后,将更新后的layer与属性树提交给合成器线程。cc::Layer -> LayerImpl

分块渲染 - tiled rendering

刚才提到,光栅化是paint之后的步骤,作用是将绘制操作转换成位图。

传入的layer也许会很大,那么光栅化一个完整的layer开销就会很大,当仅有一部分内容可见时没有必要将其完全光栅化。

因此在这一步合成器线程会将layer分割成多个图块(tiles),图块是光栅化执行的单元。

图块会在专用的光栅化线程中,在一个资源池中被光栅化,图块处理的优先级由它们与viewport区域的距离决定。 CategorizedWorkerPool

绘制合成层 - composited layer

当图块被光栅化后,合成器线程会生成“绘制四边形(draw quad)”指令。PictureLayerImpl::AppendQuads & DrawQuad

  • 一个quad就像在屏幕上的特定位置绘制图块的命令,同时考虑了图层树上应用的所有转换。
  • 每个quad都引用自内存中图块光栅化后的输出
  • quad被打包进一个CompositorFrame对象中传递给浏览器进程。

pending tree与active tree

合成器线程中有两颗图层树的副本,因此可以实现一边从最新的提交中光栅化图块,一边绘制之前的提交。

这个流程中的pending tree与active tree均引用自LayerTreeImpl,包含图层列表与属性树的对象。

pending tree: (commit)LayerTreeImpl ---(raster)--> activation
active tree:  LayerTreeImpl -------(draw)--------> LayerTreeImpl(update) ---(draw)--> 

屏幕绘制阶段 - Draw

到了最后一个阶段:画到屏幕上,此时又来到了GPU进程中。

接收CompositorFrame与处理surface

  • CompositorFrame来自多个渲染进程和浏览器进程(具有UI自身的compositor),它与一个surface对象关联,surface表示要显示的目标屏幕区域
  • surface可以嵌套其他surface,浏览器UI可以嵌套renderer,renderer可以因跨域iframe等场景嵌套其他renderer
  • display compositor运行在GPU进程中的Viz合成器线程,它会同步输入的frame并处理嵌套surface之间的依赖。SurfaceAggregator

绘制到屏幕上

  • Viz会对CompositorFrame中的quad依次执行GL调用将它们显示出来
  • 与paint阶段类似,这些GL调用被command buffer代理,从Viz合成器线程传递给主线程并执行
  • 在多数平台上display compositor的输出是会被双缓冲的(backbuffer与frontbuffer,显示器只负责从frontbuffer中取数据显示),因此quad会被绘制在backbuffer中,之后的swap指令(切换两个buffer)会使其可见。

最终绘制好的像素出现在了屏幕上。

渲染流水线总览

页面动画

介绍完流水线,有了合成器的基础,了解各种动画的原理应该就比较轻松了,该部分引用一下文章浏览器渲染流水线解析与网页动画性能优化的内容。

合成器动画

  1. 合成器本身触发并运行的,比如最常见的网页惯性滚动,包括整个网页或者某个页内可滚动元素的滚动
  2. Blink 触发然后交由合成器运行,比如说传统的 CSS Translation 或者新的 Animation API,如果它们触发的动画经由 Blink 判断可以交由合成器运行

非合成器动画

  1. 使用 CSS Translation 或者 Animation API 创建的动画,但是无法由合成器运行;
  2. 使用 Timer 或者 rAF 由 JS 驱动的动画,比较典型的就是 Canvas/WebGL 游戏,这种动画实际上是由页端自己定义的,浏览器本身并没有对应的动画的概念,也就是说浏览器本身是不知道这个动画什么时候开始,是否正在运行,什么时候结束,这些完全是页端自己的内部逻辑

对比

四种动画按渲染流水线的复杂程度和理论性能排列

  1. 合成器本身触发并运行的动画
  2. Blink 触发,合成器运行的动画
  3. Blink 触发,无法由合成器运行的动画
  4. 由 Timer/rAF 驱动的 JS 动画

理论上我们要实现 60 帧的非合成器动画,只需要保证其中每个线程包含的部分的耗时总和小于 16.7 毫秒即可

水平有限,如有误还望指正。

参考