如果你是明日方舟玩家肯定对游戏官网[2]有深刻的印象,不得不说鹰角的前端很厉害。
作为前端开发者肯定是第一时间F12开始审查元素不难发现页面中不少特效都是通过<canvas>
标签实现的。
例如这个阵营Logo的粒子动画:
很明显使用了 canvas2d 中的 像素操作,今天我们一起研究下它是怎么实现的。
如果觉得有收获还望大家点赞、收藏
后续内容论述较多,就先把最终效果放上来了。
不知道为啥要运行两次,想试用的同学再点下运行就可以看了
应掘友要求整理上传了份源码:
github.com/XIwE1/ark-p…[3]
顺便简述下实现方法,希望能帮助你理解
主要使用三个类:Particle、LogoImg、ParticleCanvas
draw
、更新方法update
、替换方法change
particleData
drawCanvas
、改变粒子数组方法changeImg
流程:
实例化一个ParticleCanvas
对象prtCanvas
点击某个图片clickLogo
时调用prtCanvas.changeImg(particleData)
方法传入其粒子数组信息。
首次 changeImg,直接赋值
非首次,对比粒子数组 移除/生成粒子,并随机映射
这里就已经实现粒子动画了,粒子的生成和移动就不细说了看代码!
然后就是吸引/排斥:
鼠标在实例对象prtCanvas
对应的画布移动时触发mousemove
回调,根据回调参数重新计算鼠标位置mouseX/mouseY
prtCanvas
的绘制画布方法drawCanvas
一直随着事件循环在执行,drawCanvas
中遍历画布粒子数组并调用每一项的update
方法并传入重新计算后的mouseX/mouseY
particle.update
中又根据距离和设置好的引力/斥力重新计算vx/vy
...
this.ParticleArr.forEach((particle) => {
particle.update(this.mouseX, this.mouseY);
particle.draw();
});
复制代码
Particle 的 draw 方法符合面向对象的写法是接收一个 content 上下文参数,图方便就直接读取了
实现该动画主要的步骤为:
解析图片通过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);
};
......
复制代码
ImageData
的data
属性为 Uint8ClampedArray[5] 类型的一维数组,包含了指定区域里每个像素点的RGBA格式的整型数据,范围在0至255之间(包括255)。
每一个像素点有4个值占据data数组4个索引位置,对应像素rgba(R, G, B, A)的四个值。如图:
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);
}
}
}
};
......
复制代码
首先我们观察到动画中的粒子是从随机位置(或者有一套算法确定位置,但肯定不在原位置)出现的,并逐渐位移向目标位置,同时会逐渐清晰(不透明度++)。
所以我们需要调整粒子类:
x、y
属性表示粒子当前位置 mx、my
属性表示粒子需要移动的距离 vx、vy
属性表示粒子在方向上的移动速度 time
属性表示粒子过渡动画所耗时间 update
方法在粒子更新时调用,在其中动态计算mx、my、vx、vy
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
。由此使粒子不断远离中心。
调整粒子类Particle
的update
方法,重新计算vx、vy
:
Radius(斥力影响范围)
、Inten(斥力标准值)
。(mouseX, mouseY)
为斥力中心。直线距离distance
。Radius / distance
获得 中心影响范围 与 直线距离 的比例disPercent
。
比例越大越接近中心,受到的斥力也越大。夹角angle
、比例disPercent
和斥力值Inten
,转换为粒子x、y轴的速度repX
、repY
。vx += repX
& vy += repY
,粒子逐渐远离中心。注意:canvas坐标系采用第四象限,即x轴正向为右,y轴正向为下
ucs.png
如图,假设某点Z
为斥力中心,同时取三个粒子,位置分别为:A.边界外``B.边界内``C.边界上
。
用dx、dy
代表粒子与中心的x、y
轴距离,并用正负表示方向。
例如A粒子 dx = 2 \- 4 = \-2
、dy = 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 += repX
、 vy += 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