阅读 632

浏览器渲染机制(二)

首先,回顾一下,上一篇文章的内容,我们首先介绍了content概念,知道了我们最终要的结果是显示在屏幕上的像素,了解了浏览器渲染的目标【html/css/js 转换到 正确的opengl调用来调整像素的样式】,了解了Style,了解了Layout,了解了Panit,现在所有的绘制操作已经记录在显示项列表(display items)中了,那么现在就可以开始本篇文章的阅读。

资料:docs.google.com/presentatio…

读前提要

因为浏览器渲染机制的两篇文章是基于前面资料中的ppt为出发点研究,所以文章的顺序没有按照完整的渲染顺序编写,读起来体验应该比较一般,文章中也可能存在或多或少的问题,但是我会在后续进行修正,并梳理出一篇清晰易懂的文章。

raster光栅化

显示项列表中的绘制操作由一个称为光栅化的进程执行。光栅化可以将显示项列表转换成颜色值的位图。生成的位图中每个单元都保存着这个位图的颜色值与透明度的编码(如下图FFFFFFF,其实就是RGBA的16进制表示)。

光栅化这个过程还会去解码嵌入在页面中的图像资源,绘制操作会引用压缩的数据(比如JPEG,PNG等等),而光栅化会调用适当的解码器对其进行适当的解压。

  • 过去的GPU只是作为一个内存,这些内存被OpenGL纹理对象引用,我们会将栅格化的位图放到主内存里,然后上传到GPU,用来分担内存压力。
  • 现在的GPU也可以执行生成位图的命令(“加速光栅化”),这属于硬件加速,但是无论是硬件光栅化还是软件光栅化,本质都是生成了某种内存中像素的位图。

现在仅仅是位图存储在了内存中,像素还没有显示在屏幕上。GPU光栅化并不是直接调用的GPU,而是通过Skia图形库(谷歌维护的2D图形库,在Android,Flutter,Chromium都有使用)。

Skia提供了某种抽象层,可以理解更加复杂的东西,比如贝塞尔曲线。Skia是开源的,装载在Chrome二进制文件中,而不是存在于一个单独的代码库中。当需要光栅化的显示项(display item)时,会先去调用SkCanvas上面的方法,他是Skia的调用入口,SkCanvas提供了Skia内部更多的抽象,在硬件加速的时候,它会构建另一个绘图操作缓冲区,然后对其进行刷新,在栅格化任务结束时,通过flush操作,我们获得了真正的GL指令,GL指令运行在GPU进程中。

Skia和GL指令可以运行在不同的进程中,也可以运行在同一个进程,于是产生了两种调用方式。

  1. In Process Raster
  2. Out of Process Raster

1.In Process Raster

老版本采用了这种方式,Skia是在渲染进程中执行的,他会生产GL调用指令,GPU有单独的GPU process,这种模式下Skia没办法直接进行渲染系统的调用。在初始化Skia的时候,会给它一个函数指针表(这个指针指向了GL API,但不是真正的OpenGL API,而是Chromium提供的代理)。下面的GpuChannelMsg_FlushCommandBuffers是一个命令缓冲区,会将函数指针表转换成真正的OpenGl API。

单独的GPU进程有利于隔离GL操作,提升稳定性和安全性,这种模式也成为沙箱机制(就是把不安全操作放在单独的进程去执行)。

(图中 GLES2后端映射到桌面的OpenGL 2.1)

2.Out of Process Raster

新版本把绘制的操作都放在了GPU进程里面,在GPU进程中去运行Skia,可以提升性能。

光栅化的绘制操作包装在GPU的命令缓冲区在发送到IPC通道(进程间通信方式)。

接下来就是去执行GL指令,GL指令一般是由底层的so库提供的,在Windows平台还会被转换成DirectX(微软的图形API,用于图形加速)

问题

现在我们从content 一点一点的讲到了如何转换到内存里面的像素,但是页面的呈现并不是一个静态的过程(页面滚动,js脚本执行,动画等等。。。),在发生变化的时候去重新运行整个管道代价是十分昂贵的。

