PixiJS 实战万圣节主题横版 H5

上个月初我们准备了一期万圣节主题 H5 用于推广 APP 的拍照效果,10月24日上线,截止万圣节当天共 560 万 PV。对我个人比较有纪念意义的是,它算是我的第一个真正意义上的百万级 PV H5,也是我第一次用 2D 渲染引擎。虽然开发的时候遇到不少问题,但解锁挑战的过程非常有趣。

准备工作

在动手之前,我先了解了几款 HTML5 2D 动画引擎,包括 PixiJS、Fabric.js、Paper.js、EaselJS、Collie 。我需要一款易上手、可靠、高性能的渲染引擎,最终选择了 PixiJS 。

因为它:

  • GitHub 20k+ Star,广泛的用户基础意味着问题更容易找到答案。
  • 仍在不断更新,示例代码使用了 ES6 语法。
  • 文档及示例友好,还有一篇质量不错的 官方教程中文版 可供入门。
  • 追求性能,官网称它在 2D 渲染上无人望其项背。

由于笔者的项目面向海外市场,需要照顾可能在一些发展中国家占主流的中低端机用户,加上之前的 H5 已经有在中低端机上表现欠佳的经历,所以性能因素非常影响我的选择。

在着手实现之前,先来了解一下设计输出的视觉稿:

视觉稿中的图2至图6就是我们要用 PixiJS 实现的场景。设计师希望以类似“一键到底”的效果呈现,用户左右滑动屏幕在“主题馆”之间切换,然后点击入口点按钮进入其他页面浏览更多内容。

动画部分需要实现:招牌霓虹闪烁,浏览到图5位置时月亮缓慢升起,图5和图6建筑后方烟雾扩散,蝙蝠和女巫上下漂浮等。

切图与布局

在开始切图之前,必须先结合动画需求把内容分层。在这个案例中,动画需求直接影响了内容是否能够合并成一张图片呈现,也影响了切图阶段的工作量。一张静态背景图显然是无法让月亮动起来的。

于是我把内容拆分成了8层:

L6-8 组成背景部分:纯色矩形叠加星星素材组成“星空”,然后是月亮,最上面是背景建筑群。L3-5 组成中景部分:主题馆建筑后面有两片正在扩散的烟雾,前面是不断闪烁的霓虹招牌。L1-2 组成前景部分:贯穿整个场景的马路旁立着路灯,左侧近处有一栋建筑,蝙蝠和女巫上下漂浮。

用切图工具把每一层中相对独立的内容切成图片。我用的是 Cutterman,使用 @2X 分辨率 。霓虹招牌比较特殊,需要按照动画的关键帧切图。加上要做5种语言版本,每个招牌最终切出了 10-30 张图片。由于招牌的关键帧内容有所不同,切图工具自动选择的区域可能存在误差,导致动画效果不协调,所以切图时必须手动选择区域。

倒序地把每一层内容绘制到画布上就得到了完整的静态场景。为了适配不同屏幕尺寸,我用浏览器可视区域的高度作为参考来设置内容的尺寸和位置。

下一步便是实现动画。

关键帧动画

关键帧动画适用于精灵需要有规律地改变外观的场景。以用户看到的第一个霓虹招牌为例,每个语言切出6张关键帧图片,然后以预先编排好的顺序播放就能实现闪烁效果。其他招牌同理。

在 PixiJS 中使用 AnimatedSprite 创建关键帧动画:

1
2
3
4
5
6
7
const sprite = new PIXI.extras.AnimatedSprite([
PIXI.loader.resources[require('@img/frame_1.png')].texture,
PIXI.loader.resources[require('@img/frame_2.png')].texture,
...
])
sprite.animationSpeed = 0.2
sprite.play()

AnimatedSprite 类的构造方法接收一个由关键帧图片转化成的纹理组成的数组。纹理是指可以被 GPU 处理的图片。PixiJS 默认使用 WebGL 和 GPU 渲染,所以图片都需要转换成纹理。animationSpeed 用于控制动画的播放速度。

游戏循环动画

游戏循环动画适用于精灵需要匀速改变尺寸、位置或角度的场景。PixiJS 提供了 ticker 用于添加游戏循环动画,游戏循环中的代码每秒将被执行60次。

