大家好,我是柒八九
。今天这篇文章是Chromium
最新「渲染架构」 RenderingNG
的译文系列文章的「第二篇」 -- 在RenderingNG
渲染过程中关键数据结构和它们所担当的角色。
针对RenderingNG的介绍可以参考之前的文章。
frame
树frame
被称为「远程帧」Skia
进行光栅化RenderingNG
表示如何将栅格化的内容「拼接在一起」,并使用GPU
有效地绘制它的数据格式Quad
,它只是一类纹理瓦片的别称在渲染流程中出现了大致「五种」比较重要的数据结构。
Blink
渲染器所消费我们通过一个例子,来解释刚才所说的数据结构。大致的文档结构如下:
// 主 frame 为foo.com
<html>
<div style="overflow: hidden;
width: 100px;
height: 100px;">
// 子 frame (foo.com/etc)
<iframe
style="filter: blur(3px);
transform: rotateZ(1deg);
width: 100px;
height: 300px"
id="one"
src="foo.com/etc"></iframe>
</div>
// 子 frame (bar.com)
<iframe
style="top:200px;
transform: scale(1.1);
translateX(200px)"
id="two"
src="bar.com"></iframe>
</html>
Chrome 有时候会选择一个与「父框架」不同的渲染进程来处理跨域框架cross-origin frame。
在上面的提供文档结构中,一共出现了「3个框架结构」。
在站点隔离Site Isolation机制的作用下,Chromium
将会启用两个渲染进程来渲染该页面结构。
❝每个渲染进程都有「属于自己的」对网页内容进行描述的
frame
树 ❞
❝一个渲染在不同进程的
frame
被称为远程帧Remote Frame ❞
「远程帧」在被引用的渲染进程像「占位符」一样,仅仅保存了用于标识该frame
「最基础」信息,例如:尺寸信息等。也就是说,远程帧中不包含对应帧在渲染过程中需要任何有用信息。
与之相反,本地帧Local Frame包含了对应frame
的「所有数据」(DOM树和样式数据)转化为可以渲染和显示的东西所需的所有信息。(这里有点绕口)
❝渲染管线rendering pipeline是以本地帧树片段local frame tree fragment的粒度来操作的 ❞
假如存在如下的文档结构:
// 主 frame 为foo.com
<html>
// 子 frame (bar.com)
<iframe src="bar.com">
// 子 frame (foo.com/etc)
<iframe src="foo.com/etc"></iframe>
</iframe>
</html>
foo.com
作为主frame
, bar.com
作为子frame
,而foo.com/etc
作为bar.com
的子frame
。
尽管,现在也和最上面的示例一样,也存在两个渲染进程,但是此时存在三个 「局部frame
树片段」,两个存在于与foo.com
所对应的渲染进程中,另外一个位于与bar.com
所对应的渲染进程中。
为了将多个「本地帧树」合成一个「合成器帧」, Viz会同时从三个本地帧的「根节点」请求对应的合成器帧,随后将其聚合到一起。
虽然,主帧foo.com
和子帧foo.com/other-page
位于同一个帧树上,并且同一个「渲染进程」中处理他们的渲染过程,但是,它们位于不同的局部frame树片段local frame tree fragments所以存在「不同」的文档生命周期document lifecycles。由于这个原因,不可能在一次更新中为两者生成一个合成器帧。渲染过程没有足够的信息来将foo.com/etc
生成的合成器帧直接合成到foo.com
主帧的合成器帧中。例如,在foo.com
进程外的bar.com
可能通过CSS或者其他方式改变foo.com/ect
对应的显隐。
❝像设备比例因子device scale factor和视口大小viewport size这样的「视觉属性」会影响到渲染输出,并且「必须在本地帧树片段之间同步」。 ❞
每个本地框架树片段的根部都有一个与之相关的widget
对象。视觉属性的更新先到主frame的部件,然后再从上到下传播到其余部件。
当视口大小改变时
这个过程「不是即时」的,所以复制的视觉属性也包括一个同步令牌sync token。Viz合成器使用这个「同步令牌」来等待「所有」本地frame树片段提交一个具有当前同步令牌的合成器帧。这个过程避免了混合具有不同视觉属性的合成器frame。
❝「不可变的片段树」是渲染管道的「布局阶段」的输出 它表示页面上所有元素的位置和大小 ❞
❝「每个片段fragment代表一个DOM元素的一部分」 ❞
通常情况下,每个元素只有一个片段,但如果在渲染管道中绘制Paint阶段被分割Split到不同的页面,则会有更多的片段。
在布局之后,每个片段都变得不可改变Immutable,不再被改变。
还设置了一些额外的限制。
这些限制使我们能够在随后的布局中「重新使用」一个片段。
大多数「布局」都是典型的增量更新incremental updates,例如,一个网络应用在用户点击某个元素时更新一小部分用户界面。理想情况下,「布局」应该只做与屏幕上「实际改变的内容」相对应的工作。我们可以通过尽可能多地「重复使用」以前的树的部分来实现这一点。
「内联内容」使用一个稍微不同的表示方法。我们使用一个扁平化flat的「列表」来表示内联内容。主要的「好处」是,内联内容的扁平化列表表示是快速的,对检查或查询内联数据结构很有用,而且「缓存效率高」。
「扁平化的列表」是按照其内联布局子树的深度优先搜索depth-first search的顺序为每个内联格式化上下文lnline formatting context创建的。
❝列表中的每个条目都是一个存有(「对象,后代数量」)等特定信息的元组Tuple。 ❞
例如,考虑这个DOM。
<div style="width: 0;">
<span style="color: blue; position: relative;">北宸</span>
<b>南蓁</b>
.
</div>
「宽度属性被设置为0,以便在 "北宸 "和 "南蓁"之间进行换行」。从而形成两个「Line Box」
这种情况的内联格式化上下文被表示为一棵树时,它看起来像下面这样。
{
"Line box": {
"Box <span>": {
"Text": "北宸"
}
},
"Line box": {
"Box <b>": {
"Text": "南蓁"
}
},
{
"Text": "."
}
}
对应的扁平list 如下:「每个条目都是(对象,后代数量)的元组信息」
<span>
, 1)<b>
, 1)这个数据结构有「很多消费者」:可访问性API和几何API,如getClientRects
,和contenteditable
。每个消费者都有不同的要求。这些组件通过一个游标cursor访问扁平化数据结构。
游标有MoveToNext
, MoveToNextLine
, CursorForChildren
等API。
❝众所周知,「DOM」是一棵由元素(加上文本节点)组成的树,而CSS可以对元素应用各种样式 ❞
属性对应四种类型的效果处理:
❝「属性树」是解释「视觉和滚动效果」如何应用于DOM元素的数据结构 ❞
它们提供了回答问题的方法,例如:一个给定布局尺寸和位置的DOM元素,它应该被放置在相对于屏幕的哪个位置?以及:应该使用什么顺序的GPU操作来应用视觉和滚动效果?
网站中的「视觉效果」和「滚动效果」在它们的全貌中是非常复杂的。因此,属性树所做的最重要的事情是「将这种复杂性转化为一个单一的数据结构」,精确地表示它们的结构和意义,同时去除DOM和CSS的其余复杂性。
例如:
RenderingNG
将属性树用于很多目的。
Core Web Vitals
中的布局偏移和最大内容的绘制❝每个Web文档都有四个「独立的属性树」:变换Transform、剪切clip、视觉效果effect和滚动Scroll ❞
❝属性树中的「每个节点代表一个DOM元素应用的滚动或视觉效果」 ❞
如果它恰好有「多种效果」,那么对于同一个元素,每棵树上可能有「不止一个属性树节点」。
每个属性树的拓扑结构topology就像DOM树一样,分散排布。例如,如果有三个DOM元素有溢出剪切overflow clip,那么将有「三个剪切树节点」,剪切树的结构将遵循溢出剪切之间的「包含块关系」。
❝每个DOM元素都有一个「属性树状态属性」,它是一个「4元组」(
transform
,clip
,effect
,scroll
),表示该元素的「最近的祖先」如何剪切、变换和效果该元素节点。 ❞
这非常方便,因为有了这些信息,我们就能准确地知道适用于该元素的剪切、变换和效果的「列表」,以及它们的「顺序」。这告诉我们它在屏幕上的位置以及如何绘制它。
// 主 frame 为foo.com
<html>
<div style="overflow: scroll;
width: 100px;
height: 100px;">
// 子 frame (foo.com/etc)
<iframe
style="filter: blur(3px);
transform: rotateZ(1deg);
width: 100px;
height: 300px"
id="one"
src="foo.com/etc"></iframe>
</div>
// 子 frame (bar.com)
<iframe
style="top:200px;
transform: scale(1.1);
translateX(200px)"
id="two"
src="bar.com"></iframe>
</html>
这里根据一些属性生成了「四类属性树」。
❝一个显示项包含低级别的绘图命令,可以用
Skia
进行光栅化 ❞
显示项通常「很简单」,只有几个绘画命令,比如画一个边框或背景。「绘画操作」在布局树和相关片段上按照CSS顺序进行「迭代」,产生一个显示项列表。
例如:
<div id="green"
style="background:green;
width:80px;
height:18px"
>
Hello world
</div>
<div id="blue"
style="width:100px;
height:100px;
background:blue;
position:absolute;
top:0;
left:0;
z-index:-1;"
/>
这个HTML和CSS将产生以下「显示列表」,其中每项是一个显示项目。(从上到下依次排列)
drawRect
命令绘制大小为800x600(视图大小),颜色为白色的区块drawRect
命令在「以视图为参照物」的位置为(0,0)处绘制大小为100x100,颜色为「蓝色」的区块drawRect
命令在「以视图为参照物」的位置为(8,8)处绘制大小为80x18,颜色为「绿色」的区块drawTextBlob
命令在(8,8)处绘制Hello world
文本信息在上面的例子中,绿色 div 在 「DOM 顺序」中位于蓝色 div 之前,但 「CSS 绘制顺序」要求负 z-index 的蓝色 div 在绿色 div 之前绘制。
❝显示项大致对应于CSS绘制顺序规范的「原子步骤」 ❞
「一个DOM元素可能导致多个显示项」,例如#green有一个背景显示项和另一个内联文本显示项。这种粒度对于表现CSS绘画顺序规范的复杂性是很重要的,例如由负边距产生的交错。
<div id="green"
style="background:green;
width:80px;
height:18px;">
Hello world
</div>
<div id="gray"
style="width:35px;
height:20px;
background:gray;
margin-top:-10px;">
</div>
这个HTML和CSS将产生以下「显示列表」,其中每项是一个显示项目。(从上到下依次排列)
drawRect
命令绘制大小为800x600,颜色为白色的区块drawRect
命令在「以视图为参照物」的位置为(8,8)处绘制大小为80x18,颜色为「绿色」的区块drawRect
命令在「以视图为参照物」的位置为(8,16)处绘制大小为35x20,颜色为「灰色」的区块drawTextBlob
命令在(8,8)处绘制Hello world
文本信息「显示项目列表可以被后续更新复用」。如果一个「布局对象」在绘制树的过程中没有改变,它的显示项目就会从「以前的」列表中复制出来。
有一个针对层叠上下文Stacking Context的优化:如果在一个层叠上下文中没有布局对象的变更,那么绘制游标会「直接」跳过该上下文,并且从「之前的」显示列表中复制整个显示序列。
❝当前的属性树状态在绘制过程中被保持,显示项目列表被「划分为」拥有「相同属性树状态」的显示项目块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>
这个HTML和CSS将产生以下「显示列表」,其中每项是一个显示项目。(从上到下依次排列)
drawRect
命令绘制大小为800x600,颜色为白色的区块drawRect
命令在「以视图为参照物」的位置为(0,0)处绘制大小为100x100,颜色为「粉色」的区块drawTextBlob
命令在(0,0)处绘制Hello world
文本信息drawRect
命令在「以视图为参照物」的位置为(0,0)处绘制大小为75x200,颜色为「橘色」的区块drawTextBlob
命令在(0,0)处绘制I'm falling
文本信息属性树和绘制块关系如下:
❝绘画块的有序列表,即显示项目组和属性树状态,作为「渲染管道」图层化Layerize步骤的输入数据 ❞
整个「绘制块列表」可以合并成一个合成层并一起栅格化,但这需要在用户每次滚动时进行昂贵的栅格化操作。作为「优化处理」,可以为每个「绘制块」创建一个合成层并「单独」光栅化,以避免所有的重新光栅化,但这将很快耗尽GPU内存。
所以,图层化步骤必须在「GPU内存」和「减少事物变化时的成本」之间做出权衡。一个好的方法是「默认合并图块」,也就是「不对具有属性树状态的绘制块进行合并处理」,这些属性树状态可能会在「合成器线程」上发生变化,比如合成器线程的滚动或合成器线程的变换动画。
前面的例子最好能产生两个合成的图层。
800x600
的合成层(默认图块合并)drawRect
命令绘制尺寸为800x600,颜色为白色的图块drawRect
命令绘制位于相对于视图(0,0)位置,尺寸为100x100,且颜色为粉色的图块144x244
的合成层 (拥有属性树的图块)drawTextBlob
命令在(0,0)位置,绘制Hello world
文本信息drawRect
命令绘制位于相对于视图(0,0)位置,尺寸为75x200,且颜色为橘色的图块drawTextBlob
命令在(0,0)位置,绘制I'm falling
文本信息如果用户滚动#「scroll」,第二个合成层会被移动,但不需要栅格化。
在Chromium 最新渲染引擎--RenderingNG最后的示例中,我们得知,浏览器和渲染进程管理内容的「光栅化」,然后将「合成器帧」提交给Viz进程以呈现给屏幕。
❝合成器帧是
RenderingNG
表示如何将栅格化的内容「拼接」在一起,并使用GPU
有效地绘制它的数据格式 ❞
理论上,渲染进程或浏览器进程中的合成器compositor可以「将像素栅格化为渲染器视口的单一纹理」,并将该纹理提交给Viz。为了显示它,显示合成器只需将单个纹理中的像素复制到「帧缓冲区」的适当位置(例如,屏幕)。然而,如果该合成器想要「更新哪怕是一个像素」,它就需要对「整个视口」进行重新光栅化处理,并向Viz提交一个新的纹理。
相反,「视口被划分为瓦片Tile」。
❝一个「单独」的GPU纹理瓦片为每个瓦片提供了视口部分的光栅化像素 ❞
然后,渲染器可以更新单个瓦片,甚至只是改变现有瓦片在屏幕上的位置。例如,当滚动一个网站时,现有瓦片的位置会向上移动,只是需要为更远的页面内容栅格化一个新瓦片。
上面的图片有四张「瓦片」。当滚动发生时,「第五块」瓦片开始出现。
「GPU纹理瓦片」是一种特殊的Quad,它只是一类纹理瓦片的别称
❝「Quad」描述纹理的输入信息,并指出如何对其进行「转换」和「应用视觉效果」。 ❞
例如,内容瓦片有一个变换,表示它们在瓦片网格中的x、y位置。
这些栅格化的瓦片被包裹在「一个渲染通道」中,它是一个「quad」的列表。「渲染通道不包含任何像素信息」;相反,它有关于在哪里以及如何绘制每个quad
所需像素输出的指示。
❝每个GPU纹理瓦片都有一个「quad」 ❞
显示合成器只需要在quad
列表中进行迭代,用指定的视觉效果绘制每一个quad
,以产生渲染通道所需的像素输出。渲染通道的绘制quad
合成可以在GPU上有效地完成,因为允许的视觉效果是经过精心挑选的,可以直接映射到GPU的特性上。
除了光栅化瓦片之外,还有其他类型的quad
。例如,有一些完全不依赖纹理机制的纯色quad
,或者用于「视频」或「画布」等纹理绘制quad
。
❝「一个合成器帧也有可能嵌入另一个合成器帧」 ❞
例如,浏览器合成器会产生一个带有浏览器用户界面的合成器帧,以及一个「空的区域」以便于将渲染合成器的内容嵌入其中。另一个例子是存在「站点隔离」的多个iframe
之间。这种嵌入是表面Surface通过完成的。
当一个合成器提交一个合成器帧时,它伴随着一个用于区分合成帧的标识符,即「表面ID」。最新提交的带有特定「表面ID」的合成器帧被Viz储存起来。「另一个」合成器帧随后可以通过「表面quad」来引用它,因此Viz知道要绘制什么。(注意,表面quad
只包含表面ID,而不是纹理。)
一些「视觉效果」,如许多滤镜或高级混合模式,需要将两个或更多的quad
合并到一个「中间纹理」中。然后,中间纹理被绘制到GPU上的目标缓冲区(或者可能是另一个中间纹理),同时应用视觉效果。为了实现这一点,「一个合成器帧实际上包含一个渲染通道的列表」。并且总是有一个根渲染通道,它是最后绘制的。
每个通道必须在GPU上「按顺序执行」,分为多个 "阶段",而单个阶段可以在「单个大规模并行的GPU计算」中完成。
❝多个合成器帧被提交给Viz,它们需要被一起绘制到屏幕上。这是由一个聚合阶段Aggregation完成的,该阶段将它们转换为一个「单一的、聚合的」合成器帧 ❞
聚合将「表面quad」替换成他们指定的合成器帧。
这也是一个优化不必要的中间纹理或屏幕外内容的机会。例如,在很多情况下,一个独立网站的iframe
的合成器帧不需要它自己的中间纹理,可以通过绘制quad
直接绘制到框架缓冲区。聚合阶段会找出这样的优化,并根据单个渲染合成器无法访问的全局来应用这些优化。
以本文开头的例子做讲解
// 主 frame 为foo.com
<html>
<div style="overflow: hidden;
width: 100px;
height: 100px;">
// 子 frame (foo.com/etc)
<iframe
style="filter: blur(3px);
transform: rotateZ(1deg);
width: 100px;
height: 300px"
id="one"
src="foo.com/etc"></iframe>
</div>
// 子 frame (bar.com)
<iframe
style="top:200px;
transform: scale(1.1);
translateX(200px)"
id="two"
src="bar.com"></iframe>
</html>
foo.com/index.html
surface: ID =0quad
:以3px的模糊度绘制,并夹入渲染通道0quad
:ID =2,用比例和平移变换绘制quad
quad
bar.com/index.html
surface: ID=2quad
「分享是一种态度」,这篇文章,是一篇译文,算是一个自我学习过程中的一种记录和总结。主要是把自己认为重要的点,都罗列出来。同时,也是为大家节省一下「排雷和踩坑的时间」。当然,可能由于自己认知能力所限,有些点,没能表达很好。
参考资料: