使用spritejs操作canvas

大概是一个多月前,从掘金推荐上了解到spritejs这个canvas库,碰巧中秋活动需要做一个比较复杂的canvas游戏,因此决定在项目中尝试使用spritejs。

这篇文章并不是介绍spritejs相关api或者使用方法,而是用于记录使用经验,包括相关项目以及踩过的坑,除此之外还有一些在canvas项目中常见的问题。

<!--more-->

使用spritejs实现的两个项目

  • 中秋海报活动,这是一个通过用户选择素材,自定义祝福海报的中秋活动,由于是公司项目,因此不能开发相关源码
  • 衬衫定制,这是一个在文化衫上定制不同图案的需求,目前还没有完工,后面应该会进行开源,链接我后面再补上了~

关于spritejs,参考:

1. 几个概念

由于之前接触过cocos和phaser等游戏引擎,因此对于spritejs中场景、精灵等概念并不是十分陌生,但是spritejs和游戏引擎最大的区别在于:它十分轻量,接口也比较友好。

在spritejs中,我们需要理解的有下面几个概念。

1.1. 精灵 Sprite

canvas动画之绘制思路这篇博客中提到过,我们可以使用面向对象的思维来绘制图形。在spritejs中,精灵就是最基本的对象(从其命名就可见一斑)。

画布上每个独立的元素都可以看做是一个精灵,Sprite类提供了操作精灵相关的接口,

  • attr,修改属性,如背景色、图片资源、边框、位置、尺寸等,跟$.attr()类似,这使得我们可以使用类似DOM的方法操作精灵
  • animate,在原生的canvas中实现动画是比较麻烦的,spritejs封装了动画的接口,可以使用类似于css3动画属性控制精灵的动画效果
  • on,spritejs代理了容器上的mouse和touch事件,我们可以直接给精灵注册相关事件处理函数,此外还支持自定义事件
    • 可以使用内置afterdraw事件,控制绘制的图片

借助这些接口,可以在Layer上快速绘制和修改精灵。

此外,也可以继承Sprite类,实现自定义精灵,在上面的项目中,通过继承Sprite,实现了可展示选择效果的SelectSprite类,在点击时会出现高亮边框和缩放、删除等按钮。

1.2. 分组 Group

如果是一组相关的精灵(比如一组具有相同动画的元素),可以将他们进行分组,方法是创建一个group对象,然后通过group.append(sp)将元素添加到group里,参考文档

group对象和精灵具有相同的操作接口,此外还可以通过clip属性裁剪精灵。

1.3. 层 Layer

一个layer就是就是一个canvas画布,层是由场景进行管理的,可以调用scene.layer实例化一个层,其实质就是根据配置参数,创建了一个canvas节点。

一个scene可以创建多个layer。不同的层属于不同的canvas,因此其画布状态不会相互干扰,可以完成各自特定的工作。下面是文化衫定制项目中的一段代码,其中

  • scrawlLayer 用于实现涂鸦效果,涂鸦完成后会导出图片并绘制到baseLayer上
  • baseLayer用于绘制基本的图片、文字等素材
  • shadeLayer用于实现相框和遮罩效果,其层级最高,但不影响baseLayer的操作
this.scene = scene
const scrawlLayer = scene.layer('scrawl', {autoRender: false});
const baseLayer = scene.layer('base');
const shadeLayer = scene.layer('shade');

scrawlLayer.zIndex = 2
this.scrawlLayer = scrawlLayer

layer.zIndex = 3
this.layer = baseLayer

shadeLayer.zIndex = 4
this.shadeLayer = scrawlLayer

有了layer之后,可以通过layer.append(sp),将实例化的精灵添加到对应的layer上。精灵在层上的状态,由精灵本身的属性和状态控制,层并不需要处理这些细节。

如果需要操作原生的dom节点,可以通过

  • layer.canvas获取到对应的canvas节点,比如为canvas加个不同的边框啥的
  • layer.context获取到绘制上下文,在某些需要直接使用上下文的地方(比如getImageData)派得上用场

1.4. 场景 Scene

场景用来管理多个层,实例化时需要指定一个dom容器,后续创建的layer,都将挂载到这个容器节点下。

关于场景,一个比较重要的地方就是分辨率的问题。

const scene = new Scene('#container', {
   resolution: [600, 600],
   viewport: [300, 300],
})

我们知道,canvas画布本身的大小和canvas在文档流中css样式的大小时可以不一致的(类似于img标签)在构造函数的配置参数中,

  • resolution 用来指定画布的分辨率大小
  • viewport 用来指定画布的css样式大小

