Life of a Pixel Chromium浏览器内核渲染原理 学习笔记

Chromium浏览器内核中,来自前端的内容如何在最终转换成为屏幕上的各个像素点,也就是浏览器内核渲染过程,这是一个复杂的工程上的一系列步骤。对于这些复杂的步骤我们需要把握的方面包括该过程每个阶段的设计思想、数据模型、数据模型的交互。深入而仔细地理解上述内容,有利与我们阅读庞大源码中始终保持清醒找准定位。下面我将基于我对Life of a Pixel的理解仔细来讨论这个过程。

Life of a Pixel输入与输出

首先需要谈谈输入与输出,这一系列步骤的输入成为Web Content。它由一系列现有协议所定义的一套描述Web内容的文本主要组成,当然也包括其他引用内容。现有的协议(我们常称为编程语言),通常是HTML、CSS与JavaScript。这三者分别有定义了Web内容的结构内容、样式、逻辑。但这并非严格分工,在当下流行的前端设计思想(前后端分离)中JavaScript正在承担着越来越多的职责。JavaScript本身也正在不断地变得独立,近年来逐渐跳出浏览器这个平台来独立发挥其作用(node.js、react-vr)。

另一方面,谈谈输出。如何绘制屏幕上的像素,涉及到计算机图形学方面的理论与工程。在传统上,我们可以认为计算机向屏幕输出内容经历了以下步骤。应用软件将其想要表达的图形内容转换成为对操作系统与图形相关的函数库的调用(OpenGL、Direct X等),这些函数库通过操作系统中安装驱动程序及其他操作系统有关服务向硬件(GPU等)传送数据与指令,并操纵硬件的计算核心与存储器(GPU等)完成光栅化等步骤、最终将硬件(GPU等)存储器中的最终内容转化为向屏幕输出的信号。在这方面,由函数库(OpenGL)所提供的API都是比较低级的。

举个例子,原来我也做过OpenGL相关的编程,虽然现代OpenGL提供一些模型与协议(如管线等)来简化工作,但是其提供的模型以及依照模型设计的API带有明显的硬件气息。对于OpenGL来说,调用者要精确输入预设的或者利用GPU程序计算出的数据其所绘制内容的在几何坐标系下的位置、颜色以及绘制顺序。

由输入输出来大致推断,浏览器的工作是按照先行前端协议(前端编程语言)及其附属多媒体、数据等方面的内容精确理解前端开发者对于Web页面的描述,并通过数据的层层流动与转换计算推断出图形函数库所需的各类信息。现行前端协议的复杂性与兼容性与鲁棒性要求使得这一步骤的实现十分复杂与庞大。而对于性能与稳定的要求更提升了设计与实现上的难度。实现这一系列不仅仅是技术上,而且也是软件工程上的难题。

页面生命周期综述

由上述对于输入与输出的讨论,我们可以理解Chromium团队提出以下有关于页面生命周期。

  1. 基于Web Content经过若干步骤产生相关数据模型。
  2. 数据模型随时间、交互等因素的不断更新。

其中,对于第二点要求尽可能少地快速地修改由初由第一步骤产生的数据模型,尽可能降低计算成本。其原因在于,第一步骤产生数据模型所进行的相关计算与数据模型的交互对于现有平均性能水平来说依然十分昂贵,所以不断重复执行第一步骤在实际环境中不可取。

初步渲染步骤概论

对于上面提及过的在输入与输出之间的相关步骤,我们将在下面按照前后顺序进行有关论述。

DOM

对于HTML来说,其语法规则有着明显的树状特征。这使得利用HTML能够方便地描述出文档结构并附带部分内容。所以我们需要提取出其中地结构信息与内容信息。在这一步中,HTML文档解析器将解析HTML文档中的文本并转换其为DOM树。

有关于DOM树,不得不提的是JavaScript对于DOM树的操作能力,我认为这也是JavaScript的核心内容之一。JavaScript借由该能力能够对页面进行控制、更新与内容添加、更改、删除等操作,我认为这是前后端分离思想的技术基础。上述能力对DOM树的实际操作由V8引擎具体完成,该引擎实现了JavaScript的操作DOM树的API,使得JavaScript具备该能力。

CSS(style)

CSS有着两方面的主要作用,筛选或者指定其作用的HTML标签,定义其所对应HTML标签的内容。对于我们来说,其本质在于筛选或者查找出DOM节点,并将样式信息与DOM节点对应起来。

其中有个问题需要注意,在CSS文件对于某一个或者几个DOM节点的样式的描述中,可能存在着未定义、重复定义、冲突、无效的样式定义。针对这个问题,Chromium团队引入了重计算(recalc)针对DOM树每一个节点计算所有对应的Computed Style。

Layout

有了上面两个步骤提供的信息,我们需要进一步的转换。将DOM节点与Compute Style一道转换为视觉几何结构(Layout)。在这一步中我们需要解决的问题包括文字、表格、布局等等元素的在页面最终位置、排布及大小。为了能够对这些信息进行有效计算并且整理,Chromium构造了Layout Tree。这个数据结构旨在容纳结构有关信息并且进行上述工作。

