我们来看看关键数据结构,它们是渲染流水线的输入和输出。
这些数据结构如下所示:
- 帧树由本地节点和远程节点组成,分别代表各个 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 有时可能会选择在与其父级框架不同的呈现进程中呈现跨源框架。
在示例代码中,总共有三个帧:
启用网站隔离后,Chromium 会使用两个渲染进程来呈现此网页。每个渲染进程都有针对该网页帧树的表示形式:
在其他进程中渲染的帧表示为远程帧。远程帧包含在渲染中用作占位符所需的最少信息,例如其尺寸。否则,远程帧不包含呈现其实际内容所需的任何信息。
相比之下,本地帧表示经过标准渲染流水线的帧。本地帧包含将该帧的数据(例如 DOM 树和样式数据)转换为可渲染和显示内容所需的所有信息。
渲染流水线按本地帧树 fragment 的粒度运行。我们来看一个更复杂的示例,其中 foo.com
是主框架:
<iframe src="bar.com"></iframe>
以及以下 bar.com
子帧:
<iframe src="foo.com/etc"></iframe>
虽然仍然只有两个渲染程序,但现在有三个本地帧树 fragment,其中两个位于 foo.com
的渲染进程中,一个位于 bar.com
的渲染进程中:
为了为网页生成一个合成器框架,Viz 会同时从三个本地框架树的根帧中请求一个合成器帧,然后对其进行汇总。另请参阅“合成器帧”部分。
foo.com
主框架和 foo.com/other-page
子框架属于同一帧树,并在同一进程中呈现。不过,由于这两个帧属于不同的本地帧树 fragment,因此它们仍然具有独立的文档生命周期。因此,在一次更新中,不可能为这两者生成一个合成器帧。渲染进程没有足够的信息来将为 foo.com/other-page
生成的 compositor 帧直接合成到 foo.com
主帧的 compositor 帧中。例如,外部进程 bar.com
父级帧可能会通过使用 CSS 转换 iframe 或使用其 DOM 中的其他元素遮挡 iframe 的部分来影响 foo.com/other-url
iframe 的显示。
视觉媒体资源更新广告瀑布流
设备缩放比例和视口大小等视觉属性会影响渲染的输出,并且必须在本地帧树 fragment 之间同步。每个本地帧树 fragment 的根都有一个与之关联的 widget 对象。视觉属性更新会先转到主框架的 widget,然后再从上到下传播到其余的 widget。
例如,当视口大小发生变化时:
此过程并非瞬时完成,因此复制的视觉属性还包含一个同步令牌。Viz 合成器使用此同步令牌等待所有本地帧树 fragment 提交包含当前同步令牌的合成器帧。此过程可避免将具有不同视觉属性的合成器帧混合在一起。
不可变 fragment 树
不可变的 fragment 树是渲染流水线布局阶段的输出。它表示页面上所有元素的位置和大小(不应用转换)。
每个 fragment 都代表 DOM 元素的一部分。通常,每个元素只有一个 fragment,但如果它在打印时分布在不同的页面上,或者在多列上下文中分布在不同的列中,则可能有多个 fragment。
布局完成后,每个 fragment 都将变得不可变,并且不会再更改。重要的是,我们还会施加一些额外的限制。我们不会:
- 允许在树中使用任何“向上”引用。(子项不能包含指向其父项的指针。)
- 沿树形结构“向上传递”数据(子项仅读取其子项中的信息,而不会读取其父项中的信息)。
这些限制允许我们将 fragment 用于后续布局。如果没有这些限制,我们就需要经常重新生成整个树,这会很耗费资源。
大多数布局通常是增量更新,例如,Web 应用在用户点击某个元素时更新界面的一小部分。理想情况下,布局应仅执行与屏幕上实际发生的更改成正比的工作。我们可以通过尽可能重复使用上一个树的许多部分来实现这一点。这意味着(通常)我们只需重新构建树的脊柱。
将来,这种不可变设计可让我们执行一些有趣的操作,例如根据需要跨线程边界传递不可变 fragment 树(以便在其他线程中执行后续阶段),生成多个树以实现流畅的布局动画,或执行并行推测性布局。它还让我们有机会实现多线程布局。
内嵌 fragment 项
内嵌内容(主要是样式文本)使用略有不同的表示法。我们使用表示树的扁平列表来表示内嵌内容,而不是使用包含框和指针的树形结构。主要优势在于,内嵌的扁平列表表示法速度快,适用于检查或查询内嵌数据结构,并且内存效率高。这对网页渲染性能极其重要,因为文本渲染非常复杂,如果不经过高度优化,很容易成为流水线中最慢的环节。
系统会按其内嵌布局子树的深度优先搜索顺序为每个内嵌格式设置上下文创建一个扁平列表。列表中的每个条目都是一个元组(对象、子代数量)。 例如,假设存在以下 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 个)
- (Text "Hi", 0)
- (线条框,3)
- (Box <b>, 1)
- (Text "there", 0)
- (Text ".", 0)
这种数据结构有许多使用者:无障碍功能 API 以及几何图形 API(如 getClientRects
和 contenteditable
)。每种类型都有不同的要求。
这些组件通过便捷的游标访问平面数据结构。
光标具有 API,例如 MoveToNext
、MoveToNextLine
、CursorForChildren
。这种光标表示法对文本内容非常有用,原因有很多:
- 按深度优先搜索顺序迭代速度非常快。由于此操作类似于光标移动,因此使用频率很高。由于它是扁平列表,因此深度优先搜索只会递增数组偏移量,从而实现快速迭代和内存局部性。
- 它提供广度优先搜索,例如,在绘制线条和内嵌框的背景时,就需要使用广度优先搜索。
- 知道子孙数量有助于快速移动到下一个同级兄弟(只需将数组偏移量按该数量递增即可)。
属性树
DOM 是元素(以及文本节点)的树,CSS 可以对元素应用各种样式。
这会以四种方式显示:
- 布局:布局约束算法的输入。
- 绘制:如何绘制和光栅元素(而不是其后代)。
- 可视化:应用于 DOM 子树的光栅/绘制效果,如转换、滤镜和裁剪。
- 滚动:沿轴对齐和圆角剪裁以及包含的子树的滚动。
属性树是一种数据结构,用于说明视觉效果和滚动效果如何应用于 DOM 元素。 它们提供了回答以下问题的方法:给定 DOM 元素的布局大小和位置,该元素相对于屏幕的位置在哪里?以及:应使用什么序列的 GPU 操作来应用视觉和滚动效果?
网络上的视觉和滚动效果极为复杂。 因此,属性树最重要的事情就是将这种复杂性转化为能够精确表示其结构和含义的单一数据结构,同时消除 DOM 和 CSS 的其他复杂性。这样一来,我们就可以更放心地实现合成和滚动算法。具体而言:
- 可能容易出错的几何图形和其他计算可以集中到一个位置。
- 构建和更新属性树的复杂性被隔离到一个渲染流水线阶段。
- 与完整的 DOM 状态相比,将属性树发送到不同的线程和进程要容易得多、快得多,因此可以将其用于许多用例。
- 用例越多,我们能够从基于构建的几何图形缓存中获得的优势就越多,因为它们可以重复使用彼此的缓存。
RenderingNG 会将属性树用于多种用途,包括:
- 将合成与绘制分离,以及从主线程合成。
- 确定最佳合成 / 绘制策略。
- 测量 IntersectionObserver 几何图形。
- 避免为屏幕外元素和 GPU 纹理图块执行工作。
- 高效且准确地使绘制和光栅失效。
- 在 Core Web Vitals 中衡量布局偏移和 Largest Contentful Paint。
每个网页文档都有四个单独的属性树:转换、剪裁、效果和滚动。(*) 转换树表示 CSS 转换和滚动。(滚动转换表示为二维转换矩阵。) 裁剪树表示溢出剪辑。效果树代表所有其他视觉效果:不透明度、滤镜、蒙版、混合模式以及其他类型的剪辑(例如剪辑路径)。滚动树表示滚动相关信息,例如滚动如何链接在一起;需要它才能在混合渲染器线程中执行滚动。属性树中的每个节点都代表 DOM 元素应用的滚动或视觉效果。如果它恰好具有多个效果,则每个树中同一元素的属性树节点可能不止一个。
每个树的拓扑就像 DOM 的稀疏表示。例如,如果有三个具有溢出剪裁的 DOM 元素,则将有三个剪裁树节点,并且剪裁树的结构将遵循溢出剪裁之间的容器块关系。这些树之间还存在关联。这些链接表示节点的相对 DOM 层次结构,以及应用顺序。例如,如果 DOM 元素上的转换位于具有过滤器的另一个 DOM 元素下方,那么转换当然会在过滤器之前应用。
每个 DOM 元素都有一个属性树状态,它是一个 4 元组(transform、clip、effect、scroll),表示对该元素有效的最近的祖先 clip、transform 和 effect 树节点。这样非常方便,因为有了这些信息,我们就可以确切知道应用于该元素的剪辑、转换和效果的列表,以及它们的顺序。这会告诉我们它在屏幕上的位置以及如何绘制它。
示例
(来源)
<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,以生成显示项列表。
例如:
<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 会生成以下显示列表,其中每个单元格都是一个显示项:
View 的背景 | #blue 背景 |
#green 背景 |
#green 内嵌文本 |
---|---|---|---|
drawRect ,尺寸为 800x600,颜色为白色。 |
尺寸为 100x100、位于 0,0 位置且颜色为蓝色的 drawRect 。 |
尺寸为 80x18 的 drawRect ,位于 8,8 位置,颜色为绿色。 |
位置为 8,8 且文本为“Hello world”的 drawTextBlob 。 |
显示屏项列表的排序为从后到前。在上面的示例中,绿色 div 在 DOM 顺序中位于蓝色 div 之前,但 CSS 绘制顺序要求负 z-index 蓝色 div 在绿色 div(第 4.1 步)之前(第 3 步)绘制。显示项大致对应于 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 内嵌文本 |
---|---|---|---|
尺寸为 800x600 且颜色为白色的 drawRect 。 |
尺寸为 80x18 的 drawRect ,位于 8,8 位置,颜色为绿色。 |
尺寸为 35x20 的 drawRect ,位于 8,16 位置,颜色为灰色。 |
位置为 8,8 且文本为“Hello world”的 drawTextBlob 。 |
显示项列表会被存储并供后续更新重复使用。如果布局对象在绘制树遍历期间未发生变化,则其显示项会从上一个列表中复制。另一项优化依赖于 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>
这将生成以下显示列表,其中每个单元格都是一个显示项:
View 的背景 | #scroll 背景 |
#scroll 内嵌文本 |
#orange 背景 |
#orange 内嵌文本 |
---|---|---|---|---|
drawRect ,尺寸为 800x600,颜色为白色。 |
尺寸为 100x100、位于 0,0 位置且颜色为粉色的 drawRect 。 |
位置为 0,0 且文本为“Hello world”的 drawTextBlob 。 |
尺寸为 75x200、位于 0,0 位置且颜色为橙色的 drawRect 。 |
位置为 0,0 且带有文本“I'm Falling”的 drawTextBlob 。 |
然后,转换属性树和绘制分块将如下所示(为简洁起见,进行了简化):
绘制块的有序列表(即显示项组和属性树状态)是渲染流水线分层步骤的输入。整个绘制分块列表可以合并到单个合成图层中并一起光栅化,但这会导致每次用户滚动时都需要进行昂贵的光栅化。您可以为每个绘制分块创建一个合成图层,并单独进行光栅化,以避免所有重新光栅化,但这会很快耗尽 GPU 显存。“分层”步骤必须在 GPU 内存与情况发生变化时降低费用之间进行权衡取舍。一般来说,一个不错的方法是默认合并分块,而不合并具有预计会在合成器线程中更改的属性树状态的绘制分块,例如合成器线程滚动或合成器线程转换动画。
理想情况下,上述示例应生成两个合成图层:
- 一个包含绘制命令的 800x600 复合图层:
drawRect
,尺寸为 800x600,颜色为白色- 尺寸为 100x100、位于 0,0 位置且颜色为粉色的
drawRect
- 一个包含绘制命令的 144x224 复合图层:
- 位置为 0,0 且文本为“Hello world”的
drawTextBlob
- 翻译 0,18
rotateZ(25deg)
drawRect
,尺寸为 75x200,位于位置 0,0,颜色为橙色- 位置为 0,0 且文本为“I'm falling”的
drawTextBlob
- 位置为 0,0 且文本为“Hello world”的
如果用户滚动 #scroll
,则第二个合成图层会移动,但无需光栅化。
就示例而言,在前面关于属性树的部分中,有六个绘制区块。 以及它们的(转换、剪裁、效果、滚动)属性树状态:
- 文档背景:文档滚动、文档剪辑、根、文档滚动。
- div(三个独立的绘制块)的水平、垂直和滚动角:文档滚动、文档剪辑、
#one
模糊处理、文档滚动。 - iframe
#one
:#one
旋转、溢出滚动片段、#one
模糊处理、div 滚动。 - Iframe
#two
:#two
缩放、文档剪辑、根、文档滚动。
合成器帧:Surface、渲染 Surface 和 GPU 纹理图块
浏览器和渲染进程管理内容的光栅化,然后将合成器帧提交给 Viz 进程以在屏幕上呈现。合成器帧表示如何将光栅化内容拼接在一起,以及如何使用 GPU 高效地绘制这些内容。
卡片
从理论上讲,渲染进程或浏览器进程合成器可以将像素栅格化为渲染程序视口的全尺寸单个纹理,并将该纹理提交给 Viz。如需显示该纹理,显示合成器只需将该单个纹理的像素复制到帧缓冲区中的适当位置(例如屏幕)即可。但是,如果该合成器想要更新哪怕一个像素,也需要重新光栅化整个视口,并向 Viz 提交新的纹理。
相反,视口会分成多个图块。每个图块由单独的 GPU 纹理图块提供支持,其中包含视口部分的光栅化像素。然后,渲染程序可以更新各个图块,甚至可以直接更改现有图块在屏幕上的位置。例如,在滚动网站时,现有功能块的位置会上移,只有偶尔需要对新图块进行光栅化,以便显示页面更靠下的内容。
四边形和 Surface
GPU 纹理图块是一种特殊的“四边形”,这只是一类纹理的花哨名称。四边形用于标识输入纹理,并指明如何对其进行转换和应用视觉效果。例如,常规内容图块有一个表示它们在图块网格中的 x、y 位置的转换。
这些光栅化图块封装在渲染传递中,后者是一个四边形列表。渲染传递不包含任何像素信息;而是包含有关在何处以及如何绘制每个四边形以生成所需像素输出的指令。每个 GPU 纹理图块都有一个绘制四边形。显示合成器只需迭代四边形列表,使用指定的视觉效果绘制每个四边形,即可为渲染传递生成所需的像素输出。可以在 GPU 上高效地为渲染四边形合成渲染四边形,因为系统会精心选择允许的视觉效果,使其直接映射到 GPU 功能。
除了光栅化功能块之外,还有其他类型的绘制四边形。例如,有些纯色绘制四边形完全没有纹理支持,有些纹理绘制四边形则适用于非平铺纹理(例如视频或画布)。
合成器帧还可以嵌入其他合成器帧。 例如,浏览器合成器会生成一个带有浏览器界面的合成器框架,并生成一个空矩形来嵌入渲染合成器内容。另一个示例是网站隔离的 iframe。这种嵌入是通过表面完成的。
当混合渲染器提交混合渲染器帧时,该帧会附带一个标识符(称为Surface ID),以允许其他混合渲染器帧通过引用嵌入该帧。Viz 会存储使用特定 Surface ID 提交的最新合成器帧。然后,其他合成器帧可以稍后通过 Surface 绘制四边形引用它,因此 Viz 知道要绘制什么。(请注意,表面绘制四边形仅包含表面 ID,而不包含纹理。)
中间渲染通道
某些视觉效果(例如许多滤镜或高级混合模式)需要将两个或更多四边形绘制到中间纹理。然后,中间纹理会绘制到 GPU 上的目标缓冲区(或可能另一个中间纹理),同时应用视觉效果。为此,合成器帧实际上包含一系列渲染传递。始终有一个根渲染传递,它是最后绘制的,其目标对应于帧缓冲区,并且可能还有更多。
“渲染通道”这一名称解释了可能存在多个渲染通道的情况。每次遍历都必须在 GPU 上按顺序执行,分多次“遍历”,而单次遍历可以在单一大规模并行 GPU 计算中完成。
聚合
多个 compositor 帧会提交给 Viz,并且需要一起绘制到屏幕上。这通过聚合阶段来实现,该阶段会将它们转换为单个汇总的 compositor 帧。聚合会使用它们指定的合成器帧来取代 Surface 四边形。这也是一个优化机会,以消除不必要的中间纹理或屏幕外内容。例如,在许多情况下,网站隔离的 iframe 的 compositor 帧不需要自己的中间纹理,并且可以通过适当的绘制四边形直接绘制到帧缓冲区。汇总阶段会根据个别渲染合成程序无法访问的全局知识,找出此类优化并加以应用。
示例
以下是代表本帖开头示例的 compositor 帧。
foo.com/index.html
surface: id=0- 渲染传递 0:绘制到输出。
- 渲染通道绘制四边形:使用 3 像素模糊处理进行绘制,并剪裁到渲染通道 0。
- 渲染传递 1:
- 为
#one
iframe 的功能块内容绘制四边形,并为每个四边形指定 x 和 y 坐标。
- 为
- 渲染传递 1:
- Surface 绘制四边形:ID 为 2,使用缩放和平移转换绘制。
- 渲染通道绘制四边形:使用 3 像素模糊处理进行绘制,并剪裁到渲染通道 0。
- 渲染传递 0:绘制到输出。
- 浏览器界面:ID=1
- 渲染传递 0:绘制到输出。
- 为浏览器界面绘制四边形(也采用平铺方式)
- 渲染传递 0:绘制到输出。
bar.com/index.html
Surface:ID=2- 渲染传递 0:绘制到输出。
- 为
#two
iframe 的内容绘制四边形,并为每个四边形指定 x 和 y 位置。
- 为
- 渲染传递 0:绘制到输出。
插图作者:Una Kravets。