本文最后更新于:2 个月前
记一次前端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()
改变粒子原始位置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 declare class Point { public orx : number public ory : number constructor (orx:number , ory:number , canvas:number , colorVal:number ): Point ; update (): void render (): void changePos (newX : number , newY : number , colorVal : number ): void }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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 列表:
CanvasRenderingContext2D.beginPath()
新路径开始方法,可以看作是告诉上下文开始绘制 MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/beginPath
CanvasRenderingContext2D.arc()
这是 Canvas 绘制圆弧路径的方法。 MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/arc
CanvasRenderingContext2D.fillStyle
填充的颜色和样式,此处是用 rgba()
字符串进行设置的,跟 css 中 rgba 的写法一样 MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/fillStyle
CanvasRenderingContext2D.fill()
填充当前已存在的路径,此处由于没有调用任何路径移动方法,所以直接填充相对于填充了一个点 MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/fill
CanvasRenderingContext2D.closePath()
结束绘制方法 MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/closePath
此刻,一个基本的点类就完成了,构造出点后可以调用 render()
方法在传入的 canvas 元素上进行渲染
图像绘制 基本处理 对于图像绘制,首先需要一个画布,在这里我们选用网页自带的 canvas
画布,其有许多内置的 API 供我们作画
在这里我们构建一个新的图像处理为粒子的类,并设置其构造函数;构造函数的目标如下:
将图片像素处理成信息坐标点
处理传入的选项
监听图片加载完成事件
我们在选项设置中传入图片原地址来告诉这个类我们要构建的图片是哪一张,并新建图片对象来进行处理,当图片完成加载时对图片执行处理:
类型声明:
1 2 3 4 interface ParticleOptions { src : string }
类本身:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 class DameDaneParticle { 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
上下文,这样我们可以通过其上下文对象对这个元素调用各种绘制方法
1 2 3 4 5 6 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 值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const ele = document .createElement ('canvas' ); ele.width = this .IMG .width ; ele.height = this .IMG .height ;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 );
这段代码让我们新建的实例中的 _imgArr
属性指向了我们获取的图像数据,我们将这一部分代码放回构造函数里头:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 class DameDaneParticle { 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' ); 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 ); callback && callback () } } }
这样子我们就拿到了图像的数据,接下来我们就可以写图片转换为点实例数组的方法了
图片转换为点实例数组 在类中我们建立这样一个方法:_InitParticle
用于将图片转换为粒子实例数组,这个方法中我们设置一个参数:图片数据数组
这个方法的目的是:将前文得到的一维图片数据进行处理,提取其中的点并以此构造点实例,并将其推入渲染队列
因此我们在类中用 PointArr
来代表渲染队列,他是一个数组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class DameDaneParticle { constructor (canvas, options, callback ) { this .PointArr = [] } _InitParticle = (ImgData ) => { let imgW = this .ImgW , imgH = this .ImgH , cnt = 0 ; let arr = this .PointArr ; let r, g, b, val, position; } }
在传入的一维数组中每 4 项内容为一个像素的信息,因此我们用两个 for 循环模拟每个像素的位置,每一步的步长为 4
我们还需要设置一个颜色区间来控制渲染粒子的颜色区间范围,因此我们在构造函数增加以下项,来让使用者可以控制哪个颜色区间的像素可以被渲染:
1 2 3 4 5 6 7 8 9 10 11 interface ParticleOptions { validColor : { min : number max : number invert : boolean } }
在渲染之前还有最后一步:判断前置像素
当图片 src 源被改变时,IMG 加载完成的方法会再次被触发,为了节省性能,我们不需要重新构造点实例,而是通过之前点暴露出来的 changePos()
方法对点的起始位置进行修改
当所有的点被修改完时,浏览器会在下一次的渲染中将新点渲染出来,因此我们在遍历 图片一维数据数组
的时候与当前的 点实例数组
进行对比覆盖
需要注意的是,当新图片粒子数量没能完全覆盖上一张图片的粒子信息时,需要在最后进行一次数组裁剪操作,否则多余的点会保留
接下来我们就可以通过用户传入的许可数值范围来控制要被渲染的粒子
到这里处理点实例的步骤就完成了,此时初始化方法完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 _InitParticle = (ImgData ) => { let imgW = this .ImgW , imgH = this .ImgH , cnt = 0 ; let arr = this .PointArr ; 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) { 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); }
渲染到画布上 这一步相对比较简单,当点实例都处理好了后我们只需要对每个点调用其暴露的 render()
方法即可将其渲染到指定的画布上
我们建立一个新的方法:_Draw2Canvas
该方法每调用一次,就会进行一次渲染
此处使用浏览器内置的 requestAnimationFrame
进行绘制函数的调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 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 ( (point ) => { point.update (); point.render (); }) requestAnimationFrame (this ._Draw2Canvas ); } }
requestAnimationFrame
MDN:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame
接下来只要在图片加载完成后的回调内调用一次该函数即可开始持续渲染
1 2 3 4 5 6 7 8 9 10 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
1 2 3 4 5 6 7 interface ParticleOptions { w?: number h?: number }
原 js 中(此处 this 指向处理图像为例子的实例)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 class DameDaneParticle { constructor (w ) { this .IMG .onload = () => { 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 ); } } }
本篇小结 到这里我们就完成了图像的渲染、自定义有效颜色区间和图片缩放的功能,接下来的内容会开一篇新的文章讲解粒子的散开与聚合实现
以下是根据全篇总结出来的代码,可以自行参考
声明文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 interface ParticleOptions { src : string w?: number h?: number validColor?: { min?: number max?: number invert?: boolean } }declare class Point { public orx : number constructor ( orx: number , ory: number , colorVal: number , canvas: HTMLCanvasElement, ): void update (): void render (): void changePos (newX : number , newY : number , colorVal : number ): void }declare class DameDaneParticle { canvasEle : HTMLCanvasElement ImgW : number ImgH : number options : ParticleOptions constructor (canvas: HTMLCanvasElement, options: ParticleOptions, callback?: Function ): DameDaneParticle }
源 JS 文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 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 ; this .canvasEle = canvas; 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 ; 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 (); } } _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); } _Draw2Canvas = () => { this .hasDraw = true const w = this .canvasEle .width , h = this .canvasEle .height ; this .ctx .clearRect (0 , 0 , w, h); this .PointArr .forEach ( (point ) => { point.update (); point.render (); }) requestAnimationFrame (this ._Draw2Canvas ); } }