一颗像素的诞生

内核

2019-01-24

# 前言

magic-01.png 本文主要目的是介绍浏览器内核如何将 Web 页面内容转为像素点。这个流程我们通常称之为“渲染”。接下来的内容主要围绕着 Web 页面内容是什么,像素点是什么,整个神奇的转换过程是什么。 文中提到的代码是基于 M69 内核版本的。如果某个特性在后面会进行重构的话,我们也会在介绍。

# 内容与像素

# 页面内容

content-02.png 这里提到的页面内容,主要指的是构成一个网页或一个 Web 应用前端的全部代码。这些代码由文本,标签,样式表(用来定义如何渲染标签),以及脚本(可以动态的修改文本,标签以及样式表)。另外,内容还包括图像,视频,WebAssembly 等。Web 页面跟其他软件不同,在这里没有编译打包的概念。构成 Web 页面的内容在网络中直接传输,浏览器内核接收 Web 页面的内容,并作为渲染的输入数据。 blink 内核的一个关键的安全机制是:渲染流程只运行在沙箱进程内。

# 像素

pixel-03.png 作为流水线的终点(流水线的起点是 Web 页面内容),我们使用系统提供的图形库将像素画到屏幕上。如今,绝大多数的操作系统上,使用 OpenGL 作为图形库的标准 API。

将来会替换为更新的 API,例如 Vulkan。

图形库提供了底层的图形原语,如“textures”及“shaders”,通过图形库你可以做到“在指定坐标位置绘制一个多边形到虚拟像素缓冲区中”。不过显然图形库不会了解任何 web 或 HTML 或 CSS 这类内容。

# 目标

介绍完什么是内容以及什么是像素后,我们的目标就很清晰了,它们是:

  1. 将页面内容渲染成像素 - 将 HTML/CSS/JavaScript 转为对应的 OpenGL 指令以显示像素
  2. 构建一个可以高效的更新的数据结构

更新,指的是最初的渲染数据随着时间变化而变化。导致渲染数据更新的主要外因包括:JavaScript,用户输入,异步加载,动画,滚动,缩放。 高效,不但指的是生成的数据结构可以高效的渲染,同样还意味着这个数据结构可以被脚本语言高效的查询。

stages-04.png 我们将整个流水线生命周期分为几个阶段,不同阶段对应着不同的中间输出。首先我们会介绍整个工作流水线的每个阶段,其次回头来看如何高效更新,同时会介绍相关优化。

# 渲染流程

# 解析

HTML 标签将文档赋予了层次结构。例如一个 div 标签中有两个子 P 标签,每个 P 标签中都有自己的文本内容。因此第一步是解析标签,构建反应这个层次结构的对象模型,也就是文档对象模型(Document Object Model - 即 DOM)。 parse-05.png DOM 是树形模型,树中的节点有父亲节点,孩子节点,兄弟节点。DOM 不但是 Blink 内核中的表示页面的内部数据结构,同样其 API 会暴露给 JavaScript,用于 JavaScript 中的插叙或修改。JavaScript 引擎(V8)使用一个名为“bindings”的系统,将 DOM 树简单封装后暴露其 DOM web API。

# 样式表

构建 DOM 树后,下一步是处理 CSS 样式。CSS 选择器将属性声明应用到指定的 DOM 元素上。Web 开发者通过样式属性影响 DOM 元素的呈现效果,现在已有数百种样式属性。不过,确定一个元素应用了哪些样式并非易事,元素可以被多个样式规则命中,而这其中可能会有冲突的样式规则。 style-06.png CSS 解析器构建样式规则模型,这些样式规则以多种方式排列,以便进行更高效的查询。样式解析从文档中有效样式表中获取全部已解析的样式规则,结合浏览器缺省的规则,最终为每一个 DOM 元素计算出每一条样式属性的值。最终结果会保存在一个名为 ComputedStyle 对象中,该对象是一个庞大的 map,存储样式属性以及对应的值。通过开发者工具你可以看到任意一个 DOM 元素的计算后的样式结果。该结果同样会暴露给 JavaScript,这个机制也是基于 Blink 的 ComputedStyle 对象。

# 排版

