关键数据结构及其在 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

本系列的前几篇博文简要介绍了 RenderingNG 架构的目标、关键属性概要组件。现在,让我们深入了解关键数据结构,它们是渲染管道的输入和输出。

这些数据结构包括:

  • 帧树:由本地和远程节点组成,表示哪些网络文档位于哪个渲染进程和哪个 Blink 渲染程序中。
  • 不可变 Fragment 树表示布局约束算法的输出(和输入)。
  • 属性树,表示网络文档的转换、裁剪、效果和滚动层次结构,在整个流水线中使用。
  • 显示列表和绘制区块是光栅和分层算法的输入。
  • 合成器帧封装了使用 GPU 进行绘制的 Surface、渲染 Surface 和 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 有时可能会选择在不同于其父帧的渲染进程中渲染跨源帧。

在简介的示例中,总共有三个帧:

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

启用网站隔离功能后,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。如果没有这些限制,我们经常需要重新生成整棵树,这非常昂贵。

大多数布局通常是增量更新,例如,Web 应用会根据用户点击元素而更新界面的一小部分。理想情况下,布局应仅与屏幕上实际更改的内容成正比。我们可以通过重复使用前一个树的尽可能多的部分来实现此目的。这意味着(通常)我们只需要重建树的脊。

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

内嵌 fragment 项

内嵌内容(主要是带样式的文本)采用略有不同的表示法。 我们在表示树的扁平列表中表示内嵌内容,而不是包含框和指针的树结构。主要优势在于,内嵌的扁平列表表示方法可以快速检查或查询内嵌数据结构,而且会节省内存。 这对 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": "."
  }
}

扁平列表如下所示:

  • (线路框,2)
  • (第 <span> 个框,1)
  • (文本“嗨”,0)
  • (线路框,3)
  • (方框 <b>, 1)
  • (文本“有”, 0)
  • (文本“.”,0)

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

游标具有 MoveToNextMoveToNextLineCursorForChildren 等 API。这种光标表示方式对于文本内容非常强大,原因如下:

  • 按照深度优先搜索顺序进行迭代非常快。此方法使用的频率非常高,因为它与脱字符号移动类似。由于它是一个平面列表,因此深度优先搜索仅增加数组偏移量,从而提供快速迭代和内存位置。
  • 它提供广度优先搜索,例如在绘制行和内嵌框的背景时必不可少。
  • 了解后代的数量可以快速移到下一个同级(只需按该数字递增数组的偏移量即可)。

房源树

如您所知,DOM 是一种元素树(外加文本节点),CSS 可对元素应用各种样式。

效果主要有以下四种形式:

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

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

网页上的视觉和滚动效果要充分发挥其全部实力,显得非常复杂。因此,属性树最重要的工作是将复杂性转换为单个数据结构,以精确表示其结构和含义,同时消除 DOM 和 CSS 的其余复杂性。这使我们能够实现用于合成和滚动的算法,并且更有信心。具体而言:

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

RenderingNG 使用属性树实现多种目的,包括:

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

每个 Web 文档都有四个单独的属性树:transform、clip、Effect 和 scroll。(*) 转换树表示 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,颜色为橙色。 drawTextBlob,位置为 0,0,且文本为“我正在跌倒”。

然后,转换属性树和绘制块将变为(为简洁起见):

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

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

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

  • 一个包含绘制命令的 800x600 合成图层:
    1. drawRect,尺寸为 800x600,颜色为白色
    2. drawRect,尺寸为 100x100,位于位置 0,0,颜色为粉色
  • 一个包含绘制命令的 144x224 合成图层:
    1. drawTextBlob,位置为 0,0,文本为“Hello world”
    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 纹理图块

上一篇博文中所述(点击此处可查看有效示例),浏览器和渲染进程管理内容的光栅化,然后将合成器帧提交到可视化进程以呈现到屏幕上。RenderingNG 通过合成器帧展示了如何将光栅化内容拼接在一起并使用 GPU 高效地绘制内容。

功能块

理论上,渲染进程或浏览器进程合成器可以将像素光栅化为单个纹理(与渲染程序视口大小相同),然后将该纹理提交到 Viz。要显示该纹理,显示合成器只需将该纹理中的像素复制到帧缓冲区中的适当位置(例如屏幕)即可。不过,如果该合成器想要更新单个像素,则需要重新光栅化整个视口,并向可视化对象提交新的纹理。

而是将视口分成多个图块。单独的 GPU 纹理图块会使用部分视口的光栅化像素支持每个图块。然后,渲染程序可以更新各个图块,甚至仅更改现有图块在屏幕上的位置。例如,滚动网站时,现有图块的位置会发生变化,而只是偶尔需要针对页面靠下的内容对新图块进行光栅化。

四个牌。

上图描绘的是晴天时的图片,带有四个图块。 发生滚动时,系统会开始显示第五个功能块。其中一个功能块恰好只有一种颜色(天蓝色),并且顶部有一个视频和 iframe。这会引出下一个主题。

四边形和表面

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

GPU 纹理图块。

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

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

合成器帧也可能会嵌入其他合成器帧。例如,浏览器合成器会生成一个包含浏览器界面的合成器帧,以及将嵌入渲染合成器内容的空矩形。另一个示例是网站隔离 iframe。这种嵌入是通过 surface 完成的。

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

中间渲染通道

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

“渲染通道”就是一个因存在多个渲染通道而造成的。每个遍历都必须在 GPU 上按顺序执行,也就是分多个“遍历”,而单次遍历可以通过单次大规模并行 GPU 计算完成。

汇总

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

示例

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

  • foo.com/index.html surface:id=0
    • Render Pass 0:绘制到输出。
      • 渲染通道绘制四边形:使用 3px 模糊处理绘制,并裁剪到渲染通道 0 中。
        • 渲染通道 1
          • #one iframe 的图块内容绘制四边形,并分别绘制 x 和 y 位置。
      • Surface 绘制四边形:ID 为 2,使用 scale 和 translate 转换绘制。
  • 浏览器界面 surface:ID=1
    • Render Pass 0:绘制到输出。
      • 为浏览器界面绘制四边形(也平铺显示)
  • bar.com/index.html surface:ID=2
    • Render Pass 0:绘制到输出。
      • #two iframe 的内容绘制四边形,分别为 x 和 y 位置。

总结

感谢阅读! 以上是前两篇博文,本文简要介绍了 RenderingNG。 接下来,我们将从头到尾深入了解渲染流水线许多子组件面临的挑战和技术。这些功能即将推出!

插图作者:Una Kravets。