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

打印 上一主题 下一主题

主题 579|帖子 579|积分 1737


  • 系统在下次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链路损耗,如下图所示。

这个方案的思路是如许的,JS侧封装一个假造的 WebGLRenderingContext 对象,API与W3C标准同等,但是实在现并不调用Native侧的JSBinding接口,而是按照指定规则对WebGL Call进行编码,存储到ArrayBuffer中,然后在特定机遇(如收到VSync信号大概时执行到同步API时)通过一个Binding接口(上图flushCommands)将ArrayBuffer一次性传到Native侧,之后Native对ArrayBuffer中的指令查表、解析,最后执行渲染,如许做可以淘汰JSBinding的调用频率,假设ArrayBuffer中存储了N条同步指令,那么只需要执行1次Binding调用,淘汰了(N-1)次Binding调用的耗时,从而提升了整体性能。

双线程模型指的是将GL调用转移到独立的渲染线程执行,解放JS线程的压力。具体的做法可以参考chromium GPU Command Buffer(注意这里的Command Buffer与上面提到的解决的并不是同一个问题,不要混淆),思路是如许的,JS线程收到Binding调用后,并不直接提交,而是先encode到Command Buffer(通常利用Ring buffer数据布局)缓存起来,随后在渲染线程中访问CommandBuffer,进行Decode,调用真正的GL下令,双线程模型实现要复杂的多,需要考虑Lock Free&WaitFree、同步、参数拷贝等问题,写的不好可能性能还不如单线程模型。
最后再提一句,在chromium中,不仅实现了多线程的WebGL渲染模型,还支持了多进程Command Buffer的模型,利用多进程模型可以有效屏蔽各种硬件兼容性问题,带来更好的稳固性。
  离屏渲染

离屏Canvas在Web中还是个实验特性,不外由于实在用性,目前主流的小游戏/小步调容器根本都实现了。利用到离屏Canvas的主要是2D的 drawImage 接口以及WebGL的 texImage2D/texSubImage2D 接口,WebGL通常会利用离屏Canvas渲染文本大概做一些游戏场景的预热等等。
离屏渲染通常会利用PBuffer大概FBO来实现:


  • PBuffer: 需要通过PBuffer创建新的GL Context,每次渲染都需要切换GL上下文;
  • FBO: FBO是OpenGL提供的能力,通过 glGenFramebuffers 创建FBO,可以绑定并渲染到纹理,而且不需要切换GL上下文,性能通常会更好些(没有做过测试,严格来说也不一定,由于目前移动端GPU主要采用TBR架构,切换FrameBuffer可能会造成Tile Cache失效,导致性能下降)。
除了上面两种方案之外,Android上还可以通过SurfaceTexture(本质上是EGLImage)实现离屏渲染,不外这是一种特殊的纹理类型,只能绑到GL_TEXTURE_EXTERNAL_OES上。特别地,对于2D来说,还可以通过CPU软件渲染来间接实现离屏渲染。
离屏渲染中比较影响性能的地方是上传离屏Canvas数据到在屏Canvas,如果先readPixels再upload性能会比较差。解决方案是将离屏Canvas渲染到纹理,再通过OpenGL shareContext的方式与在屏Canvas共享纹理。如许,对于在屏Canvas来说就可以直接复用这个纹理了,具体点,对于在屏2D Context的drawImage来说,可以基于该纹理创建texture backend SkImage,然后作为图片上传。对于在屏WebGL Context的texImage2D来说,有几种方式,一种方式提供非标API,调用该API将直接绑定离屏Canvas所对应的纹理,开发者不消自己再创建纹理。另一种方式是texImage2D时,通过FBO拷贝离屏纹理到开发者当前绑定的纹理上。尚有一种方式是在texImage2D时,先删除用户当前绑定的纹理,然后再绑定到离屏Canvas所对应的纹理,这种方案有一定利用风险,由于被删除的纹理可能还会被开发者用到。

帧同步机制
所谓帧同步指的是游戏渲染循环与操作系统的表现子系统(在Android平台即为SurfaceFlinger)和底层硬件之间的同步。众所周知,在GPU加快模式下,我们在屏幕上看到的游戏大概动画需要先在CPU上完成游戏逻辑的运算,然后生成一系列渲问鼎令,再交由GPU进行渲染,GPU的渲染效果写入FrameBuffer,终极会由表现设备刷新到屏幕。
表现设备的刷新频率(即刷新率)通常是固定的,移动设备主流的刷新频率是60HZ,也即每秒刷新60次,但是GPU渲染的速度却是不固定的,它取决于绘制帧的复杂水平。这会导致两个问题,一是帧率不稳固,用户体验差;二是当GPU渲染频率高于刷新频率时,会导致丢帧、抖动大概屏幕tearing的现象。
解决这个问题的方案是引入双缓冲和垂直同步(VSYNC),双缓冲指的是预备两块图形缓冲区,BackBuffer给GPU用于渲染,FrontBuffer由表现设备进行表现,如许可以进步系统的吞吐量,进步帧率并淘汰丢帧的情况。垂直同步是为了和谐绘制的步调与屏幕刷新的步调同等,GPU必须等到屏幕完备刷新上一帧之后再进行渲染,由于GPU渲染频率高于刷新率通常是没故意义的。在PC机上早期的垂直同步是用软件模拟的,不外NVIDA和AMD后来分别出了G-SYNC和FreeSync,需要各家的硬件共同。
而Android平台上是在Android4.x引入了VSYNC机制,在之后的版本还引入了RenderThread、TripleBuffer(三缓冲)等关键特性,极大进步了Android应用的流畅度。
以下是Android平台的渲染模型,一次完备的渲染(GPU加快下)大致会颠末如下几个阶段:


  • HWC产生VSYNC变乱,分别发给SurfaceFlinger合成进程与App进程;
  • App UI线程(通过Choreographer)收到VSYNC信号后,处理用户输入(input)、动画、视图更新等变乱,然后将画图指令更新到DisplayList中,随后驱动渲染线程执行绘制;
  • 渲染线程解析DisplayList,调用hwui/skia画图模块将渲问鼎令发给GPU;
  • GPU进行绘制,绘制效果写入图形缓冲区(GraphicBuffer);
  • SurfaceFlinger进程收到VSYNC信号,取图形缓存区内容进行合成;
  • 表现设备刷新,屏幕终极表现相应画面;

值得注意的是,默认情况下App与SurfaceFlinger同时收到VSYNC信号,App生产第N帧,而SurfaceFlinger合成第N-1帧画面,也即App第N帧产生的数据在第N+1次VSYNC到来时才会表现到屏幕。VSYNC+双缓冲的模型保证了帧率的稳固,但是会导致输出延迟,且并不能解决卡顿、丢帧等问题,当UI线程有耗时操作、渲染场景过于复杂、App内存占用高等等场景就会导致丢帧。丢帧从系统层面上看缘故原由主要是由于CPU/GPU不能在规定的时间内生产帧数据导致SurfaceFlinger只能利用前一帧的数据去合成,Android通过引入VSYNC offset、Triple Buffer等策略进行了一定水平的优化,不外要想帧率流畅主要还是得靠开发者分场景去做针对性的优化。

与原生的渲染流程雷同,Canvas渲染引擎的绘制流程也是由VSYNC驱动的,在Android平台上可以通过 Choreographer注册VSYNC Callback,当VSYNC信号到来时,就可以执行一次Canvas 2D/WebGL的绘制。以WebGL单线程模型为例,一次绘制过程如下:


  • 在JS线程,游戏引擎调用Canvas WebGLContext执行WebGL Binding调用;
  • 在Android UI线程,Canvas收到平台VSYNC信号;
  • 通过消息队列调度到JS线程,在JS线程遍历Canvas实例,找到全部WebGL渲染上下文;
  • 对每个需要执行渲染(dirty)的WebGL上下文执行SwapBuffer;
