RenderingNG 中的关键数据结构

Chris Harrelson
Chris Harrelson
Daniel Cheng
Daniel Cheng
Philip Rogers
Philip Rogers
Koji Ishi
Koji Ishi
Ian Kilpatrick
Ian Kilpatrick
Kyle Charbonneau
Kyle Charbonneau

我们来看看关键的数据结构,它们是渲染流水线的输入和输出。

这些数据结构包括:

  • 帧树由本地节点和远程节点组成,分别代表各个 Web 文档所在的渲染进程和 Blink 渲染程序。
  • 不可变 Fragment 树表示布局约束算法的输出(和输入)。
  • 属性树表示网络文档的转换、裁剪、效果和滚动层次结构。这些内容在整个流水线中用到。
  • 显示列表和绘制区块是光栅和分层算法的输入。
  • 合成器帧封装了使用 GPU 进行绘制的 Surface、渲染 Surface 和 GPU 纹理图块。

在介绍这些数据结构之前,以下示例以架构审核中的一种数据结构为基础。本文档通篇会用到这个示例,并演示了数据结构如何应用于这个示例。

<!-- Example code -->
<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 有时可能会选择在与父框架不同的渲染进程中渲染跨域框架。

示例代码一共有三个帧:

包含两个 iframe 的父框架 foo.com。

启用网站隔离功能后,Chromium 会使用两个呈现进程来呈现此网页。 每个渲染进程都有针对该网页帧树的表示形式:

两个帧树,代表两个渲染进程。

在其他进程中渲染的帧表示为远程帧。远程帧存储着在渲染中充当占位符所需的最少信息,例如其尺寸。否则,远程帧不包含渲染其实际内容所需的任何信息。

而局部帧表示经过标准渲染流水线的帧。本地帧包含将该帧的数据(如 DOM 树和样式数据)转换为可渲染和显示的内容所需的所有信息。

渲染流水线按本地帧树 fragment 的粒度运行。考虑一个以 foo.com 作为主框架的更复杂的示例:

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

以及以下 bar.com 子框架:

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

虽然仍然只有两个渲染程序,但现在有三个本地帧树 fragment,其中两个在 foo.com 的渲染进程中,另一个在 bar.com 的渲染进程中:

两个渲染程序和三个帧树 fragment 的表示。

为了为网页生成一个合成器框架,Viz 会同时从三个本地框架树的根帧中请求一个合成器帧,然后对其进行汇总。另请参阅“合成器框架”部分

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

可视化属性更新广告瀑布流

设备缩放比例和视口大小等视觉属性会影响渲染的输出,并且必须在本地帧树 fragment 之间同步。每个本地帧树 fragment 的根都有一个与之关联的 widget 对象。视觉属性更新会先转到主框架的 widget,然后再从上到下传播到其余的 widget。

例如,当视口大小发生变化时:

上述文本中说明的流程示意图。

此过程不是即时的,因此复制的视觉属性还包含同步令牌。Viz 合成器会使用此同步令牌来等待所有本地帧树 fragment 提交包含当前同步令牌的合成器帧。此过程可避免将具有不同视觉属性的合成器帧混合在一起。

不可变 fragment 树

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

每个树中的 fragment 的表示,其中有一个 fragment 标记为需要布局。

每个 fragment 代表一个 DOM 元素的一部分。通常,每个元素只有一个 fragment,但如果在输出时跨不同页面拆分该 fragment,或在多列上下文中拆分列,则会有更多 fragment。

布局完成后,每个 fragment 都将变得不可变,并且不会再更改。重要的是,我们还设定了一些其他限制。我们不会:

  • 允许树中的任何“向上”引用。 (子级不能拥有指向其父级的指针。)
  • 沿着树向下“传递”数据(子级只从其子级读取信息,而不会从其父级读取信息)。

这些限制使我们能够将 fragment 重复用于后续布局。如果没有这些限制,我们就需要经常重新生成整个树,这会成本高昂。

大多数布局通常是增量更新,例如,Web 应用为响应用户点击某个元素而更新界面的一小部分。理想情况下,布局的作用应该与屏幕上实际更改成比例。为了实现这一点,我们尽可能多地重复使用之前树中的部分。这意味着(通常)我们只需要重建树干。

将来,这种不可变设计可以让我们执行一些有趣的任务,例如根据需要跨线程边界传递不可变 Fragment 树(以便在不同线程上执行后续阶段)、生成多个树以实现流畅的布局动画,或执行并行推测性布局。 它还赋予我们多线程布局的潜力。

内嵌 fragment 项

内嵌内容(主要是带样式的文本)使用的表示法略有不同。我们不是使用方框和指针的树结构,而是在表示树的平面列表中表示内嵌内容。主要优势在于,内嵌的扁平列表表示法不仅速度快,而且有助于检查或查询内嵌数据结构,而且节省内存。这对网页渲染性能极其重要,因为文本渲染非常复杂,如果不经过高度优化,很容易成为流水线中最慢的环节。

系统会按照内嵌布局子树的深度优先搜索顺序为每个内嵌格式设置上下文创建扁平列表。列表中的每个条目都是 (object, number of descendants) 的元组。例如,假设存在以下 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": "."
  }
}

扁平列表如下所示:

  • (折线框,2)
  • (第 <span> 个框,第 1 个)
  • (发送短信“你好”,0)
  • (线框,3)
  • (第 <b> 个框,1)
  • (文本“there”,0)
  • (文本“.”,0)

这种数据结构有许多使用者:无障碍功能 API 以及几何图形 API(如 getClientRectscontenteditable)。每种类型都有不同的要求。 这些组件通过便捷的游标访问平面数据结构。

游标具有 API,如 MoveToNextMoveToNextLineCursorForChildren。这种游标表示形式对于文本内容而言非常强大,原因有多种:

  • 按深度优先搜索顺序的迭代非常快。这种情况经常被使用,因为它与光标移动类似。由于深度优先搜索是扁平列表,因此只会增加数组偏移量,从而实现快速迭代和内存局部性。
  • 它提供广度优先搜索,这在绘制线条和内嵌框的背景等情况下很有必要。
  • 知道后代的数量可以快速移动到下一个同级(只需按该数字递增数组偏移量即可)。

属性树

DOM 是元素树(加上文本节点),CSS 可以为元素应用各种样式。

这通过以下四种方式呈现:

  • 布局:布局约束算法的输入。
  • 绘制:如何绘制和光栅元素(而不是其后代)。
  • 可视化:应用于 DOM 子树的光栅/绘制效果,如转换、滤镜和裁剪。
  • 滚动:所含子树的轴对齐和圆角裁剪和滚动。

属性树是一种数据结构,用于说明视觉和滚动效果如何应用于 DOM 元素。它们提供了回答以下问题的方法:给定 DOM 元素相对于屏幕的布局尺寸和位置,该元素相对于屏幕的什么位置? 以及:应使用什么序列的 GPU 操作来应用视觉和滚动效果?

网络上的视觉和滚动效果极为复杂。 因此,属性树最重要的事情就是将这种复杂性转化为能够精确表示其结构和含义的单一数据结构,同时消除 DOM 和 CSS 的其他复杂性。这使我们能够更自信地实现合成和滚动算法。具体而言:

  • 可能容易出错的几何图形和其他计算可以集中到一个位置。
  • 构建和更新属性树的复杂性被隔离到一个渲染流水线阶段。
  • 与完整的 DOM 状态相比,将属性树发送到不同的线程和进程要简单得多,也更快,因此可以将属性树用于许多用例。
  • 用例越多,我们能够从基于构建的几何图形缓存中获得的优势就越多,因为它们可以重复使用彼此的缓存。

RenderingNG 使用属性树有多种用途,包括:

  • 将合成与绘制分离,以及从主线程合成。
  • 确定最佳合成 / 绘制策略。
  • 测量 IntersectionObserver 几何图形。
  • 避免为屏幕外元素和 GPU 纹理图块工作。
  • 高效准确地将绘制和光栅失效。
  • 在 Core Web Vitals 中衡量布局偏移Largest Contentful Paint

