人的一切痛苦,本质上都是对自己无能的愤怒 -- 王小波
前言
大家好,我是柒八九
。今天这篇文章是Chromium
最新渲染架构RenderingNG
的译文系列文章的第二篇 -- 在RenderingNG
渲染过程中关键数据结构和它们所担当的角色。
针对RenderingNG的介绍可以参考之前的文章。
时间不早了,干点正事哇。
简明扼要
- {帧树|Frame Tree}: 由本地和远程节点组成
- 每个渲染进程都有属于自己的对网页内容进行描述的
frame
树 - 一个渲染在不同进程的
frame
被称为远程帧 - {渲染管线|rendering pipeline}是以{本地帧树片段| local frame tree fragment}的粒度来操作的
- 像{设备比例因子| device scale factor}和{视口大小| viewport size}这样的视觉属性会影响到渲染输出,并且必须在本地帧树片段之间同步
- 不可变的片段树是渲染管道的布局阶段的输出
它表示页面上所有元素的位置和大小
每个{片段| fragment}代表一个DOM元素的一部分 - 内联片段信息列表中的每个条目都是一个存有(对象,后代数量)等特定信息的{元组| Tuple }
- 属性树是解释视觉和滚动效果如何应用于DOM元素的数据结构
- 每个Web文档都有四个独立的属性树:{变换| Transform }、{剪切| clip }、{视觉效果| effect }和{滚动| Scroll }
- 显示list中的显示项包含低级别的绘图命令,可以用
Skia
进行光栅化 - 显示项大致对应于CSS绘制顺序规范的原子步骤
- 绘画块的有序列表,即显示项目组和属性树状态,作为渲染管道{图层化|Layerize}步骤的输入数据
- 合成器帧是
RenderingNG
表示如何将栅格化的内容拼接在一起,并使用GPU
有效地绘制它的数据格式 - 视口被划分为{瓦片|Tile}>
- Quad描述纹理的输入信息,并指出如何对其进行转换和应用视觉效果
GPU纹理瓦片是一种特殊的Quad
,它只是一类纹理瓦片的别称 - 每个GPU纹理瓦片都有一个quad
文章概要
- {帧树|Frame Tree}
- {不可变的片段树|The immutable fragment tree}
- {属性树|Property trees}
- {显示列表和绘画块|Display lists and paint chunke}
- {合成器帧|Compositor frame}:{表面| surface}、{渲染表面| render surface}、{GPU 纹理瓦片| GPU texture tile}
前置知识简讲
在渲染流程中出现了大致五种比较重要的数据结构。
- {帧树|Frame Tree}: 由本地和远程节点组成,表示对应的文档信息应该被哪个渲染进程中的
Blink
渲染器所消费 - {不可变的片段树|Immutable Fragment Tree}:代表布局阶段的信息产生
- {属性树|Property Tree}:代表了针对文档进行{转换|transform}、{剪切|clip}、{视觉效果|effect}和{滚动|scroll}等操作后的数据格式,并为后续的渲染流程所使用。
- {显示列表和绘画块|Display lists and paint chunks}: 将被传入到合成线程中,并被光栅化和分层算法所消费
- {合成器帧|Compositor frame}:将渲染接口和GPU纹理瓦片封装到一起,并使用GPU进行绘制
我们通过一个例子,来解释刚才所说的数据结构。大致的文档结构如下:
// 主 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> 复制代码
1. {帧树|Frame Tree}
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。
2. {不可变的片段树|The immutable fragment tree}
不可变的片段树是渲染管道的布局阶段的输出
它表示页面上所有元素的位置和大小
每个{片段| fragment}代表一个DOM元素的一部分
通常情况下,每个元素只有一个片段,但如果在渲染管道中{绘制| Paint}阶段被{分割| Split}到不同的页面,则会有更多的片段。
在布局之后,每个片段都变得{不可改变| Immutable },不再被改变。
还设置了一些额外的限制。
- 一个孩子节点不能有指向其父辈的指针
- 数据是单向的(某个节点只能访问其子节点的数据信息,而不能从父级获取)
这些限制使我们能够在随后的布局中重新使用一个片段。
大多数布局都是典型的{增量更新| incremental updates},例如,一个网络应用在用户点击某个元素时更新一小部分用户界面。理想情况下,布局应该只做与屏幕上实际改变的内容相对应的工作。我们可以通过尽可能多地重复使用以前的树的部分来实现这一点。
{内联|Lnline}片段信息
内联内容使用一个稍微不同的表示方法。我们使用一个{扁平化| 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 如下:每个条目都是(对象,后代数量)的元组信息
- (Line box, 2)
- (Box , 1)
- (Text "北宸", 0)
- (Line box, 3)
- (Box , 1)
- (Text "南蓁", 0)
- (Text ".", 0)
这个数据结构有很多消费者:可访问性API和几何API,如getClientRects
,和contenteditable
。每个消费者都有不同的要求。这些组件通过一个{游标| cursor}访问扁平化数据结构。
游标有MoveToNext
, MoveToNextLine
, CursorForChildren
等API。
3. {属性树|Property trees}
众所周知,DOM是一棵由元素(加上文本节点)组成的树,而CSS可以对元素应用各种样式
属性对应四种类型的效果处理:
- {布局| Layout }:作为布局阶段的数据输入
- {绘制| Paint }:如何绘制和栅格化当前元素
- {视觉处理| Visual }:将{变换| transforms}、{过滤| filters}和{剪切| clipping}等产生的效果应用于DOM 子树
- {滚动| Scrolling }:包含子树的轴对齐和圆角剪切和滚动
属性树是解释视觉和滚动效果如何应用于DOM元素的数据结构
它们提供了回答问题的方法,例如:一个给定布局尺寸和位置的DOM元素,它应该被放置在相对于屏幕的哪个位置?以及:应该使用什么顺序的GPU操作来应用视觉和滚动效果?
网站中的视觉效果和滚动效果在它们的全貌中是非常复杂的。因此,属性树所做的最重要的事情是将这种复杂性转化为一个单一的数据结构,精确地表示它们的结构和意义,同时去除DOM和CSS的其余复杂性。
例如:
- 将潜在的容易出错的几何图形和其他计算可以集中到一个地方
- 将建立和更新属性树的繁琐操作隔离到一个渲染管道中
- 与完整的DOM状态相比,将属性树发送到不同的线程和进程中要容易得多,也快得多
- 更能合理利用缓存机制
RenderingNG
将属性树用于很多目的。
- 将合成与绘制分开,将合成与主线程分开
- 确定一个最佳的合成/绘制策略
- 避免为屏幕外元素和GPU纹理工作
- 有效而准确地使绘制和光栅失效
- 测量
Core Web Vitals
中的布局偏移和最大内容的绘制
每个Web文档都有四个独立的属性树:{变换| Transform }、{剪切| clip }、{视觉效果| effect }和{滚动| Scroll }
- 变换树表示CSS变换和滚动
- 剪切树表示表示溢出剪切
- 视觉效果树表示所有其他的视觉效果:{不透明度| opacity}、{过滤器| filters}、{遮罩| masks}、{混合模式| blend modes}
- 滚动树表示关于滚动的信息
属性树中的每个节点代表一个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> 复制代码
这里根据一些属性生成了四类属性树。
4. {显示列表和绘画块|Display lists and paint chunke}
一个显示项包含低级别的绘图命令,可以用
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将产生以下显示列表,其中每项是一个显示项目。(从上到下依次排列)
- 绘制{视图| view}背景 :
drawRect
命令绘制大小为800x600(视图大小),颜色为白色的区块 - 绘制#blue 背景:
drawRect
命令在以视图为参照物的位置为(0,0)处绘制大小为100x100,颜色为蓝色的区块 - 绘制#green 背景:
drawRect
命令在以视图为参照物的位置为(8,8)处绘制大小为80x18,颜色为绿色的区块 - 处理#green 行内文本:
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将产生以下显示列表,其中每项是一个显示项目。(从上到下依次排列)
- 绘制{视图| view}背景 :
drawRect
命令绘制大小为800x600,颜色为白色的区块 - 绘制#green 背景:
drawRect
命令在以视图为参照物的位置为(8,8)处绘制大小为80x18,颜色为绿色的区块 - 绘制#gray 背景:
drawRect
命令在以视图为参照物的位置为(8,16)处绘制大小为35x20,颜色为灰色的区块 - 处理#green 行内文本:
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将产生以下显示列表,其中每项是一个显示项目。(从上到下依次排列)
- 绘制{视图| view}背景 :
drawRect
命令绘制大小为800x600,颜色为白色的区块 - 绘制#scrolll 背景:
drawRect
命令在以视图为参照物的位置为(0,0)处绘制大小为100x100,颜色为粉色的区块 - 绘制#scroll 行内文本:
drawTextBlob
命令在(0,0)处绘制Hello world
文本信息 - 处理#orange 背景:
drawRect
命令在以视图为参照物的位置为(0,0)处绘制大小为75x200,颜色为橘色的区块 - 绘制#orange 行内文本:
drawTextBlob
命令在(0,0)处绘制I'm falling
文本信息
属性树和绘制块关系如下:
绘画块的有序列表,即显示项目组和属性树状态,作为渲染管道{图层化|Layerize}步骤的输入数据
整个绘制块列表可以合并成一个合成层并一起栅格化,但这需要在用户每次滚动时进行昂贵的栅格化操作。作为优化处理,可以为每个绘制块创建一个合成层并单独光栅化,以避免所有的重新光栅化,但这将很快耗尽GPU内存。
所以,图层化步骤必须在GPU内存和减少事物变化时的成本之间做出权衡。一个好的方法是默认合并图块,也就是不对具有属性树状态的绘制块进行合并处理,这些属性树状态可能会在合成器线程上发生变化,比如合成器线程的滚动或合成器线程的变换动画。
前面的例子最好能产生两个合成的图层。
- 一个
800x600
的合成层(默认图块合并)
drawRect
命令绘制尺寸为800x600,颜色为白色的图块drawRect
命令绘制位于相对于视图(0,0)位置,尺寸为100x100,且颜色为粉色的图块
- 一个
144x244
的合成层 (拥有属性树的图块)
drawTextBlob
命令在(0,0)位置,绘制Hello world
文本信息- 平移(0,18)
- 围绕Z轴旋转顺时针旋转25度
drawRect
命令绘制位于相对于视图(0,0)位置,尺寸为75x200,且颜色为橘色的图块drawTextBlob
命令在(0,0)位置,绘制I'm falling
文本信息
如果用户滚动#scroll,第二个合成层会被移动,但不需要栅格化。
5. {合成器帧|Compositor frame}:{表面| surface}、{渲染表面| render surface}、{GPU 纹理瓦片| GPU texture tile}
在Chromium 最新渲染引擎--RenderingNG最后的示例中,我们得知,浏览器和渲染进程管理内容的光栅化,然后将合成器帧提交给Viz进程以呈现给屏幕。
合成器帧是
RenderingNG
表示如何将栅格化的内容拼接在一起,并使用GPU
有效地绘制它的数据格式
{瓦片|Tile}
理论上,渲染进程或浏览器进程中的{合成器| compositor}可以将像素栅格化为渲染器视口的单一纹理,并将该纹理提交给Viz。为了显示它,显示合成器只需将单个纹理中的像素复制到帧缓冲区的适当位置(例如,屏幕)。然而,如果该合成器想要更新哪怕是一个像素,它就需要对整个视口进行重新光栅化处理,并向Viz提交一个新的纹理。
相反,视口被划分为{瓦片|Tile}。
一个单独的GPU纹理瓦片为每个瓦片提供了视口部分的光栅化像素
然后,渲染器可以更新单个瓦片,甚至只是改变现有瓦片在屏幕上的位置。例如,当滚动一个网站时,现有瓦片的位置会向上移动,只是需要为更远的页面内容栅格化一个新瓦片。
上面的图片有四张瓦片。当滚动发生时,第五块瓦片开始出现。
{Quad and surfaces|Quad and Surfaces}
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计算中完成。
{合成|Aggregation}
多个合成器帧被提交给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 =0
- 渲染通道 0 : 绘制到输出
- 绘制
quad
:以3px的模糊度绘制,并夹入渲染通道0
- 渲染通道 1:
- 为#one的帧绘制带有x/y位置信息的
quad
- 表面绘制
quad
:ID =2,用比例和平移变换绘制
- 浏览器 UI surface: ID =1
- 渲染通道 0 : 绘制到输出
- 为 浏览器UI绘制
quad
bar.com/index.html
surface: ID=2
- 渲染通道 0 : 绘制到输出
- 为#two的帧绘制带有x/y位置信息的
quad
后记
分享是一种态度,这篇文章,是一篇译文,算是一个自我学习过程中的一种记录和总结。主要是把自己认为重要的点,都罗列出来。同时,也是为大家节省一下排雷和踩坑的时间。当然,可能由于自己认知能力所限,有些点,没能表达很好。如果大家想看原文,“墙裂推荐”看原文。
参考资料:
- 原文地址 需要🪜