您现在的位置是:首页 > 经典句子

跨平台Web Canvas渲染引擎架构的设计与思考(内含实现方案)

作者:付梓时间:2024-05-14 14:37:01分类:经典句子

简介  文章浏览阅读899次,点赞7次,收藏12次。从转行到现在,差不多两年的时间,虽不能和大佬相比,但也是学了很多东西。我个人在学习的过程中,习惯简单做做笔记,方便自己复习的时候能够快速理解,现在将自己的笔记分享出来,和大家共同学习。开源分享:【大厂前端面试题解析+

点击全文阅读


工作原理其实比较简单,一句话就可以说明白。首先封装图形API(OpenGL、Vulkan、Metal…)以支持WebGL和Canvas 2D矢量图渲染能力,对下桥接到不同操作系统和容器之上,对上通过language binding将渲染能力以标准化接口透出到业务容器的JS上下文。

举个例子,以下是淘宝小程序容器Canvas组件的渲染流程,省略了「亿」点点细节。

Canvas在Android上其实是一个SurfaceView/TextureView,通过同层渲染的方式嵌入到UCWebView中。开发者调用Canvas JS接口,最终会生成一系列的渲染指令送到GPU,渲染结果写入图形缓冲区,在合适时机通过SwapBuffer交换缓冲区,然后操作系统进行图层合成和送显。

  分层架构

从业务形态上看,不管是小程序、小游戏还是其他容器,实现上都是相似的,如下图所示,通过JSBinding实现标准Canvas接口,开发者可以通过适配在上面跑web游戏引擎(laya、egret、threejs…),下边是JS引擎,这一层可以有不同的技术选型,如老牌的V8、JSC,后起之秀quickjs、hermes等等,在这之下就是Canvas核心实现了,这一层需要分别提供WebGL、Canvas2D的能力。WebGL较为简单,基本与OpenGLES接口一一对应,简单封装即可。

Canvas 2D如果要从零开始实现的话相对来说会复杂一些(特别是文字、图片、路径的渲染等),不过技术选型上仍然有很多选择比如cairo、skia、nanovg等等,不管使用哪种方案,只要是硬件渲染,其backend只有vulkan/OpenGLES/metal/Direct3D等几种选择。

目前OpenGL使用最为广泛,还可以通过google的Angle项目适配到vulkan/directx等不同backend上。Canvas实现层之下是WAL窗体抽象层,这一层的职责就是为渲染提供宿主环境,通过EGL/EAGL等方式绑定GL上下文与平台窗体系统。下文将对相关模块的实现分别进行介绍。考虑到性能、可移植性等因素,除了与平台/容器桥接的部分需要使用OC/Java等语言实现之外,其余部分基本采用C++实现。

JS Binding机制

JS引擎通常会抽象出VM、JSContext、JSValue、GlobalObject等概念,VM代表一个JS虚拟机实例,拥有独立的堆栈空间,有点类似进程的概念,不同的VM相互是隔离的(因此在v8中以v8::Isolate命名),一个VM中可以有多个JSContext,JSContext代表一个JS的执行上下文,可以执行JS代码,JSValue代表一个JS值类型,可以是基础数据类型也可以是Object类型,每个JSContext中都会拥有一个GlobalObject对象,GlobalObject在JSContext整个生命周期内,都可以直接进行访问,它默认是可读可写的,因此可以在GlobalObject上绑定属性或者函数等,这样就可以在JSContext执行上下文中访问它们了。

要想在JS环境中使用Canvas,需要将Canvas相关接口注入到JS环境,正如Java JNI、Python Binding、Lua Binding等类似,JS引擎也提供了Extension机制,称之为JS Binding,它允许开发者使用c++等语言向JS上下文中注入变量、函数、对象等。

// V8函数绑定示例

static void LogCallback(const v8::FunctionCallbackInfov8::Value& args){…}

// Create a template for the global object and set the

// built-in global functions.

v8::Localv8::ObjectTemplate global = v8::ObjectTemplate::New(isolate);

global->Set(v8::String::NewFromUtf8(isolate, “log”),

v8::FunctionTemplate::New(isolate, LogCallback));

// Each processor gets its own context so different processors

// do not affect each other.

v8::Persistentv8::Context context =

v8::Context::New(isolate, nullptr, global);

以小程序环境为例,小程序容器初始化时,会分别创建Render和Worker,Render负责界面渲染,Worker负责执行业务逻辑,拥有独立JSContext,Canvas提供了createCanvas()和createOffscreenCanvas() 全局函数需要绑定到该JSContext的GlobalObject上,因此Worker需要有一个时机通知canvas注入API,从小程序视角来看,Worker依赖Canvas显然不合理,因此小程序提供了插件机制,每个插件都是一个动态库,Canvas作为插件先注册到Worker,随后Worker创建之后会扫描一遍插件,依次dlopen每个插件并执行插件的初始化函数,将JSContext作为参数传给插件,这样插件就可以向JSContext中绑定API了。

