渲染概述¶
本文档概述了渲染网页的步骤,以及 HTML 如何一步步地转换和分解成可以在 GPU 上执行的命令。
如果您是刚加入图形团队,并且对浏览器背景了解不多,可以从这里开始:)
高级概述¶
布局¶
从上图左侧开始,我们有一个由 DOM(文档对象模型)表示的文档。JavaScript 引擎将执行 JS 代码,要么更改 DOM,要么响应 DOM 生成的事件(或者两者都做)。
DOM 是一个高级描述,在与级联样式表 (CSS) 结合之前,我们不知道要绘制什么或在哪里绘制。将这两者结合起来并确定绘制内容、位置和方式是布局团队的职责。DOM 被转换为分层帧树,它嵌套视觉元素(框)。每个元素都指向样式树中的某个节点,该节点描述了它应该是什么样子——颜色、透明度等。结果是,我们现在确切地知道在哪里渲染什么、什么在什么上面(分层和混合)以及在哪个像素坐标上。这就是显示列表。
显示列表是一个轻量级的数据结构,因为它很浅——它主要指向回帧树。这有两个问题。首先,我们希望在此处跨越进程边界。到目前为止的所有内容都发生在内容进程中(其中有多个)。实际的 GPU 渲染发生在 GPU 进程中(在某些平台上)。其次,到目前为止的所有内容都是用 C++ 编写的;但是 WebRender 是用 Rust 编写的。因此,浅显示列表需要序列化为一个完全独立的二进制 Blob,它可以承受进程间通信 (IPC) 和语言切换(C++ 到 Rust)。结果就是 WebRender 显示列表。
WebRender¶
GPU 进程接收 WebRender 显示列表 Blob 并将其反序列化为场景。此场景包含的内容不仅仅是严格可见的元素;例如,为了预测滚动,我们可能会有几段文本扩展到可见页面之外。
对于给定的视口,场景会被剔除并精简为帧。这也是我们开始为 GPU 渲染准备数据结构的地方,例如将一些字体字形放入图集中以进行文本光栅化。
最后一步获取帧并向 GPU 提交命令以实际渲染它。GPU 将执行命令并合成最终页面。
软件¶
以上是启用 WebRender 的新方法。但在示意图中,您会注意到底部有一个第二个分支:这是不使用 WebRender(也不使用 Rust)的旧代码路径。在这种情况下,显示列表被转换为图层树。此树的目的是尝试避免在需要刷新页面时必须重新渲染所有内容。例如,在滚动时,我们应该能够通过主要移动周围的事物来重绘页面。但是,这要求这些“事物”仍然存在于我们上次绘制页面时。换句话说,可能保持静态且可重复使用的视觉元素需要绘制到它们自己的专用“页面”(缓存)中。然后,我们在重绘实际页面时可以重新组合(合成)所有这些。
确定哪些元素适合此目的,并在良好的性能与过多的内存使用之间取得平衡,是图层树的目的。每个“图层”都是某个元素的缓存图像。此逻辑还会考虑遮挡,例如,不要为已知被前面的某个元素完全遮挡的元素分配和渲染图层。
通过将图层树与任何新光栅化元素组合来重绘页面是合成器的任务。
即使图层无法完全重用,也可能只有它的一小部分失效。因此,有一个复杂的系统用于跟踪脏矩形,通过复制可以抢救的区域来启动更新,然后仅重绘无法抢救的区域。
事实上,这个想法可以扩展到显示列表本身的增量跟踪。遍历布局树并构建显示列表也不是廉价的,因此代码尝试在可能的情况下部分使显示列表失效并增量重建它。事实上,这种优化既用于非 WebRender 也用于 WebRender。
异步平移和缩放¶
前面我们提到场景可能包含比严格渲染可见内容(帧)所需的更多元素。这样做的原因是异步平移和缩放,简称 APZ。如果滚动和缩放可以绕过所有这些数据转换和 IPC 边界,而是直接更新某个图层的偏移量并重新合成,则浏览器会感觉更具响应性。(想想 VR 上下文中的后期锁定)
这个简单的想法引入了很多复杂性:您要光栅化多少额外内容,以及朝哪个方向?我们能负担多少内存?响应滚动事件并可能反过来对页面执行一些“有趣”操作的 JavaScript 怎么办?嵌套帧或嵌套滚动条怎么办?如果我们滚动太多以至于超过了我们已知的场景边界怎么办?
请参阅 异步平移和缩放 以了解所有这些以及更多内容。
更多细节¶
这是另一个示意图,它基本上重复了上一个示意图,但显示了更多细节。请注意,方向是相反的——数据流从右侧开始。对此表示歉意:)
需要注意的一些事项
有多个内容进程,目前有 4 个。这是出于安全原因(沙盒)、稳定性(隔离崩溃)和性能(多核机器)考虑;
理想情况下,每个“网页”都将在其自己的进程中运行以确保安全;这正在“裂变”术语下开发;
只有一个 GPU 进程,如果有的话;某些平台将其作为父进程的一部分;
此处未显示隔离 WebExtensions 的扩展进程;
对于非 WebRender,光栅化发生在内容进程中,我们将整个图层发送到 GPU/合成器进程(通过共享内存,仅使用实际 IPC 传输其元数据,例如宽度和高度);
如果 GPU 进程崩溃(错误或驱动程序问题),我们可以简单地重新启动它,重新发送显示列表,而浏览器本身不会崩溃;
浏览器 UI 只是另一组 DOM+JS,尽管它以提升的权限运行。也就是说,它的 JS 可以执行普通 JS 无法执行的操作。它位于父进程中,然后使用 IPC 来渲染它,就像普通内容一样。(IPC 箭头也指向 WebRender 显示列表,但为了减少混乱而省略了);
UI 事件首先路由到 APZ,以最大程度地减少延迟。通过在 GPU 进程中运行,我们可能可以访问诸如光栅化剪切蒙版之类的
GPU 进程会反馈到内容进程;特别是,当 APZ 滚动超出边界时,它会要求内容使用新的“显示端口”来扩大/移动场景;
即使在非 WebRender 的情况下,我们仍然可以在可以的情况下使用 GPU 进行合成;
WebRender 详解¶
将显示列表转换为 GPU 命令被分解为许多步骤和中间数据结构。
图片树中的每个元素都精确地指向空间树中的一个节点。出于清晰起见,仅显示其中一些链接(虚线)。
图片树¶
传入的显示列表使用“堆叠上下文”。例如,要渲染带有投影的某些文本,显示列表将包含三个项目
“启用阴影”,带有一些参数,例如阴影颜色、模糊大小和偏移量;
文本项目;
“弹出所有阴影”以停用阴影;
WebRender 将将其分解为两个不同的元素或“图片”。第一个表示阴影,因此它包含文本项目的副本,但修改为使用阴影的颜色,并将文本偏移阴影的偏移量。第二个图片包含要绘制在阴影顶部的原始文本。
第一个图片(阴影)需要模糊,这是图片的“合成”属性,我们将在稍后处理。
因此,基于堆栈的显示列表被转换为图片列表——或者更一般地,图片的层次结构,因为项目根据原始 HTML 嵌套。
示例视觉元素是 TextRun、LineDecoration 或 Image(例如 .png 文件)。
与 3D 渲染相比,图片树类似于场景图:它是构成“场景”的所有可绘制元素的父子层次结构,在本例中为网页。一个重要的区别是转换存储在单独的树(空间树)中。
空间树¶
空间树中的节点表示坐标转换。每当 DOM 层次结构需要子元素相对于其父元素进行转换时,我们都会向树中添加一个新的空间节点。然后,所有这些子元素都将指向此节点作为其“局部空间”参考(又名坐标系)。用传统的 3D 术语来说,它是一个场景图,但仅包含转换节点。
这些节点称为帧,如“坐标系”
参考帧对应于
<div>
;滚动帧对应于页面的可滚动部分;
粘性帧对应于某些固定位置 CSS 样式。
然后,图片树中的每个元素都指向此树中的一个空间节点,因此通过上下遍历树,我们可以找到每个元素应该渲染的绝对位置(向下遍历)以及每个元素需要多大(向上遍历)。最初,转换信息是图片树的一部分,就像在传统的场景图中一样,但出于技术原因,视觉元素及其转换被分开了。
其中一些节点是动态的。滚动帧显然可以滚动,但参考帧也可以使用属性绑定来启用与 JavaScript 的实时链接,以动态更新(目前)转换和不透明度。
与轴线对齐的变换(缩放和平移)被认为是“简单的”,并且在概念上组合成单个“CoordinateSystem”(坐标系)。当我们遇到非轴线对齐的变换时,我们会开始一个新的CoordinateSystem。我们从根节点的CoordinateSystem 0开始,例如,当我们遇到具有旋转或3D变换的参考系时,会将其切换到CoordinateSystem 1。然后这将成为其所有子节点的CoordinateSystem索引,直到我们遇到另一个(嵌套的)非简单变换,依此类推。粗略地说,只要我们在同一个CoordinateSystem中,变换栈就足够简单,以至于我们有合理的概率能够将其扁平化。例如,这让我们可以直接以最终的比例栅格化文本,优化掉一些中间图像(离屏纹理)。
布局代码将元素相对于其父元素进行定位。因此,要将元素放置在实际页面上,我们需要遍历空间树一直到根节点并应用每个变换;结果是一个LayoutToWorldTransform
。
最后一步是从世界坐标转换为设备坐标,这涉及到DPI缩放等。
WebRender术语 |
粗略类比 |
---|---|
空间树 |
场景图 - 仅变换 |
图像树 |
场景图 - 仅可绘制对象(分组) |
空间树根节点 |
世界空间 |
布局空间 |
局部/对象空间 |
图像 |
渲染目标(有点类似;参见下面的渲染任务) |
布局到世界变换 |
局部到世界变换 |
世界到设备变换 |
世界到裁剪空间变换 |
裁剪树¶
最后,我们还有一个裁剪树,其中包含裁剪形状。例如,一个圆角div会产生一个裁剪形状,并且由于div可以嵌套,所以最终会形成另一棵树。通过指向一个裁剪形状,视觉元素将根据此形状以及裁剪树中上方的所有父形状进行裁剪。
与CoordinateSystem类似,一系列简单的2D裁剪形状可以折叠成可以在顶点着色器中处理的东西,并且几乎没有额外的成本。更复杂的裁剪必须首先栅格化成蒙版,然后我们从中采样以在像素着色器中根据需要进行discard
。
总之,在场景构建结束时,显示列表变成了图像树,加上一个告诉我们什么东西相对于什么东西放置在哪里的空间树,以及一个裁剪树。
渲染任务树¶
现在在一个理想的世界中,我们可以简单地遍历图像树并开始绘制事物:每个图像一个绘制调用来渲染其内容,加上一个绘制调用将图像绘制到其父级。但是,回想一下,我们示例中的第一个图像是需要模糊的“文本阴影”。我们不能直接栅格化模糊文本,因此我们需要多个步骤或“渲染通道”来获得预期的效果
将文本栅格化到离屏渲染目标中;
应用一个或多个缩小通道,直到模糊半径合理;
应用水平高斯模糊;
应用垂直高斯模糊;
使用结果作为后续内容的输入,或将其混合到页面上的最终位置(或更一般地,在包含的父表面/图像上)。
在一般情况下,我们需要哪些通道以及有多少通道取决于图像应该如何合成(CSS 滤镜、SVG 滤镜、效果)及其参数(例如非常大的模糊半径与小的模糊半径)。
因此,我们遍历图像树并构建一个渲染任务树:每个高级抽象(例如“模糊我”)都被分解成获得效果所需的必要渲染通道。结果再次是一棵树,因为渲染通道可以有多个输入依赖项(例如混合)。
(参见游戏,这与 Frostbite Framegraph 有所呼应,因为它动态构建渲染通道 DAG 并动态分配输出的存储)。
如果存在需要首先栅格化的复杂裁剪形状,以便其输出可以作为纹理进行采样以进行裁剪/丢弃操作,那么这也将作为依赖项最终出现在此树中……(我想?)。
一旦我们有了整个依赖项树,我们就会分析它以查看哪些任务可以组合成一个通道以提高效率。我们可以在可能的情况下乒乓渲染目标,但有时依赖项跨越渲染任务树的多个级别,因此需要进行一些复制。
一旦我们确定了通道并为希望在纹理缓存中持久化的任何内容分配了存储,我们最终就开始渲染。
当将元素栅格化到图像的离屏纹理中时,我们将通过遍历变换层次结构一直到图像的变换节点来定位它们,从而产生一个Layout To Picture
变换。然后,图像将使用Picture To World
坐标变换进入页面。
缓存¶
就像软件栅格化器中的图层一样,当文档的某些部分发生变化时,并不总是需要重新绘制所有内容。WebRender 中图层的等价物是切片 - 预期一起渲染和更新的一组图像。切片是根据启发式算法和布局提示/标志自动创建的。
在实现方面,切片重用了图像的大量现有机制;事实上,它们以某种“虚拟图像”的形式实现。这种相似性是有道理的:两者都需要在缓存中分配离屏纹理,两者都将所有子节点定位并渲染到其中,然后两者都将其自身绘制到其父级作为父级绘制的一部分。
如果切片预计不会发生太大变化,我们会为其提供一个TileCacheInstance。它本身由Tile组成,其中每个Tile都会跟踪其包含的内容、正在发生变化的内容以及由于结果而是否需要失效和重新绘制。因此,更改造成的“损坏”可以本地化到单个Tile,而我们保留缓存的其余部分。如果Tile不断出现大量失效,它们将以类似四叉树的结构递归地划分自身以尝试本地化失效。(反之,如果一段时间内没有任何内容使它们失效,它们将重新组合子节点)。
驻留¶
为了发现失效的Tile,我们需要一种快速的方法来比较其上一帧的内容与当前帧的内容。为了加快此速度,我们使用驻留;类似于字符串驻留,这意味着每个TextRun
、Decoration
、Image
等等都在存储库(DataStore
)中注册,并因此通过其唯一 ID 进行引用。然后可以将缓存内容编码为 ID 列表(每种可驻留元素类型一个这样的列表)。然后差异化只是一个快速的列表比较。
回调¶
GPU 文本渲染假设各个字体字形已在纹理图集中可用。同样,SVG 不会在 GPU 上渲染。这两种输入都在场景构建期间准备就绪;通过 Rust 本身内部的线程池进行字形栅格化,以及通过不透明回调(返回到 C++)生成 blob 的 SVG。