那么我们如何去提高性能呢???

Compositing Update

在Layout操作完成之后,按理是去进行Paint,但是我们直接Paint代价是十分昂贵的,于是引入了一个图层合成加速的概念。

那么什么是图层合成加速?

图层合成加速是把整个页面按照一定规则划分成多个图层,在渲染的时候,只要操作必要的图层,其他的图层只要参与合成就好了,以这种方式提高了渲染的效率,完成这个工作的线程叫做:Compositor Thread(合成器线程),合成器线程还具备处理事件输入的能力,比如滚动事件,但是如果在js中进行了事件的注册和监听,它会把输入事件转发给主线程。

主线程把页面拆分成多个可以独立光栅化的层,并在另一个线程(合成器线程)中将这些层合并。

这使得某些RenderLayer拥有自己独立的缓存,它们被称作合成图层(Compositing Layer),内核会为这些RenderLayer创建对应的GraphicsLayer。

  • 拥有自己的GraphicsLayer的RenderLayer在绘制的时候会绘制在自己的缓存里面
  • 没有自己的GraphicsLayer的RenderLayer会向上查找父节点的GraphicsLayer,直到RootRenderLayer(他总是有自己的GraphicsLayer)为止,然后绘制在有GraphicsLayer的父节点的缓存里面。

这样就形成了与RenderLayer Tree对应的GraphicsLayer Tree。当Layer的内容发生变化时,只需要更新对应的GraphicsLayer即可,而单一缓存架构下,就会更新整个图层,会比较耗时。这样就提高了渲染的效率。但是过多的GraphicsLayer也会带来内存的消耗,虽然减少了不必要的绘制,但也可能因为内存问题导致整体的渲染性能下降。因而图层合成加速追求的是一个动态的平衡。

图层化的决策目前是由Blink来负责,根据DOM树生成一个图层树,并以DisplayList记录每个图层的内容。

了解了图层合成加速的概念以后,我们再来看看发生在Layout操作之后的Compositing update(合成更新),合成更新就是为特定的RenderLayer创建GraphicsLayer的过程,如下所示:

Prepaint

属性树:在描述属性的层次结构这一块,之前的方式是使用图层树的方式,如果父图层具有矩阵变换(平移、缩放或者透视)、裁剪或者特效(滤镜等),需要递归的应用到子节点,时间复杂度是O(图层数),这在极端情况下会有性能问题。

于是,为了提高性能,引入了属性树的概念,合成器提供了变换树,裁剪树,特效树等。每个图层都有若干节点id,分别对应不同属性树的矩阵变换节点、裁剪节点和特效节点。这样的时间复杂度就是O(要变化的节点),如下所示:

prepaint的过程其实就是构建属性树的过程。

commit

前面提到了我们在paint这个阶段前,多了两件事情要做,先把页面拆成了很多图层,还构建了属性树。paint阶段做了什么?paint阶段把绘制的操作放在了display item里面。

下面就来到了commit阶段,这个阶段会更新图层和属性树的副本到合成器线程。

Tiling

合成器线程接收到数据之后,不会立即开始合成,而是把图层进行分块,这里涉及到了一个叫做“分块渲染”的技术,分块渲染会将网页的缓存分成一块一块的,比如256*256的块,之后进行分块渲染。

为什么要分块渲染?

  • .GPU合成通常是使用OpenGL ES贴图实现的,这时候的缓存实际就是纹理(GL Texture),很多GPU对纹理的大小是有限制的,比如长宽必须是2的幂次方,最大不能超过2048或者4096等。无法支持任意大小的缓存。
  • 分块缓存,方便浏览器使用统一的缓冲池来管理缓存。缓冲池的小块缓存由所有WebView共用,打开网页的时候向缓冲池申请小块缓存,关闭网页是这些缓存被回收。

