canvaskit-wasm —— 在浏览器中直接使用 skia 的能力渲染 sketch 文件

2,477 阅读10分钟

Skia 是 Flutter、Chromium 以及 Android 项目中都使用了的 2d 图形库。为了能够让 Flutter 在浏览器中运行,Google 的工程师将 skia 编译成了能够在浏览器中运行的 WebAssembly 版本。
在看到 flutter 能够在 web 中运行的那一刻,我第一个想到的是,能不能直接在浏览器中直接进行设计并生成代码呢?
Figma 是一个能够在浏览器中运行的类似 Sketch 的扁平化 UI 设计工具,它使用了 C++ 和 WebGL,这一套技术栈看着就比较复杂,要做类似的产品似乎很困难。
但有了 canvaskit,在浏览器中要实现类似功能,就很有希望。因为我们可以确认的是,chrome、flutter 有的渲染能力,我们通过使用 canvaskit 都能够实现。

于是,第一步我想尝试开始做一个在浏览器中渲染 sketch 设计稿文件的项目。
Demo 地址:skeditor.github.io/
仓库地址:github.com/skeditor/sk…

以下是实现该功能的一些总结。

首先,为什么要用 canvaskit

即然 chrome 中已经使用了 skia 那么,我们还为什么还需要再用一个 wasm 版本的 skia。 这怎么可能比直接编译成 native 的性能高呢?

canvas 2d drawPath 代码

从 chromium 代码看 canvas 2d 确实也是调用了 skia 的接口。按理说 canvas 2d 性能应该更好。 确实是这样,如果只看单次绘制的话,canvas 2d 会比较好,但如果多次绘制,在有缓存的情况下 canvaskit 能够做到更好的优化。具体原因有:

1 Canvaskit 能够缓存绘制中间对象

比如绘制 path,如果使用 canvas 2d 的话,每次绘制都需要调用 canvas 2d 上的绘制指令方法。

class SomeView {
    paint() {
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.lineTo(100, 0);
        ctx.lineTo(100,100);
        ctx.stroke();
        ctx.fill();
    }
}

而如果使用 canvaskit 的话 path 只需要初始化一次,在后续绘制时使用之前的 path 引用能够有更好的性能。


class SomeView {
    constructor() {
        this.path = this.createPath();
        this.paint = this.createPaint();
    }
    
    createPath() {
        const path = new Canvaskit.Path();
        path.moveTo(0, 0);
        path.lineTo(100,0);
        path.lineTo(100,100);
        return path;
    }  
    
    createPaint() {
        const paint = new Canvaskit.Paint();
        paint.setColor(Canvaskit.RED);
        return paint;
    }
    
    paint() {
        skCanvas.drawPath(this.path, this.paint);
    }
}

2 避免了 gpu 内存和 cpu 内存交换

有一些看起来比较简单的操作,使用 canvas 2d 的话其实比较笨拙。 比如如果要设置一个 Group 图层的透明度,canvas 2d 只提供了 globalAlpha 这个属性可以设置。 其效果是会影响每一次绘制命令,stroke、fill、drawImage 等等,而不是作用在一个整体上。

image.png

如果使用 canvas 2d,要正确的在 Group 上应用透明度,那么需要创建一个临时的 canvas 将内容绘制进去,然后通过 getImageData 方法拿回像素点信息,一个个处理完成后,通过 putImageData 设置回去。 参考 paper.js 的 BlendMode 实现,就是用的这种方法。 (BlendMode 和 透明度都需要应用到整体,同理,blur 也是)

putImageDatagetImageData 方法调用是比较慢的,因为浏览器用的是 gpu 来绘制,而这两个操作都涉及到gpu和cpu内存的交换。

如果用 canvaskit 就比较简单,临时的 canvas 在 webGL 上对应的就是 frameBuffer,在 gpu 上处理完成后可以当作 texture 再次在 gpu 上绘制。 使用上也非常简单:

const layerPaint = new Canvaskit.Paint();
layerPaint.setAlphaf(0.5);
skCanvas.saveLayer(layerPaint);
groupView.paint();
skCanvas.restore();

总之,canvas2d 主要的性能问题就在于,绘制中间对象没法缓存,以及部分能力需要 CPU 计算这两点上。 而 canvaskit 在提供了类似 canvas 2d 的接口的同时,又让我们不用去操心 webGL 上的技术问题,可以说非常适合复杂的 2d 图形绘制场景了。

除了性能原因外,canvaskit 还有一些额外的突出能力。

3 path boolean operations 布尔运算

这在图形编辑器中是一个非常常见的功能,设计师一般通过将简单图形拼接起来,来绘制复杂的图形。 虽然,paper.js 也有这个能力,但 canvaskit 的 boolean op 在可靠性和性能上肯定是更高的。

4 文字排版功能

使用 canvas2d 的 fillText 方法,我们只能够绘制单行文本,如果要换行的话,那么需要使用 canvas 2d 上的 measureText 方法获取文字宽度,然后手动换行排版。这部分工作繁琐(比如处理 line breaking、 letter spacing),而且可能还不够准确(比如连字的情况)。

Canvaskit 中的 Paragraph 则提供了一个简单的接口,让实现多行文本变得非常简单。 当然这里还有个问题就是必须要加载字体文件,而一个中文字体文件可能会比较大。

canvaskit 使用过程中的经验

cavnaskit 在使用上并不困难,主要是文档不够通俗易懂,这里记录下我认为需要注意的点。

1 获取 canvas

使用 canvaskit 的时候,我们主要的绘制指令都在 SkCanvas 上。 而 SkCanvas 又是从 SkSurface 上获取的。

const surface = Canvaskit.MakeCanvasSurface(el);
const canvas = surface.getCanvas();
canvas.drawPath(path, paint);
surface.flush();

这里的 MakeCanvasSurface 实际上是一个封装了的比较便利的方法,实际上内部调用的应该是 MakeOnScreenGLSurface, 实际使用中最好还是使用 MakeOnScreenGLSurface, surface 的创建成本比较低,每次绘制的时候都可以重新创建。

2 坐标系统

和 canvas 2d 类似,canvaskit 的 canvas 上提供了

  • translate
  • rotate
  • scale
  • skew 等方法。
    此外还提供了
  • concat —— 在当前坐标变换上再应用一个 matrix。
  • getTotalMatrix —— 获取当前所有应用了的坐标变换。
    实际应用中 concat 更加实用,getTotalMatrix 比较适合 debug。
    此外还有一个非常实用的方法quickReject,可以用来判断一个绘制区域是否超出 cnavas 或者 clip 边界。

3 Path 图层绘制

在绘制 Sketch 文件过程中,最复杂的应该就是 Path 图层了。 参考代码
Sketch 中的 path 拥有包括:fill、border、shadow、innerShadow、blur 等属性。

fill, 除了纯色外还有渐变、图片填充等,需要在 paint 上设置 shader 实现。
border, 在 sketch 中除了居中对齐外,border 还可以靠外边界和内边界对齐。实现方法则是通过将 border 宽度设置成 2 倍,再应用 clip 这种方式。
shadow,需要根据 fill 和 border 重新计算出阴影区域,这个地方需要用到 boolean operation。然后在绘制的 paint 上设置 MaskFilter。
blur,并不只是在 path 上应用,实际上所有图层上都可以应用。就像之前提到的透明度一样,也需要作为一个整体应用,所以是在 layerPaint 上设置了 ImageFilter 来实现。

4 Text 图层绘制

Text 图层的绘制主要复杂的地方在于将 sketch 的 TextStyle 对应到 skia 的 TextStyle 上。 参考代码

skia 的 ParagraphStyle 中,行高是通过 heightMultiplier 实现的。如果要让每一行都有固定行高,需要设置 structStyle, 并将 forceStrutHeight 设置为 true。
目前这里有个没法实现的难题,就是sketch可以不显式设置行高,但那个隐式的行高究竟是多少,还需要根据字体信息来计算。