这里实在还涉及到一个问题,如果当前Canvas渲染的内容未发生变革,是否还需要监听VSYNC信号? 这就是所谓的OnDemand Rendering和Continuously Rendering模型。在 OnDemand 模型下,应用层调用了Canvas API就会标志状态为dirty同时向系统请求VSYNC,下一次收到VSYNC callback时执行绘制,而在Continuously 模型下,会不停向系统请求下一次VSYNC,在VSYNC Callback时再去判定是否需要绘制。理论上OnDemand模型更为合理,克制了不必要的通信,功耗更低, 不外Continuously模型实现上更为简单。Android与Flutter均采用了OnDemand模型,而我们则同时支持两种模式。
以上仅仅考虑了Canvas自身的渲染流程,在上文窗体情况搭建中,Android平台我们终极选择了TextureView作为Canvas的Render Target,那么在引入了TextureView之后,从操作系统的角度看,宏观的渲染流程又是怎样的呢? 我画了这张图,为简单起见,这里以TextureView Thread代表Canvas的渲染线程。

TextureView基于SurfaceTexture,由于没有独立Surface,渲染合成依赖于Android HWUI,TextureView生产完一帧的数据后,还需触发一次view invalidate,再走一次ViewRootImpl#doTraversal流程,因此整体流水线更长,从图上可知,在没有丢帧的情况下,表现也会延迟,第N帧的绘制在第N+2帧才会表现到屏幕上。
同时,TextureView下卡顿、丢帧的情况也更为复杂,偶然纵然FPS很高但是依然感觉卡顿,下面是常见的两种丢帧情况。
第一种丢帧情况是第N帧TextureView线程渲染超时,导致错过了N+1帧UI线程的绘制。

第二种丢帧情况是UI线程卡顿而TextureView线程渲染较快,导致第N+1帧时UI线程上传的是TextureView第N+1帧的纹理,而第N帧的纹理被忽略掉了。

以上可见,在游戏等重渲染场景,SurfaceView是比TextureView更好的选择,别的,分析卡顿通常需要对整个系统的底层机制有较深了解才气顺利解决问题,这对开发者也提出了更高的要求。
调试
最后讨论下调试的话题。对于Canvas渲染引擎,传统的调试方法如日志、断点调试、systrace对于问题诊断依然非常有效。不外由于引擎会用到Java/OC/C++/JS等语言,调试的链路大大延长,开发者需要根据履历大概对问题的分析进行针对性的调试,有一定的难度。除了利用上面几种方式调试之外,还可以利用一些GPU调试工具辅助,下面简要介绍下。
  Gapid(Graphic API Debugger)

Gapid是Android平台提供的GPU调试工具,功能非常强盛,它可以Inspect 恣意Android应用的OpenGLES/Vulkan调用,无论是系统的GL上下文(如hwui/skia等)还是应用自己创建的GL上下文都能追踪到,细化到每一帧的话,可以查看该帧全部的Draw Call、GL状态机的运行状态、FrameBuffer内容、创建的Texture、Shader、Program等等。通过这个工具除了可以验证渲染正确性之外,还可以辅助性能调优(如频仍的上下文切换、大纹理的分配等等)、诊断可能发生的GPU内存泄露等等。

总结一下

面试前要精心做好预备,简历上写的知识点和原理都需要预备好,项目上多想想难点和亮点,这是面试时能和别人不一样的地方。
尚有就是表现出自己的谦虚好学,以及对于未来连续进阶的规划,企业招人更偏爱稳固的人。
万事开头难,但是步调员这一条路对峙几年后发展空间还优劣常大的,统统重在对峙。
开源分享:【大厂前端面试题解析+焦点总结学习笔记+真实项目实战+最新解说视频】
为了资助各人更好更高效的预备面试,特别整理了《前端工程师面试手册》电子稿文件。


前端面试题汇总


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

数据人与超自然意识

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表