如果resolutionviewport不是按照一定比例指定尺寸,则绘制的图形会发生变形。这跟spritejs没关系,是由canvas本身决定的。

在项目中用到了一个scene.snapshot的方法,导出某个scene下所有layer的画布合成的图片,如果需要导出大分辨率且图案清晰的图片,则除了指定比较大的resolution之外,还需要指定比较大的viewport(导出的图片尺寸由viewport决定,而清晰度由resolution决定),这两个属性是可以在初始化之后继续修改的。

此外,spritejs实现了屏幕适配的功能,以及flex布局,接口和配置参数等于css十分相似。

2. 遇见的问题

2.1. 精灵texturesSize为0

有时候需要获取精灵的原始图片尺寸,使用sp.texturesSize获取到的却是[0,0]

const robot = new Sprite('https://p5.ssl.qhimg.com/t01c33383c0e168c3c4.png');
robot.texturesSize // [0,0]

这是因为图片的加载都是异步的,在同步代码中无法直接获取图片的尺寸。解决办法是使用scene。proload方法进行资源预加载。

下面是封装的一个预加载的方法,图片如果已经加载完毕,则可以使用texturesSize获取到对应的尺寸

preloadTexture(src) {
    let scene = this.preloadScene
    let id = src // id直接使用src进行命名,多个scene之间可以共用图片缓存
    return scene.preload({
        id,
        src,
    }).then(() => {
        return id
    })
}

2.2. 图片预加载的问题

在常规的前端项目中,我们也可以使用背景图、new Image等方法进行图片预加载

function preload(list) {
    let i = 0;
    let load = () => {
        if (!lisfut[i]) {
            return
        }
        this.diy.preloadTexture(list[i]).then(() => {
            i++
            if (i < list.length) {
                load()
            }
        })
    }
    load()
}

发现的问题是:貌似spritejs的预加载会不会使用图片缓存,需要重新请求一次图片源文件。因此

暂时的处理方式是只使用scene.proload进行预加载,而不是自己实现的预加载。这个问题没有经过严格的验证~

2.3. 源码打包

spritejs使用的是typescript进行开发,因此使用npm安装进行打包时,需要注意相关语法 在通过继承实现新的精灵类型,会提示下面错误

Class constructor Sprite cannot be invoked without 'new'

但是看起来代码是正常的,查资料发现可能是文件路径的问题

但这个应该不是问题的关键,后面发现sprite源码时ts写的,通过npm安装引用的也是对应的源码。

临时的解决办法是引用浏览器版本的spritejs,解决这个问题。通过安装babel相关插件,应该也可以解决,暂时没有进一步研究。

2.4. 截图导出

在实例化layer时,配置 autoRenderfalse后,导致scene无法导出,在导出前需要将其移除

 // BUGFIX 
const scrawlLayer = scene.layer('scrawl', {autoRender: false});

猜测其内部实现是需要等待所有canvas绘制完毕调用完毕

 this.scene.removeLayer(this.scrawlLayer)

关于这个问题我去项目下提了一个issue,月影大神给出了回复

你配置了layer的autoRender是false的话,就要在snapshot之后自己手工调用一下layer.draw,因为snapshot需要等待layer绘制完成

2.5. 分组点击事件定位不准

上面提到通过自定义精灵实现了一个SelectSprite的类,在点击时会出现高亮边框和缩放、删除等按钮,这个功能最开始是通过分组来实现的,即将边框、删除、缩放按钮都通过精灵实现,然后与原本的精灵放在同一个分组下。

该方法在视觉效果上已达到了预期目标,在进行事件处理时发现分组的点击事件中,e.xe.y的位置都有较大偏差,尚不清楚是框架问题还是我自身代码的bug....,后面使用的自定义精灵类实现,之前的代码找不到了~

3. canvas其他问题

3.1. 图片跨域与nginx跨域配置

由于浏览器的同源策略限制,canvas的图片资源也存在跨域的问题。这个问题之前也提到过,我在项目中使用过两种处理办法

  • 如果跨域图片的数量较少,且为静态图片,则可以使用内联base64文件进行处理
  • 静态资源服务器配置CORS

其中第一种处理方法已在canvas动画之粒子效果这篇提到过,这里主要记录一下nginx配置cors的方法。

nginx配置cors

通过nginx的add_header指令,可以添加cors响应头。

