【Canvas实战】仿明日方舟 Logo 粒子动画

448次阅读  |  发布于1年以前

前言

如果你是明日方舟玩家肯定对游戏官网[2]有深刻的印象,不得不说鹰角的前端很厉害。

作为前端开发者肯定是第一时间F12开始审查元素不难发现页面中不少特效都是通过<canvas>标签实现的。

例如这个阵营Logo的粒子动画:

很明显使用了 canvas2d 中的 像素操作,今天我们一起研究下它是怎么实现的。

如果觉得有收获还望大家点赞、收藏

最终效果

后续内容论述较多,就先把最终效果放上来了。

demo

不知道为啥要运行两次,想试用的同学再点下运行就可以看了

源码

应掘友要求整理上传了份源码:

github.com/XIwE1/ark-p…[3]

顺便简述下实现方法,希望能帮助你理解

主要使用三个类:Particle、LogoImg、ParticleCanvas

  1. Particle:记录粒子位置、颜色、大小、动画耗时 和 x/y 方向上的移动速度,提供绘制粒子方法draw、更新方法update、替换方法change
  2. LogoImg:记录图片解析后的粒子数组信息particleData
  3. ParticleCanvas:记录目标画布、画布中的粒子数组和鼠标在画布中的位置,提供绘制画布方法drawCanvas、改变粒子数组方法changeImg

流程:

这里就已经实现粒子动画了,粒子的生成和移动就不细说了看代码!

然后就是吸引/排斥:

this.ParticleArr.forEach((particle) => {
  particle.update(this.mouseX, this.mouseY);
  particle.draw();
});
复制代码

Particle 的 draw 方法符合面向对象的写法是接收一个 content 上下文参数,图方便就直接读取了

分析

实现该动画主要的步骤为:

  1. 解析图片转换为粒子
  2. 绘制时添加动画
  3. 根据鼠标位置对粒子进行排斥

解析图片通过Canvas的getImageData获取像素数据实现。

较难点在于 绘制动画 和 粒子排斥,涉及到 数学应用 和 动画/交互逻辑。

先简单复习下像素操作相关的知识,也可以查看我之前写的文章[4]

像素操作

canvas提供了 绘制图片 和 获取图片像素 的方法,但在绘制图片或者获取图片信息用于操作之前,首先要获取目标图片源

我们通过在JS里创建Image对象 在onload回调时读取数据源。

一旦获得了源图对象,我们就可以使用 drawImage 方法将它渲染到 canvas 里。

通过canvas的getImageData方法可以获得ImageData对象,而ImageData.data属性中存储着canvas对象真实的像素数据。

  ......
    let img = new Image();
    img.src = src;
    // canvas 获取粒子位置数据
    img.onload = () => {
      // 获取图片像素数据
      const tmp_canvas = document.createElement("canvas"); // 创建一个空的canvas
      const tmp_ctx = tmp_canvas.getContext("2d");

      tmp_ctx?.drawImage(img, 0, 0, imgW, imgH); // 将图片绘制到canvas中
      const imgData = tmp_ctx?.getImageData(0, 0, imgW, imgH).data; // 获取像素点数据
      tmp_ctx?.clearRect(0, 0, width, height);
    };
    ......
复制代码

ImageDatadata属性为 Uint8ClampedArray[5] 类型的一维数组,包含了指定区域里每个像素点的RGBA格式的整型数据,范围在0至255之间(包括255)。

每一个像素点有4个值占据data数组4个索引位置,对应像素rgba(R, G, B, A)的四个值。如图:

canvas动画

canvas的动画主要是通过 在一些定时方法中去执行重绘操作实现的。

canvas实现动画的过程通常是 清理->绘制->清理->绘制... 不断重复的过程。

一般通过 setTimeOut、setInterval、requestAnimationFrame 等定时执行的方法去调用重绘,实现动画的操控。

实现

生成粒子/绘制画布

像素会经过一系列操作转换为粒子,粒子绘制到画布后初始位置随机,并逐渐向目标方向移动。 画布不断调用粒子中的更新方法和绘制方法,重新绘制画布。

粒子类

创建粒子类Particle,其构造器接收 像素对象 为参数转换粒子实例对象

class Particle {
  totalX: number; // 粒子x轴的目标位置
  totalY: number; // 粒子y轴的目标位置
  r: number; // 粒子的半径
  color: number[]; // 粒子的颜色
  opacity: number; // 粒子的透明度
  constructor(totalX: number, totalY: number, time: number, color: number[]) {
    // 目标位置dx、dy,总耗时time
    this.totalX = totalX;
    this.totalY = totalY;
    // 设置粒子的颜色和半径
    this.r = 1.2;
    this.color = [...color];
    this.opacity = 0;
  }
  // 在画布中绘制粒子
  draw() {}
  // 更新粒子
  update() {}
  // 切换粒子
  change() {}
}
复制代码

