简要记录Canvas
简介
Windy是一个查看全球天气的网站,实现了一些气象数据,例如风和洋流矢量数据的前端动态可视化,如图:

如何实现屏幕上运动的粒子效果? 参考https://github.com/Esri/wind-js和https://www.cnblogs.com/fuckgiser/p/6424007.html提供的解决思路,抛开地理数据的读取、坐标转换和其他因素的干扰,只讨论用纯前端canvas实现粒子的运动实现方法。
地理数据粒子运动绘制原理
风场、海流等矢量数据或海温、海水盐度等标量数据,一般使用的文件格式为NetCDF (Network Common Data Form)网络通用数据格式,其广泛应用于大气科学、水文、海洋学、环境模拟、地球物理等诸多领域。NetCDF 数据一般包括地理坐标信息、矢量信息(如运动方向)、标量信息(如运动速度)和其他信息,使用NetCDF 的数据,可以在屏幕插值间隔相等的网格数据点,每个网格都包含一系列的数据信息。
以风场为例,有一个围棋棋盘(风向向量场,NetCDF数据插值可得),每一个格子就是一个向量,最开始时随手拿一个棋子,随机仍放在一个格子上,这就是风(粒子)的起点。下一回合(下一帧或下一个时间间隔),根据当前格子的向量值(速度方向),和当前标量值(速度大小)移动棋子,就是风在当前的风速下拖着尾巴跳到下一个格子上的效果。如果棋子重复以上操作不停的移动,直到格子的向量值为零(风停)。
所以说只要初始一个起点,就能刮起一股风来。初始5000个棋子(起点),就能刮起5000股风。基于每一帧状态的管理,可以很简单的模拟出风向图的效果。
初步绘制运动粒子
现在将粒子运动的路径完全随机(粒子运动位置的计算不是本文考虑的内容,现在只讨论如何实现运动的粒子效果),绘制粒子运动的路径本质就是Canvas绘路径,代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#drawing {
position: absolute;
top: 0px;
left: 0px;
z-index: 0;
}
.backgound {
height: 800px;
width: 1000px;
background-color: rgb(0, 162, 255)
}
</style>
</head>
<body>
<div>
<div class="backgound"></div>
<canvas id="drawing" width="1000" height="800">A drawing of something.</canvas>
</div>
<script>
let drawing = document.getElementById("drawing");
let context = drawing.getContext("2d");
let particles = [];//粒子集合
let numPoints = 10;//粒子数量
//粒子的构造函数,x,y代表位置,m magnitude,代表风速大小,d direction代表反向 范围0到2π
let Particle = function (x, y, m, d) {
this.x = x
this.y = y
this.ox = x//old值。初始化为x
this.oy = y
this.m = m
this.d = d
}
//初始化10个粒子
for (let i = 0; i < numPoints; i++) {
particles.push(new Particle(Math.random() * 1000, Math.random() * 800, Math.random() * 10, Math.random() * 2 * Math.PI))
}
//let rand = () => (Math.random() - 0.5) * 2;//-1到1随机数
//线宽度
context.lineWidth = 2
setInterval(() => {
context.beginPath();
particles.forEach((e, i) => {
context.strokeStyle = "white";
//更新数据
e.ox = e.x;
e.oy = e.y;
e.m = e.m * (Math.random() + 1.6) * 0.5//随机设置下一个m的速度,是上一个的80%到130%
e.d = e.d * (Math.random() + 1.4) * 0.5 //每一步稍微改变一下例子运动方向,模拟运动场景
e.x += Math.cos(e.d) * e.m;
e.y += Math.sin(e.d) * e.m;
//绘制线
context.moveTo(e.ox, e.oy);
context.lineTo(e.x, e.y);
})
context.stroke();
}, 30);
</script>
</body>
</html>
如下实现了一个简单的粒子运动路径绘制,使用Particles初始化是个随机的点,包括位置(x,y)和速度m,每走一步,代码会重新随机设置其粒子的速度和方向,模拟真实运动场景。具体如下图:

擦除粒子走过的路径
擦除之前的绘制路径。
解决的办法是利用canvas的globalCompositeOperation属性,即全局符合操作
默认的globalSompositeOperation是source-over属性,代表同一个画布前后画了两个图形,如果两个图形发生重叠,默认是新画的图形会覆盖在旧图形上,如果属性改为destination-in模式,就是在旧图形上展示两个图形重叠的部分。