关于JSEngine和Binding有两个需要注意的点(以V8为例):

关于线程安全。JSContext通常设计为非线程安全的,需要注意不要在非JS线程中访问JS资源。其次,在V8中一个线程可能有多个JSContext,需要使用v8::Context::Scope切换正确的JSContext;

关于Binding对象的生命周期。众所周知,C与JS语言内存管理方式不一样,C需要开发者手动管理内存,JS由虚拟机管理。对于C++ Binding的JS对象的生命周期理论上需要跟普通JS对象一致,因此需要有一种机制,当JS对象被GC回收时,需要通知到C++ Binding对象,以便执行相应的析构函数释放内存。事实上,JS引擎通常会提供让一个JS对象脱离/回归GC管理的机制,且JS对象的生命周期均有钩子函数可以进行监听。V8中有Handle(句柄)的概念,Handle分为LocalHandle、PersistentHandle、Weak PersistentHandle。LocalHandle在栈上分配,由HandleScope控制其作用域,超出作用域即被标记为可释放,PersistentHandle在堆上分配,生命周期长,通常需要开发者显式通过PersistentHandle#Reset的方式释放对象。通过SetWeak函数可以让一个PersistentHandle转为一个Weak PersistentHandle,当没有其他引用指向Weak句柄时就会触发回调,开发者可以在回调中释放内存。

最后再讨论下Binding代码如何跨JSEngine的问题。

当前主流的JSEngine有V8、JavaScriptCore、QuickJS等,如果需要更换JSEngine的话,Binding代码需要重写,成本有点高(Canvas接口非常多),因此理论上可以再封装一个抽象层,屏蔽不同引擎的差异,对外提供一致接口,基于抽象层编写一次Binding代码,就可以适配到多个JSEngine(使用IDL生成代码是另外一条路),目前我们使用了UC团队提供的JSI SDK适配多JS引擎。

平台窗体抽象层设计

要想做到跨平台,就需要设计一个抽象的平台胶水层,胶水层的职责是对下屏蔽各个平台间的实现差异,对上为Canvas提供统一的接口操作Surface,封装MakeCurrent、SwapBuffer等行为。实现上可以借鉴Flutter Engine,Flutter Engine的Shell模块对GL胶水层做了较好的封装,可以无缝接入到Android、iOS等主流平台,扩展到新平台比如鸿蒙OS也不在话下。

当设计好GL胶水层接口后,分平台进行实现即可。以Android为例,如果想创建一个GL上下文并绘制到屏幕上,必须通过EGL绑定平台窗体环境,即Surface或者是ANativeWindow对象,而能够创建Surface的View只有SurfaceView和TextureView(如果是一个全屏游戏没有其他Native View的话,还可以考虑直接使用NativeActivity,这里先不考虑这种情况),应该如何选择?这里可以从渲染原理上分析下两者的差异再分场景进行决策。

先看SurfaceView的渲染流程,简单来说分为如下几个步骤(硬件加速场景):

通过SurfaceView申请的Surface创建EGL环境;

Surface通过dequeueBuffer向SurfaceFlinger请求一块GraphicBuffer(可理解为一块内存,用于存储绘图数据),随后所有绘制内容都会写到这块Buffer上;

当调用EGL swapBuffer之后,会将GraphicBuffer入队到BufferQueue;

SurfaceFlinger在下一个VSYNC信号到来时,取GraphicBuffer,进行合成上屏;

对比SurfaceView,TextureView的渲染流程更长一些,主要经历以下关键阶段:

通过TextureView绑定的SurfaceTexture创建EGL环境;

生产端(Surface)通过dequeueBuffer从SurfaceTexture管理的BufferQueue中获得一块GraphicBuffer,后续所有绘制内容都会写到这块Buffer上;

当调用EGL swapBuffer之后,会将GraphicBuffer入队到SurfaceTexture内部的BufferQueue;