每个 Web 文档都有四个独立的属性树:转换、裁剪、效果和滚动。(*) 转换树表示 CSS 转换和滚动。(滚动转换表示为 2D 转换矩阵)。裁剪树表示溢出剪辑。效果树代表所有其他视觉效果:不透明度、滤镜、蒙版、混合模式以及其他类型的剪辑(例如剪辑路径)。滚动树表示有关滚动的信息,例如滚动是如何链接起来的;在合成器线程上执行滚动时需要用到它。属性树中的每个节点都表示 DOM 元素应用的滚动或视觉效果。如果碰巧有多个效果,那么对于同一元素,每个树中可能有多个属性树节点。

每个树的拓扑都像是 DOM 的稀疏表示法。例如,如果有三个包含溢出剪辑的 DOM 元素,那么会有三个剪辑树节点,并且剪辑树的结构将遵循溢出剪辑之间的包含屏蔽关系。 树之间还存在联系。 这些链接表示节点的相对 DOM 层次结构,并由此指示应用顺序。 例如,如果 DOM 元素上的某个转换位于带有过滤器的另一个 DOM 元素之后,那么当然,该转换在过滤器之前应用。

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

示例

来源

<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>

对于上述示例(与简介中的示例略有不同),以下是生成的属性树的关键元素:

属性树中各种元素的示例。

显示列表和绘制区块

显示项包含可使用 Skia 光栅化的低层级绘制命令(请参阅此处)。显示项通常比较简单,只需几个绘制命令即可,如绘制边框或背景。绘制树遍历按照 CSS 绘制顺序遍历布局树和关联的 fragment,以生成显示项列表。

例如:

一个蓝色方框,绿色长方形内包含“Hello World”字样。

<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”。

显示项列表从后到前排序。在上面的示例中,按照 DOM 顺序,绿色 div 排在蓝色 div 之前,但 CSS 绘制顺序要求负的 Z-index 蓝色 div 在绿色 div(第 3 步)之前绘制(第 4.1 步)。显示项大致对应于 CSS 绘制顺序规范的原子步骤。一个 DOM 元素可能会生成多个显示项,例如,#green 有一个用于显示背景的显示项,另一个是内嵌文本的显示项。这种粒度对于表示 CSS 绘制顺序规范的完整复杂性非常重要,例如由负外边距创建的交错:

一个绿色矩形,其中局部叠加了一个灰色框,上面显示“Hello world”字样。

<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,尺寸为 35x20,位于位置 8,16,颜色为灰色。 drawTextBlob,位置为 8,8,文本为“Hello world”。

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

当前属性树状态会在绘制树遍历期间保持不变,并且显示项列表会分组为具有相同属性树状态的显示项的“区块”。以下示例对此进行了演示:

一个粉色盒子和一个橙色倾斜的盒子。

<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,尺寸为 100x100,位于位置 0,0,颜色为粉色。 drawTextBlob,位置为 0,0,文本为“Hello world”。 drawRect,尺寸为 75x200,位于位置 0,0,颜色为橙色。 位置为 0,0 且带有文本“I'm Falling”的 drawTextBlob

然后,转换属性树和绘制区块将如下所示(为简洁起见):

上表的图片,即数据块 1 中的前两个单元格、数据块 2 中的第三个单元格以及数据块 3 中的后两个单元格。

绘制块的有序列表(即显示项组和属性树状态)是渲染流水线分层步骤的输入。可将整个绘制块列表合并为单个合成层,并光栅化在一起,但每次用户滚动时都需要开销高昂的光栅化。您可以为每个绘制区块创建一个合成层,并单独进行光栅化,以避免所有重新光栅化,但这会很快耗尽 GPU 内存。“分层”步骤必须在 GPU 内存与情况发生变化时降低费用之间进行权衡取舍。一种很好的通用方法是,默认合并分块,而不是合并具有属性树状态预计会在合成器线程上发生变化的绘制区块,例如使用 compositor-thread 滚动或 compositor-thread 转换动画。

理想情况下,上面的示例应生成两个合成层:

  • 一个包含以下绘制命令的 800x600 合成层:
    1. drawRect,尺寸为 800x600,颜色为白色
    2. drawRect,尺寸为 100x100,位于位置 0,0,粉色
  • 一个 144x224 的合成层,其中包含以下绘制命令:
    1. 位置为 0,0 且文本为“Hello world”的 drawTextBlob
    2. 翻译 0,18
    3. rotateZ(25deg)
    4. drawRect,尺寸为 75x200,位于位置 0,0,颜色为橙色
    5. drawTextBlob”的位置为 0,0,文本为“我倒了”

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