tiling图块也是栅格化的基本单位,栅格化会根据图块与可见视口的距离安排优先顺序进行栅格化。离得近的会被优先栅格化,离得远的会降级栅格化的优先级。这些图块拼接在一起,就形成了一个图层,如下所示:

Activate

在Commit之后,Draw之前有一个Activate操作。Raster和Draw都发生在合成器线程里的Layer Tree上,但是我们知道Raster操作是异步的,有可能需要执行Draw操作的时候,Raster操作还没完成,这个时候就需要解决这个问题。

它将LayerTree分为了

  • PendingTree:负责接收commit,然后将Layer进行Raster操作
  • ActiveTree:会从这里取出光栅化好的Layer进行draw操作。

主线程的图层树由LayerTreeHost拥有,每个图层以递归的方式拥有其子图层。Pending树、Active树、Recycle树都是LayerTreeHostImpl拥有的实例。这些树被定义在cc/trees目录下。之所以称之为树,是因为早期它们是基于树结构实现的,目前的实现方式是列表。

Draw

好,现在到了Draw这个步骤,当每个图块都被光栅化之后,合成器线程会为每个图块生成draw quads(在屏幕的指定位置绘制图块的指令,也包含了属性树里面的变换,特效等操作),这些draw quads会被封装在CompositorFrame对象里面,CompositorFrame对象也是Render Process的产物,它会被提交到Gpu Process中,我们平时提到的60fps输出帧率指的帧其实就是CompositorFrame。

Draw指的就是 把光栅化的图块,转换成draw quads的过程。

Display Compositor

Display

Draw操作完成之后,就生成了CompositorFrame,它会被输出到Gpu process,它会从多个来源的Render Process接收CompositorFrame。

多个来源:

  • Browser Process也有自己的Compositor来生成Compositor Frame,这些一般是用来绘制Browser UI(导航栏,窗口等)
  • 每次创建tab或者使用iframe,会创建一个独立的Render Process。

Display Compositor运行在Viz Compositor thread,Viz会调用OpenGL指令来渲染Compositor Frame里面的draw quads,把像素点输出到屏幕上。

VIz也是双缓冲输出的,它会在后台缓冲区绘制draw quads,然后执行交换命令最终让它们显示在屏幕上。

双缓冲机制:

在渲染的过程中,如果只对一块缓冲区进行读写,这样会导致一方面屏幕要等待去读,而GPU要等待去写,这样要造成性能低下。一个很自然的想法是把读写分开,分为:

  • 前台缓冲区(Front Buffer):屏幕负责从前台缓冲区读取帧数据进行输出显示。
  • 后台缓冲区(Back Buffer):GPU负责向后台缓冲区写入帧数据。

这两个缓冲区并不会直接进行数据拷贝(性能问题),而是在后台缓冲区写入完成,前台缓冲区读出完成,直接进行指针交换,前台变后台,后台变前台,那么什么时候进行交换呢,如果后台缓存区已经准备好,屏幕还没有处理完前台缓冲区,这样就会有问题,显然这个时候需要等屏幕处理完成。屏幕处理完成以后(扫描完屏幕),设备需要重新回到第一行开始新的刷新,这期间有个间隔(Vertical Blank Interval),这个时机就是进行交互的时机。这个操作也被称为垂直同步(VSync)。

垂直同步也会在后续进行介绍。

viz

viz是做什么的?

在 Chromium 中 viz 的核心逻辑运行在 GPU 进程中,负责接收其他进程(渲染进程?)产生的 viz::CompositorFrame(简称 CF),然后把这些 CF 进行合成,并将合成的结果最终渲染在窗口上。

CF是什么?

一个 CF 对象表示一个矩形显示区域中的一帧画面。内部存储了 3 类数据,分别是 CompositorFrameMetadata, TransferableResoruce 和 RenderPass/DrawQuad,如下图所示:

  • CompositorFrameMetadata:CF的元数据,比如画面的缩放级别,滚动区域。。。
  • TransferableResoruce :CF引用到的资源。
  • RenderPass/DrawQuad:CF包含的绘制操作。viz::RenderPass 由一系列相关的 viz::DrawQuad 构成。

