[译] Chromium 下一代渲染架构(三):关键数据结构

1,227 阅读21分钟

本文是 RenderingNG 系列文章的第一篇:

  1. [译] Chromium 下一代渲染架构(一):RenderingNG

  2. [译] Chromium 下一代渲染架构(二):RenderingNG 架构概述

  3. [译] Chromium 下一代渲染架构(三):关键数据结构

  4. [译] Chromium 下一代渲染架构(四):VideoNG

  5. [译] Chromium 下一代渲染架构(五):LayoutNG

  6. [译] Chromium 下一代渲染架构(六):BlinkNG


现在我们深入研究下渲染流水线输入和输出的关键数据结构。

这些数据结构是:

  • 框架树(frame trees),由本地和远程节点组成,表示每个 Web 文档所在的渲染进程和 Blink 渲染器。
  • 不可变片段树(immutable fragment tree) 表示布局约束算法的输入和输出。
  • 属性树(property trees),表示 Web 文档的转换、裁剪、效果和滚动层次结构,用于整个流水线中。
  • 显示列表和绘制块(display lists and paint chunks) 是栅格化和分层算法的输入。
  • 合成器帧(compositor frames) 封装了表面(Surface)、渲染表面(Render Surface)和 GPU 纹理图块,用于 GPU 绘制。

在讲述这些数据结构之前,我想展示一个上篇文章中的示例。我将在这篇文章中多次使用这个例子,来展示数据结构是如何应用的。

<html>
  <div style="overflow: hidden; width: 100px; height: 100px;">
    <iframe style="filter: blur(3px);
      transform: rotateZ(1deg);
      width: 100px; height: 300px"
      id="one" src="foo.com/etc"></iframe>
  </div>
  <iframe style="top:200px;
    transform: scale(1.1) translateX(200px)"
    id="two" src="bar.com"></iframe>
</html>

框架树

Chrome 有时可能会选择在不同的渲染进程中渲染跨域 frame。

在上面的示例中,共有三个 frame:

Chromium 考虑站点隔离,会使用两个渲染进程来渲染此网页。每个渲染进程都有自己的网页框架树:

在不同进程中渲染的框架表示为远程框架(remote frame)。远程框架只包含占位所需的最少信息,例如其尺寸。除此之外,远程框架不包含渲染实际内容所需的任何信息。

相反,本地框架(local frame)则会经过标准的渲染流水线,像上篇文章中描述的那样。本地框架包含渲染和显示该框架所需的所有信息,用这些信息可以将框架的数据(例如 DOM 树和样式数据)转换为可以渲染和显示的东西。

渲染流水线以本地框架树片段为粒度来运行。来看一个更复杂的例子,一个包含 bar.com 子框架的foo.com 主框架:

<iframe src="bar.com"></iframe>

bar.com 中又有一个子框架:

<iframe src="foo.com/etc"></iframe>

现在仍然只有两个渲染器,但有三个本地框架树片段,两个在 foo.com 的渲染进程中,一个在 bar.com 的渲染进程中:

为了给网页生成一个合成器框架,Viz 同时从三个本地框架树的根框架中请求一个合成器框架,然后聚合它们。(另请参阅本文后面的合成器框架部分)

foo.com 主框架和 foo.com/etc 子框架是同一框架树的一部分,在同一进程中渲染。但是,这两个框架仍然具有独立的文档生命周期,因为它们属于不同的本地框架树片段。因此,不可能在一次更新中为两者生成一个合成器帧。渲染进程没有足够的信息将 foo.com/etc 的合成器框架直接合成到 foo.com 主框架的合成器框架中。因为进程外的 bar.com 框架可能会影响 foo.com/etc 框架的显示,例如,通过使用 CSS 转换 iframe 或用 DOM 中的其他元素遮挡 iframe 的一部分。

视觉属性更新瀑布流

