[HTML5] 用 canvas 实现星空动画

为了提醒自己对新知识保持探索精神,这个空间用了宇宙作为主题,今天给这个页面来了个星空背景。为巩固学习 canvas 的用法,没有直接使用百度来的代码,而是自己捣鼓了一下。 这里将过程简单记录,加深下印象。

canvas 元素的用法

初始化

canvas 中所有图像处理方法都是基于其 context 对象,而非 canvas 元素本身。因此凡用到 canvas 都要先实例化 context 对象。

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); // 2d 为固定参数,canvas 目前仅支持 2D 场景渲染

往 canvas 添加内容的方式通常有三种:

其中第一种用得最多,星空背景就是用这个方式实现的,下边列出有用到的方法。

创建渐变实例

创建放射状环形渐变实例,准备用以渲染后边定义的路径:

const gradient = ctx.createRadialGradient(x0, y0, r0, x1, y1, r1);

其中 x0y0r0 分别是环形内圈的坐标和半径;x1y1r1 是外圈的坐标和半径。

并使用若干个 addColorStop 方法填充颜色:

gradient.addColorStop(0, 'rgb(255, 255, 255)');
gradient.addColorStop(0.5, 'rgba(200, 200, 0, 0.4)');
gradient.addColorStop(1, 'transparent');

其中前一参数是介于 0.0 与 1.0 之间的值,表示渐变中开始与结束之间的位置;后者是色值,支持 HEX、RGB、RGBA、HSL 等格式。

创建路径

arc 方法创建圆形路径,其中 xy 是坐标;r 是半径;sAngleeAngle 分别是起始、结束角度,以弧度单位;counterclockwise 为 false (默认)时按顺时针方向描绘,true 则相反。

ctx.beginPath();
ctx.arc(x, y, r, sAngle, eAngle, counterclockwise);
ctx.closePath();

在描绘路径前后必须分别执行 beginPathclosePath 方法确定路径的起止。

填充路径

指定 fillStyleglobalAlpha,填充刚才创建的圆形路径:

ctx.fillStyle = gradient;
ctx.globalAlpha = 0.5;
ctx.fill();

其中 fillStyle 是填充模式,也可以用色值或者 pattern 实例(使用 createPattern 方法创建,这里不详述)赋值;globalAlpha 则是介于 0.0 与 1.0 之间的透明度。

绘制星空动画

动画原理简单来说就是快速轮番播放多张图片,让人眼觉得那是连续的动画。我把它分成两步进行:单帧绘制和多帧播放。

单帧绘制

首先是创建 Star 对象,用于记录每颗星的各种状态,包括坐标、角度、透明度等,这样后边的绘制程序就能分别对每颗星进行渲染。由于几何知识都还给老师了,这里用正圆做运行轨迹,相关属性比较简单。

const Star = function (R, D, dD, r, rg, rgb, alpha, deltaAlpha) {
    this.R = R; // 运行轨道半径
    this.D = D; // 当前偏转角度
    this.dD = dD; // 每帧偏转的角度,通过改变偏转角度实现按轨道运行
    this.r = r; // 星星自身半径
    this.rg = rg; // 星星亮部半径
    this.alpha = alpha; // 当前透明度,用于控制明暗
    this.maxAlpha = alpha; // 最大透明度
    this.deltaAlpha = deltaAlpha; // 每帧增减的透明度,通过改变透明度实现闪烁
};

然后为 Start 对象增加渲染方法,用到了上边提及过的方法。

Star.prototype.render = function () {

    // 通过改变透明度实现闪烁
    this.alpha += this.deltaAlpha;
    if (this.alpha > this.maxAlpha || this.alpha < this.maxAlpha - 0.2) {
        this.deltaAlpha = -this.deltaAlpha;
    }

    // 当前偏转角自增,并根据角度与轨道半径计算出当前坐标
    this.D += this.dD;
    const A = degreesToRadians(this.D);
    const x = Math.cos(A) * this.R + this.xo;
    const y = -Math.sin(A) * this.R + this.yo;

    // 创建放射状环形渐变实例
    const color = rgb.join(', ');
    const gradient = ctx.createRadialGradient(x, y, this.r, x, y, this.rg);
    gradient.addColorStop(0, 'rgba(' + color + ', 1)');
    gradient.addColorStop(1, 'transparent');

    // 创建圆形路径
    ctx.beginPath();
    ctx.arc(x, y, this.rg, 0, 2 * Math.PI);
    ctx.closePath();

    // 填充路径
    ctx.fillStyle = gradient;
    ctx.globalAlpha = this.alpha;
    ctx.fill();
};

这里用了一个角度转弧度的函数:

const degreesToRadians = function (degrees) {
    return degrees / 360 * 2 * Math.PI;
};

再来就是实例化多个 Star 对象。从屏幕边缘到屏幕中心,每隔一段距离放置一个星。离边缘越近的星自身半径越大、透明度越低,这样转起来会有近大远小的透视效果。加上一些随机值让星星的分布看起来自然些。

for (let i = 1; i <= opts.starNumber; i++) {
    const R = canvas.width / 2 / opts.starNumber * i;
    const D = 360 / opts.starNumber * i * rand(1, 1.7);
    const dD = -opts.maxSpeed / 60 * rand(0.3, 1); // about 60 frames per second
    const r = opts.maxStartRadius / opts.starNumber * i;
    const rg = opts.maxStartLightRadius / opts.starNumber * i;
    const rgb = opts.colors[Math.floor(rand(opts.colors.length))];
    const alpha = (1 - 0.1) / opts.starNumber * i + 0.1;
    const deltaAlpha = rand(0.002, 0.01);

    stars.push(new Star(R, D, dD, r, rg, rgb, alpha, deltaAlpha));
}

这里用到一个自定义的随机函数:

const rand = function (min, max) {
    if (arguments.length < 2) {
        max = min;
        min = 0;
    }
    return min + Math.random() * (max - min);
};

多帧播放

多帧播放比较简单,这里用了浏览器自带的 requestAnimationFrame 方法:

const doAnimation = function () {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    for (let i = 0; i < opts.starNumber; i++) {
        stars[i].render();
    }
    animationId = window.requestAnimationFrame(doAnimation);
};

doAnimation();

如果浏览器不支持,可以自定义一个:

if (!window.requestAnimationFrame) {
    window.requestAnimationFrame = function (callback) {
        return setTimeout(callback, 16); // 一秒 60 帧,大概等于 16ms 一帧
    };
}
if (!window.cancelAnimationFrame) {
    window.cancelAnimationFrame = function (animationId) {
        clearTimeout(animationId);
    };
}

视觉控制

由于每颗星都是绕其轨道中心旋转的,因此控制了每颗星的中心位置就相当于控制了整体视觉。添加一个鼠标移动事件:

$(window).on('mousemove', updateStage);

内容如下:

const updateStage = function (evt) {
    const xo = canvas.width / 2;
    const yo = canvas.height / 2;
    const dx = evt.clientX - xo;
    const dy = evt.clientY - yo;
    for (let i = 0; i < opts.starNumber; i++) {
        stars[i].xo = xo - dx * stars[i].R * 0.0002;
        stars[i].yo = yo - dy * stars[i].R * 0.0002;
    }
};

当鼠标移动后,这些星星的运行轨道就不再是同心圆;其圆心的移动距离与各自轨道半径成正比,也就有了一定的透视效果。