如果你是明日方舟玩家肯定对游戏官网[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的亮点。
不要光看不实践哦,后续会持续更新前端相关的知识,欢迎大家关注第一时间收到更新消息哦
写作不易,如果觉得有收获还望大家点赞、收藏
才疏学浅,如有问题或建议欢迎大家指教。
6月5日,一张券商降薪截图在社交媒体疯传。截图提到,当日上午,某中字头头部券商召开大会,除了MD外全员降薪,且降薪不只是降奖金,而是直接降底薪。按照职级不同,SA1降6K,SA3降8K,VP降8K—10K。据了解,降薪大概率整体属实,但具体幅度有所差异,且不同区域、不同业务条线目前掌握的降薪情况也不尽相同。
今日,蔚来 CEO 李斌在 2023 高通汽车技术与合作峰会上爆料,蔚来第二代技术平台的全系车型已标配第三代骁龙座舱平台。
Meta公司周一(5月22日)推出了一个开源AI语言模型——大规模多语言语音(Massively Multilingual Speech, MMS)模型,可以识别和产生1000多种语言的语音——比目前可用的模型增加了10倍。研究人员表示,他们的模型可以转换1000多种语言,但能识别4000多种语言。
歌手孙燕姿在更新动态中回应了近日引发争议的“顶流AI歌手孙燕姿”,笑称粉丝已经接受她是“冷门”歌手,而AI成为了目前的顶流。
5月31日晚,荣耀方面对澎湃新闻记者表示,上海荣耀智能科技开发有限公司是荣耀位于上海的研究所,是荣耀在中国的5个研究中心之一,重点方向在终端侧核心软件、图形算法、通信、拍照等方面研究开发工作。荣耀强调,坚持以用户为中心,开放创新,与全球合作伙伴一起为用户提供最佳产品解决方案。
据北京市市场监督管理局公示信息,5月24日,苹果电子产品商贸(北京)有限公司因发布虚假广告被北京市东城区市场监督管理局处以20万元的行政处罚。
据外媒5月24日消息,全球最大的个人电脑制造商联想表示,在2023年1-3月期间,该公司裁员了约5%,这是由于PC市场不景气导致的。
日前,有网络博主号称拍摄到了小米首款汽车MS11的高清视频。从视频中可以看出,新车依旧包裹大面积的伪装,据该博主称,他之所以确定这是小米汽车,是因为靠近观察之后,发现它的三角形大灯轮廓和其最初手绘的小米汽车假想图几乎一模一样。
超过 350 名从事人工智能工作的高管、研究人员和工程师签署了这份由非盈利组织人工智能安全中心发布的公开信,认为人工智能具备可能导致人类灭绝的风险,应当将其视为与流行病和核战争同等的社会风险。
日前,以押注“颠覆性创新”著称的ARK Invest创始人Cathie Wood在接受媒体采访时表示,软件提供商将是人工智能狂潮的下一个受益板块。英伟达每卖出1美元的硬件,软件供应商SaaS供应商就会产生8美元的收入。
据报道,阿里巴巴研究员吴翰清已于近期离职,钉钉显示其离职时间是5月19日。在阿里内部,研究员的职级为P10。据消息人士透露,吴翰清离职后,选择AI短视频赛道创业,已经close一轮融资。对于上述消息,截至发稿,阿里尚未回应。
阿里巴巴集团官微宣布,2023年六大业务集团总计需新招15000人,其中校招超过3000人。同时表示,“近日,关于淘宝天猫、阿里云、菜鸟、本地生活各个业务裁员谣言传得很厉害,但谣言就是谣言。我们的招聘正在紧锣密鼓的进行。”
“现今每一个存在的应用都将被AI 2.0重构,我觉得整个AI大模型带来的机遇和技术浪潮,会比过去Windows和安卓大10倍。”李开复表示。
苹果发布Vision Pro头显,正式宣布开启空间计算时代;苹果还发布新款MacBook Air,新款Mac Studio,并展示了iOS17、iPadOS 17、macOS Sonoma和watchOS10等新系统;Vision Pro头显售价3499美元,将于2024年初正式在美国市场发售;华尔街并不看好Vision Pro,苹果股价周一创历史新高后由涨转跌。
5月25日,长城汽车就比亚迪秦PLUS DM-i、宋PLUS DM-i采用常压油箱,涉嫌整车蒸发污染物排放不达标的问题进行举报。
近日,一个名为“贾跃亭”的抖音账号悄然出现,带有“FF创始人、合伙人、首席产品及用户生态官, LeEco 乐视创始人”等标签,IP 地址显示为美国。
5月29日消息,继上周远超预期的财报业绩预测引得股价和市值史诗级暴涨后,今日,英伟达(NVIDIA)创始人兼CEO黄仁勋穿着标志性的皮衣,意气风发地出现在台北电脑展COMPUTEX 2023上,在主题演讲期间先是现场给自家显卡带货,然后一连公布涉及加速计算和人工智能(AI)的多项进展。
近日,苹果位于天猫的Apple Store官方旗舰店挂出直播预告,表示将在5月31日晚19时开启官方直播,这也是苹果官方在电商平台的全球首次直播。
前京东集团副总裁、京东探索研究院副院长梅涛自今年初离职后,确认在 AI 领域创业,成立生成式 AI 公司 HiDream.ai。