设备的宽高比和视口大小等视觉属性会影响渲染输出,并且必须在本地框架树片段之间同步。每个本地框架树片段的根都有一个与之关联的 widget 对象。在从上到下传播 widget 之前,视觉属性更新会先到达主框架的 widget。例如,当视口大小发生变化时:

此过程不是瞬间完成的,因此复制的视觉属性还包括一个同步令牌。Viz 合成器使用此同步令牌等待所有本地框架树片段提交具有当前同步令牌的合成器帧。这个过程避免了混合具有不同视觉属性的合成器帧。

不可变的片段树

不可变片段树是渲染流水线在布局阶段的输出。它表示页面上所有元素的位置和大小(未应用 transform)。

每个片段代表一个 DOM 元素的一部分。通常每个元素只有一个片段,但如果在打印时将其拆分为不同的页面,或者在多列上下文中拆分为列,则可能会有更多片段。

布局之后,每个片段都变得不可变并且永远不会再更改。重要的是,我们还设置了一些额外的限制。我们:

  • 不允许树中有任何“向上”引用(孩子不能有指向其父母的指针)。
  • 不自上而下“冒泡”数据(一个孩子只从它的孩子那里读取信息,而不是从它的父母那里)。

这些限制允许我们在后续布局中重用片段。如果没有这些限制,我们需要经常重新生成整棵树,这很昂贵。

大多数布局通常是增量更新,例如,Web 应用程序更新 UI 的一小部分以响应用户单击元素。理想情况下,布局应该只与屏幕上实际更改的内容成比例地更新。我们可以通过尽可能多地重用前一棵树的部分片段来实现这一点。这意味着(通常)我们只需要重建树的主干。

将来,这种不可变设计将允许我们做一些有趣的事情,例如在需要时跨线程边界传递不可变片段树(在不同线程上执行后续阶段),生成多个树以实现平滑的布局动画,或执行并行推测布局。它还为我们提供了多线程布局的潜力。

内联片段项

内联内容(主要是样式化的文本)使用稍微不同的表示。我们用一维列表来表示内联内容组成的树,而不是用上面那种带有框和指针的树结构。主要好处是内联的一维列表速度很快,对于检查或查询内联数据结构很有用,并且内存高效。这对于 Web 渲染性能非常重要,因为文本的渲染非常复杂,并且很容易成为管道中最慢的部分,除非进行高度优化。

作为一个有趣的历史记录,这与Internet Explorer 之前表示其 DOM 的方式非常相似,因为它最初是以类似于文本编辑器的方式构建的。

按照深度优先遍历内联布局子树的顺序为每个内联格式化上下文创建列表。列表中的每个条目都是一个这样的的元组:(对象, 后代数量)。例如这个 DOM:

<div style="width: 0;">
  <span style="color: blue; position: relative;">Hi</span> <b>there</b>.
</div>

(请注意,width 属性为 0,所以 “Hi” 和 “there” 之间会换行)当这种情况的内联格式化上下文表示为树时,它如下所示:

{
  "Line box": {
    "Box <span>": {
      "Text": "Hi"
    }
  },
  "Line box": {
    "Box <b>": {
      "Text": "There"
    }
  },
  {
    "Text": "."
  }
}

列表如下所示:

  • (Line box, 2)
  • (Box <span>, 1)
  • (Text “Hi”, 0)
  • (Line box, 3)
  • (Box <b>, 1)
  • (Text “There”, 0)
  • (Text “.”, 0)

这种数据结构有很多消费者:可访问性 API 和几何 API,例如 getClientRectscontenteditable。每个都有不同的要求,可以通过游标方便地访问这种数据结构。

游标的 API 有 MoveToNextMoveToNextLineCursorForChildren 等。用游标表示文本内容非常强大,原因有很多:

  • 以深度优先顺序进行迭代非常快。这种使用方式经常遇到,因为它类似于插入符号的移动。由于它是一个一维列表,深度优先搜索只是增加数组偏移量,可以提供快速的遍历和内存定位。
  • 它提供广度优先搜索,例如,在绘制线条和内联框的背景时,这是必需的。
  • 知道后代的数量可以快速移动到下一个兄弟姐妹(只需将数组偏移量增加该数字即可)。