在代码中,以下结构描述了有关信息。

// A LayoutRect contains the information needed to generate a CGRect that may or
// may not be flipped if positioned in RTL or LTR contexts. |boundingWidth| is
// the width of the bounding coordinate space in which the resulting rect will
// be used. |position| is used to describe the location of the resulting frame,
// and |size| is the size of resulting frame.
struct LayoutRect {
CGFloat boundingWidth;
LayoutRectPosition position;
CGSize size;
};

Layout树与DOM树的节点关系并非一对一,有一些情况下DOM节点并不需要有其对应的Layout对象或者其可以放入其他有关的Layout对象中(通常为父节点对应的Layout对象)。其中有一点正在变化的内容需要注意,legacy layout object与LayoutNG。LayoutNG的提出,是为了解决当前的Layout对象中输入输出及其他中间内容混杂并且在计算过程中父子节点相互引用的问题。原先的设计首先带来了节点数据有效性的确定的问题,正在计算的节点需要判断其引用的数据是其需要的最终状态,不然节点当前所计算出的数据依然可能要在稍后重新计算,这提升了算法设计的复杂度。与此对应,LayoutNG的输入与输出分离,而且输出一旦产生其状态就已经确定且不可以修改,结构清晰,所以在算法设计上相对简单有效率。

Paint

依据Layout树提供的信息,我们可以计算出一些更加基本的信息如坐标系位置、大小、颜色、绘制顺序等。在Paint步骤,我们将Layout树转换为一组绘制的操作列表。而在更新过程中,前一步操作会将Layout树拆分为一个个独立的图层,而在该步骤中针对每一个图层都会生成其独立的绘制操作列表。在该过程期间,我们需要将结构信息转换为绘制的堆栈顺序,堆栈内部的各个节点是有着相对独立的绘制过程的Paint Phrase。具体地结合代码来说,Paint步骤将产生一组DrawXXXOp的数据结构。

Raster

光栅化将绘制信息转换为在内存(通常为现存)中的位图。由上面步骤产生的DrawXXXOp数据结构通过raster步骤转换为ImageDecodeCache。其中raster步骤并不在渲染进程中进行,而在GPU进程(设计上的概念)中进行。其原因在于该步骤需要产生GL调用(一种在运行时动态绑定到OpenGL API地址的代理函数),而由于浏览器的沙箱策略禁止渲染进程直接进行GL调用。这能有效避免恶意代码对实际的OpenGL API漏洞的利用,并且防止GL或者驱动程序中的一些缺陷带来的不稳定因素使得渲染进程崩溃,进而降低浏览器整体的稳定性。在这个过程中,DrawXXXOp数据结构将由渲染进程传递到GPU进程。GPU进程将进行该数据结构的DrawRectOpResult()的调用。

DrawRectOpResult结构操作SKIA提供的接口产生进而GL调用,相较于GL函数组提供的功能,SKIA能够提供进行一些更加高级的计算机图形学计算。SKIA也运用到了Google 的其他项目中,如Android。

在此仍然需要提到GPU进程的有关一些要点。GPU进程能够在崩溃后被浏览器重新启动,这一切不被用户感知,并且一个GPU进程能够服务于多个Web Content渲染进程,这也包括UI的渲染进程。而在Windows平台,GL调用会最终通过ANGLE翻译成Direct X调用。其原因在于OpenGL的支持在Windows下实际效果(个人推断为性能、支持程度等方面的因素)并不理想,而Direct X虽然在精度上略逊于OpenGL(专业工业设计软件等大多使用OpenGL调用),但是其在显卡驱动的兼容、性能方面等强于OpenGL(部分OpenGL API在普通显卡的驱动上支持不良甚至不被支持,这也是有昂贵专业显卡硬件及其驱动产品的原因之一)。值得一提的是,Direct X就是微软自己发起并不断维护的。

结论说明

初步渲染的结果依然保存在内存(内存、显存)中,这也代表了相关数据结构及其需要的数据以及被计算或者生成完毕。这些数据结构与其中的数据将运用到下面的更新过程中,作为数据输入与支撑。

更新步骤概论

由于在涉及范围较大时,Paint与Raster这两部操作十分昂贵。针对浏览器应用,要保证60 FPS甚至更高,如果低于这个线用户可能会感受到卡顿影响用户体验。所以更新步骤的设计思想主要是对尽可能少的数据结构进行Paint与Raster步骤。而该设计思想下,主要努力的方向我认为分为以下两点:细化操作粒度、为操作设置优先级来确保用户能看到的体验。还有一个方面是,由于JavaScript的单线程性质,如何在JavaScript做昂贵操作阻塞主线程的情况下,尽可能快的处理一些能利用已有信息当下能够处理的相应,这是一个难题。更新步骤中设计有关机制与设计思想,比较难以理解。我将在下面的内容中,基于我的理解论述这一系列的步骤。