随后TextureView触发frameAvailable,通知系统进行重绘(view#invalidate);

系统在下次VSYNC信号到来的时候进行重绘,在UI线程生成DisplayList,然后驱动渲染线程进行真正渲染;

渲染线程会将步骤2中的GraphicBuffer作为一张特殊的纹理(GL_TEXTURE_EXTERNAL_OES)上传,与View Hierarchy上其他视图一起通过SurfaceFlinger进行合成;

由以上两者的渲染流程对比可发现,SurfaceView的优势是渲染链路短、性能好,但是相比普通的View,没法支持Transform动画,通常全屏的游戏、视频播放器优先选择SurfaceView。而TextureView则弥补了SurfaceView的缺陷,它跟普通的View完全兼容,同样会走HWUI渲染,不过缺陷是内存占用比SurfaceView高,渲染需要在多个线程之间同步整体性能不如SurfaceView。

具体如何选择需要分场景来看,以我们为例,我们这边同时支持在SurfaceView和TextureView中渲染,但是由于目前主要服务于淘宝小程序互动业务,而在小程序容器中,需要通过UC提供的WebView同层渲染技术将Canvas嵌入到WebView中,由于业务上需要同时支持全屏和非全屏互动,且需要支持各种CSS效果,因此只能选择EmbedSurface模式,而EmbedSurface不支持SurfaceView,因此我们选择的是TextureView。

渲染管线

Canvas渲染引擎的核心当然是渲染了,上层的互动业务的性能表现,很大程度取决于Canvas的渲染管线设计是否足够优秀。这一部分会分别讨论Canvas2D/WebGL的渲染管线技术选型及具体的方案设计。

  Canvas2D Rendering Context
基础能力

从Canvas2D标准来看,引擎需要提供的原子能力如下:

路径绘制,包括直线、矩形、贝塞尔曲线等等;

路径填充、描边、裁剪、混合,样式与颜色设置等;

图元变换(transform)操作;

文本与位图渲染等。

软件渲染 VS 硬件渲染

软件渲染指的是使用CPU渲染图形,而硬件渲染则是利用GPU。使用GPU的优势一方面是可以降低CPU的使用率,另外GPU的特性(擅长并行计算、浮点数运算等)也使其性能通常会更好。但是GPU在发展的过程中,更多关注的是三维图形的运算,二维矢量图形的渲染似乎关注的较少,因此可以看到像freetype、cairo、skia等早期主要都是使用CPU渲染,虽然khronos组织推出了OpenVG标准,但是也并没有推广开来。目前主流的移动设备都自带GPU,因此对于Canvas2D的技术选型来说,我们更倾向于使用硬件加速的引擎,具体分析可以接着往下看。

技术选型

Canvas2D的实现成本颇高,从零开始写也不太现实,好在社区中有很多关于Canvas 2D矢量绘制的库,这里仅列举了一部分比较有影响力的,主要从backend、成熟度、移植成本等角度进行评判,详细如下表所示。

Cairo和Skia是老牌的2D矢量图形渲染引擎了,成熟度和稳定性都很高,且同时支持软件与硬件渲染(cairo的硬件渲染支持比较晚),性能上通常skia占优(也看具体case),不过体积大的多。nanovg和GCanvas以小而美著称,性能上GCanvas更优秀一点,nanovg需要经过特别的定制与调优,文字渲染也不尽如人意。Blend2D是一个后起之秀,通过引入并发渲染、JIT编译等特性宣称比Caico性能更优,不过目前还在beta阶段,且硬伤是只支持软件渲染,没办法利用GPU硬件能力。最后ejecta项目最早是为了在非浏览器环境支持W3C Canvas标准,有OpenGLES backend,自带JSBinding实现,不过可惜的是现在已无人维护,性能表现也比较一般。

我认为技术选型没有最好的方案,只有最适合团队的方案,从实现角度来看,以上列举的方案均可以达到目标,但是没有银弹,选择不同的方案对技术同学的要求、产品的维护成本、性能&稳定性、扩展性等均会产生深远的影响。以我们团队为例,业务形态上看主要服务于淘系互动小程序业务,面向的是淘宝开放平台上的商家、ISV开发者等, 我们对于Canvas渲染引擎最主要的诉求是跨平台渲染一致性、性能、稳定性,因此nanovg、blend2d、ejecta不满足需求。从团队资源的角度看,我们更倾向于使用开箱即用、维护成本低的方案,ejecta、GCanvas不满足需求。最后从组织架构上看,我们团队主要负责手淘跨平台相关产品,其中包括Flutter,而Flutter自带了skia,它同时满足开箱即用、高性能&高可用等特点,而且由于Chromium同样使用了skia,因此渲染一致性也得到了保证,所以复用skia对于我们来说是相对比较优的选择,但与此同时我们的包大小也增大了很多,未来需要持续优化包大小。

渲染管线细节

这里主要介绍下基于Skia的Canvas 2D渲染流程。JSBinding代码的实现较简单,可以参考chromium Canvas 2D的实现,这里就不展开了。

看下渲染的流程,关键步骤如下,其中4~6步与当前Flutter Engine基本保持一致:

开发者创建Canvas对象,并通过 Canvas.getContext(‘2d’) 获取2D上下文;

通过2D上下文调用Canvas Binding API,内部实际上通过SkCanvas调用Skia的绘图API,不过此时并没有绘制,而是将绘图命令记录下来;

当平台层收到Vsync信号时,会调度到JS线程通知到Canvas;

Canvas收到信号后,停止记录命令,生成SkPicture对象(其实就是个DisplayList),封装成PictureLayer,添加到LayerTree,发送到GPU线程;

GPU线程Rasterizer模块收到LayerTree之后,会拿到Picture对象,交给当前Window Surface关联的SkCanvas;

这个SkCanvas先通过Picture回放渲染命令,再根据当前backend选择vulkan、GL或者metal图形API将渲染指令提交到GPU。

文字渲染

文字渲染其实非常复杂,这里仅作简要介绍。

目前字体的事实标准是OpenType和TrueType,它们通过使用贝塞尔曲线的方式定义字体的形状,这样可以保证字体与分辨率无关,可以输出任意大小的文字而不会变形或者模糊。

众所周知,OpenGL并没有提供直接的方式用于绘制文字,最容易想到的方式是先在CPU上加载字体文件,光栅化到内存,然后作为GL纹理上传到GPU,目前业界用的最广泛的是 Freetype 库,它可以用来加载字体文件、处理字形,生成光栅化的位图数据。如果每个文字对应一张纹理显然代价非常高,主流的做法是使用 texture atlas 的方式将所有可能用到的文字全部写到一张纹理上,进行缓存,然后根据uv坐标选择正确的文字,有点类似雪碧图。

以上还只是文字的渲染,当涉及到多语言、国际化时,情况会变得更加复杂,比如阿拉伯语、印度语中连字(Ligatures)的处理,LTR/RTL布局的处理等,Harfbuzz 库就是专门用来干这个的,可以开箱即用。

从Canvas2D的文字API来看,只需要提供文本测量和基本的渲染的能力即可,使用OpenGL+Freetype+Harfbuzz通常就够用了,但是如果是一个GUI应用如Android、Flutter,那么还需要处理断句断行、排版、emoji、字体库管理等逻辑,Android提供了一个minikin库就是用来干这个的,Flutter中的txt模块二次封装了minikin,提供了更友好的API。目前我们的Canvas引擎的文字渲染模块跟Flutter保持一致,直接复用libtxt,使用起来比较简单。

上面涉及到的一些库链接如下:

Freetype: https://www.freetype.org/

Harfbuzz: https://harfbuzz.github.io/

minikin: https://android.googlesource.com/platform/frameworks/minikin/

flutter txt:

https://github.com/flutter/engine/blob/master/third_party/txt

位图渲染

位图渲染的基本流程是下载图片 -> 图片解码 -> 获得位图像素数据 -> 作为纹理上传GPU -> 渲染位图,拿到像素数据后,就可以上传到GPU作为一张纹理进行渲染。不过由于上传像素数据也是个耗时过程,可以放到独立的线程做,然后通过Share GLContext的方式使用纹理,这也是Flutter目前的做法,Flutter会使用独立的IO线程用于异步上传纹理,通过Share Context与GPU线程共享纹理,与Flutter不一样的是,我们的图片下载和解码直接代理给原生的图片库来做。

  WebGL Rendering Context

WebGL实现比2D要简单的多,因为WebGL的API基本与OpenGLES一一对应,只需要对OpenGLES API简单进行封装即可。这里不再介绍OpenGL本身的渲染管线,而主要关注下WebGL Binding层的设计,从技术实现上主要分为单线程模型和双线程模型。

单线程模型即直接在JS线程发起GL调用,这种方式调用链路最短,在一般场景性能不会有大的问题。但是由于WebGL的API调用与业务逻辑的执行都在JS线程,而某些复杂场景每帧会调用大量的WebGL API,这可能会导致JS线程阻塞。

通过profile可以发现,这个场景JS线程的阻塞可能并不在GPU,而是在CPU,原因是JS引擎Binding调用本身的性能损耗也很可观,有一种优化方案是引入Command Buffer优化JSBinding链路损耗,如下图所示。

总结

=============================================================

从转行到现在,差不多两年的时间,虽不能和大佬相比,但也是学了很多东西。我个人在学习的过程中,习惯简单做做笔记,方便自己复习的时候能够快速理解,现在将自己的笔记分享出来,和大家共同学习。

开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

个人将这段时间所学的知识,分为三个阶段:

第一阶段:HTML&CSS&JavaScript基础

第二阶段:移动端开发技术

第三阶段:前端常用框架

推荐学习方式:针对某个知识点,可以先简单过一下我的笔记,如果理解,那是最好,可以帮助快速解决问题;如果因为我的笔记太过简陋不理解,可以关注我以后我还会继续分享。

大厂的面试难在,针对一个基础知识点,比如JS的事件循环机制,不会上来就问概念,而是换个角度,从题目入手,看你是否真正掌握。所以对于概念的理解真的很重要。

点击全文阅读

郑重声明:

本站所有活动均为互联网所得,如有侵权请联系本站删除处理

我来说两句