属性树

我们知道,DOM 是一棵元素树(加上文本节点),CSS 可以将各种样式应用于元素。

这些主要有四种效果:

  • 布局(Layout): 布局约束算法的输入。
  • 绘制(Paint): 如何绘制和光栅化元素(但不是它的后代)。
  • 视觉(Visual): 应用于 DOM 子树的光栅/绘图效果,例如变换、过滤器和裁剪。
  • 滚动(Scrolling): 包含轴对齐和圆角裁剪,以及子树的滚动。

属性树是解释视觉和滚动效果如何应用于 DOM 元素的数据结构。属性树回答了以下问题:相对于屏幕,给定的 DOM 元素在哪里,它的布局大小和位置如何?并且,应该使用什么顺序的 GPU 操作来应用视觉和滚动效果?

Web 的视觉和滚动效果非常复杂。因此,属性树所做的最重要的事情就是将这种复杂性转化为精确表示其结构和含义的单个数据结构,同时消除 DOM 和 CSS 剩下的复杂性。这让我们可以更有信心地实现合成和滚动算法。特别是:

  • 容易出错的几何图形和其他计算可以集中到一个地方。
  • 构建和更新属性树的复杂性被统一隔离到一个渲染管道阶段。
  • 将属性树发送到不同的线程和进程比发送完整的 DOM 状态更容易和更快,因此可以将它们用于许多场景。
  • 使用场景越多,我们从构建在上面的几何缓存中获得的好处就越多,因为它们可以重用彼此的缓存。

RenderingNG 将属性树用于多种用途,包括:

每个 Web 文档都有四个独立的属性树:变换(transform)、裁剪(clip)、效果(effect)和滚动(scroll)(*) 。

  • 变换树表示 CSS 变换和滚动。(滚动变换表示为 2D 变换矩阵。)
  • 裁剪树表示溢出裁剪
  • 效果树表示所有其他视觉效果:不透明度、过滤器、蒙版、混合模式和其他类型的裁剪,例如裁剪路径。
  • 滚动树表示有关滚动的信息,例如滚动如何链接在一起;需要在合成器线程上执行滚动。

属性树中的每个节点代表 DOM 元素应用的滚动或视觉效果。如果碰巧有多种效果,则同一元素在每棵属性树中可能有多个节点。

每棵树的拓扑结构就像 DOM 的稀疏表示。例如,如果有 3 个带有溢出裁剪的 DOM 元素,那么将有 3 个裁剪树节点,裁剪树的结构将遵循溢出裁剪之间的包含块关系。树之间也有联系,这些联系表示了节点的相对层次结构,和应用程序的顺序。例如,如果 DOM 元素上的转换低于另一个带有过滤器的 DOM 元素,那么转换当然会在过滤器之前应用。

每个 DOM 元素都有一个属性树状态,它是一个 4 元组(变换、裁剪、效果、滚动),指示对该元素生效的最近的祖先裁剪、变换和效果树节点。这非常方便,因为有了这些信息,我们就可以准确地知道应用于该元素的裁剪、变换和效果的列表以及顺序。这告诉我们它在屏幕上的位置以及如何绘制它。

(*) 有四棵树,滚动只适用于包含的子树。如果一个元素随滚动条一起滚动,它就会被滚动条包含。position: absolute 和 position: fixed 元素经常不在它的祖先元素中滚动。因为这些祖先元素不在 fixed- 或 absolute- 定位元素的包含容器链中(DOM 元素的包含容器链是它的包含容器,加上包含容器的包含容器,依此类推,直到递归到根元素)。 但是,所有其他视觉效果都适用于整个 DOM 子树。由于这种不匹配,属性树的裁剪和滚动方面的拓扑有时与视觉效果完全不同。这也是为什么大多数视觉效果会为所有后代引入一个包含容器(示例)。属性树的完整结构相当复杂;有关此主题的信息,请参阅此处的长代码注释。

