CanvasKit 是以 WASM 为编译目标的 Web 平台图形绘制接口,其目标是将 Skia 的图形 API 导出到 Web 平台。
从代码提交记录来看,CanvasKit 作为了一个 Module 放置在整个代码仓库中,最早的一次提交记录在 2018 年 9 月左右,是一个比较新的 codebase

本文简单介绍一下 Skia 是如何编译为 Web 平台的,其性能以及未来的可应用场景

编译原理

整个 canvaskit 模块的代码量非常少:

  1. .gitignore
  2. CHANGELOG.md
  3. Makefile
  4. WasmAliases.h
  5. canvaskit/ 发布代码,canvaskit介绍文档
  6. canvaskit_bindings.cpp
  7. compile.sh 编译脚本
  8. cpu.js
  9. debug.js
  10. externs.js
  11. fonts/ 字体资源文件
  12. gpu.js
  13. helper.js
  14. htmlcanvas/
  15. interface.js
  16. karma.bench.conf.js
  17. karma.conf.js
  18. package.json
  19. particles_bindings.cpp
  20. perf/ 性能数据
  21. postamble.js
  22. preamble.js
  23. ready.js
  24. release.js
  25. serve.py
  26. skottie.js
  27. skottie_bindings.cpp
  28. tests/ 测试代码

整个模块我们可以看到其实没有修改包括任何 skia 的代码文件,只是在编译时指明了 skia 的源码依赖,同时写了一些胶水代码,从这里可以看出 skia 迁移至 WASM 并没有付出很多额外的改造工作。

编译

设置好 WASM 工具链 EmscriptenSDK 的环境变量后运行 compile.sh 就会在out文件夹中得到canvaskit.jscanvaskit.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 优化的流程:

CanvasKit简介 | Aichi_B7A - 图1

可应用场景

根据官方文档 (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

工程效率的一些思考

](https://tedaliez.github.io/2019/09/28/%E5%B7%A5%E7%A8%8B%E6%95%88%E7%8E%87%E7%9A%84%E4%B8%80%E4%BA%9B%E6%80%9D%E8%80%83/)[Older

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/