如果你是明日方舟玩家肯定对游戏官网[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的亮点。
不要光看不实践哦,后续会持续更新前端相关的知识,欢迎大家关注第一时间收到更新消息哦
写作不易,如果觉得有收获还望大家点赞、收藏
才疏学浅,如有问题或建议欢迎大家指教。
本文由哈喽比特于1年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/WeJ57PuYH5GO2wQgaeLWSg
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。