CanvasKit 是以 WASM 为编译目标的 Web 平台图形绘制接口,其目标是将 Skia 的图形 API 导出到 Web 平台。
从代码提交记录来看,CanvasKit 作为了一个 Module 放置在整个代码仓库中,最早的一次提交记录在 2018 年 9 月左右,是一个比较新的 codebase
本文简单介绍一下 Skia 是如何编译为 Web 平台的,其性能以及未来的可应用场景
编译原理
整个 canvaskit 模块的代码量非常少:
.gitignoreCHANGELOG.mdMakefileWasmAliases.hcanvaskit/ 发布代码,canvaskit介绍文档canvaskit_bindings.cppcompile.sh 编译脚本cpu.jsdebug.jsexterns.jsfonts/ 字体资源文件gpu.jshelper.jshtmlcanvas/interface.jskarma.bench.conf.jskarma.conf.jspackage.jsonparticles_bindings.cppperf/ 性能数据postamble.jspreamble.jsready.jsrelease.jsserve.pyskottie.jsskottie_bindings.cpptests/ 测试代码
整个模块我们可以看到其实没有修改包括任何 skia 的代码文件,只是在编译时指明了 skia 的源码依赖,同时写了一些胶水代码,从这里可以看出 skia 迁移至 WASM 并没有付出很多额外的改造工作。
编译
设置好 WASM 工具链 EmscriptenSDK 的环境变量后运行 compile.sh 就会在out文件夹中得到canvaskit.js和canvaskit.wasm这两个编译产物,这里为了分析选择编译一个 debug 版本:
./compile.sh debug
debug 版本会得到一个未混淆的 canvaskit.js,方便我们分析其实现
编译产物浅析
为了快速了解整个模块的情况,直接观察 canvaskit.js 和 canvaskit.wasm 文件,先来看下 canvaskit.js
js 代码量比较大,这里摘取一段最能展示其运行原理的代码:
function makeWebGLContext(canvas, attrs) {
var contextAttributes = {
alpha: get(attrs, 'alpha', 1),
depth: get(attrs, 'depth', 1),
stencil: get(attrs, 'stencil', 0),
antialias: get(attrs, 'antialias', 1),
premultipliedAlpha: get(attrs, 'premultipliedAlpha', 1),
preserveDrawingBuffer: get(attrs, 'preserveDrawingBuffer', 0),
preferLowPowerToHighPerformance: get(attrs, 'preferLowPowerToHighPerformance', 0),
failIfMajorPerformanceCaveat: get(attrs, 'failIfMajorPerformanceCaveat', 0),
majorVersion: get(attrs, 'majorVersion', 1),
minorVersion: get(attrs, 'minorVersion', 0),
enableExtensionsByDefault: get(attrs, 'enableExtensionsByDefault', 1),
explicitSwapControl: get(attrs, 'explicitSwapControl', 0),
renderViaOffscreenBackBuffer: get(attrs, 'renderViaOffscreenBackBuffer', 0),
};
if (!canvas) {
SkDebug('null canvas passed into makeWebGLContext');
return 0;
}
if (contextAttributes['explicitSwapControl']) {
SkDebug('explicitSwapControl is not supported');
return 0;
}
return GL.createContext(canvas, contextAttributes);
}
CanvasKit.GetWebGLContext = function(canvas, attrs) {
return makeWebGLContext(canvas, attrs);
};
var GL= {
init:function () {
GL.miniTempBuffer = new Float32Array(GL.MINI_TEMP_BUFFER_SIZE);
for (var i = 0; i < GL.MINI_TEMP_BUFFER_SIZE; i++) {
GL.miniTempBufferViews[i] = GL.miniTempBuffer.subarray(0, i+1);
}
},
createContext:function (canvas, webGLContextAttributes) {
var ctx = (canvas.getContext("webgl", webGLContextAttributes)
|| canvas.getContext("experimental-webgl", webGLContextAttributes));
return ctx && GL.registerContext(ctx, webGLContextAttributes);
},registerContext:function (ctx, webGLContextAttributes) {
var handle = _malloc(8);
assert(handle, 'malloc() failed in GL.registerContext!');
var context = {
handle: handle,
attributes: webGLContextAttributes,
version: webGLContextAttributes.majorVersion,
GLctx: ctx
};
if (ctx.canvas) ctx.canvas.GLctxObject = context;
GL.contexts[handle] = context;
if (typeof webGLContextAttributes.enableExtensionsByDefault === 'undefined' || webGLContextAttributes.enableExtensionsByDefault) {
GL.initExtensions(context);
}
return handle;
},makeContextCurrent:function (contextHandle) {
GL.currentContext = GL.contexts[contextHandle];
Module.ctx = GLctx = GL.currentContext && GL.currentContext.GLctx;
return !(contextHandle && !GLctx);
},
}
代码中出现了大量的 WebGL 指令和 2d 的绘制 js 代码,其实这一块就是 EmscriptenSDK 对 OpenGL 的胶水代码 (https://emscripten.org/docs/porting/multimedia_and_graphics/OpenGL-support.html)), 换言之,canvaskit 的绘制代码没有脱离浏览器提供的 webgl 和 context2d 的相关接口,毕竟这也是目前在浏览器进行绘制操作的唯一途径
那编译的 wasm 文件做了啥呢?简单看一下对应 wasm 的一部分代码, 这也是一个比较庞大的文件,我们只关注一下 wasm 和 js 连接的桥梁代码:
(import "env" "_eglGetCurrentDisplay" (func $_eglGetCurrentDisplay (result i32)))
(import "env" "_eglGetProcAddress" (func $_eglGetProcAddress (param i32) (result i32)))
(import "env" "_eglQueryString" (func $_eglQueryString (param i32 i32) (result i32)))
(import "env" "_emscripten_glActiveTexture" (func $_emscripten_glActiveTexture (param i32)))
(import "env" "_emscripten_glAttachShader" (func $_emscripten_glAttachShader (param i32 i32)))
(import "env" "_emscripten_glBeginQueryEXT" (func $_emscripten_glBeginQueryEXT (param i32 i32)))
(import "env" "_emscripten_glBindAttribLocation" (func $_emscripten_glBindAttribLocation (param i32 i32 i32)))
(import "env" "_emscripten_glBindBuffer" (func $_emscripten_glBindBuffer (param i32 i32)))
(import "env" "_emscripten_glBindFramebuffer" (func $_emscripten_glBindFramebuffer (param i32 i32)))
(import "env" "_emscripten_glBindRenderbuffer" (func $_emscripten_glBindRenderbuffer (param i32 i32)))
(import "env" "_emscripten_glBindTexture" (func $_emscripten_glBindTexture (param i32 i32)))
(import "env" "_emscripten_glClear" (func $_emscripten_glClear (param i32)))
(import "env" "_emscripten_glClearColor" (func $_emscripten_glClearColor (param f64 f64 f64 f64)))
(import "env" "_emscripten_glClearDepthf" (func $_emscripten_glClearDepthf (param f64)))
(import "env" "_emscripten_glCompileShader" (func $_emscripten_glCompileShader (param i32)))
...
这里省略了一部分,但是仍然可以看出,wasm 对绘制的支持全部依赖其运行环境中 js 注入的函数实现
以这里的_emscripten_glBindTexture函数为例,对应到 js 为:
var asmGlobalArg = {}
var asmLibraryArg = {
"_emscripten_glBindTexture": _emscripten_glBindTexture
}
Module['asm'] = function(global, env, providedBuffer) {
env['memory'] = wasmMemory
;
env['table'] = wasmTable = new WebAssembly.Table({
'initial': 1155075,
'maximum': 1155075,
'element': 'anyfunc'
});
env['__memory_base'] = 1024;
env['__table_base'] = 0;
var exports = createWasm(env);
assert(exports, 'binaryen setup failed (no wasm support?)');
return exports;
};
var asm =Module["asm"]
(asmGlobalArg, asmLibraryArg, buffer);
function _emscripten_glBindTexture(target, texture) {
GL.validateGLObjectID(GL.textures, texture, 'glBindTexture', 'texture');
GLctx.bindTexture(target, GL.textures[texture]);
}
GLctx 通过代码我们也能找到对应:
createContext:function (canvas, webGLContextAttributes) {
var ctx = (canvas.getContext("webgl", webGLContextAttributes)
|| canvas.getContext("experimental-webgl", webGLContextAttributes));
return ctx && GL.registerContext(ctx, webGLContextAttributes);
},registerContext:function (ctx, webGLContextAttributes) {
var handle = _malloc(8);
assert(handle, 'malloc() failed in GL.registerContext!');
var context = {
handle: handle,
attributes: webGLContextAttributes,
version: webGLContextAttributes.majorVersion,
GLctx: ctx
};
if (ctx.canvas) ctx.canvas.GLctxObject = context;
GL.contexts[handle] = context;
if (typeof webGLContextAttributes.enableExtensionsByDefault === 'undefined' || webGLContextAttributes.enableExtensionsByDefault) {
GL.initExtensions(context);
}
return handle;
},makeContextCurrent:function (contextHandle) {
GL.currentContext = GL.contexts[contextHandle];
Module.ctx = GLctx = GL.currentContext && GL.currentContext.GLctx;
return !(contextHandle && !GLctx);
}
所以这里的 bindTexture 实际上就是 WebGL 的 bindTexture 指令 (https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/bindTexture#Syntax)
分析到这里,我们可以得到一个基本结论: canvaskit 中绘制的实现全部在 canvaskit.js 中调用浏览器绘制 API 来实现,而计算相关的内容全部放在了 wasm 中实现
编译脚本解析
通过对编译产物的分析,我们可以发现 canvaskit 绝大部分的绘制都是借助了 Web API 中的 2d 或 webgl 绘制 API 来完成的。这里需要分析的是 canvaskit 如何搭建了 skia 原生绘制代码和浏览器绘制 API 的桥梁。
看到 compile.sh 发现最后一句话涉及到很多 canvaskit 目录下的文件,因此直接结合编译日志的相关内容分析。
其他的日志都是常规的 skia 编译命令,只不过执行程序换成了 em++ 而已,em++ 就是 EmscriptenSDK 中的编译器命令,可以类比为 g++,这些命令会把 skia 编译为几个静态库
我们略过之前的 skia 编译命令来到最后一段,这是真正生成 WASM 产物的地方,其中有大量的逻辑是涉及到 canvaskit 中的胶水代码的。略去链接, 编译器优化设置, Skia 静态库路径的指定, Skia 宏定义和头文件路径指定,我们将会得到:
/Users/JianGuo/VSCodeProject/emsdk/emscripten/1.38.28/em++ \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/debug.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/cpu.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/gpu.js \
--bind \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/preamble.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/helper.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/interface.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/skottie.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/preamble.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/util.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/color.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/font.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/canvas2dcontext.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/htmlcanvas.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/imagedata.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/lineargradient.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/path2d.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/pattern.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/radialgradient.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/htmlcanvas/postamble.js \
--pre-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/postamble.js \
--post-js /Users/JianGuo/Desktop/skia/skia/modules/canvaskit/ready.js \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/fonts/NotoMono-Regular.ttf.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/canvaskit_bindings.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/particles_bindings.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/skottie_bindings.cpp \
modules/skottie/utils/SkottieUtils.cpp \
-s ALLOW_MEMORY_GROWTH=1 \ # 允许申请比TOTAL_MEMORY更大的内存
-s EXPORT_NAME=CanvasKitInit \ # js中Module的名字
-s FORCE_FILESYSTEM=0 \ # 开启文件系统支持,用于js中对native的文件系统进行模拟
-s MODULARIZE=1 \ #启用Module的方式生成js,开启后编译的js产物将拥有一个Module作用域,而非全局作用域
-s NO_EXIT_RUNTIME=1 \ # 禁止使用exit函数
-s STRICT=1 \ # 确保编译器不使用弃用语法
-s TOTAL_MEMORY=128MB \ # WASM分配的总内存,如果比此内存更大的场景就需要扩展堆大小
-s USE_FREETYPE=1 \ # 使用emscripten-ports导出的freetype库
-s USE_LIBPNG=1 \ # 使用emscripten-ports导出的libpng库
-s WARN_UNALIGNED=1 \ # 编译时警告未对齐(align)
-s USE_WEBGL2=0 \ # 不使用WebGL2
-s WASM=1 \ # 编译为WASM
-o out/canvaskit_wasm_debug/canvaskit.js # 指定编译路径
其中,pre-js <file>表示将指定文件的内容插入到生成的 js 文件前, post-js表示将指定文件的内容插入到生成的 js 文件后,我们以skia/modules/canvaskit/htmlcanvas/htmlcanvas.js为例,看看这些插入的文件都干了啥:
CanvasKit.MakeCanvas = function(width, height) {
var surf = CanvasKit.MakeSurface(width, height);
if (surf) {
return new HTMLCanvas(surf);
}
return null;
}
function HTMLCanvas(skSurface) {
this._surface = skSurface;
this._context = new CanvasRenderingContext2D(skSurface.getCanvas());
this._toCleanup = [];
this._fontmgr = CanvasKit.SkFontMgr.RefDefault();
this.decodeImage = function(data) {
}
this.loadFont = function(buffer, descriptors) {
}
this.makePath2D = function(path) {
}
this.getContext = function(type) {
}
this.toDataURL = function(codec, quality) {
}
this.dispose = function() {
}
}
其实就是对齐了一下浏览器实现,同时对齐了一下 Skia 内部的接口而已。
最后我们还剩下一段没有分析:
/Users/JianGuo/VSCodeProject/emsdk/emscripten/1.38.28/em++ \
...
--bind \
...
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/fonts/NotoMono-Regular.ttf.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/canvaskit_bindings.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/particles_bindings.cpp \
/Users/JianGuo/Desktop/skia/skia/modules/canvaskit/skottie_bindings.cpp \
modules/skottie/utils/SkottieUtils.cpp \
...
根据文档,这段命令要求 em++ 以 Embind(https://emscripten.org/docs/porting/connecting_cpp_and_javascript/embind.html#embind) 连接 C++ 代码和 JS 代码%E8%BF%9E%E6%8E%A5C++%E4%BB%A3%E7%A0%81%E5%92%8CJS%E4%BB%A3%E7%A0%81), embind 简单来说就是 emscriptenSDK 提供的将 C/C++ 代码暴露给 JavaScript 的便捷能力。这里不做重点介绍,我们直接看 canvaskit 用到的一个代码:
particles_bindings.cpp:
#include <emscripten.h>
#include <emscripten/bind.h>
using namespace emscripten;
EMSCRIPTEN_BINDINGS(Particles) {
class_<SkParticleEffect>("SkParticleEffect")
.smart_ptr<sk_sp<SkParticleEffect>>("sk_sp<SkParticleEffect>")
.function("draw", &SkParticleEffect::draw, allow_raw_pointers())
.function("start", select_overload<void (double, bool)>(&SkParticleEffect::start))
.function("update", select_overload<void (double)>(&SkParticleEffect::update));
function("MakeParticles", optional_override([](std::string json)->sk_sp<SkParticleEffect> {
static bool didInit = false;
if (!didInit) {
REGISTER_REFLECTED(SkReflected);
SkParticleAffector::RegisterAffectorTypes();
SkParticleDrawable::RegisterDrawableTypes();
didInit = true;
}
SkRandom r;
sk_sp<SkParticleEffectParams> params(new SkParticleEffectParams());
skjson::DOM dom(json.c_str(), json.length());
SkFromJsonVisitor fromJson(dom.root());
params->visitFields(&fromJson);
return sk_sp<SkParticleEffect>(new SkParticleEffect(std::move(params), r));
}));
constant("particles", true);
}
上面代码经过 em++ 编译后会直接将其功能内嵌进 wasm 文件中。至此,整个编译流程就分析完了
小结
这里用一张图来总结一下整个 canvaskit 的编译流程, 图中省去了编译器优化和 js 优化的流程:
可应用场景
根据官方文档 (https://skia.org/user/modules/canvaskit)), canvaskit 基于 skia 的 API 设计向 web 平台提供了更加方便的图形接口,可以说起到了类似 GLWrapper 的作用。
得益于 Skia 本身的其他扩展功能,canvaskit 相比于浏览器原生绘制能力,支持了许多更加上层的业务级别功能,例如 skia 的动画模块 skottie(https://skia.org/user/modules/skottie)
Skia 中的 skottie 本身就支持 Lottie 动画解析和播放,由于 Skia 良好的跨平台能力,Android 和 iOS 平台现在均可以使用 Skia 框架来播放 Lottie 动画,canvaskit 则运用 WebAssembly 的技术来将跨平台的范围扩展到 web 上,使得 web 平台可以通过 canvaskit 的 skottie 相关接口直接播放 lottie 动画
对于 Web 应用而言,canvaskit 提供了开发者更加友好的图形接口,并提供了常见的图形概念(例如 Bitmap,Path 等),减少了上层应用开发者对于绘制接口的理解负担,开发者只需要理解 Skia 的图形概念即可开发图形界面,有了 skia 他们也不需要理解复杂的 webgl 指令。
小结
得益于 WASM 的理念和 EmscriptenSDK 的能力,越来越多的 native 库可以直接导出 web 上供开发者使用。CanvasKit 可以说是 C++ Library 向 Web 平台迁移的又一最佳实践。EmscriptenSDK 已经做到将 Skia 这种规模的 C++ 项目以 WASM 的方式迁移至 Web 平台,并保证其代码功能的一致性。整个迁移的过程的代价也就是编译工具链的替换和一部分胶水代码。
[Newer
工程效率的一些思考
APT 插件实战
](https://tedaliez.github.io/2019/06/29/APT%E6%8F%92%E4%BB%B6%E5%AE%9E%E6%88%98/)
https://tedaliez.github.io/2019/07/14/CanvasKit%E7%AE%80%E4%BB%8B/
