记一次前端Canvas粒子效果实战-1 - Shiina's Blog

记一次前端Canvas粒子效果实战-1

2022 年 9 月 16 日 - 01:33:15 发布
18.2K 字 61分钟
本作品采用 CC BY-NC-SA 4.0 进行许可。
本文距离首次发布已经过去了 603 天,请注意文章的时效性!

记一次前端Canvas粒子效果实战-1

前言

这是模仿明日方舟官网的一个粒子特效做出来的一个小玩具,与另一位团队的大佬讨论了一周,两者各自分工写了本效果的一部分功能,最后将两者的代码相互结合了一下,构成了现在做出来的成品

由于是原生 Js 所以性能比较差,很多地方都还没有优化,因此不推荐在实际项目中直接随意使用

本文主要用于分析此效果的源码,讲解各种效果的实现思路

本章实现的功能:

  • 绘制图像
  • 自定义渲染宽高
  • 捕获有效的颜色像素

另一位大佬的 Github 主页:https://github.com/Flame-Y

未来应该会继续写续篇,看情况更新

效果预览

图片散开 / 组合

图片切换

鼠标吸附粒子

鼠标排斥粒子

在线体验与源码下载

线上体验地址:http://shiinafan.gitee.io/ark-particle-imitate/

蓝奏云:https://wwp.lanzouj.com/iVLf50bra9ve

GitHub: https://github.com/QingXia-Ela/Ark-Particle-Imitate

Gitee: https://gitee.com/shiinafan/Ark-Particle-Imitate

制作思路与过程

点实例

一开始构建点的实例比较简单,我们的目标就是让点知道自己该在哪里渲染即可

后面会根据功能的需求不断进行修改,代码量会越来越大,需要格外注意

我们新建一个 Point 类,并对外暴露三个方法:

  • update() 每一帧动画中更新粒子位置信息,粒子散开聚合和受鼠标影响时会使用
  • render() 渲染粒子到画布上
  • changePos() 改变粒子原始位置
declare class Point {
  /** 粒子源位置 x */
  public orx: number
  /** 粒子源位置 y */
  public ory: number
  /**
   * 点实例
   * @param {number} orx 目标位置 x
   * @param {number} ory 目标位置 y
   * @param {HTMLCanvasElement} canvas canvas 元素
   * @param {number} colorVal RGB 总和
   */
  constructor(orx:number, ory:number, canvas:number, colorVal:number): Point;
  /** 更新粒子位置信息 */
  update(): void
  /** 渲染粒子 */
  render(): void
  /**
   * 改变粒子位置
   * @param {number} newX 粒子新的 X 位置
   * @param {number} newY 粒子新的 Y 位置
   * @param {number} colorVal RGB 总和
   */
  changePos(newX: number, newY: number, colorVal: number): void
}
class Point {
  constructor(orx, ory, canvas, colorVal) {
    this.orx = orx
    this.ory = ory
    // 获得要绘制的目标元素
    this.canvas = canvas
    // 颜色黑白处理
    const c = Math.floor(colorVal / 3)
    this.color = `${c},${c},${c}`
  }
  /** 更新粒子位置信息 */
  update() {}
  render() {
    const ctx = this.canvas.getContext('2d')
    ctx.beginPath();
    // 绘制圆点
    ctx.arc(this.orx, this.ory, 1, 0, 360);
    ctx.fillStyle = `rgba(${this.color},1)`;
    ctx.fill();
    ctx.closePath();
  }
  changePos(newX, newY, colorVal) {}
}

其中 render() 使用的 api 列表:

此刻,一个基本的点类就完成了,构造出点后可以调用 render() 方法在传入的 canvas 元素上进行渲染

图像绘制

基本处理

对于图像绘制,首先需要一个画布,在这里我们选用网页自带的 canvas 画布,其有许多内置的 API 供我们作画

在这里我们构建一个新的图像处理为粒子的类,并设置其构造函数;构造函数的目标如下:

  • 将图片像素处理成信息坐标点
  • 处理传入的选项
  • 监听图片加载完成事件

我们在选项设置中传入图片原地址来告诉这个类我们要构建的图片是哪一张,并新建图片对象来进行处理,当图片完成加载时对图片执行处理:

类型声明:

interface ParticleOptions {
  /** 图片路径 */
  src: string
}

类本身:

class DameDaneParticle {
  /**
   * @param {HTMLCanvasElement} canvas 要绘制的目标元素
   * @param {ParticleOptions} options 选项设置
   * @param {Function} callback 回调函数
   */
  constructor(canvas, options, callback) {
    const { src } = options
    /** 设置备份到自身属性 */
    this.options = options
    /** 图片对象 */
    this.IMG = new Image();
    this.IMG.src = src;

    /** 图片宽高 */
    this.ImgW = 0, this.ImgH = 0;

    // 监听图片加载完成事件
    this.IMG.onload = () => {
      // 设置当前实例使用的图片宽高
      this.ImgW = this.IMG.width
      this.ImgH = this.IMG.height
      
      // 回调函数
      callback && callback()
    }
  }
}

接下来我们开始处理图片,我们可以把图片看作是一个二维数组,数组中每一项都代表着一个像素的信息

那么我们要怎么将图片转换为数组呢?这里我们通过 canvas 内置的方法进行图像处理

我们新建一个 canvas 元素,并获取其 2d 上下文,这样我们可以通过其上下文对象对这个元素调用各种绘制方法

const ele = document.createElement('canvas');
// 设置该虚拟元素宽高,宽高来源于已加载好的图片信息
ele.width = this.IMG.width;
ele.height = this.IMG.height;
// 获取画布上下文
const eleCtx = ele.getContext('2d');

接下来我们将介绍 canvas 上下文和 ImageData 对象的几个 api:

对上述 API 进行组合使用后即可拿到一个一维数组的图像数据,其中包含每个像素的 RGBA 值:

const ele = document.createElement('canvas');
// 设置该虚拟元素宽高,宽高来源于已加载好的图片信息
ele.width = this.IMG.width;
ele.height = this.IMG.height;
// 获取画布上下文
const eleCtx = ele.getContext('2d');

// 在新建的 canvas 元素上绘制图像
eleCtx.drawImage(this.IMG, 0, 0, this.ImgW, this.ImgH);

// 让当前实例获取图片数据数组
this._imgArr = eleCtx.getImageData(0, 0, this.ImgW, this.ImgH).data;

// 清理 canvas 元素上的图像
eleCtx.clearRect(0, 0, canvas.width, canvas.height);

这段代码让我们新建的实例中的 _imgArr 属性指向了我们获取的图像数据,我们将这一部分代码放回构造函数里头:

class DameDaneParticle {
  /**
   * @param {HTMLCanvasElement} canvas 要绘制的目标元素
   * @param {ParticleOptions} options 选项设置
   * @param {Function} callback 回调函数
   */
  constructor(canvas, options, callback) {
    const { src } = options
    /** 图片对象 */
    this.IMG = new Image();
    this.IMG.src = src;

    // 监听图片加载完成事件
    this.IMG.onload = () => {

      const ele = document.createElement('canvas');
      // 设置该虚拟元素宽高,宽高来源于已加载好的图片信息
      ele.width = this.IMG.width;
      ele.height = this.IMG.height;
      // 获取画布上下文
      const eleCtx = ele.getContext('2d');

      // 在新建的 canvas 元素上绘制图像
      eleCtx.drawImage(this.IMG, 0, 0, this.ImgW, this.ImgH);

      // 让当前实例获取图片数据数组
      this._imgArr = eleCtx.getImageData(0, 0, this.ImgW, this.ImgH).data;

      // 清理 canvas 元素上的图像
      eleCtx.clearRect(0, 0, canvas.width, canvas.height);

      // 回调函数
      callback && callback()
    }
  }
}

这样子我们就拿到了图像的数据,接下来我们就可以写图片转换为点实例数组的方法了

图片转换为点实例数组

在类中我们建立这样一个方法:_InitParticle 用于将图片转换为粒子实例数组,这个方法中我们设置一个参数:图片数据数组

这个方法的目的是:将前文得到的一维图片数据进行处理,提取其中的点并以此构造点实例,并将其推入渲染队列

因此我们在类中用 PointArr 来代表渲染队列,他是一个数组

class DameDaneParticle {
  constructor(canvas, options, callback) {
    // 渲染队列数组
    this.PointArr = []

    // ...之前已写过的部分
  }
  /**
   * 图片初始化函数,**此项为内置 api, 不建议随便调用**
   * @param {Uint8ClampedArray} ImgData 图片数据数组
   */
  _InitParticle = (ImgData) => {
    // 设置初始值
    let imgW = this.ImgW, imgH = this.ImgH, cnt = 0;

    // 指向数组
    let arr = this.PointArr;
    // rgb,点位置
    let r, g, b, val, position;
  }
}

在传入的一维数组中每 4 项内容为一个像素的信息,因此我们用两个 for 循环模拟每个像素的位置,每一步的步长为 4

我们还需要设置一个颜色区间来控制渲染粒子的颜色区间范围,因此我们在构造函数增加以下项,来让使用者可以控制哪个颜色区间的像素可以被渲染:

interface ParticleOptions {
  validColor: {
    /** RGB 总和最小值 */
    min: number
    /** RGB 总和最大值 */
    max: number
    /** 翻转区间 */
    invert: boolean
  }
  // ... 之前写过的部分
}

在渲染之前还有最后一步:判断前置像素

当图片 src 源被改变时,IMG 加载完成的方法会再次被触发,为了节省性能,我们不需要重新构造点实例,而是通过之前点暴露出来的 changePos() 方法对点的起始位置进行修改

当所有的点被修改完时,浏览器会在下一次的渲染中将新点渲染出来,因此我们在遍历 图片一维数据数组 的时候与当前的 点实例数组 进行对比覆盖

需要注意的是,当新图片粒子数量没能完全覆盖上一张图片的粒子信息时,需要在最后进行一次数组裁剪操作,否则多余的点会保留

接下来我们就可以通过用户传入的许可数值范围来控制要被渲染的粒子

到这里处理点实例的步骤就完成了,此时初始化方法完整代码如下:

  /**
   * 图片初始化函数,**此项为内置 api, 不建议随便调用**
   * @param {Uint8ClampedArray} ImgData 图片数据数组
   */
  _InitParticle = (ImgData) => {
    // 设置初始值
    let imgW = this.ImgW, imgH = this.ImgH, cnt = 0;

    // 指向数组
    let arr = this.PointArr;
    // rgb,点位置
    let r, g, b, val, position;

    // 解构出颜色信息
    const { validColor } = this.options;

    // 步长
    const gap = 4;
    for (var h = 0; h < imgH; h += gap) {
      for (var w = 0; w < imgW; w += gap) {
        // 当前像素的 r 值位置
        position = (imgW * h + w) * 4;
        // 计算 rgb 总和
        r = ImgData[position],
          g = ImgData[position + 1],
          b = ImgData[position + 2];
        val = r + g + b;

        /**
         * 判断像素是否符合设置的条件
         */
        if (
          (validColor.invert && (val <= validColor.min || val >= validColor.max)) ||
         (!validColor.invert && val >= validColor.min && val <= validColor.max)
        ) {
          // 判断是否有前置像素,如果有前置像素则将其位置进行替换
          if (arr[cnt] && !cancelParticleAnimation) {
            const point = arr[cnt];
            point.changePos(w, h, val);
          }
          else arr[cnt] = new Point(w, h, this.canvasEle, val);
          cnt++;
        }
      }
    }

    // 去除多余的粒子
    if (cnt < arr.length)
      this.PointArr = arr.splice(0, cnt);
  }

渲染到画布上

这一步相对比较简单,当点实例都处理好了后我们只需要对每个点调用其暴露的 render() 方法即可将其渲染到指定的画布上

我们建立一个新的方法:_Draw2Canvas 该方法每调用一次,就会进行一次渲染

此处使用浏览器内置的 requestAnimationFrame 进行绘制函数的调用:

class DameDaneParticle {
  constructor() {
    // ... 之前的代码

    /** 是否触发了绘制函数的标记 */
    this.hasDraw = false;
  }

  _Draw2Canvas = () => {
    this.hasDraw = true;
    const w = this.canvasEle.width, h = this.canvasEle.height;
    // 绘制新的粒子前,先清除画布
    this.ctx.clearRect(0, 0, w, h);

    // 遍历每一个点并调用他们身上的绘制方法
    this.PointArr.forEach(
      /** @param {Point} point */
      (point) => {
        /** 更新坐标,后文会对该函数进行补充 */
        point.update();
        // 渲染粒子
        point.render();
      })
    requestAnimationFrame(this._Draw2Canvas);
  }
}

requestAnimationFrame MDN:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame

接下来只要在图片加载完成后的回调内调用一次该函数即可开始持续渲染

class DameDaneParticle {
  constructor() {
    // ... 之前的代码

    this.IMG.onload = () => {
      // ... 之前的代码
      this._Draw2Canvas()
    }
  }
}

写到这里后,你就可以尝试在浏览器中 new 一个这样的粒子对象,并对其进行初始设置后,用 vscode 的 LiveServer 打开项目。

这个时候浏览器应该可以直接看见你选择的图像了,如果图片过大可能导致浏览器崩溃,建议选择清晰度比较低的图像,接下来会讲解怎么控制图片缩放与粒子大小控制和横竖间距。

如果通过本地文件打开的话图像会无法加载,因为受到跨域限制。

图片缩放

让我们回到最初的构造函数中,构造函数里面我们是读取图片的属性来直接设置元素宽高,为了达成能自行设置图片宽高的功能,我们需要允许使用者传入自定义宽度,并让图片进行缩放

