我们来看看关键数据结构,它们是渲染流水线的输入和输出。
这些数据结构包括:
- 帧树由本地和远程节点组成,这些节点表示哪些网页文档位于哪个渲染进程和哪个 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 都会变为不可变,并且永远不会再发生更改。重要的是,我们还设定了一些其他限制。我们不会:
- 允许树中的任何“向上”引用。 (子项不能包含指向其父项的指针。)
- 沿树形结构“向上传递”数据(子项仅读取其子项中的信息,而不会读取其父项中的信息)。
这些限制允许我们将 fragment 用于后续布局。如果没有这些限制,我们就需要经常重新生成整个树,这会很耗费资源。
大多数布局通常是增量更新,例如,Web 应用为响应用户点击某个元素而更新界面的一小部分。理想情况下,布局的作用应该与屏幕上实际更改成比例。为了实现这一点,我们尽可能多地重复使用上一个树中的部分。这意味着(通常)我们只需要重建树干。
将来,这种不可变设计可让我们执行一些有趣的操作,例如根据需要跨线程边界传递不可变 fragment 树(以便在其他线程中执行后续阶段),生成多个树以实现流畅的布局动画,或执行并行推测性布局。它还让我们有机会实现多线程布局。
内嵌 fragment 项
内嵌内容(主要是样式文本)使用略有不同的表示法。我们在表示树的平面列表中表示内嵌内容,而不是包含方框和指针的树结构。主要优势在于,内嵌的扁平列表表示法速度快,适用于检查或查询内嵌数据结构,并且内存效率高。这对于 Web 渲染性能至关重要,因为文本渲染非常复杂,除非进行高度优化,否则很容易成为流水线中最慢的部分。
系统会按其内嵌布局子树的深度优先搜索顺序为每个内嵌格式设置上下文创建一个扁平列表。列表中的每个条目都是一个元组(对象、子代数量)。 例如,请考虑以下 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)
- (Box <span>, 1)
- (发送短信“你好”,0)
- (线条框,3)
- (第 <b> 个框,1)
- (文本“there”,0)
- (文本“.”,0)
此数据结构有很多使用方:无障碍功能 API 和几何图形 API,例如 getClientRects
和 contenteditable
。每种都有不同的要求。
这些组件通过便捷光标访问扁平数据结构。
光标具有 API,例如 MoveToNext
、MoveToNextLine
、CursorForChildren
。这种光标表示法对文本内容非常有用,原因有很多:
- 按深度优先搜索顺序迭代速度非常快。这种情况经常被使用,因为它与光标移动类似。由于它是扁平列表,因此深度优先搜索只会递增数组偏移量,从而实现快速迭代和内存局部性。
- 它提供广度优先搜索,例如,在绘制线条和内嵌框的背景时,就需要使用广度优先搜索。
- 知道后代的数量可以快速移动到下一个同级(只需按该数字递增数组偏移量即可)。
属性树
DOM 是元素树(加上文本节点),CSS 可以为元素应用各种样式。
这会以四种方式显示:
- 布局:布局约束算法的输入。
- 绘制:如何绘制和光栅元素(而不是其后代)。
- 视觉:应用于 DOM 子树的光栅/绘制效果,例如转换、滤镜和剪裁。
- 滚动:沿轴对齐和圆角剪裁以及包含的子树的滚动。
属性树是一种数据结构,用于说明视觉效果和滚动效果如何应用于 DOM 元素。 它们提供了回答以下问题的方法:给定 DOM 元素的布局大小和位置,该元素相对于屏幕的位置在哪里?以及:应使用哪个 GPU 操作序列来应用视觉效果和滚动效果?
在 Web 上,视觉效果和滚动效果非常复杂。 因此,属性树最重要的作用是将这种复杂性转换为一个精确表示其结构和含义的单一数据结构,同时移除 DOM 和 CSS 的其余复杂性。这使我们能够更自信地实现合成和滚动算法。具体而言:
- 可能容易出错的几何图形和其他计算可以集中到一个位置。
- 构建和更新属性树的复杂性被隔离到一个渲染流水线阶段。
- 与完整的 DOM 状态相比,将属性树发送到不同的线程和进程要容易得多、快得多,因此可以将其用于许多用例。
- 用例越多,我们从之上构建的几何图形缓存带来的好处就越多,因为它们可以重复使用彼此的缓存。
RenderingNG 会将属性树用于多种用途,包括:
- 将合成与绘制分离,以及从主线程合成。
- 确定最佳合成 / 绘制策略。
- 测量 IntersectionObserver 几何图形。
- 避免为屏幕外元素和 GPU 纹理图块执行工作。
- 高效且准确地使绘制和光栅失效。
- 在 Core Web Vitals 中衡量布局偏移和 Largest Contentful Paint。
每个网页文档都有四个单独的属性树:转换、剪裁、效果和滚动。(*) 转换树表示 CSS 转换和滚动。(滚动转换表示为 2D 转换矩阵)。剪辑树表示溢出剪辑。效果树表示所有其他视觉效果:不透明度、滤镜、遮罩、混合模式以及其他类型的剪辑(例如 clip-path)。滚动树表示滚动相关信息,例如滚动如何链接在一起;需要它才能在混合渲染器线程中执行滚动。属性树中的每个节点都代表 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 会生成以下显示列表,其中每个单元格都是一个显示项:
视图背景 | #blue 背景 |
#green 背景 |
#green 内嵌文本 |
---|---|---|---|
drawRect ,尺寸为 800x600,颜色为白色。 |
尺寸为 100x100、位于 0,0 位置且颜色为蓝色的 drawRect 。 |
drawRect ,尺寸为 80x18,位于位置 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>
这会生成以下显示列表,其中每个单元格都是一个显示项:
View 的背景 | #green 背景 |
#gray 背景 |
#green 内嵌文本 |
---|---|---|---|
尺寸为 800x600 且颜色为白色的 drawRect 。 |
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>
这会生成以下显示列表,其中每个单元格都是一个显示项:
View 的背景 | #scroll 背景 |
#scroll 内嵌文本 |
#orange 背景 |
#orange 内嵌文本 |
---|---|---|---|---|
尺寸为 800x600 且颜色为白色的 drawRect 。 |
drawRect ,尺寸为 100x100,位于位置 0,0,颜色为粉色。 |
drawTextBlob ,位置为 0,0,文本为“Hello world”。 |
drawRect ,尺寸为 75x200,位于位置 0,0,颜色为橙色。 |
位置为 0,0 且文本为“I'm falling”的 drawTextBlob 。 |
然后,转换属性树和绘制分块将如下所示(为简洁起见,进行了简化):
绘制分块的有序列表(即一组显示项和一个属性树状态)是渲染流水线的分层步骤的输入。整个绘制分块列表可以合并到单个合成图层中并一起光栅化,但这会导致每次用户滚动时都需要进行昂贵的光栅化。您可以为每个绘制分块创建一个合成图层,并单独进行光栅化,以避免所有重新光栅化,但这会很快耗尽 GPU 显存。分层步骤必须在 GPU 显存和降低费用之间进行权衡。一般来说,一个不错的方法是默认合并分块,而不合并具有预计会在合成器线程中更改的属性树状态的绘制分块,例如合成器线程滚动或合成器线程转换动画。
理想情况下,上述示例应生成两个合成图层:
- 一个包含绘制命令的 800x600 复合图层:
drawRect
,尺寸为 800x600,颜色为白色drawRect
,尺寸为 100x100,位于位置 0,0,粉色
- 一个包含绘制命令的 144x224 复合图层:
- 位置为 0,0 且文本为“Hello world”的
drawTextBlob
- 翻译 0,18
rotateZ(25deg)
- 尺寸为 75x200、位于 0,0 且颜色为橙色的
drawRect
- 位置为 0,0 且文本为“I'm falling”的
drawTextBlob
- 位置为 0,0 且文本为“Hello world”的
如果用户滚动 #scroll
,则第二个合成图层会移动,但无需光栅化。
示例:在上一节中介绍的属性树中,有 6 个绘制分块。以及它们的(转换、剪裁、效果、滚动)属性树状态:
- 文档背景:文档滚动、文档剪辑、根、文档滚动。
- 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 实现的。
当合成器提交合成器帧时,会附带一个标识符(称为 Surface ID),从而允许其他合成器帧以引用方式嵌入它。Viz 会存储使用特定 Surface ID 提交的最新合成器帧。然后,其他合成器帧可以稍后通过 Surface 绘制四边形引用它,因此 Viz 知道要绘制什么。(请注意,表面绘制四边形仅包含表面 ID,而不包含纹理。)
中间渲染通道
某些视觉效果(例如许多滤镜或高级混合模式)需要将两个或更多四边形绘制到中间纹理。然后,中间纹理会绘制到 GPU 上的目标缓冲区(也可能是另一个中间纹理),同时应用视觉效果。为此,合成器帧实际上包含一系列渲染传递。始终有一个根渲染传递,它是最后绘制的,其目标对应于帧缓冲区,并且可能还有更多。
之所以称为“渲染通过”,是因为可以进行多次渲染。每个传递都必须在 GPU 上以多个“传递”的形式顺序执行,而单个传递可以在单个超并行 GPU 计算中完成。
聚合
多个 compositor 帧会提交给 Viz,并且需要一起绘制到屏幕上。这通过聚合阶段来实现,该阶段会将它们转换为单个汇总的 compositor 帧。聚合会将 Surface 绘制四边形替换为它们指定的合成器帧。这也是优化掉不必要的中间纹理或屏幕外内容的好机会。例如,在许多情况下,网站隔离的 iframe 的 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。