筛选像素

因为并不是每一个像素点都需要绘制,所以在获得了上文ImageData.data的像素数据后,先对数据进行一遍筛选,同时将符合条件的像素点生成为粒子。

......
    img.onload = () => {
      // 获取图片像素数据
      ......
      const imgData = tmp_ctx?.getImageData(0, 0, imgW, imgH).data; // 获取像素点数据
      tmp_ctx?.clearRect(0, 0, width, height);

      // 筛选像素点
      for (let y = 0; y < imgH; y += 5) {
        for (let x = 0; x < imgW; x += 5) {
          // 像素点的索引
          const index = (x + y * imgW) * 4;
          // 在数组中对应的值
          const r = imgData![index];
          const g = imgData![index + 1];
          const b = imgData![index + 2];
          const a = imgData![index + 3];
          const sum = r + g + b + a;
          // 筛选条件
          if (sum >= 100) {
            const particle = new Particle(x, y, animateTime, [r, g, b, a]);
            this.particleData.push(particle);
          }
        }
      }
    };
......
复制代码

创建粒子

首先我们观察到动画中的粒子是从随机位置(或者有一套算法确定位置,但肯定不在原位置)出现的,并逐渐位移向目标位置,同时会逐渐清晰(不透明度++)。

所以我们需要调整粒子类:

  1. x、y属性表示粒子当前位置
  2. mx、my属性表示粒子需要移动的距离
  3. vx、vy属性表示粒子在方向上的移动速度
  4. time属性表示粒子过渡动画所耗时间
  5. update方法在粒子更新时调用,在其中动态计算mx、my、vx、vy
  6. draw方法在画布中绘制粒子
class Particle {
  x: number; // 粒子x轴的初始位置
  y: number; // 粒子y轴的初始位置
  totalX: number; // 粒子x轴的目标位置
  totalY: number; // 粒子y轴的目标位置
  mx?: number; // 粒子x轴需要移动的距离
  my?: number; // 粒子y轴需要移动的距离
  vx?: number; // 粒子x轴移动速度
  vy?: number; // 粒子y轴移动速度
  time: number; // 粒子移动耗时
  r: number; // 粒子的半径
  color: number[]; // 粒子的颜色
  opacity: number; // 粒子的透明度
  constructor(totalX: number, totalY: number, time: number, color: number[]) {
    // 设置粒子的初始位置x、y,目标位置dx、dy,总耗时time
    this.x = (Math.random() * width) >> 0;
    this.y = (Math.random() * height) >> 0;
    this.totalX = totalX;
    this.totalY = totalY;
    this.time = time;
    // 设置粒子的颜色和半径
    this.r = 1.2;
    this.color = [...color];
    this.opacity = 0;
  }
  /** 更新粒子
   * @param {number} mouseX 鼠标X位置
   * @param {number} mouseY 鼠标Y位置
   */
  update(mouseX?: number, mouseY?: number) {
    // 设置粒子需要移动的距离
    this.mx = this.totalX - this.x;
    this.my = this.totalY - this.y;
    // 设置粒子移动速度
    this.vx = this.mx / this.time;
    this.vy = this.my / this.time;
    this.x += this.vx;
    this.y += this.vy;
    // 随着移动不断增加透明度
    if (this.opacity < 1) this.opacity += opacityStep;
  }
  // 在画布中绘制粒子
  draw() {
    context.beginPath()
    context.value!.fillStyle = `rgba(${this.color.toString()})`;
    context.value!.arc(this.x, this.y, this.r * 2, 0, 2 * Math.PI);
    context.value!.fill();
    context.closePath()
  }
}
复制代码

绘制画布

在明确怎么创建粒子后,需要将粒子绘制到画布上,画布不断更新其中的粒子实现动画效果。

于是我们创建图片类LogoImg、画布类ParticleCanvas便于 存放数据 和 操作画布。

/** Logo图片类 */
class LogoImg {
  src: string;
  name: string;
  particleData: Particle[]; // 用于保存筛选后的粒子
  constructor(src: string, name: string) {
    this.src = src;
    this.name = name;
    this.particleData = [];
    let img = new Image();
    img.crossOrigin = '';
    img.src = src;
    // canvas 获取粒子位置数据
    img.onload = () => {
      // 获取图片像素数据
      const tmp_canvas = document.createElement("canvas"); // 创建一个空的canvas
      const tmp_ctx = tmp_canvas.getContext("2d");
      const imgW = width;
      const imgH = ~~(width * (img.height / img.width));
      tmp_canvas.width = imgW;
      tmp_canvas.height = imgH;
      tmp_ctx?.drawImage(img, 0, 0, imgW, imgH); // 将图片绘制到canvas中
      const imgData = tmp_ctx?.getImageData(0, 0, imgW, imgH).data; // 获取像素点数据
      tmp_ctx?.clearRect(0, 0, width, height);

      // 同上筛选像素点
    };
  }
}

