canvas实现炫酷的黑客帝国数字雨特效

8,940 阅读5分钟

动机

最近重温黑客帝国,发现这个数字雨特效很炫酷,之前也看到网络上有相关类似的代码,我先自己思考了一种实现方式,最后参考网上给出的一种思路,最后写成了一个vue插件放在npm上,下面先上特效gif


是不是和电影里的很像,不过还是有点差距

自己的实现方法(失败)

对于这种较为复杂的动画特效,canvas是首选,当然css肯定也可以做,不过肯定超级复杂,代码量巨大。首先我第一眼看到这个特效,思路是这样的:
(1) 一般canvas用于绘制静态的图像,由于本例是动画效果,肯定得调用setTimeout或者setInterval或者raf,这里采用raf,不断绘制图像达到动态的效果,而且应当采用raf,它优于setTimeout/setInterval的地方在于它是由浏览器专门为动画提供的API,在运行时浏览器会自动优化方法的调用,并且如果页面不是激活状态下的话,动画会自动暂停,有效节省了CPU开销
(2) 绘制一个黑色的背景
(3) 由于数字雨看上去是独立的一条条的向下运动的数字字母序列,因此我需要新建一个DigitRain类,里面设置了该数字雨的各种属性,来控制该条数字雨的运动特性,代码见下面

    //数字雨类(参数是配置对象)
    function DigitRain(configObj){
        //数字雨的位置(x轴)
        this.digitRainXPos = configObj.digitRainXPos,
        //数字雨的位置(y轴)
        this.digitRainYPos = configObj.digitRainYPos,
        //数字雨的下落速度
        this.rainVelocity = configObj.rainVelocity,
        //数字雨的颜色
        this.rainColor = configObj.rainColor,
        //数字雨的拖尾长度
        this.rainTailLength = configObj.rainTailLength,
        //数字雨的文本内容
        this.rainText = configObj.rainText,
        ...
    }

(4)然后写一个draw方法来控制其运动,最终在canvas里面调用fillText来画出文字

最终我写了一会发现困难太多,特别是文字拖尾效果的处理很麻烦,而且达不到效果,于是便作罢

换一种思路

参考了网上的一种思路,这种思路可谓是化繁为简,而且很容易理解,不得不佩服
(1) 同样是采用raf实现动画效果,首先根据canvas宽度和字体大小计算出雨滴下落的列数(宽度/字体大小),采用一个rainDropArray(长度是列数)记录下每个列的文字的y轴的位置,初始都为0,核心数据结构就是这个rainDropArray
(2) requestAnimationFrame的参数函数里,用for循环遍历rainDropArray,然后用fillText向canvas画上文字,x轴位置就是数组的index*字体大小,y轴位置就是rainDropArray[i]的值,而且每次fillText都用封装的random方法获取字符串的随机数字字母
(3)拖尾效果的处理:这里很巧妙,对于拖尾效果,只需要在requestAnimationFrame的参数函数里fillRect(0,0,.canvas.width,canvas.height)即可,而fillStyle设置为rgba(0,0,0,alpha),这样每次画图时都会画这么一个黑色背景,从而覆盖了之前画的字母,让字母颜色变淡,达到拖尾效果,通过控制alpha的值的大小来控制拖尾的长短,注意画图时没有用clearReact清除上次所画的内容,每次都是叠加上次所画的效果

如上图,虽然看着图中有很多字母,其实requestAnimationFrame每次只画了红圈内的字母,也就是对应每列的字母,其余颜色变淡的字母都是requestAnimationFrame以前画出来的,只不过被新画的黑色背景遮住了从而变暗,这样就完美的实现了拖尾效果 (4)最开始时rainDropArray的每个值都是0,且所有列下落速度一样,因此动画刚开始是会是如下效果

整整齐齐的下落,因此需要在字母到达canvas底部时做处理,让其有先后顺序,代码如下,触底后给定一定概率让其的y轴位置重新置位0,从而达到该列循环下落的效果

 if(textYPostion>this.canvasHeight){
       if(Math.random()>0.9){
           this.rainDropPositionArray[i]=0;
       }
 }

vue插件封装后的代码

总体代码量不多,不到150行,template部分就一个canvas

<template>
    <canvas id="vue-matrix-raindrop"></canvas>
</template>

