微信小程序 Canvas 动画踩坑——棘手的椭圆

最近正在给我的微信小程序开发一个天气预报的页面,页面的效果如下:

最终效果

计划页面有两个 canvas,一个用来实时绘制上半部分的天气动画,一个用来绘制中间的气温趋势折线图。

上半部分天气动画在不同的天气下有不同的效果,比如不同强度的降雨会有相应的雨滴从上面落下,阴天多云的时候顶部会有云朵在漂浮,多云转晴的话后面再加上个太阳。

降雨的动画不难,无非就是随机生成一些雨滴对象,每一帧实时更新位置即可。

关键在云朵的动画上,我们需要生成随机宽高的椭圆形云朵,并且云朵沿着随机椭圆轨迹运动。

根据高中数学学习的关于椭圆方程的知识,我们知道,中心点位于点 (h,k) 的椭圆方程为:

椭圆标准公式

a 为半长轴,b 为半短轴。在这里可以简单理解成 a 为椭圆宽的 1/2,b 为椭圆高的 1/2。

可是仅通过这个方程我们还没办法写出动画算法,因为无法计算出 x,y 在任意时刻的值。

还好,维基百科给了我们另一个公式:

椭圆参数公式

这是椭圆的参数方程,其中 t 为椭圆中心点到椭圆上任意一点所确定的直线与x
轴的夹角,可以通过三角函数计算出在任意角度时点 (x,y) 的坐标。

有了这个公式就好办了,代码走起!

我们先封装一个生成指定范围内随机数的方法,方便后面使用。

function random(min, max) {
    return Math.random() * (max - min) + min;
}

好了,现在我们来写一个云的类,就叫做 Cloud。

按照我们的设想,云应该是一个绕椭圆轨迹运动的椭圆。所以我们需要给 Cloud 对象传入一个运动轨迹中点,然后让 Cloud 对象在初始化的时候随机生成一些变量,然后在每一帧的时候更新自己的位置并且绘制到 canvas 上。

function Cloud() { }
Cloud.prototype = {
    init: function (x, y) {
        this.x = x // 椭圆运动轨迹中点 x
        this.y = y // 椭圆运动轨迹中点 y
        this.a = random(20, 25) // 椭圆运动轨迹 a
        this.b = random(5, 15) // 椭圆运动轨迹 b
        this.h = random(60, 80) // 云的高
        this.w = random(90, 120) // 云的宽
        this.color = 'rgba(255,255,255,0.3)'
        this.t = random(0,Math.PI * 2) // 运动起始角度
        this.v = random(0.1, 0.5) // 运动角速度
        this.d = random(0,2) > 1 ? true : false // 运动方向
    },
    draw: function () {
        ctx.setFillStyle(this.color)
        // 绘制椭圆
        fillEllipse(ctx, this.x + this.a * Math.cos(this.t), this.y + this.b * Math.sin(this.t), this.w / 2, this.h / 2)
        this.update() // 更新下一帧的位置
    },
    update: function () {
        if (this.d) {
            this.t = this.t < Math.PI * 2 ? this.t += this.v : 0
        } else {
            this.t = this.t > 0 ? this.t -= this.v : Math.PI * 2
        }
    }
}

代码很简单,我们给了 Cloud 类三个方法。

在实例化 Cloud 对象的时候,调用 init 方法,传入云朵所围绕运动的椭圆轨迹中点坐标,并且随机生成一系列的随机变量。

在每一帧的时候调用 Cloud 对象的 draw 方法,通过角度 t 计算出新的绘制坐标,并在 canvas 上绘制一个云朵。

然后 Cloud 对象内部调用自己的 update 方法,根据自己的角速度变量计算出下一帧的角度 t。

接下来开始绘制椭圆

在 draw 方法内,我们调用了一个名叫 fillEllipse 的方法,按照设想,我们可以传入椭圆的中点坐标、a 和 b 来绘制出一个椭圆。
现在我们来用代码实现这个方法。

