简介

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

如何实现屏幕上运动的粒子效果? 参考https://github.com/Esri/wind-jshttps://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属性,即全局符合操作

默认的globalSompositeOperationsource-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]可通过源文件构成的巨大粒子的格网,双线性插值得到。同时真实的矢量数据绘制也必须考虑地理坐标与屏幕像素的转换,进而实现风向图粒子运动的路径。