在构建 DOM 树并完成计算全部样式后,接下来的步骤是为所有元素决定其显示位置。 layout-07.png 对于块(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 的排版系统预期可以简化架构,基于该系统可以构建新的排版算法。

# 绘制

了解排版后,接下来是绘制时间。 DisplayItem-08.png 绘制将绘制操作记录在 display item list 中。绘制操作可以是“在某个坐标用这个颜色绘制一个矩形”。一个排版对象可能有多个 display item,每个 display item 对应了该排版对象不同的可视区域,区域可以是背景,前景,外框等。注意,这个阶段只是记录,该记录可以回放。这个我们稍后再聊。 注意,绘制元素要依赖元素叠加的正确顺序。通过样式表可以可以控制这个顺序(z-index)。另外,一个元素相对另一个元素甚至可以部分在前部分在后。这是由于绘制分为多个阶段,每个绘制阶段都会遍历它的子树。 元素叠加-09.png

# 光栅化

raster-10.png 在一个进程中执行 display item list 中记录的绘制操作的流程称为光栅化。光栅化出来的位图通常保存在 OpenGL texture object 引用的一块 GPU 内存中。GPU 还可以通过指令生成位图(加速光栅化)。注意,像素现在还未显示在屏幕上。 skia-11.png 光栅化通过 Skia 库进行 OpenGL 调用。Skia 提供一个硬件抽象层。

Skia 是由 Google 维护的一个开源代码。它存在与 Chrome 项目中,但是位于一个独立的代码库。Android OS 产品中也用到了这个图形库。Skia 的 GPU 加速实现了自己的绘图操作缓冲区,该缓冲区在光栅化任务结束的时候刷新。

# GPU

gpu-12.png 由于 renderer 进程是沙箱进程,无法直接进行系统调用,因此,由 Skia 发出的 GL 调用会通过“command buffer”的方式代理到另外一个进程。GPU 进程接收 command buffer,进行实际的 GL 调用。沙箱进程是一个原因,这种隔离方式还可以使内核避免不稳定或不安全的图形驱动的影响。

将来我们会进行重构,将光栅化移到 GPU 进程中,该优化会改善性能,今后还会支持 Vulkan

# 动画

flow-13.png 以上是整个流水线的图示,内容通过这个流水线变为存储在内存中的像素。不过,渲染并不总是静态的,页面的滚动,缩放,动画,动态加载,JavaScript等都会导致页面变化。而如果完整的运行整个流水线则开销巨大。渲染器不断生成动画帧。如果每秒帧数低于 60 的话,滚动/动画则会发生卡顿。 invalidation.png 流水线中的每个阶段都有特定的刷新职责。

# 合成

合成做了两件事情:

  • 将页面分成不同的图层(主线程)
  • 在另外一个线程中将这些图层进行合并(合成线程) 我们常见的动画,滚动,缩放等都用到了图层的变化与合成。合成线程可以处理用户输入消息。这种架构的优势在于,即使在主线程繁忙的场景(例如运行 JavaScript),用户滚动页面仍然不会觉得卡顿。 layer tree.png 图层同样也是树状结构。作用于一个排版对象上的特定的样式属性会导致该排版对象生成一个图层。如果一个排版对象没有直接对应一个图层的话,该排版对象将被绘制到其最近的拥有图层的祖先节点的图层内。 加入合成处理后,我们的流水线图更新如下: pipeline-compositing.png

目前,构建图层树这个阶段位于绘制阶段之前,而且每个图层独立绘制。将来,构建图层会放在绘制后进行。

# 上传

绘制完成后,上传会更新合成器线程上拷贝的一份图层树,这样可以保证位于合成器线程上的图层树拷贝与位于主线程的原始图层树的状态相同。

# 分块(tiling)

tiling.png 之前我们提到过光栅化阶段位于绘制阶段之后,该阶段会将绘制操作变成位图。图层有可能非常大,意味着光栅化一个巨大的图层,它的开销也十分巨大,另外,我们也没有必要去光栅化一个图层中不可见的部分。因此,合成器线程会将图层切割为多个分块(tile)。 分块是光栅化的工作单元。一个专用光栅化线程池用于分块的光栅化。根据一个分块距离视窗的远近决定该分块的优先级。(一个层会有多个分块去对应不同的分辨率)。

# 绘画

drawing.png 一旦全部分块光栅化完毕,合成器线程生成“绘画矩形”。绘画矩形指的是在屏幕的指定位置上绘制一个分块的指令,该指令同时还会考虑到图层树的各种变换。每个绘画矩形引用了对应的分块的内存,该内存存储了光栅化后的输出(记住,目前屏幕上还没有任何像素)。绘画矩形被封装到一个合成器帧对象,该对象会提交到浏览器进程。 注意,合成器进程有两份图层树的拷贝,这样光栅化和绘制就可以同时进行。当前提交的图层树用来做分块的光栅化,前一次提交的图层树用来做绘制。这个机制称为激活。

# 显示

在一个名为“viz”(visuals 的简称)的服务中,浏览器进程运行一个名为显示合成器的组件。显示合成器聚合了来自全部 renderer 进程提交的合成器帧对象,其中还包括了 WebContents 以外的浏览器 UI 的合成器帧对象。显示合成器调用 GL 指定绘制绘画矩形资源,还记得光栅化过程中调用 GL 指令的方式么?显示合成器也用类似方式访问 GL 进程。 大多数平台上,显示合成器的输出都是双缓冲的,备份缓冲用于绘画矩形,再交换到前台用于显示。 最终,像素呈现到屏幕上了。 flow.png