基于QuickJS扩展2D canvas图形接口

基于QuickJS扩展2D canvas图形接口

在Web技术领域中,canvas是一个使用非常广泛的功能,可以支持开发者在原有的HTML能力之外,拓展矢量图形绘制能力,常用于实现矢量动画、粒子特效、图表、游戏等等场景。而canvas在HTML里面只是一个画布,本身并不具备绘图能力,需要依托JS脚本来绘制图形。

canvas是W3C(万维网联盟)标准集中的其中一项标准,该功能目前主要实现在浏览器中。对于大部分IoT终端来说,浏览器是一个太复杂的系统,包括它的性能、资源占用等都是大部分IoT终端无法承受的。而目前,越来越多IoT领域的GUI框架,开始引入前端技术,支持使用JS来写UI,那么canvas也成为一个非常棒的高级功能,包括鸿蒙的JS应用、RTT的柿饼UI以及阿里云IoT的HaaS UI都部分或完整的支持了canvas功能。

所以本文主要探讨在IoT带屏领域里面,使用quickjs引擎以及Skia图形引擎来拓展2dcanvas场景的基本方法。

QuickJS

QuickJS(官网地址:QuickJS Javascript Engine)是一个小型并且可嵌入的JS引擎,由Bellard大神(Qemu、FFmpeg的作者)于2019年推出的开源方案。相比较其他一些嵌入式的JS引擎,QuickJS在性能、标准支持等方面,都表现优异。在运行性能上,几乎可以媲美jitless的V8引擎,而启动性能又碾压之;并且QuickJS支持最新的ES2020规范,包括模块、异步等等。所以像鸿蒙JS应用,阿里云IoT的HaaS轻应用等框架,都开始拥抱并使用QuickJS作为其JS的运行引擎。所以这里也选择使用QuickJS来进行canvas2d的接口拓展。

下图是QuickJS作者放出的比较不同JS引擎的benchmark测试,分数越高代表性能越好:

基于QuickJS扩展2D canvas图形接口

引擎启动

QuickJS的运行环境主要有两个关键信息:

  • JSRuntime:JS运行时,可以同时存在多个Runtime,互相独立,在Runtime维度,不支持多线程,所以QuickJS不支持跨线程调用与回调
  • JSContext:JS上下文环境,每个JSContext都有自己的全局(global)对象,一个JSRuntime可以创建多个JSContext,它们之间可以通过引用方式共享JS对象(一般JS扩展,包括cfunction以及cmodule的扩展是以context为载体的)

以下为一个QuickJS实例启动的代码示例:

// 创建Runtime
JSRuntime *rt = JS_NewRuntime();
// 创建Context
JSContext *ctx = JS_NewContext(rt);

// 在这里可以添加各种cfunction和cmodule的扩展,提供给JS调用

// eval js
JSValue val = JS_Eval(ctx, buf, buf_len, filename, eval_flags);
JS_FreeValue(ctx, val); // 引用计数,需要使用者注意管理js对象引用
// 或者eval binary(quickjs支持导出字节码,加速执行)
// js_std_eval_binary,参考quickjs-libc.c

// 这里是线程loop(用于接收执行timer,Promise等pending任务,以及其他线程callback的消息)
// 在Linux系统,可以使用quickjs-libc.c里自带的js_std_loop
// 对于一些RTOS系统,可以参考后进行适配

// 退出loop
// 销毁Context
JS_FreeContext(ctx);
// 销毁Runtime
JS_FreeRuntime(rt);

C函数/属性扩展

空引擎初始化之后,只会包含在ES规范中定义的JS语法。而对于大部分的真正使用场景,都需要拓展业务场景所必须的扩展功能,所有的JS引擎都会支持这样的一些扩展能力。扩展一般用于往global对象上binding一些C函数与属性(也可以往其他对象上):

// 需binding的C函数
JSValue wrap_Foo(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
    // argv为参数列表
    printf("run global.foo(arg).\n");
    int a = 0;
    if (argc > 0) {
        JS_ToInt32(ctx, &a, argv[0]);
    }
    // return a+1
    return JS_NewInt32(ctx, a + 1);
}

