2. CanvasKit 绘图 API

CanvasKit 是 Google 的 Skia 项目的一部分,旨在为 JavaScript 提供一个完备的 2D 绘图 API。 Cocoa 也引入了 CanvasKit 来作为标准 2D 绘图的 API。

有关于 CanvasKit 本身的使用方法,读者可参见 Skia 文档 ,这里不再赘述。 本节仅仅介绍 CanvasKit 在 Cocoa 中的导入和使用。

2.1. 矢量图的光栅化

CanvasKit 是一套矢量绘图 API,其绘制流程可以粗略地分为两步:绘制和光栅化。 光栅化可以由 CanvasKit 本身来完成,也可以由 Glamor 来完成,前者仅仅支持软件光栅化 (没有 GPU 加速),且光栅化结果不能作为内容在屏幕上显示,只能得到像素图 (用户也可以将其保存为 JPG, PNG 等格式的图片),而后者支持 GPU 光栅化, 且专为需要显示到屏幕上的内容而设计。 另外,Glamor 光栅化也支持基于图层树的渲染,这一点我们将在后面的内容中重点介绍。

在这一节中,我们仅仅介绍如何仅仅使用 CanvasKit 来绘图和光栅化。 也就是说,我们不会创建任何窗口,所有的绘制结果都作为图像文件保存到文件系统上。

2.2. 导入 CanvasKit

CanvasKit 已经作为内建模块包含在 Cocoa 中,可以使用如下语句导入:

1// 我们推荐使用这种导入方式,以便于配合使用 TypeScript 的类型声明.
2// CanvasKit 的 .d.ts 文件位于 Cocoa 项目的 //typescript/internal/canvaskit.d.ts
3import * as CanvasKit from 'internal://canvaskit';

备注

初次导入 CanvasKit 可能是一个比较耗时的操作,因为 V8 会解析和编译 WebAssembly 代码, 我们计划在未来的 Cocoa 版本中改善这个问题。

然后,在导入的 CanvasKit 对象中,包含了一个 canvaskit 属性,这是一个 CanvasKit 实例,通过它来访问所有的 CanvasKit 对象,为了简便起见,可以为该字段定义一个全局的别名:

1// 如果使用 TypeScript,TS 编译器可以自动推导出 canvaskit 的类型,
2// 进而在编辑器(如 vscode)中提供自动补全建议.
3const canvaskit = CanvasKit.canvaskit;
4
5// 或者,若是使用纯 JavaScript,也可以直接以如下方式导入:
6import {canvaskit} from 'internal://canvaskit';

于是,我们可以通过 canvaskit 对象访问 CanvasKit 的所有对象,例如,创建一个路径对象:

1// 如果使用 TypeScript,path 对象具有类型 CanvasKit.Path,
2// CanvasKit 中的其它对象也同理.
3const path = new canvaskit.Path();

下面这个例子配合了 TypeScript 类型声明:

 1// 绘制一个星形路径
 2// 来自 Skia 官方示例 https://fiddle.skia.org/c/@shader 中的代码片段.
 3function star(): CanvasKit.Path {
 4    const R = 60.0, C = 128.0;
 5    const path = new canvaskit.Path();
 6    path.moveTo(C + R, C);
 7    for (const i = 0; i < 15; i++) {
 8        const a = 0.44879895 * i;
 9        const r = R + R * (i % 2);
10        path.lineTo(C + r * Math.cos(a), C + r * Math.sin(a));
11    }
12    return path;
13}

警告

由于 WebAssembly 的内存不受 JavaScript 的 GC 机制的控制, 因此任何在 CanvasKit 中使用 new 或者 Make* 系列函数创建的 JavaScript 对象, 都必须使用 delete 方法手动释放其内存 ,否则 Cocoa 会因为内存耗尽而异常退出。

2.3. 简单绘图实例

使用 MakeSurface 函数可以创建一个具有 CPU 光栅化能力的绘图表面(Surface), 然后可以获得在该表面上的 Canvas 对象来进行绘图。 创建的 Surface 使用 SRGB 色彩空间,未预乘 alpha 的 8888 颜色格式(像素内存对于 JavaScript 而言是不可访问的)。

 1import * as std from 'core';
 2import {canvaskit} from 'internal://canvaskit';
 3
 4// 宽 256 像素,高 256 像素的绘图表面
 5const surface = canvaskit.MakeSurface(256, 256);
 6if (!surface) {
 7    throw Error('Failed to create a surface');
 8}
 9