// 画布类
class ParticleCanvas {
  canvasEle: HTMLCanvasElement;
  ctx: CanvasRenderingContext2D;
  width: number;
  height: number;
  ParticleArr: Particle[];
  constructor(target: HTMLCanvasElement) {
    this.canvasEle = target;
    this.ctx = target.getContext("2d") as CanvasRenderingContext2D;
    this.width = target.width;
    this.height = target.height;
    this.ParticleArr = [];
  }
  // 改变画布数据源
  changeImg(img: LogoImg) {
      this.ParticleArr = img.particleData.map(
        (item) =>
          new Particle(item.totalX, item.totalY, animateTime, item.color)
      );
  }
  // 画布绘制方法
  drawCanvas() {
    this.ctx.clearRect(0, 0, this.width, this.height);
    this.ParticleArr.forEach((particle) => {
      particle.update();
      particle.draw();
    });
    window.requestAnimationFrame(() => this.drawCanvas());
  }
}
复制代码

切换动画

在切换图片(即粒子数据源)时,复用页面上已存在的粒子,将其随机映射到新的位置。 由粒子数量对比分为 相同、大于、小于 3种情况,根据情况画布中的粒子数组进行移除或添加。

可以发现在切换图片的时候并不是清空画布并重新生成所有粒子,已存在的粒子会按比例复用并移动到新的目标位置,即旧粒子随机对应新粒子(官方应该有一套算法确定映射,但肯定不会顺序对应)。

所以我们在画布类ParticleCanvas.changeImg切换数据源时对比新旧粒子数量,遍历新粒子数组,每次循环判断复用arr[idx].change(...);,还是生成新粒子。

之后对比newLen < oldLen,变少了就通过splice删除,变多了则在上述遍历中已通过new Particle(...)添加。

最后随机打乱粒子最终对应的位置,每次循环随机的取一个粒子arr[randomIdx] 和 倒序的取一个粒子arr[tmp_len],并且上限逐渐递减tmp_len--(避免多个粒子映射到同一个粒子上)。

 // 改变图片 如果已存在图片则进行额外切换操作
  changeImg(img: LogoImg) {
    if (this.ParticleArr.length) {
      // 如果当前粒子数组大于新的粒子数组 删除多余的粒子
      let newPrtArr = img.particleData;
      let newLen = newPrtArr.length;
      let arr = this.ParticleArr;
      let oldLen = arr.length;

      // 调用change修改已存在粒子
      for (let idx = 0; idx < newLen; idx++) {
        const { totalX, totalY, color } = newPrtArr[idx];
        if (arr[idx]) {
          // 找到已存在的粒子 调用change 接收新粒子的属性
          arr[idx].change(totalX, totalY, color);
        } else {
          arr[idx] = new Particle(totalX, totalY, animateTime, color);
        }
      }

      if (newLen < oldLen) this.ParticleArr = arr.splice(0, newLen);
      let tmp_len = arr.length;
      // 随机打乱粒子最终对应的位置 使切换效果更自然
      while (tmp_len) {
        // 随机的一个粒子 与 倒序的一个粒子
        let randomIdx = ~~(Math.random() * tmp_len--);
        let randomPrt = arr[randomIdx];
        let { totalX: tx, totalY: ty, color } = randomPrt;

        // 交换位置
        randomPrt.totalX = arr[tmp_len].totalX;
        randomPrt.totalY = arr[tmp_len].totalY;
        randomPrt.color = arr[tmp_len].color;
        arr[tmp_len].totalX = tx;
        arr[tmp_len].totalY = ty;
        arr[tmp_len].color = color;
      }
    } else {
      this.ParticleArr = img.particleData.map(
        (item) =>
          new Particle(item.totalX, item.totalY, animateTime, item.color)
      );
    }
  }
复制代码

粒子排斥

每个粒子会根据与鼠标距离的比例受到x、y方向的力,在转换为对应方向上的速度后重新计算粒子的移动轨迹(这涉及到一些三角函数),即可实现粒子排斥效果。

整理思路

明显观察到画布会以鼠标为中心对粒子进行一定范围的排斥,越接近中心排斥的速度越快。

我们可以向particle对象的update方法中传入鼠标在canvas画布中的位置mouseX, mouseY

并结合粒子当前位置(x, y)排斥力度Inten 重新计算移动速度vx、vy。由此使粒子不断远离中心