合成(compositing)

Chromium针对上述设计思想与难点引入和compositing这个解决方案。在该解决方案下,页面将被分解成各自独立渲染的图层。这些图层由一个叫做合成线程的线程进行处理并绘制。在主线程上,表现为将Layer树经由Paint Layer转换为cc::Layer,而cc::Layer就是上面提到的独立额图层。cc::Layer也是合成器操作的基本单元。Paint Layer可以理解成候选的cc::Layer,它通过上下文及相应机制进行宣选择、合并与转换。cc::Layer是一个列表类型的数据结构,由Graphics Layer通过UpdateAssignmentIfNeeded进行构建。以上步骤均位于compositing assignment步骤中,但是未来将会计划被移到Paint之后,具体原因不明。

// Base class for composited layers. Special layer types are derived from
// this class. Each layer is an independent unit in the compositor, be that
// for transforming or for content. If a layer has content it can be
// transformed efficiently without requiring the content to be recreated.
// Layers form a tree, with each layer having 0 or more children, and a single
// parent (or none at the root). Layers within the tree, other than the root
// layer, are kept alive by that tree relationship, with refpointer ownership
// from parents to children.
class CC_EXPORT Layer : public base::RefCounted<Layer> {
...
};

Prepaint

该步骤会将一些绘制属性引用到某些图层(cc::Layer)中,绘制属性在这时会与图层绑定。该步骤的材料所提供的信息不多,但是个人认为可以参考Photoshop中的图层模型和对应绘制属性(黑白、亮度、对比度等等)来推断。日后,针对该方便的研究需要通过额外的文档或者源代码来进行。

Commit&Tilling

在主线程绘制完成后,会将更新后的数据结构cc::Layer列表与属性树与合成线程impl(也就是上面提到的合成线程)同步。impl在同步操作后,会将图层需要绘制的部分提取出来,变成更小粒度的Tile。而Tile是栅格化工作的基本单元,记录了需要被栅格化的部分在页面中的位置以及绘制步骤等相关信息。Tile生成后会被放入Tile池中,由栅格化线程根据优先级进行栅格化操作。栅格化优先级是有浏览器视窗距离Tile所指定位置的距离推断的。栅格化线程可能有多个,存在于GPU进程中。

在栅格化过程后,Tile会生成Quad,而Quad是在屏幕特定位置绘制Tile(已经栅格化完成)的命令。其具体路径是Tiles->AppendQuads()->CompositorFrame。而CompositorFrame中包含了DrawQuardList。值

得注意的是,CompositorFrame(合成帧)是渲染进程的输出,也是后面渲染进程与GPU进程之间传递的数据结构。而Tile的栅格化操作一般在GPU进程中进行,这样可以获得更好的性能。

Activate&Draw

为了进一步优化Commit&Tilling效率,缓解渲染进程与GPU线程可能出现的不协同性,Chromium在渲染进程的impl线程引入了Pending Tree与Active Tree这两种工作流。Pending Tree中接收的的是由主线程提交到impl线程的图层(包含图层和属性树)列表,并适时进行栅格化。Active Tree中接收栅格化后的图层(包含栅格化结果),并进行绘制操作。该多工作流模型的引入使得一边raster一边draw成为了可能,这提高了吞吐量。

Display

这里需要扩展描述一个模型,在Chromium,渲染进程与GPU进程之间并不是一对一的。在实际模型中,很可能是多个渲染进程对应一个GPU进程。在这里,个人推断是每个标签页对应一个渲染进程。同时需要注意,浏览器进程里的UI框架中的合成器也会与GPU进程通信。所以可以这么理解,GPU进程在运行时负责整个软件的栅格化与绘制操作。

GPU进程与其对应通信的模块之间传递的是合成帧,合成帧与surface(它出现在屏幕中的位置)有关。这其中有一个表面聚合的概念,现有材料提供的信息不多,个人认为是涉及合成帧之间的重叠位置的处理与优化。

剩下的操作在GPU进程中。它会利用合成帧中的Quad提供的信息生成并执行一组GL调用。而GL调用会通过命令缓冲区进行序列化并进行代理调用。上述过程在via线程中进行。而真正的GL调用将会在gpu线程(与GPU进程区分开)中进行,该线程最终将通过OpenGL的API,来进行实际的屏幕绘制操作。

在新的Display操作模式下,via线程将使用SKIA进行绘制操作,并将结果(deferred display list 数据结构)传递给gpu线程,最终位于gpu线程中的SKIA后端会根据所获得的信息做实际的GL调用(或者Valkan调用)。

最后的一些细节

由于现代显示器一般使用双缓冲区,在特定时刻一个缓冲区的用于绘制,一个缓冲区的用于显示。在前者完成绘制后,通过Swap操作,前者内容(帧)将会被显示到屏幕中而后者会被用于下一帧的绘制。不断地如此往复。

栅格化操作和显示操作均在GPU进程中进行,前者原因在于利用GPU对栅格化操作进行加速。