10const canvas = surface.getCanvas();
11
12// 在此处使用 canvas 对象进行绘图
13// ...
14
15// 将当前 Surface 内容保存为 Image 对象,该对象独立于 Surface 存在,
16// 也需要手动 delete
17const snapshot = surface.makeImageSnapshot();
18
19// 将 Image 对象编码(默认为 PNG),转换为 Buffer 对象,
20// 使用 MakeFromAdoptBuffer 来避免不必要的内存拷贝
21const buffer = std.Buffer.MakeFromAdoptBuffer(snapshot.encodeToBytes());
22
23// 释放内存
24snapshot.delete();
25surface.delete();
26
27// 将该 Buffer 对象写入文件系统
28std.File.WriteFileSync('./output.png', buffer);
29
30// 或使用异步方式以获得更为精细的控制
31std.File.Open('./output.png', std.File.O_RDWR | std.File.O_CREAT, 0o644).then((file) => {
32    return file.write(buffer, 0, buffer.length, 0);
33}).then(() => {
34    std.print("Done!\n");
35}).catch((error) => {
36    std.print("Failed to save image file\n");
37});

上面所演示的绘图方式可以在不创建窗口的情况下进行绘图,其结果是一张像素图。 这一绘图流程全部使用 CPU 进行光栅化,且光栅化代码是通过 WebAssembly 执行的, 因此性能并不理想,但这一机制仍然十分适合在运行时动态地产生一些纹理对象 (CanvasKit 中的 ImageBitmap 对象可以很容易地转换为 Glamor 中的 CkImageCkBitmap 对象,进而转换为由 Glamor 管理的纹理对象,这在后面的小节中将会介绍), 尤其是在目前 Glamor 还不支持离屏(Offscreen)渲染的情况下。

2.4. 利用 Picture 处理绘图指令

Picture 可以保存绘制过程中执行的绘制指令和绘制使用到的数据对象(例如像素图、字体等), 然后将它们重放(Replay)就可以完全复原原先的绘制流程和绘制结果。 可以将 Picture 比喻为绘图脚本,包含了高度过程化的绘图指令。 一个 Picture 可以由 CanvasKit 中的 SkPicture 对象来表示。

要创建一个 Picture,无需通过创建绘制表面来获得 Canvas,而是通过创建 PictureRecorder 对象来获得一个适用于 Picture 的 Canvas 对象。

下面的示例生成一个 Picture 对象:

 1const recorder = new canvaskit.PictureRecorder();
 2
 3// 用户指定一个矩形 rect 用于表示 Picture 的绘制边界,
 4// 超出该边界的绘图内容可能会被裁剪(Picture 并不强制要求 replay 时必须进行这个裁剪工作),
 5// 该矩形称为 cull rectangle.
 6const canvas = recorder.beginRecording(rect);
 7
 8// 使用 canvas 对象来绘图
 9// ...
10
11// 该 SkPicture 对象也需要在不需要时手动 delete
12const picture = recorder.finishRecordingAsPicture();
13
14// recorder 对象不再需要,及时 delete
15recorder.delete();

SkPicture 对象可以被序列化(通常该操作开销很小,针对 Glamor 的应用场景还有特定优化, 在后面的小节中将会介绍),也可以被反序列化。不同于 CanvasKit 官方文档介绍的一点是, Cocoa CanvasKit 的 SkPicture 对象按照用途不同有两种序列化方式:

标准格式序列化

使用 SkPicture.serialize 方法可以按照 Skia 的标准格式序列化一个 Picture, 所有的像素图、字体等资源都会在序列化的 Picture 中被复制一份并作为序列化结果的一部分。 或者说,序列化后的 Picture 是完全独立的,自带了 replay 需要的所有资源,没有对外部资源的引用。 自然,标准格式的 Picture 会占用相对较多的内存空间。

为内部传输优化的序列化

使用 SkPicture.serializeForTransfer 方法可以得到专为内部传输优化的序列化结果, 在介绍 Glamor 和 CanvasKit 组合使用的小节中会详细介绍这种序列化方式。

两种序列化方式都返回包含了序列化结果的 Uint8Array 对象。可以将序列化的 Picture 保存到文件系统,一般用 .skp 作为文件后缀名。将其保存到文件系统上之后, 还可以把该文件上传到 Skia 的在线 Debugger 以逐步分析绘图指令。

若要反序列化,直接使用 MakePicture 方法即可。

备注

注意,MakePicture 方法只能反序列化标准格式的 Picture。 只有 Glamor 的 CkPicture 对象有能力反序列化第二种 Picture 格式。