这里我首先想到的是利用之前的椭圆参数方程,沿着椭圆绘制出一条路径,然后填充路径即可。(后来发现这是一个坑,详见后文)

function fillEllipse(ctx, x, y, a, b) {
    let dt = 1 / Math.max(a, b) // 绘制时的角度增量 Δt
    ctx.beginPath()
    ctx.moveTo(x + a, y) // 移动到起始点
    for (let t = 0; t < Math.PI * 2; t += dt) {
        // x=h+a*cos(t)
        // y=k+b*sin(t)
        ctx.lineTo(x + a * Math.cos(t), y + b * Math.sin(t))
    }
    ctx.closePath()
    ctx.fill()
}

代码很简单,先粗略的计算出一个合适的角度增量 Δt,然后循环计算角度 t 从 0 增加到 2π 过程中的每一个点的坐标,绘制出一条路径,然后填充路径。

效果如下:

云朵效果动图

这时遇到了问题,在微信开发工具上模拟的时候效果很不错,可是当放到真机上测试的时候发现,为什么云的运动速度变得异常的缓慢。

根据我们之前的绘制原理,每一次的增量移动是在每一帧的时候完成的,而不是根据时间增量所计算的。所以移动的速度会直接和帧率挂钩,当帧率大的时候,位置增量更新的就更快,反之就会更慢。

我在绘制的时候是采用的每绘制一帧间隔 16 毫秒,即约 60 帧每秒。
为了查看实际的帧率,我在每一帧的绘制方法里面加上了这样一句:

let now = new Date()
let fps = Math.round(1000 / (now - last)) // 帧率 FPS
last = now

这样通过每一帧绘制的实际间隔时间,计算出当前的帧率,然后把这个数字绘制在 canvas 上。(高频率的输出不应使用 console.log(),会非常影响性能。)

结果如下:

实际帧率1

可以看到,实际的帧率在 20-30 帧每秒之间浮动,远远低于设定的 60 帧每秒。
CPU 使用率和内存占用也是出乎意料的非常高。

查看前面的代码,不难发现,问题一定是出在了绘制椭圆的那个方法上。

每秒 60 次循环计算出椭圆上各个点的位置并绘制路径,计算量非常之大,并且大量的路径数据添加到上下文中,导致内存占用也开始飙升。

显然,这个方法一定是不可行的!

开始填坑...

查阅小程序的开发文档以后,其中一个 canvas 的 Api 让我看到了希望。

canvasContext.scale(scaleWidth, scaleHeight)
定义:在调用scale方法后,之后创建的路径其横纵坐标会被缩放。多次调用scale,倍数会相乘。
参数:scaleWidth 和 scaleHeight 分别为横纵坐标缩放的倍率。

我们只需要计算出椭圆在横纵坐标上相较于正圆的缩放倍率,调用 scale 方法后,再绘制一个正圆,不就可以画出需要的椭圆形状了吗。

而绘制正圆的方法非常简单,用 arc 方法直接绘制一个完整的圆形路径即可。

代码如下:

function fillEllipseByScale(ctx, x, y, a, b) {
    let r = Math.max(a,b) // 取 a,b 中的大者作为圆形的半径
    let scaleX = a / r // 横坐标缩放倍率
    let scaleY = b / r // 纵坐标缩放倍率
    ctx.scale(scaleX, scaleY) // 设置缩放
    ctx.beginPath()
    ctx.arc(x, y, r, 0, 2 * Math.PI) // 绘制圆形
    ctx.closePath()
    ctx.scale(1 / scaleX, 1 / scaleY) // 缩放回正常倍率
    ctx.fill()
}

这样每次绘制椭圆只会向 canvas 上下文中添加少量的动作,计算量也大大降低。

我们再用同一台手机运行,测试一下实际帧率。

实际帧率2

可以看到,这次帧率稳定在了 63 帧,与我们所设置的帧绘制间隔一致了,CPU 使用率和内存占用也降低到了之前的一半,性能有了很大的提升。

发表评论

电子邮件地址不会被公开。