这里的目标图形就是第一个图形,源图形就是第二个图形
如果新图形的有透明度的话,就是fillStyle是一个由rgba组成的话,destination-in重叠的部分也会变透明。利用这一个特性,可以在每一步(每一帧)绘制粒子路径之前,绘制一个destination-in属性的覆盖整个canvas图形的透明矩形,那么canvas原有的图形都会继续显示,但是增加了透明度,换句话说就是模糊了一点!然后在绘制新的线段,然后新的线段就不会受到透明度的影响。
然后就这样一步步绘制线段,之前的线段会越来越透明,直到消失不见,这样就实现了粒子运动的效果。
之前的代码一步步绘制图形时用的是setTimeout方法,但是不如window.requestAnimationFrame()方法好,其主要的优势有两点:
1、`requestAnimationFrame` 会把每一帧中的所有DOM操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率,一般来说,这个频率为每秒60帧。
2、在隐藏或不可见的元素中,`requestAnimationFrame`将不会进行重绘或回流,这当然就意味着更少的的cpu,gpu和内存使用量。
实现
代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#drawing {
position: absolute;
top: 0px;
left: 0px;
z-index: 0;
}
.backgound {
height: 800px;
width: 1000px;
background-color: rgb(0, 162, 255)
}
</style>
</head>
<body>
<div>
<div class="backgound"></div>
<canvas id="drawing" width="1000" height="800">A drawing of something.</canvas>
</div>
<script>
let drawing = document.getElementById("drawing");
let context = drawing.getContext("2d");
let particles = [];
let numPoints = 100;
let Particle = function (x, y, m, d) {
this.x = x
this.y = y
this.ox = x
this.oy = y
this.m = m
this.d = d
}
//初始化100个粒子
for (let i = 0; i < numPoints; i++) {
particles.push(new Particle(Math.random() * 1000, Math.random() * 800, Math.random() * 10, Math.random() * 2 * Math.PI))
}
//let rand = () => (Math.random() - 0.5) * 2;//-1到1随机数
context.lineWidth = 3
context.fillStyle = 'rgba(255, 255, 255,0.5)'//透明画布,
var progress = 0;
function render() {
progress++;
context.globalCompositeOperation = "destination-in";
context.fillRect(0, 0, 1000, 800);//画新对象,但是显示的是旧对象,就是旧线,但是旧线开始透明
// debugger
context.globalCompositeOperation = 'source-over';
context.beginPath();
particles.forEach((e, i) => {
context.strokeStyle = "white";
//更新数据
e.ox = e.x;
e.oy = e.y;
e.m = e.m * (Math.random() + 1.6) * 0.5//随机设置下一个m的速度,是上一个的80%到130%
e.d = e.d * (Math.random() + 1.4) * 0.5 //每一步稍微改变一下例子运动方向,模拟运动场景
e.x += Math.cos(e.d) * e.m;
e.y += Math.sin(e.d) * e.m;
//绘制线
context.moveTo(e.ox, e.oy);
context.lineTo(e.x, e.y);
})
context.stroke();
}
render();
//当前执行时间
var nowTime = 0;
//记录每次动画执行结束的时间
var lastTime = Date.now();
//我自己定义的动画时间差值.控制帧率
var diffTime = 50;
//requestAnimationFrame效果
(function animloop() {
//记录当前时间
nowTime = Date.now()
// 当前时间-上次执行时间如果大于diffTime,那么执行动画,并更新上次执行时间
if (nowTime - lastTime > diffTime) {
lastTime = nowTime
render();
}
rafId = requestAnimationFrame(animloop);
//如果等于500停止动画
if (progress == 500) {
cancelAnimationFrame(rafId)
}
})();
</script>
</body>
</html>
效果如下:

实际的地理矢量数据绘制,如风场绘制的原理大致于此,但是又在其基础上增加了在每一帧绘制之前计算每个粒子位置(x y ),方向(u v)与风速(m)。其中x y 是利用oldx,oldy还有u v m以及缩放尺度等变量计算得到。而下一帧的[u v m]可通过源文件构成的巨大粒子的格网,双线性插值得到。同时真实的矢量数据绘制也必须考虑地理坐标与屏幕像素的转换,进而实现风向图粒子运动的路径。