server {
    listen 80;
    listen 443;

    ssl on;
    ssl_certificate      /usr/local/etc/nginx/config/server.crt;
    ssl_certificate_key  /usr/local/etc/nginx/config/server_nopwd.key;

    root /Users/Txm/Desktop/test/app;

    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';

    if ($request_method = 'OPTIONS') {
        return 204;
    }
}

上面是我在本地开发时进行的nginx静态资源服务器的配置,由于单条add_header Access-Control-Allow-Origin只能配置*或者单个域名,如果需要支持多个域名跨域,则可以使用map结构指定,配置如下

# 静态资源
map $http_origin $corsHost {
    default *;
    "~http://g.shymean.com" http://g.shymean.com;
    "~https://g.shymean.com" https://g.shymean.com;
}

server {
    # 其他配置同上
    add_header Access-Control-Allow-Origin $corsHost;
}

另外一种思路是通过location指令进行过滤,只对指定来源的请求返回cors请求头,此时$add_header设置为*或者具体域名也就无所谓了。

关于nginx跨域配置,可以参考Nginx 允许多个域名跨域访问

图片配置crossOrigin

配置了cors后,图片还需要进行设置crossOrigin才行

let closeImg = new Image()
closeImg.crossOrigin = 'Anonymous' // 表示元素的跨域资源请求不需要凭证标志设置
closeImg.src = 'https://img.shymean.com/shirt/close.png'

实际上只要crossOrigin的属性值不是use-credentials,全部都会解析为anonymous,包括空字符串,包括类似'xxx'这样的字符。

参考: 解决canvas图片getImageData,toDataURL跨域问题

3.2. canvas 绘制自定义字体

在项目中遇见了一个需要绘制自定义字体的功能。解决办法是:通过css定义字体名

@font-face {
  font-family: "_________";  //下划线填字体名称
  src: url("_________");  //下划线填字体文件
}

然后在canvas中使用对应的字体名即可,需注意

  • 必须再等到字体下载完成之后再去渲染canvas,字体才能有作用
  • canvas中所引用的字体必须在文档流中有标签(span,p等)引用改字体(该问题待验证

参考:利用font-face定义的字体怎么在canvas里应用

3.3. 十六进制与rgb颜色转换

在项目中,由于接口传回的是十六进制的像素值,在画布上操作像素值时getImageData获取到的是rgba形式的色值,有时候需要进行单位换算,下面是一个十六进制颜色转rgb的JavaScript实现。

function hex2Rgb(hexColor) {
    var sColor = hexColor.toLowerCase();
    if (sColor.length === 4) {
        var sColorNew = "#";
        for (let i = 1; i < 4; i += 1) {
            sColorNew += sColor.slice(i, i + 1).concat(sColor.slice(i, i + 1));
        }
        sColor = sColorNew;
    }
    //处理六位的颜色值
    var sColorChange = [];
    for (let i = 1; i < 7; i += 2) {
        sColorChange.push(parseInt("0x" + sColor.slice(i, i + 2)));
    }

    return sColorChange
}

3.4. 颜色替换

有一个需求是对指定区域内的图片颜色进行替换,实现类似于滤镜的效果。其大致思路是获取指定区域的ImageData,然后替换成指定的颜色。

这里的问题是区域内每个像素点的色值是不同的,不能简单地理解为把改区域所有的像素点都替换成统一的颜色,而是需要进行翻转

参考:試試看Canvas (2),調整Canvas圖片色調,其核心代码是

function draw2(imgObj,w,h){  
  ctx2.drawImage(imgObj,x,y);  
  var imgData = ctx2.getImageData(x, y, imgObj.width, imgObj.height);    
  var data = imgData.data;
  for(var i = 0; i < data.length; i += 4) {
    // 這邊以下將會套用新的RGB色彩
    // red
    data[i] = _colorR - (255 - data[i]);
    // green
    data[i + 1] = _colorG - (255 - data[i + 1]);
    // blue
    data[i + 2] = _colorB - (255 -data[i + 2]);
  }
  ctx2.putImageData(imgData,0,0)
}

4. 小结

这篇博客有点标题党的嫌疑:虽然用spritejs作为标题,实际上却没有详细介绍其使用方法。

由于之前花了挺长一段时间学习canvas,更早之前还使用了cocos等引擎,因此在使用spritejs时,基本上就是对着文档找接口,与其通篇介绍API的使用,不如把自己遇见的问题记下来。

总之,spritejs比较轻量,接口也比较友好,用于实现一些小规模的项目还是不错的。