CF 是 viz 中的核心数据结构,它代表某块区域中UI的一帧画面,使用 DrawQuad 来存储 UI 要显示的内容。它代表了 viz 运行时的数据流。

CF合成

CF 合成指的是viz线程把 CF 中的内容或者多个 CF 合成到一起,形成一个完整的画面。

CF渲染

CF 的渲染主要由 viz::DirectRendererviz::OutputSurface 负责,他们将合成的结果渲染到程序选择的渲染目标上去。

CC

现在我们在统一的梳理一下cc的功能作用以及cc的工作流程。

我们先回顾一下,看看上面这个图,Blink 进行了DOM,Style,Layout,comp.assign,prepaint,paint。

我们可以发现,Paint是blink和cc对接的桥梁。

整体的流程其实可以 理解成:

Blink一顿操作 -> paint生成了cc模块的数据源(cc:layer)->commit->(Tiling->)Raster->Active->draw(submit)->Viz(呈像)

就是Blink一顿操作,并在paint阶段生成cc的数据源,cc进行一系列操作并最终在draw阶段将结果(CF)提交给viz。也就是说,Blink负责网页内容绘制,cc负责将绘制的结果合成并提交给viz。

cc的架构设计

cc的设计相对比较简单,我们可以把他理解成一个多线程调度的异步流水线,运行在 Browser 进程中的 cc 负责合成浏览器非网页部分的 UI,运行在 Renderer 进程中的 cc 负责网页的合成。

chromium.googlesource.com/chromium/sr… how cc works官网有这样一张图,我觉得可以很好的反应cc的核心逻辑。

cc的多线程体现在在不同的阶段,cc运行在不同线程中,Paint 运行在 Main 线程,CommitActivateSubmit 运行在 Compositor 线程,而 Raster 运行在专门的 Raster 线程。

下面我们开始对cc流水线的各个阶段进行分析。

Paint

Paint阶段会产生cc的数据源(cc:layer树),一个cc:layer会表示一个矩形区域的UI,它有很多子类,用于存储不同类型的UI数据:

  • cc::PictureLayer。用于实现自绘型的UI组件,比如上层的各种 Button,Label 等都可以用它来实现。它允许外部通过实现 cc::ContentLayerClient 接口提供一个 cc::DisplayItemList 对象,它表示一个绘制操作的列表,记录了一系列的绘制操作,比如画线,画矩形,画圆等。通过 cc::PaintCanvas 接口可以方便的创建复杂的自绘 UI。cc::PictureLayer 还是唯需要 Raster 的 cc::Layer。它经过 cc 的流水线之后转换为一个或多个 viz::TileDrawQuad 存储在 viz::CompositorFrame 中。
  • cc::TextureLayer 对应 viz 中的 viz::TextureDrawQuad,所有想要使用自己的逻辑进行 Raster 的 UI 组件都可以使用这种 Layer,比如 Flash 插件,WebGL等。
  • cc::SurfaceLayer 对应 viz 中的 viz::SurfaceDrawQuad,用于嵌入其他的 CompositorFrame。Blink 中的 iframe 和视频播放器可以使用这种 Layer 实现。
  • cc::SolidColorLayer 用于显示纯色的 UI 组件。
  • cc::VideoLayer 以前用于专门显示视频,被 SurfaceLayer 取代。
  • cc::UIResourceLayer/cc::NinePatchLayer 类似 TextureLayer,用于软件渲染。

Blink 通过以上各种 cc::Layer 来描述 UI 并实现和 cc 的对接。由于 cc::Layer 本身可以保存 Child cc::Layer,因此给定一个 Layer 对象,它实际上表示一棵 cc::Layer 树,这个 Layer 树即主线程 Layer 树,因为它运行在主线程中,并且主线程有且只有一棵 cc::Layer 树。