<script>
    export default {
    name: 'vue-matrix-raindrop',
    //插件的各种参数
    props:{
        //canvas宽度
        canvasWidth:{
            type:Number,
            default:800
        },
        //canvas高度
        canvasHeight:{
            type:Number,
            default:600
        },
        //下落字体大小
        fontSize:{
            type:Number,
            default:20
        },
        //字体类型
        fontFamily:{
            type:String,
            default:'arial'
        },
        //字体文本内容,会随机从字符串里取一个
        textContent:{
            type:String,
            default:'abcdefghijklmnopqrstuvwxyz'
        },
        //字体颜色
        textColor:{
            type:String,
            default:'#0F0',
            validator:function(value){
                 var colorReg = /^#([0-9a-fA-F]{6})|([0-9a-fA-F]{3})$/g
                 return colorReg.test(value)
            }
        },
        //canvas背景颜色,可自定义
        backgroundColor:{
            type:String,
            default:'rgba(0,0,0,0.1)',
            validator:function(value){
              var reg = /^[rR][gG][Bb][Aa][\(]((2[0-4][0-9]|25[0-5]|[01]?[0-9][0-9]?),){2}(2[0-4][0-9]|25[0-5]|[01]?[0-9][0-9]?),?(0\.\d{1,2}|1|0)?[\)]{1}$/;
              return reg.test(value);
            }
        },
        //下落速度
        speed:{
            type:Number,
            default:2,
            validator:function(value){
              return value%1 === 0;
            }
        }
    },
    mounted:function(){
          this.initRAF();
          this.initCanvas();
          this.initRainDrop();
          this.animationUpdate();
    },
    methods:{
         //初始化requestAnitaionFrame,注意兼容性
    	 initRAF(){
             window.requestAnimationFrame = (function(){
               return window.requestAnimationFrame       ||
                      window.webkitRequestAnimationFrame ||
                      window.mozRequestAnimationFrame    ||
                      window.oRequestAnimationFrame      ||
                      function( callback ){
                        window.setTimeout(callback, 1000 / 60);
                      };
                })();
             window.cancelAnimationFrame = (function () {
               return window.cancelAnimationFrame ||
                      window.webkitCancelAnimationFrame ||
                      window.mozCancelAnimationFrame ||
                      window.oCancelAnimationFrame ||
                      function (id) {
                        window.clearTimeout(id);
                      };
               })();
        },
        //初始化canvas
    	initCanvas(){
             this.canvas = document.getElementById('vue-matrix-raindrop');
             //需要判断获取到的canvas是否是真的canvas
             if(this.canvas.tagName.toLowerCase() !== 'canvas'){
                console.error("Error! Invalid canvas! Please check the canvas's id!")
             }
             this.canvas.width = this.canvasWidth;
             this.canvas.height = this.canvasHeight;
             this.canvasCtx = this.canvas.getContext('2d');
             this.canvasCtx.font = this.fontSize+'px '+this.fontFamily;
             this.columns = this.canvas.width / this.fontSize;
       },
       //初始化数字雨下落的初始y轴位置
       initRainDrop(){
            for(var i=0;i<this.columns;i++){
                this.rainDropPositionArray.push(0);
            }
       },
       //核心动画函数,控制数字雨下落
       animationUpdate(){
         //控制雨滴下落的速度
       	 this.speedCnt++;
       	 //speed为1最快,越大越慢
       	 if(this.speedCnt===this.speed){
           this.speedCnt = 0;
           //绘制背景
           this.canvasCtx.fillStyle=this.backgroundColor;
           this.canvasCtx.fillRect(0,0,this.canvas.width,this.canvas.height);
           //绘制文字
           this.canvasCtx.fillStyle=this.textColor;
           //遍历每一列的数字雨,然后在canvas上绘制该数字字母
           for(var i=0,len=this.rainDropPositionArray.length;i<len;i++){
             this.rainDropPositionArray[i]++;
             var randomTextIndex = Math.floor(Math.random()*this.textContent.length);
             var randomText = this.textContent[randomTextIndex];
             var textYPostion = this.rainDropPositionArray[i]*this.fontSize;
             this.canvasCtx.fillText(randomText,i*this.fontSize,textYPostion);
             //数字雨触碰canvas底部则一定概率重新回到顶部继续下落
             if(textYPostion>this.canvasHeight){
               if(Math.random()>0.9){
                 this.rainDropPositionArray[i]=0;
               }
             }
           }
         }
         window.requestAnimationFrame(this.animationUpdate)
       }
    },
    data () {
    	return {
    	    canvasCtx:null,
            canvas:null,
            columns:0,
            rainDropPositionArray:[],
            speedCnt:0
    	}
    }
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

github地址点这里