动画特辑(二) 音乐律动

5,527 阅读6分钟

前两天在网易云听歌的时候,看到有个功能叫鲸鱼音效,点进去长下面这个样子。这是一个很常见的音乐律动动画,在音乐播放的过程中,CD 周围的曲线抖动,给听者带来一些听觉视觉上的双重冲击。

律动

今天尝试用代码来实现一个【类似】的效果,着急的朋友可以跳过文章,直接查看最终效果。喜欢的朋友不要吝啬点赞,评论,关注,这样我会更有动力产出相关的文章。

效果

(网易的鲸鱼音效,居然是黑胶 VIP 专享。)

前情提要

这篇文章默认要求读者已经掌握了 Canvas 的基本用法,如果还不太熟悉的同学可以先复习一下。后面的内容不会复述如何用 Canvas 绘制线条的基础知识。

之前也有写过基础的曲线动画

唯一的难点

这个效果里面唯一的难点是如何实现线条动画,有一定的随机性。讲到随机第一反应当然是 Math.random(),这个 JS 的方法可以概率平均地生成 0~1 的随机数。但因为它的随机结果太离散,前后两次执行的结果没有任何关联,直接用在视觉上的结果往往不太理想。譬如在一个平面上,每次横坐标移动一个像素,纵坐标取一个随机的高度,把前后的坐标连起来,那么 Math.random() 的结果是这样的。

毫无章法的线条

很显然这样的随机方式无法转化为曲线随机得动画,为了实现效果图中自然的随机线条动画,在这个例子中,将会用到一种叫 Berlin Noise 的算法。

什么是 Berlin Noise?

Berlin Noise 是一个非常强大的算法,常用于生成随机内容,例如在游戏领域可以用来生成波形,起伏不平的材质或者纹理等等。Berlin Noise 有维度的概念,比如一维可以用来模拟手绘线条,二维可以用来生成火焰、迷雾等平面效果等等。

作为算法的普通使用者,算法具体的实现暂时先放一边。从 github 上找到相关的实现库 simplex-noise。来写两个 Demo 直观感受下这个算法的魅力。

simplex-noise 的基本用法

const simplex = new SimplexNoise();
const value2d = simplex.noise2D(x, y);
const value3d = simplex.noise3D(x, y, z);
const value4d = simplex.noise4D(x, y, z, w);

通过构造器创建一个实例,实例上有 noise2D, noise3D, noise4D 的方法可以调用,参数个数和维度相关,2D 就传两个,3D 就传三个。这些方法都会返回一个算法计算得到的结果,区间是 [-0.5, 0.5]

还是原来的例子,改成每次横坐标移动一像素,纵坐标改成根据时间参数 ty (每次自增) 计算 noise 结果。

// noise2D 返回的结果是[-0.5, 0.5] 的区间值,后面的计算是为了让线条落在在屏幕内
this.y = this.noise.noise2D(this.ty, 0) * window.innerHeight * 0.5 + window.innerHeight * 0.5;

注意上面的例子和 Math.random() 的例子,x 轴移动的速率是一样的,没有放大横坐标,也没有加贝塞尔曲线相关的代码,图像上的自然曲线是noise 生成的。你可以在 Codepen 上修改代码查看效果。

从图像上看,相临的两个随机 y 值之间存在一定的关联,前后两次 y 值的差距和传给 noise2D 的参数有关,上面的例子中会在每次渲染后修改 ty 参数,从而影响下一次 noise 结果。

// 每次重绘之后,修改 ty
this.ty += 0.01;

前后两次参数越接近,得到的结果差距也越小,差距越大,则结果差距也越大。你可以才 Codepen 上修改代码试验一下。

this.ty += 0.001;

this.ty += 0.1;

x 轴的移动速率都是一样的

还可以用 noise3D 生成的随机图像动画。(随机图像 2D 就够了,多一个维度用来做动画)

核心代码如下:

ctx.save();
const size = 800;

var imgData = ctx.createImageData(size, size);
this.tx = 0;
for (var x = 0; x < size; x++) {
  this.ty = 0;
  for (var y = 0; y < size; y++) {
    // noise 随机生成一个 alpha 值
    const alpha = (this.noise.noise3D(this.tx, this.ty, this.tz) + 0.5) * 255;
    const index = (x + y * size) * 4;
    imgData.data[index + 3] = alpha;
    this.ty += 0.01;
  }
  this.tx += 0.01;
}
ctx.putImageData(imgData, 0, 0);
ctx.restore();
this.tz += 0.01;

上面的代码遍历了每一个像素点,调用 noise3D 方法生成随机 alpha 值。传入三个参数,tx, ty 的计算逻辑是为了让相邻的两个像素点更加接近,而 tz 的存在是为了让每一次重绘都产生新的变化,以生成动画。如果 tz 是个固定值,那么图像生成后就不会有变化。

结论:使用 noise 方法时,每次都需要传入维度值,相同维度值的输入会得到相同的结果。传入相近的维度值,那么下一次再进行运算得到的结果也会和上一次得到的随机结果相近。

应用

找对了算法,剩下的工作量几乎可以忽略不计。思路就是使用 Berlin Noise 【随机】生成 360 个点的坐标,再用线条把所有点连起来。

第一次尝试

for (let degree = 0; degree < 360; degree++) {
  const radius = (degree / 180) * Math.PI;
  const length =
    base +
    this.noise.noise2D(
      radius,
      t
    ) *
      scale;

  this.vectors.push({
    x: length * Math.cos(radius),
    y: length * Math.sin(radius),
  });
}

this.drawLine();

这里生成坐标位置不是直接随机生成一个点的 x, y 值,而是先创建【随机长度】,再通过长度和角度换算成坐标值。然而,直接出来的结果是下面这样。

首次尝试

由于起点和终点的 radius 没有直接关联关系,体现在动画上的时候会出现非常明显的割裂。

利用三角函数的周期性

想要解决起点和终点的差异性问题,这里利用三角函数的周期性,计算每个角度的正弦/余弦值作为 noise 的维度参数,之后相邻的每两个点的坐标都是相近的,最终得到的结果是相近的。

修改代码:

const length =
    base +
    this.noise.noise2D(
      Math.cos(radius),
      Math.sin(radius),
      t
    ) *
      scale;

同样例子中也用了 noise3D 方法,第三个参数 t 会在每一次渲染时自增,每次都有新的变化,noise 计算结果也不一样(但是相近),这样就形成了动画。

最后把这些点串起来配上律动感很强的音乐。

效果

配了一首很酷的说唱,如果你看到这里,一定不要错过纯享版。所有的代码都可以在我的 CodePen 主页上找到。

更近一步

到目前为止,音乐播放跟动画是没有任何关联的,如果需要联动也可以使用 Audio API 获取音乐帧信息实时反映在动画上。

const audioElement = document.getElementById("audio");
// 创建 audio context
const audioContext = new AudioContext();
const audioSource = audioContext.createMediaElementSource(audioElement);

// 创建解析器
const analyser = audioContext.createAnalyser();

// 关联解析器,最终输出到设备音响
audioSource.connect(analyser);
analyser.connect(audioContext.destination);

// 样本的窗口大小
analyser.fftSize = 256;

const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);

function renderFrame() {
    requestAnimationFrame(renderFrame);
    // 每个 requestAnimationFrame 回调里面更新 dataArray
    analyser.getByteFrequencyData(dataArray);
    // 音频数据
    console.log(dataArray);
}

引用

我也开了个微信公众号,欢迎扫码关注,后续内容会持续更新到公众号上。