举个例子

查看代码

<html>
  <div style="overflow: scroll; width: 100px; height: 100px;">
    <iframe style="filter: blur(3px);
      transform: rotateZ(1deg);
      width: 100px; height: 300px"
  id="one" srcdoc="iframe one"></iframe>
  </div>
  <iframe style="top:200px;
      transform: scale(1.1) translateX(200px)" id=two
      srcdoc="iframe two"></iframe>
</html>

对于这个的示例,以下是生成的属性树的关键元素:

我在此图中省略了一些更复杂的问题,例如表示视觉视口的节点和用于引擎内部性能优化的节点,例如绘制偏移和隔离。此外,该图未指出树之间的连接。例如,3px blur 应用于变换树的 #one rotate

显示列表和绘制块

显示列表中的一个显示项包含可以使用 Skia 进行光栅化的低级绘图指令(参见此处) 。显示项通常很简单,只有几个绘图命令,例如绘制边框或背景。绘制树按照 CSS 绘制顺序遍历布局树和相关的片段,生成显示列表。

例如:

<div id="green" style="background:green; width:80px;">
    Hello world
</div>
<div id="blue" style="width:100px;
  height:100px; background:blue;
  position:absolute;
  top:0; left:0; z-index:-1;">
</div>

此 HTML 和 CSS 将生成以下显示列表,其中每个单元格都是一个显示项:

视图的背景#blue 背景#green 背景#green 内嵌文本
drawRect 尺寸为 800x600,颜色为白色drawRect 尺寸为 100x100 的位置为 0,0,颜色为蓝色drawRect 尺寸为 80x18,位于 8,8 位置,颜色为绿色drawTextBlob 位置 8,8 和文本 “Hello world”

显示项目列表从最底层往上层排序。在上面的示例中,绿色 div 在 DOM 顺序中位于蓝色 div 之前,但 CSS 绘制顺序要求负 z-index 的蓝色 div 在(步骤 3)绿色 div(步骤 4.1)之前绘制。显示项目大致对应于 CSS 绘制顺序规范的原子步骤。单个 DOM 元素可能会导致多个显示项,例如 #green 有一个显示项作为背景,另一个显示项用于内联文本。这个粒度对于完整表示 CSS 绘制顺序规范的复杂性很重要,例如由负边距创建的交错:

<div id="green" style="background:green; width:80px;">
    Hello world
</div>
<div id="gray" style="width:35px; height:20px;
  background:gray;margin-top:-10px;"></div>

这将产生以下显示列表,其中每个单元格都是一个显示项:

视图的背景#green 背景#gray 背景#green 内嵌文本
drawRect 尺寸为 800x600,颜色为白色drawRect 尺寸为 80x18,位于 8,8 位置,颜色为绿色drawRect 在位置 8,16 处尺寸为 35x20,颜色为灰色drawTextBlob 位置 8,8 和文本 “Hello world”

显示项目列表被存储下来,后面更新时会重用。如果在绘制树遍历期间布局对象没有更改,则从前一个列表中复制其显示项。另一个优化依赖于 CSS 绘制顺序规范的一个属性:堆叠上下文以原子方式绘制。如果堆叠上下文中没有布局对象发生更改,则绘制树遍历时会跳过这个堆叠上下文并从前一个列表中复制整个显示项目序列。

绘制树在遍历期间维护属性树状态,并且显示项列表被分组为“块”(chunk),在同一个块中的显示项共享相同的属性树状态。这在以下示例中进行了演示:

<div id="scroll" style="background:pink; width:100px;
   height:100px; overflow:scroll;
   position:absolute; top:0; left:0;">
    Hello world
    <div id="orange" style="width:75px; height:200px;
      background:orange; transform:rotateZ(25deg);">
        I'm falling
    </div>
</div>

这将产生以下显示列表,其中每个单元格都是一个显示项:

视图的背景#scroll 背景#scroll 内嵌文本#orange 背景#orange 内嵌文本
drawRect 尺寸为 800x600,颜色为白色drawRect 在位置 0,0 处尺寸为 100x100,颜色为粉红色drawTextBlob 位置为 0,0,文本为“Hello world”drawRect 尺寸为 75x200,位置 0,0,颜色为橙色drawTextBlob 位置为 0,0 和文本“I'm falling”

然后变换(transform)属性树和绘制块的关系是(为了简洁已简化):

绘制块的有序列表是渲染流水线中分层(layerize)这一步骤的输入,一个绘制块就是一个显示项组加上一个属性树状态。整个绘制块列表可以合并到单个合成层中一起光栅化,但这需要在用户每次滚动时进行昂贵的光栅化。也可以为每个绘制块创建合成层并单独光栅化以避免所有块都重新光栅化,但这会很快耗尽 GPU 内存。分层步骤必须根据实际情况在 GPU 内存和降低绘制成本之间进行权衡。一个不错的通用方法是默认合并块,而对一些特殊的绘制块则不合并,这些绘制块拥有属性树状态,那些属性树状态预期会在合成器线程上被更改。例如合成器线程滚动或合成器线程变换动画。

前面的示例在理想情况下应该生成两个合成层:

  • 包含绘图命令的 800x600 合成层:

    1. drawRect 尺寸为 800x600,颜色为白色
    2. drawRect 尺寸为 100x100 ,位置为 0,0,颜色为粉红色
  • 包含绘图命令的 144x224 合成层:

    1. drawTextBlob 位置为 0,0,文本为 “Hello world”
    2. translate 0,18
    3. rotateZ(25deg)
    4. drawRect 尺寸为 75x200 ,位置为 0,0,颜色为橙色
    5. drawTextBlob 位置为 0,0 和文本 “I'm falling”

如果用户滚动 #scroll,则移动第二个合成层,但不需要光栅化。

对于此处的示例,在属性树的上一节中,有六个绘制块。连同它们的(变换、裁剪、效果、滚动)属性树状态,它们是:

  • 文档背景:文档滚动、文档裁剪、根目录、文档滚动。
  • div 的水平、垂直和滚动角(三个独立的绘制块):文档滚动、文档裁剪、#one 模糊、文档滚动。
  • iframe #one#one 旋转、溢出滚动裁剪、#one 模糊、div 滚动。
  • iframe #two#two 缩放、文档裁剪、根、文档滚动。

合成器帧:表面、渲染表面和 GPU 纹理图块

正如上一篇文章中所讨论的,浏览器和渲染进程管理内容的光栅化,然后将合成器帧提交给 Viz 进程以呈现到屏幕上。RenderingNG 用合成器帧来表示如何将光栅化的内容拼接在一起,并使用 GPU 有效地绘制这些内容。

图块

理论上,渲染进程或浏览器进程合成器可以将像素光栅化为渲染器视口的完整大小的单个纹理,并将该纹理提交给 Viz。为了显示它,显示合成器只需将像素从单个纹理复制到帧缓冲区中的适当位置(例如屏幕)。但是,如果该合成器想要更新单个像素,则需要重新光栅化整个视口并向 Viz 提交新纹理。

相反,视口可以被分成图块。每个图块包含视口的一部分已光栅化的像素,每个图块背后都有一个 GPU 纹理图块来支持。然后,渲染器可以更新单个图块,甚至只是更改现有图块在屏幕上的位置。例如,当滚动网站时,现有图块的位置会向上移动,并且仅偶尔需要对新图块进行光栅化以获取页面下方的内容。

上图描绘了一个阳光明媚的日子,有四个图块。当滚动发生时,第五个图块开始出现。其中一个图块恰好只有一种颜色(天蓝色),在它们上面有一个视频和一个 iframe。这就引出了下一个话题。

四边形和表面