Commit

Commit 阶段的核心作用是将保存在 cc::Layer 中的数据提交到 cc::LayerImpl 中。cc::LayerImplcc::Layer 一一对应,只不过运行在 Compositor 线程中(也称为 Impl 线程)。在 Commit 完成之后会根据需要创建 Tiles 任务,这些任务被 Post 到 Raster 线程中执行。

Tiling+Raster

在 Commit 阶段创建的 Tiles 任务(cc::RasterTaskImpl)在该阶段被执行。Tiling 阶段最重要的作用是将一个 cc::PictureLayerImpl 根据不同的 scale 级别,不同的大小拆分为多个 cc::TileTask 任务。Raster 阶段会执行每一个 TileTask,将 DisplayItemList 中的绘制操作 Playback 到 viz 的资源中。 由于 Raster 比较耗时,属于渲染的性能敏感路径,因此Chromium在这里实现了多种策略以适应不同的情况。这些策略主要在两方面进行优化,一方面是 Raster 结果(也就是资源)存储的位置,一方面是 Raster 中 Playback 的方式。这些方案被封装在了 cc::RasterBufferProvider 的子类中,下面一一进行介绍:

  • cc::GpuRasterBufferProvider 使用 GPU 进行 Raster,Raster 的结果直接存储在 SharedImage 中。(前文以及提到过的硬件加速)
  • cc::OneCopyRasterBufferProvider 使用 Skia 进行 Raster,结果先保存到 GpuMemoryBuffer 中,然后再将 GpuMemoryBuffer 中的数据通过 CopySubTexture 拷贝到资源的 SharedImage 中。GpuMemeoryBuffer 在不同平台有不同的实现,也并不是所有的平台都支持,在 Linux 平台上底层实现为 Native Pixmap(来自X11中的概念),在 Windows 平台上底层实现为 DXGI,在 Android 上底层实现为 AndroidHardwareBuffer,在 Mac 上底层实现为 IOSurface。
  • cc::ZeroCopyRasterBufferProvider 使用 Skia 进行 Raster,结果保存到 GpuMemoryBuffer 中,然后使用 GpuMemoryBuffer 直接创建 SharedImage。
  • cc::BitmapRasterBufferProvider 使用 Skia 进行 Raster,结果保存到共享内存中。

Raster最终会产生一个资源,这个资源被记录在了cc:PictureLayerImpl 中,他们会在Draw阶段被放在CF中。

Activate

在 Impl 端有三个 cc::LayerImpl 树,分别是 Pending,Active,Recycle 树。Commit 阶段提交的目标其实就是 Pending 树,Raster 的结果也被存储在了 Pending 树中。

在 Activate 阶段,Pending 树中的所有 cc::LayerImpl 会被复制到 Active 树中,为了避免频繁的创建 cc::LayerImpl 对象,此时 Pending 树并不会被销毁,而是退化为 Recycle 树。

和主线程 cc::Layer 树不同,cc::LayerImpl 树并不是自己维护树形结构的,而是由 cc::LayerTreeImpl 对象来维护 cc::LayerImpl 树的。三个 Impl 树分别对应三个 cc::LayerTreeImpl 对象。

Draw

Draw 阶段并不执行真正的绘制,而是遍历 Active 树中的 cc::LayerImpl 对象,并调用它的 cc::LayerImpl::AppendQuads 方法创建合适的 viz::DrawQuad 放入 CompositorFrame 的 RenderPass 中。cc::LayerImpl 中的资源会被创建为 viz::TransferabelResource 存入 CompositorFrame 的资源列表中。至此一个 viz::CompositorFrame 对象创建完毕,最后通过 cc::LayerTreeFrameSink 接口将该 CompositorFrame 发送到给 viz 进程(GPU进程)进行渲染。

总结

现在整个渲染流程就基本结束了,前端的代码已经变成了屏幕上的像素点了。

文章分类
前端
文章标签