以“糖果屋”招牌上旋转的蛋糕和糖果为例,添加每次转动 0.02 弧度的游戏循环动画:

1
2
3
4
app.ticker.add(delta => {
cake.rotation -= 0.02 * delta // 逆时针旋转
candy.rotation += 0.02 * delta // 顺时针旋转
})

然后,蛋糕和糖果将每 1/60 秒转动 0.02 弧度。 delta 表示帧延迟率,根据延迟率改变转动的弧度数,能够缓解老旧设备上的卡顿感。ticker 适合用来实现循环的匀速的动画。

辅助动画库

游戏循环动画已经足够处理一些简单的动画逻辑,但是如果我们想要给动画加上条件判断或者是舒适的过渡效果,它就显得不那么顺手了。于是我在项目中同时引入了 Anime 动画库,辅助实现一部分动画。其中就包括月亮升起的动画:

1
2
3
4
5
6
7
anime({
targets: sprite,
y: 0, // 垂直位置变为0
round: 1, // 平滑移动
duration: 1000, // 过渡持续1秒
easing: 'easeOutQuad' // 以慢速结束的过渡效果
})

烟雾扩散动画同样使用 Anime 实现,实际上是逐渐放大、透明。在应用了 Anime 的过渡效果后,用户滑动屏幕时画面的滚动也变得更加平滑。它的加入不仅使动画更易实现,而且提高了动画质量。

交互

场景的触摸滑动是通过舞台的触摸事件: touchstarttouchmovetouchend 实现的。如果说用户的视角是一台摄像机,那么我要做的不是转动它,而是移动整幅画面。

首先,计算出 5 个焦点区域的舞台位置:

x 表示舞台的水平位置 stage.xwh 分别表示浏览器可视区域的宽和高。

在用户向左滑动屏幕的过程中,不断让舞台的水平位置向左移动(用户手指移动的距离),就可以产生滑动跟手的效果。在用户结束触摸后,根据滑动方向继续移动舞台,使下一个焦点区域显示在屏幕上。

1
2
3
4
5
6
7
8
9
10
11
// 启用舞台交互
this.app.stage.interactive = true
this.app.stage.on('touchstart', event => {
// 监听触摸开始,在这里保存起始点坐标。
})
this.app.stage.on('touchmove', event => {
// 监听手指移动,在这里判断滑动方向,制造跟手效果。
})
this.app.stage.on('touchend', event => {
// 监听触摸结束,在这里判断滑动方向,平滑移动舞台到下一个焦点。
})

主题馆入口点按钮的点击是通过 tap 事件实现的:

1
2
3
4
5
// 启用精灵交互
entrance.interactive = true
entrance.on('tap', () => {
// 在这里路由到下一个页面
})

加载

由于使用的图片资源很多,必须在渲染之前确保所有图片加载完毕,避免用户看到的不完整的场景。

PixiJS 提供了 loader 用于加载资源:

1
2
3
4
5
6
7
8
9
10
PIXI.loader.add([
require('@img/image_1.png'),
...
]).on('progress', (loader) => {
// 更新加载进度
this.progress = parseInt(loader.progress)
}).load(() => {
// 加载完毕
// 在这里结束 Loading
})

优化

1. 图片当然都是经过压缩的。权衡了设计师对画质的追求,最后我把首屏加载的图片量控制在 1.4mb 。

2. PixiJS 默认使用的 WebGL 渲染能够提供更好的性能,但是一些老旧设备并不支持。比如在一台 Android 4.4 测试机上,我们发现了画面持续闪烁的现象。这个问题在强制使用 Canvas 渲染模式后得到解决,并且动画性能也没有明显下降。

1
new PIXI.Application({ forceCanvas: true })

3. PixiJS 支持使用图片中的一块区域作为精灵的材质。为了减少请求数,同时避免小概率的图片加载失败,我曾尝试将图片素材合成一两张大的雪碧图。测试后发现性能明显下降,最终放弃。

4. 为了提高清晰度和消除锯齿,我是用浏览器可视区域两倍的尺寸渲染,然后把 Canvas 缩放到 1/2 。