GPU 纹理图块是一种特殊的四边形(quad),四边形只是一类纹理的名称。四边形标识输入纹理,并表示如何对其进行转换和应用视觉效果。例如,常规内容图块有一个指示 x、y 位置的变换,这个变换指示了它在图块网格中的位置。

这些光栅化的图块被包裹在一个渲染通道中,它是一个四边形列表。渲染过程不包含任何像素信息;相反,它有关于在何处以及如何绘制每个四边形以产生所需像素输出的说明。每个 GPU 纹理图块都有一个绘制四边形(draw quad)。显示合成器只需遍历四边形列表,使用指定的视觉效果绘制每个四边形,为渲染通道生成所需的像素输出。可以在 GPU 上高效地为渲染通道合成绘制四边形,因为允许的视觉效果已经经过了仔细的选择,可以直接映射到 GPU 的能力。

除了光栅化图块之外,还有其他类型的绘制四边形。例如,有纯色绘制四边形根本没有纹理支持,或者纹理绘制四边形用于视频或画布等非平铺纹理。

一个合成器帧也可以嵌入另一个合成器帧。例如,浏览器合成器生成一个带有浏览器 UI 的合成器帧,和一个将嵌入渲染合成器内容的空矩形。另一个例子是站点隔离的 iframe。这种嵌入是通过表面(surface) 完成的。

当合成器提交合成器帧时,它会附带一个标识符,称为表面 ID,其他合成器帧通过引用这个 ID 来嵌入它。使用特定表面 ID 提交的最新合成器帧由 Viz 存储。然后另一个合成器帧稍后可以通过表面绘制四边形引用它,因此 Viz 知道要绘制什么。(请注意,表面绘制四边形仅包含表面 ID,不包含纹理)

中间渲染通道

一些视觉效果,例如许多过滤器或高级混合模式,需要将两个或更多四边形绘制到中间纹理。然后中间纹理被绘制到 GPU 上的目标缓冲区(或者可能是另一个中间纹理),同时应用视觉效果。为此,合成器帧实际上包含渲染通道列表。总有一个根渲染通道,它是最后绘制的,其目的地对应于帧缓冲区,并且可能还有更多。

多个渲染通道的可能性解释了“渲染通道”的名称。每个通道都必须在 GPU 上按顺序执行,而多个通道中的每一个通道可以在大规模并行 GPU 计算中完成。

聚合

多个合成器帧提交给 Viz,它们需要一起绘制到屏幕上。这是通过聚合阶段完成的,该阶段将它们转换为单个聚合的合成器帧。聚合用它们指定的合成器帧替换表面绘制四边形。这也是一个优化屏幕外不必要的中间纹理或内容的机会。例如,在许多情况下,站点隔离 iframe 的合成器帧不需要自己的中间纹理,并且可以通过适当的绘制四边形直接绘制到帧缓冲区中。聚合阶段根据那些单个渲染合成器无法访问的全局知识来计算出此类优化,并应用它们。

例子

以下是代表本文开头示例的实际合成器帧。

  • foo.com/index.html 表面:ID = 0
    • 渲染通道 0: 绘制到输出。
      • 渲染通道的绘制四边形:使用 3px 模糊和裁剪绘制到渲染通道 0。
        • 渲染通道 1:
          • iframe #one 的图块的绘制四边形,每个四边形都有 x 和 y 位置。
      • 表面绘制四边形:关联的表面 ID 为 2,使用缩放(scale)和平移(translate)变换绘制。
  • 浏览器 UI 界面:ID = 1
    • 渲染通道 0: 绘制到输出。
      • 浏览器 UI 的绘制四边形(也是图块化的)
  • bar.com/index.html 表面:ID = 2
    • 渲染通道 0: 绘制到输出。
      • iframe #two 的内容的绘制四边形,每个四边形都有 x 和 y 位置。

总结

谢谢阅读!连同前两篇文章,RenderingNG 的概述到此结束。接下来将深入探讨渲染流水线的许多子组件中的挑战和技术,从头到尾。这些很快就会到来!

原文链接:developer.chrome.com/blog/render…