// 获取当前context的global对象
JSValue global_obj = JS_GetGlobalObject(ctx);
// 往global对象上绑定env属性
JSValue environment = JS_NewObject(ctx);
// globalThis.$env.platform
JS_SetPropertyStr(ctx, environment, "platform",
                      JS_NewString(ctx, "AliOS Things"));
// globalThis.$env.version
JS_SetPropertyStr(ctx, environment, "version",
                      JS_NewString(ctx, "0.0.1"));
// globalThis.$env
JS_SetPropertyStr(ctx, global_obj, "$env",
                      environment);

// 往global对象上绑定c函数
// C函数原型
// typedef JSValue JSCFunction(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv);
JS_SetPropertyStr(ctx, global_obj, "foo",
                      JS_NewCFunction(ctx, wrap_Foo, "foo", 1));

JS_FreeValue(ctx, global_obj);

扩展好之后,在JS端使用:

// console.log需要按照c函数扩展的方法扩展
console.log("platform:", globalThis.$env.platform);
// 打印platform:AliOS Things
console.log("version:", globalThis.$env.version);
// 打印version:0.0.1

console.log("foo:", globalThis.foo(1));
// 打印foo:2

C模块(ES6 module)扩展

模块是ES6(2015)中的规范,像JerryScript、Duktape等最高支持ES5的引擎是不支持该功能的。以下为在QuickJS中扩展一个module的方法。

模块定义:

JSModuleDef

关键函数:

  1. JS_NewCModule:用于创建一个JSModuleDef,

函数原型:

JSModuleDef *JS_NewCModule(JSContext *ctx, const char *name_str,

JSModuleInitFunc *func);

参数列表:context,name(模块名),initFunc(模块初始化函数)

  1. JS_AddModuleExportList:用于添加模块export列表
  2. JS_AddModuleExport:用于添加模块export

类似js端的exports.xxx = xxx

可参考quickjs-libc.c中的js_init_module_std和js_init_module_os

在JS端的使用方法:

// 示例为使用std模块打开一个文件
import * as std from "std";
let f = std.open("xxx.txt");
let buf = new Uint8Array(10);
f.read(buf, 0, 10);
f.close();

扩展2dcanvas

最开始提到了,这里会使用Skia作为2dcanvas底层需要的矢量引擎,作为示例为了方便移植Skia到IoT场景(Linux和RTOS系统),这边选择了比较老的一个Skia版本,但是它的功能是能够完全支持2dcanvas所需要的能力的。

2dcanvas的具体标准内容可以监考:HTML Standard;或者也可以参考w3cschool里的内容:HTML Canvas 参考手册。包括40+接口,涵盖“颜色、样式、线条、路径、矩阵变换”等。

在浏览器里面,要使用2dcanvas的主要步骤为:1、创建canvas标签;2、创建2dcanvas上下文;3、调用api执行绘图操作。

// 创建canvas标签,然后通过domApi插入节点(也可以通过html直接创建)
var canvas = document.createElement("canvas");
// 获取2d上下文
var ctx = canvas.getContext("2d");
// 设置填充样式
ctx.fillStyle = "#FF0000";
// 填充矩形区域
ctx.fillRect(20,20,150,100);

那么在这里,因为不是在浏览器场景,所以关键需要先支持创建2dcanvas的上下文环境,我在这里通过在quickjs上扩展createCanvasContext接口来支持创建上下文。

创建2dcanvas上下文

首先先按照上述quickjs的C函数扩展方法,实现一个createCanvasContext函数,并绑定好一个C的context对象。因为Skia是C++实现的,所以这里我们也使用C++来实现。首先,先构造一个C的context对象:

// 先构建一个基础的context对象,包含需要真正做渲染的Skia画布
struct CanvasContext
{
    // Skia图形引擎的画布对象
    SkCanvas* canvas; 
    SkBitmap* bitmap;
    // 用于绘制填充对象时的画笔
    SkPaint fillPaint;
    // 用于绘制描边对象时的画笔
    SkPaint strokePaint;
    // 绘制图形时使用的Path对象
    SkPath path;
};

然后往quickjs中扩展createCanvasContext函数:

// 声明canvas对应的js类
static JSClassID _js_canvas_class_id;
static void _js_canvas_finalizer(JSRuntime* rt, JSValue val)
{
    // 在js canvas对象被GC前,用来销毁C的CanvasContext对象
    CanvasContext* context = (CanvasContext*)JS_GetOpaque(val, _js_canvas_class_id);
    if (context) {
        delete context->bitmap;
        delete context->canvas;
        delete context;
    }
}
static JSClassDef _js_canvas_class = {
        "Canvas",
        .finalizer = _js_canvas_finalizer,  // CanvasContext GC前的资源回收
};

// 需binding的C函数
JSValue createCanvasContext(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv)
{
    // 创建C的context对象
    CanvasContext* context = new CanvasContext;
    SkBitmap* bmp = new SkBitmap;
    // 这里需要创建Skia绘制时的图像buffer,需要对接到设备屏幕
    // bmp->setConfig(SkBitmap::kARGB_8888_Config, screenWidth, screenHeight);
    // bmp->setPixels(fb_buf);
    // TODO 这边示例先用内存buffer代替下,创建一块200x200的buffer
    bmp->setConfig(SkBitmap::kARGB_8888_Config, 200, 200);
    bmp->allocPixels();
    // 初始化清空画布
    memset(bmp->getPixels(), 0, bmp->getSize());
    context->bitmap = bmp;
    context->canvas = new SkCanvas(*bmp);
    // 设置描边样式
    context-strokePaint.setStyle(SkPaint::kStroke_Style);
    
    // 创建canvas对应的JS对象
    JSValue obj = JS_NewObjectClass(ctx, _js_canvas_class_id);
    // TODO 之后需要在这里挂载2dcanvas的所有api
    
    // 绑定canvas对象
    JS_SetOpaque(obj, context);
    // 返回jscanvas对象
    return obj;
}

// 定义JS的canvas类
JS_NewClassID(&_js_canvas_class_id);
JS_NewClass(JS_GetRuntime(ctx), _js_canvas_class_id, &_js_canvas_class);
// 按照quickjs扩展方法扩展createCanvasContext函数
JS_SetPropertyStr(ctx, globalObject, "createCanvasContext", JS_NewCFunction(context, &createCanvasContext, "createCanvasContext", 0));

通过以上的扩展,JS端已经可以通过createCanvasContext来创建canvas上下文了。只不过目前这个上下文对象上还没有挂载任何2dcanvas的api,所以接下来,就先来尝试一下扩展填充矩形的API。

扩展第一个API

首先先来扩展一个较简单的填充矩形的API。填充矩形主要分为两个步骤:1、设置画笔样式fillStyle;2、调用fillRect接口执行绘制矩形操作。JS脚本如下:

// 创建canvas上下文,也就是上一个步骤中扩展的C函数
var ctx = createCanvasContext();
// 设置填充样式
ctx.fillStyle = "#FF0000";
// 填充矩形区域,x=20,y=20,w=100,h=100
ctx.fillRect(20,20,100,100);

所以,这里需要先扩展两个API,fillStyle和fillRect。先来增加一下fillStyle的设置,因为这是一个属性设置,而不是接口调用,这块上面quickjs扩展没提到,其实也是类似的,可以通过quickjs扩展对象的getter/setter方法;以及扩展一个fillRect函数,具体实现代码如下:

// 设置填充样式
JSValue setFillStyle(JSContext* ctx, JSValue this_val, int argc, JSValue* argv)
{
    // 获取绑定的CanvasContext对象
    CanvasContext* canvasCtx = static_cast< CanvasContext* >(JS_GetOpaque(this_val, _js_canvas_class_id));
    if (canvasCtx == NULL) return JS_EXCEPTION;
    if (argc < 1) return JS_EXCEPTION;

    if (JS_IsString(argv[0])) {
        const char* jcolor = JS_ToCString(ctx, argv[0]);
        // 通过字符串解析为RGBA色值
        // 颜色格式可能是#RGB, #RRGGBB, rgb(r,g,b), rgba(r,g,b,a)以及HSL等等
        // 这里先不实现,先简单直接写死红色先吧
        // 给fillPaint设置颜色
        canvasCtx->fillPaint.setColor(SkColorSetARGB(0xFF, 0xFF, 0x0, 0x0));
        JS_FreeCString(ctx, jcolor);
	} else if (JS_IsObject(argv[0])) {
        // 看W3C标准规范,fillStyle可能是CanvasGradient,包括线性渐变和径向渐变,这里先不实现
    }
}
// 填充矩形
JSValue fillRect(JSContext* ctx, JSValue this_val, int argc, JSValue* argv)
{
    // 获取绑定的CanvasContext对象
    CanvasContext* canvasCtx = static_cast< CanvasContext* >(JS_GetOpaque(this_val,_js_canvas_class_id1));
    if (canvasCtx == NULL) return JS_EXCEPTION;
    if (argc < 4) return JS_EXCEPTION;
    
    double x, y, w, h;
    if (JS_ToFloat64(ctx, &x, argv[0]) == 0 
        && JS_ToFloat64(ctx, &y, argv[1]) == 0 
        && JS_ToFloat64(ctx, &w, argv[2]) == 0 
        && JS_ToFloat64(ctx, &h, argv[3]) == 0) {
        SkRect rect;
        rect.set(x, y, x + w, y + h);
        // 调用canvas绘制矩形
        canvasCtx->canvas->drawRect(rect, canvasCtx->fillPaint);
    }
    return JS_UNDEFINED;
}

JSValue createCanvasContext(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv)
{
    ... 省略其他代码
        
    // 创建canvas对应的JS对象
    JSValue obj = JS_NewObjectClass(ctx, _js_canvas_class_id);
    // TODO 之后需要在这里挂载2dcanvas的所有api
    // 设置fillStyle样式
    JS_DefinePropertyGetSet(ctx, obj, JS_NewAtom(ctx, "fillStyle"), JS_UNDEFINED, JS_NewCFunction(ctx, &setFillStyle, "fillStyle", 1), 0);
    // fillRect填充矩形的api
    JS_SetPropertyStr(ctx, obj, "fillRect", JS_NewCFunction(ctx, &fillRect, "fillRect", 1));
    
    ... 
}

这样子,支持fillRect填充矩形区域的接口就扩展完成了,然后再程序中通过quickjs运行上面的JS脚本,就可以绘制出下面的图形了

基于QuickJS扩展2D canvas图形接口

绘制复杂路径

通过上面的扩展的方式,就可以开始扩展其他更多的接口了,接下来尝试一下扩展复杂路径的绘制,这里需要扩展的接口会有好几个与Path相关的。可以在规范中看到,与Path有关的接口包含以下一些常用接口等:

  • beginPath: 开始一条路径,或重置当前的路径
  • closePath: 创建从当前点回到起始点的路径
  • moveTo: 把路径移动到画布中的指定点,不创建线条
  • lineTo: 添加一个新点,然后在画布中创建从该点到最后指定点的线条
  • quadraticCurveTo: 创建二次贝塞尔曲线
  • bezierCurveTo: 创建三次方贝塞尔曲线
  • arcTo: 创建两切线之间的弧/曲线
  • arc: 创建弧/曲线(用于创建圆形或部分圆)
  • rect: 创建矩形
  • fill: 填充当前绘图(路径)
  • stroke: 绘制已定义的路径(描边)

通过以上的接口,就可以实现各种丰富的矢量图形。比如,可以通过arc接口绘制圆弧:

var ctx = createCanvasContext();
ctx.strokeStyle = 'red';
ctx.beginPath();
// 圆心坐标x, 圆心坐标y, 半径radius, startAngle, endAngle
ctx.arc(100, 100, 50, 0, 2 * Math.PI);
ctx.stroke();

按照示例需要,来扩展benginPath,arc以及stroke接口:

// 填充矩形
JSValue beginPath(JSContext* ctx, JSValue this_val, int argc, JSValue* argv)
{
    // 获取绑定的CanvasContext对象
    CanvasContext* canvasCtx = static_cast< CanvasContext* >(JS_GetOpaque(this_val,_js_canvas_class_id1));
    if (canvasCtx == NULL) return JS_EXCEPTION;
    // 重置Path
    canvasCtx->path.reset();
    return JS_UNDEFINED;
}
// 创建圆弧的接口
JSValue arc(JSContext* ctx, JSValue this_val, int argc, JSValue* argv)
{
    // 获取绑定的CanvasContext对象
    CanvasContext* canvasCtx = static_cast< CanvasContext* >(JS_GetOpaque(this_val,_js_canvas_class_id1));
    if (canvasCtx == NULL) return JS_EXCEPTION;
    if (argc < 4) return JS_EXCEPTION;
    // 创建圆弧
    double x, y, radius, startAngle, endAngle;
    if (JS_ToFloat64(ctx, &x, argv[0]) == 0 
        && JS_ToFloat64(ctx, &y, argv[1]) == 0 
        && JS_ToFloat64(ctx, &radius, argv[2]) == 0 
        && JS_ToFloat64(ctx, &startAngle, argv[3]) == 0
        && JS_ToFloat64(ctx, &endAngle, argv[4]) == 0) {
        SkRect oval;
        oval.set(x - radius, y - radius, x + radius, y + radius);
        // 弧度转为角度
        startAngle = startAngle * 180.f / M_PI;
    	double sweepAngle = endAngle * 180.f / M_PI - startAngle;
        // 创建圆弧
        canvasCtx->path.addArc(oval, startAngle, sweepAngle);
    }
    return JS_UNDEFINED;
}
// 绘制线条的接口
JSValue stroke(JSContext* ctx, JSValue this_val, int argc, JSValue* argv)
{
    // 获取绑定的CanvasContext对象
    CanvasContext* canvasCtx = static_cast< CanvasContext* >(JS_GetOpaque(this_val,_js_canvas_class_id1));
    if (canvasCtx == NULL) return JS_EXCEPTION;
    // 使用strokePaint来绘制路径线条
    if (!canvasCtx->path.empty()) {
        canvasCtx->canvas->drawPath(canvasCtx->path, canvasCtx->strokePaint);
    }
    return JS_UNDEFINED;
}

JSValue createCanvasContext(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv)
{
    ... 省略其他代码
        
    // 创建canvas对应的JS对象
    JSValue obj = JS_NewObjectClass(ctx, _js_canvas_class_id);
    // TODO 之后需要在这里挂载2dcanvas的所有api
    // beginPath重置路径
    JS_SetPropertyStr(ctx, obj, "beginPath", JS_NewCFunction(ctx, &beginPath, "beginPath", 0));
    JS_SetPropertyStr(ctx, obj, "arc", JS_NewCFunction(ctx, &arc, "arc", 0));
    JS_SetPropertyStr(ctx, obj, "stroke", JS_NewCFunction(ctx, &stroke, "stroke", 0));
    
    ... 
}

实现了这几个接口之后,运行上面的JS脚本,就可以绘制出以下的图形了:

基于QuickJS扩展2D canvas图形接口

更多扩展

有了以上的接口扩展经验,2dcanvas规范中的所有接口都可以使用类似的方式来扩展了,Skia引擎是能够完全支持2dcanvas标准规范的,这里就不一一展开细说了。

动画

在Web开发领域,有了2dcanvas之后,还有基于canvas实现矢量动画也是非常常用的功能;而动画的基本工作原理就是,每隔一定时间重新调用绘制图形,并且每一帧绘制的图形根据时间戳进行差值计算,使得图形具备平滑过渡的效果。

一个最简单的实现就是在JS上通过不断的调用setTimeout定时来定时绘制图形。在quickjs中,setTimeout函数有在quickjs-libc.c中有参考实现,如果是RTOS系统,可以参考来实现一下。这里就不具体展开了。

结语

本文主要内容是在IoT领域中(主要指Linux和RTOS的带屏设备上),使用quickjs和Skia来实现W3C中对于2dcanvas的规范的一个基本方法的探讨。如果在quickjs和Skia已经适配移植完成的情况下,实现起来是比较容易的,主要的思路就是借助quickjs的C函数扩展方法来将Skia的一些接口扩展到JS层。希望能对大家有所帮助。

开发者支持

如需更多技术支持,可加入钉钉开发者群,或者关注微信公众号。

基于QuickJS扩展2D canvas图形接口

更多技术与解决方案介绍,请访问HaaS官方网站https://haas.iot.aliyun.com

上一篇:Unity3D 开发一套2d游戏的记录


下一篇:OpenCV图像识别初探-50行代码教机器玩2D游戏