就示例而言,在前面关于属性树的部分中,有六个绘制块。 连同它们的(转换、裁剪、效果、滚动)属性树状态,它们是:

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

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

浏览器和渲染进程管理内容的光栅化,然后将合成器帧提交给 Viz 进程以将其呈现在屏幕上。合成器帧表示如何将光栅化内容拼接在一起并使用 GPU 高效绘制这些内容。

功能块

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

相反,视口会分成多个图块。每个图块都由一个单独的 GPU 纹理图块构成,其中包含视口部分区域的光栅化像素。然后,渲染程序可以更新各个图块,甚至可以直接更改现有图块在屏幕上的位置。例如,在滚动网站时,现有功能块的位置会上移,只有偶尔需要对新功能块进行光栅化,以便显示页面更靠下的内容。

四个板块。
此图片描绘的是晴天的图片,有四个图块。 当发生滚动操作时,系统会开始显示第五个图块。其中一个功能块恰好只有一种颜色(天蓝色),并且顶部有一个视频和 iframe。

四边形和曲面

GPU 纹理图块是一种特殊的“四边形”,这只是一类或另一类纹理的花哨名称。四边形用于标识输入纹理,并指明如何对输入纹理进行转换和应用视觉效果。例如,常规内容图块有一个转换指示其在图块网格中的 x、y 位置。

GPU 纹理图块。

这些光栅化图块封装在一个渲染通道(即四边形列表)中。渲染通道不包含任何像素信息;而是提供了关于在何处以及如何绘制每个四边形以产生所需像素输出的说明。每个 GPU 纹理图块都有一个“绘制四边形”。显示合成器只需遍历一系列四边形,使用指定的视觉效果绘制每个四边形,从而为渲染通道生成所需的像素输出。可以在 GPU 上高效地为渲染四边形合成渲染四边形,因为系统会精心选择允许的视觉效果,使其直接映射到 GPU 功能。

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

一个合成器帧也有可能嵌入另一个合成器帧。例如,浏览器合成器会生成一个带有浏览器界面的合成器框架,并生成一个空矩形来嵌入渲染合成器内容。另一个例子是网站隔离 iframe。这种嵌入是通过表面完成的。

当合成器提交合成器帧时,会附带一个标识符(称为 Surface ID),从而允许其他合成器帧以引用方式嵌入它。使用特定 surface ID 提交的最新合成器帧由 Viz 存储。之后,另一个合成器帧可以通过表面绘制四组引用该帧,这样 Viz 就知道要绘制什么。 (请注意,表面绘制四边形仅包含表面 ID,而不包含纹理。)

中间渲染通道

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

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

汇总

多个合成器帧会提交到 Viz,它们需要一起绘制到屏幕上。这是通过聚合阶段实现的,即将其转换为单个聚合的合成器帧。聚合会使用它们指定的合成器帧来取代 Surface 四边形。这也是一个优化机会,以消除不必要的中间纹理或屏幕外内容。例如,在许多情况下,网站隔离 iframe 的合成器帧不需要自己的中间纹理,并且可以通过适当的绘制四边形直接绘制到帧缓冲区。聚合阶段会确定此类优化,并根据各个渲染合成器无法获取的全局知识应用这些优化。

示例

以下是代表本博文开头部分示例的合成器帧。

  • foo.com/index.html surface:id=0
    • 渲染通道 0:绘制到输出。
      • 渲染通道绘制四边形:使用 3px 模糊处理进行绘制,然后裁剪到渲染通道 0。
        • 渲染通道 1
          • #one iframe 的图块内容绘制四边形,每个四边形分别位于 x 和 y 位置。
      • 表面绘制四边形:ID 为 2,使用缩放和平移转换进行绘制。
  • 浏览器界面:ID=1
    • 渲染通道 0:绘制到输出。
      • 绘制浏览器界面的四边形(同样平铺)
  • bar.com/index.html surface:ID=2
    • 渲染通道 0:绘制到输出。
      • #two iframe 的内容绘制四边形,每个四边形分别位于 x 和 y 位置。

插图作者:Una Kravets。