设计方案

调整粒子类Particleupdate方法,重新计算vx、vy

  1. 设置固定值 Radius(斥力影响范围)Inten(斥力标准值)
  2. 设置鼠标位置 (mouseX, mouseY) 为斥力中心。
  3. 计算每个粒子与中心的 直线距离distance
  4. 通过 Radius / distance 获得 中心影响范围 与 直线距离 的比例disPercent比例越大越接近中心,受到的斥力也越大。
  5. 将 粒子与中心形成的 夹角angle比例disPercent斥力值Inten,转换为粒子x、y轴的速度repXrepY
  6. vx += repX & vy += repY粒子逐渐远离中心。

实现

注意:canvas坐标系采用第四象限,即x轴正向为右,y轴正向为下

ucs.png

如图,假设某点Z为斥力中心,同时取三个粒子,位置分别为:A.边界外``B.边界内``C.边界上

dx、dy代表粒子与中心的x、y轴距离,并用正负表示方向。

例如A粒子 dx = 2 \- 4 = \-2dy = 2 \- 4 = \-2,通过三角函数Math.atan2[6]计算出 夹角angle = Math.atan2(-2, \-2)

再通过angle和 正弦/余弦函数 计算出 sin = Math.sin(angle)cos = Math.cos(angle)

disPercent * Inten计算出的力度转换为x、y方向上的速度 repX = cos * disPercent * \-Inten ... 因为是排斥,所以我们使用-Inten 去掉负号则是吸引效果了

重新计算vx += repXvy += repY

// Particle.class -> update
  update(mouseX?: number, mouseY?: number) {
      ....
      if (mouseX && mouseY) {
          let dx = mouseX - this.x;
          let dy = mouseY - this.y;
          let distance = Math.sqrt(dx ** 2 + dy ** 2);
          // 粒子相对鼠标距离的比例 判断受到的力度比例
          let disPercent = Radius / distance;
          // 设置阈值 避免粒子受到的斥力过大
          disPercent = disPercent > 7 ? 7 : disPercent;
          // 获得夹角值 正弦值 余弦值
          let angle = Math.atan2(dy, dx);
          let cos = Math.cos(angle);
          let sin = Math.sin(angle);
          // 将力度转换为速度 并重新计算vx vy
          let repX = cos * disPercent * -Inten;
          let repY = sin * disPercent * -Inten;
          this.vx += repX;
          this.vy += repY;
      }
      ....
    }
复制代码

同理可计算B、C粒子的速度。

优化

减少绘制操作

canvas绘制圆(arc)相比绘制矩形(rect)会消耗更多的性能,arc 每次绘制都要开启、闭合路径,而 rect 则直接绘制。

当粒子数量过多时会有明显的性能差异,且在较小比例的情况下圆和矩形视觉上是类似的,所以可以用fillRect(...) 替换 arc(...)。

面向对象

将画布、粒子、配置、图片抽象为类,通过对象的属性和方法去渲染、切换。这里很多参数都固定了就没再去抽象配置类,感兴趣的同学可以试试。

事件循环

因为浏览器执行机制是 宏任务->微任务->渲染->宏任务... 这样一个循环,因此页面上的粒子排斥效果也不是实时的,有可能鼠标到了某个位置但是刚结束上一次循环的计算和渲染。

所以在页面上监听mousemove事件 回调使用requestAnimationFrame,回调中根据鼠标位置在页面上添加一个白圈,表明当前循环渲染的位置,优化视觉效果,详情查看index.html中的代码。

粒子渐入

因为方便计算和还原粒子本身颜色 所以没有实现不透明度逐渐增加的操作(一开始是写了的 但考虑到还原粒子),导致动画少了渐入的视觉,追求完美复原的同学可以研究下。

感觉主要问题在粒子筛选的条件上,使用#fff背景可以观察到画布中有黑色的粒子。

题外话

真的很喜欢明日方舟的美术风格、游戏剧情,从各方面来说都是一款佳作话说这算安利了吧

开服咸鱼玩家,以前的号忘了另起炉灶,欢迎大家加我好友一起 白嫖三模令姐 FIGHT FOR THE DAWN ,ID:鸩羽昙#9367。

QQ截图20221030042852.png

祝大家新卡池一发入魂~

结语

canvasAPI数量精简,参数清晰,学习并不复杂,更多的是如何实践应用。如果感兴趣的话建议自己实现一些功能,相信你也能发现canvas的亮点。

不要光看不实践哦,后续会持续更新前端相关的知识,欢迎大家关注第一时间收到更新消息哦

写作不易,如果觉得有收获还望大家点赞、收藏

才疏学浅,如有问题或建议欢迎大家指教。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8