目前,字体这里显示效果不是很好,因为只加载了一套默认的中英字体,没有加载用户设置的字体文件。后续可以考虑从 Google fonts 加载字体,或者将常用字体放在 cdn。

canvaskit 中创建 Paragraph 需要字体,而提供字体有两种方法,FontMgr 和 TypefaceFontProvider。 前者需要在构造函数中传入一组字体 arrayBuffer,后者则可以在构造完成后再注册字体文件。

除了 emoji 大部分绘制的文字都是矢量的,sketch 中有些操作会将 text 直接当作 path 来处理。这个时候我们就需要将 paragraph 转换成 path。 skia 中有这个能力但 canvaskit 没有提供,目前在我修改了的 skia 版本中暴露了一个 getPath 方法,还存在些问题,比如没有 stroke、underline 等。 代码参考

由于拿不到用户原本的字体文件,text 渲染是目前最影响还原度的问题了。

5 优化缩放体验

Sketch 这类设计工具的一大特点就是可以将大量画板放在一个页面内,让用户可以通过放大缩小,像地图一样实现一个全局概览。
如果大量内容放在一个页面,每次重绘那压力可想而之肯定非常大了。
优化的方向是 1 降低绘制次数, 2 使用缓存。
1 自不必具体再说,使用 requestAnimationFrame 隔一段时间判断一下,画布是否移动,再决定是否绘制就好。
2 这里指的缓存,就是绘制结果,图片(texture)。 矢量内容绘制成本比较高,但其绘制出来的图片再次绘制的成本就比较低了。

image.png 这里的缓存策略参考了 Figma,就像很多地图应用一样,这里也使用了 Tile (瓦片)的方式,将视口内容分割成瓦片,每个瓦片大小是 256 * 256。每个瓦片足够小,这样的好处是在绘制瓦片的时候不会卡死用户的界面。 瓦片在平移和缩放的时候都可以重用,这样能够让用户感觉响应更加灵敏。
每个瓦片也是在 requestAnimationFrame 回调中计算的,并且会在回调函数统计瓦片绘制的耗时,以避免卡住用户界面。
前面提到的 quickReject 在瓦片绘制时非常有用,可以跳过瓦片外需要绘制的内容。

每个 Tile 都是获取了一个临时的 Offscreen Surface 来进行绘制,然后保存成了 SkImage,虽然 Figma 是将 1024 个小 Tile 放在一个大 Texture 上,但目前没看出来这种区别在性能上有什么体现,因为将 Tiles 绘制到画布上的耗时看起来还是比较小的。

这部分的优化目前还比较粗糙,在Tile管理和单个Tile的绘制性能上跟 figma 比差距都很大。 所有缓存的 Tile 用 LRU 保留了 1024 个,更理想的情况下,我认为应该保留那些刚好能够覆盖整个内容区域的 Tiles。

6 misc

在 sketch 文件的绘制过程中,symbol ,也就是可复用的组件的绘制实现也是比较复杂的。但主要都是些布局相关的内容,跟 canvaskit 关系不大。

另外,sketch 在缩放的过程,有部分 UI 内容大小不会随缩放而改变(比如画板标题,选中框),并且需要每次都重绘。这部分内容我称之为 overlay,不会用 tile 优化,每一帧都需要重绘。

使用 canvaskit 还需要注意,要自己管理好自己的制造的垃圾了,不用的对象要调用 delete 释放掉。

canvaskit 相关学习资料:

  • skia 官方文档
  • skia sharp
    Xamarin 使用了 skia,所以做了个 c# 的 binding。微软的文档写的很不错,有很多示例代码和图片。
  • skia 源代码
    其中用 canvaskit 实现 canvas 2d 接口的这个文件相当于一个demo,很值得一看。
  • chromium 源代码
  • flutter engine 源代码
    从 chromium 和 flutter 中学习 skia 的使用也是不错的的思路。另外 chromium 的在线代码浏览工具是真的好用。