但好在 canvas 上下文中的 drawImage 自带缩放绘制功能,因此我们只需要通过传入的指定宽度并通过他计算指定高度即可;同理,我们也可以通过相同的方式自定义高度,本文只对图片宽度做了比例缩放,图片高度的缩放读者可以根据本文的示例代码自行尝试制作。

更多请参阅 MDN 官网:https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/drawImage

interface ParticleOptions {
  // ...之前的内容
  /** 渲染宽度,可省略,如果只设置该项则图片高度会根据宽度进行缩放 */
  w?: number
  /** 渲染高度,可省略 */
  h?: number
}

原 js 中(此处 this 指向处理图像为例子的实例)

class DameDaneParticle {
  constructor(w) {
    // ...之前的内容

    this.IMG.onload = () => {
      // 源代码
      // this.ImgW = this.IMG.width
      // this.ImgH = this.IMG.height

      // 新的计算方式,这里对宽度进行了缩放
      if (typeof w === 'number') this.ImgW = w;
      else this.ImgW = this.IMG.width;
      if (typeof h === 'number') this.ImgH = h;
      else this.ImgH = Math.floor(this.ImgW * (this.IMG.height / this.IMG.width));

      // 获取数据
      const ele = document.createElement('canvas');
      ele.width = this.ImgW;
      ele.height = this.ImgH;

      const eleCtx = ele.getContext('2d');
      /**
       * 绘制图片到上下文的五个参数释义,这是三个重载中的其中一个
       * @param { HTMLImageElement | SVGImageElement | HTMLVideoElement | HTMLCanvasElement | ImageBitmap } image 符合条件的对象
       * @param { number } dx 绘制的起始坐标 X
       * @param { number } dy 绘制的起始坐标 Y
       * @param { number } dw 绘制的图像宽度
       * @param { number } dh 绘制的图像高度
       */
      eleCtx.drawImage(this.IMG, 0, 0, this.ImgW, this.ImgH);

      // ...之前的内容
    }
  }
}

本篇小结

到这里我们就完成了图像的渲染、自定义有效颜色区间和图片缩放的功能,接下来的内容会开一篇新的文章讲解粒子的散开与聚合实现

以下是根据全篇总结出来的代码,可以自行参考

声明文件:

/**
 * 每张图片的粒子设置声明
 */
interface ParticleOptions {
  /** 图片路径 */
  src: string
  /** 渲染宽度,可省略,**但建议设置为350左右,并在此基础上进行调整**,如果只设置该项则图片高度会根据宽度进行缩放 */
  w?: number
  /** 渲染高度,可省略,**设置该项时图片不会进行缩放** */
  h?: number
  /** 有效颜色区间,默认 `300 ~ 765` 为有效区间,颜色计算方式为 `R G B` 三通道值的总和 */
  validColor?: {
    /** 最小值,默认 300 */
    min?: number
    /** 最大值,默认 765 */
    max?: number
    /** 
     * 范围反向覆盖
     * 
     * 当设置范围为 `50 ~ 300` 之间时,启用此项后范围会转变成 `0 ~ 50 && 300 ~ 765` 
     */
    invert?: boolean
  }
}

declare class Point {
  public orx: number
  /**
   * 点示例,**该类为内部类,不建议调用**
   * @param {number} orx 目标位置 x
   * @param {number} ory 目标位置 y 
   * @param {HTMLCanvasElement} canvas canvas 元素
   * @param {number} colorVal RGB 总和
   */
  constructor(
    orx: number,
    ory: number,
    colorVal: number,
    canvas: HTMLCanvasElement,
  ): void

  /** 更新粒子坐标 */
  update(): void

  /** 渲染粒子 */
  render(): void

  /**
   * 改变粒子位置
   * @param newX 粒子新的 X 位置
   * @param newY 粒子新的 Y 位置
   * @param colorVal RGB 总和
   */
  changePos(newX: number, newY: number, colorVal: number): void
}

declare class DameDaneParticle {
  /** 传入的 canvas 元素 */
  canvasEle: HTMLCanvasElement
  /** 最终图像宽度 */
  ImgW: number
  /** 最终图像高度 */
  ImgH: number
  /** 当前图像设置 */
  options: ParticleOptions

  constructor(canvas: HTMLCanvasElement,
    options: ParticleOptions,
    /** 图片加载完并开始渲染时的回调 */
    callback?: Function): DameDaneParticle
}

源 JS 文件:

class Point {
  constructor(orx, ory, canvas, colorVal) {
    this.orx = orx
    this.ory = ory
    // 获得要绘制的目标元素
    this.canvas = canvas
    // 颜色黑白处理
    const c = Math.floor(colorVal / 3)
    this.color = `${c},${c},${c}`
  }
  /** 更新粒子位置信息 */
  update() { }
  /** 渲染粒子 */
  render() {
    const ctx = this.canvas.getContext('2d')
    ctx.beginPath();
    // 绘制圆点
    ctx.arc(this.orx, this.ory, 1, 0, 360);
    ctx.fillStyle = `rgba(${this.color},1)`;
    ctx.fill();
    ctx.closePath();
  }
  /** 改变粒子位置 */
  changePos(newX, newY, colorVal) { }
}

class DameDaneParticle {
  constructor(canvas, options, callback) {
    // 预处理
    const initOptions = {
      src: '',
      validColor: {
        min: 300, max: 765, invert: false
      }
    }
    for (const i in initOptions) {
      if (typeof options[i] === 'undefined') options[i] = initOptions[i];
    }

    // 解构
    const { src } = options;

    // 元素宽高
    this.w = canvas.width, this.h = canvas.height;

    /** 传入的 canvas 元素 */
    this.canvasEle = canvas;
    /** 传入的 canvas 元素 2D 上下文 */
    this.ctx = canvas.getContext('2d');

    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;

    /** 图片对象 */
    this.IMG = new Image();
    this.IMG.src = src;
    // 图片信息
    this.ImgW = 0, this.ImgH = 0;

    /** 粒子位置信息数组 */
    this.PointArr = [];
    /** 执行了动画触发标记 */
    this.hasDraw = false;

    // options 备份
    this.options = options;

    // 图片加载完成
    this.IMG.onload = () => {
      // 设置解构
      const { renderX, renderY, w, h } = this.options;

      // 渲染起始位置
      this.renderX = renderX;
      this.renderY = renderY;

      if (typeof w === 'number') this.ImgW = w;
      else this.ImgW = this.IMG.width;
      if (typeof h === 'number') this.ImgH = h;
      else this.ImgH = Math.floor(this.ImgW * (this.IMG.height / this.IMG.width));

      // 获取数据
      const ele = document.createElement('canvas');
      ele.width = this.ImgW;
      ele.height = this.ImgH;

      const eleCtx = ele.getContext('2d');
      eleCtx.drawImage(this.IMG, 0, 0, this.ImgW, this.ImgH);
      this._imgArr = eleCtx.getImageData(0, 0, this.ImgW, this.ImgH).data;
      eleCtx.clearRect(0, 0, canvas.width, canvas.height);
      // 第一次初始化图片
      this._InitParticle(this._imgArr, true);
      if (!this.hasDraw) this._Draw2Canvas();

      callback && callback();
    }
  }

  /**
   * 图片初始化函数,**此项为内置 api, 不建议随便调用**
   * @param {Uint8ClampedArray} ImgData 图片数据数组
   * @param {boolean} rebuildParticle 是否重组图像
   */
  _InitParticle = (ImgData, rebuildParticle = false) => {
    if (!ImgData) ImgData = this._imgArr;

    let imgW = this.ImgW, imgH = this.ImgH, cnt = 0;

    let arr = this.PointArr;

    let { validColor, cancelParticleAnimation } = this.options;

    let r, g, b, val, position;
    const gap = 4;
    for (var h = 0; h < imgH; h += gap) {
      for (var w = 0; w < imgW; w += gap) {
        position = (imgW * h + w) * 4;
        r = ImgData[position],
          g = ImgData[position + 1],
          b = ImgData[position + 2];
        val = r + g + b
        // 像素符合条件
        if ((validColor.invert && (val <= validColor.min || val >= validColor.max)) || (!validColor.invert && val >= validColor.min && val <= validColor.max)) {
          // 判断是否有前置像素
          if (arr[cnt] && !cancelParticleAnimation) {
            const point = arr[cnt];
            point.changePos(w, h, val)
          }
          else arr[cnt] = new Point(w, h, this.canvasEle, val);
          cnt++;
        }
      }
    }

    if (cnt < arr.length)
      this.PointArr = arr.splice(0, cnt);
  }

  /** 绘制到 canvas,**此项为内置 api, 不建议随便调用** */
  _Draw2Canvas = () => {
    this.hasDraw = true
    const w = this.canvasEle.width, h = this.canvasEle.height;
    this.ctx.clearRect(0, 0, w, h);
    this.PointArr.forEach(
      /** @param {Point} point */
      (point) => {
        point.update();
        point.render();
      })
    requestAnimationFrame(this._Draw2Canvas);
  }
}
个人信息
avatar
Shiinafan
文章
38
标签
52
归档
4
导航