利用噪声生成混沌优雅的背景动画

1,803 阅读3分钟

预览

可以先在下面这里在线体验效果↓(请用PC浏览)

moayuisuda.github.io/moa-line/.

噪声

噪声概述

噪声在我的理解中是“连续的随机”,比如你输入noise(0.01), noise(0.02), noise(0.03)......参数很接近,那么生成的结果值虽然随机,但也同样会很接近。如果想深入了解噪声的原理,推荐这一篇 blog.csdn.net/candycat199…

想象一下连绵起伏的海面,相邻位置的高度是不是就符合这种“连续且随机”呢?

直观印象

这次用到的是三维噪声simplex3,你可以将它想象成一个装着-1到1区间内连续随机数的大立方体,我们输入一个立方体内的坐标(x, y, z),就可以取出一个随机值,我用three.js生成了一个立方体来直观展示噪声的特性↓

  const span = 100; // 正方体长度
  // 要想得到连续的随机值,传入的参数必须也相差很小,一般要在0.1这种数量级以下。
  const scale = 100; 
  /* scale = 100代表一个three.js的单位长度映射为0.01(1/100)个噪声立方体单位长度。也就是说
  我们在three.js中生成的正方体是100*100*100,但是仅仅映射了噪声立方体内1*1*1的情况。*/
  
  
  for (let z = 0; z < span; z++) {
    for (let y = 0; y < span; y++) {
      for (let x = 0; x < span; x++) {
        let point = new THREE.Vector3(x, y, z); // 每一个point都是组成立方体的一个点
        // 比如当x,y,z为(4,5,5)时映射的噪声立方体坐标为(0.04, 0.05, 0.05)
        // noise.simplex3()的范围-1到1,seed范围就是0到1
        let seed = 0.5 * (noise.simplex3(x / scale, y / scale, z / scale) + 1);
        
        // *color会直观反映seed,这里的三个参数范围就是0-1(1就相当于255),颜色越黑,值越小
        let color = new THREE.Color(seed, seed, seed);
        Geometry.vertices.push(point);
        Geometry.colors.push(color);
      }
    }
  }

改变scale为1000↓

let seed = 0.5 * (noise.simplex3(x / scale, y / scale, z / scale) + 1);
let color = new THREE.Color(seed, seed, seed); // 颜色越黑,值越小

可以看到颜色变化已经不明显了,因为scale变大了10倍,上面这两步中相邻x, y, z之间x / scale, y / scale, z / scale相差也变小了10倍,导致noise.simplex3(x / scale, y / scale, z / scale)相差也很小,color也就自然相差很小了。

开始正题

先来画圆吧

<!DOCTYPE html>
<html>
  <head>
    <style>
      body {
        margin: 0;
      }

      .main {
        background: #000;
        height: 100vh;
      }
    </style>
  </head>

  <body>
    <div class="main"></div>
  </body>
  <script>
    const wave = function({
      dom, // 挂载在哪个dom上
      span = 50, // 单个元素的大小
      zIndex = -999 // canvas的z-index
    }) {
      // 将父dom变为层叠上下文
      dom.style.position = "relative";
      dom.style.zIndex = 0;
      dom.style.overflow = "hidden";

      const canvas = document.createElement("canvas");
      const context = canvas.getContext("2d");

      // 因为是个“背景”,所以需要将z-index设置得很小,如果父元素不是层叠上下文,canvas将会直接被父元素遮盖住。
      canvas.style.position = "absolute";
      canvas.style.zIndex = zIndex;
      canvas.height = parseInt(getComputedStyle(dom)["height"]);
      canvas.width = parseInt(getComputedStyle(dom)["width"]);

      dom.appendChild(canvas);

      let r = span / 2; // 单个元素的半径

      // 单个定位点
      function Point({ cx, cy }) {
        this.cx = cx;
        this.cy = cy;
      }

      // 定位点的draw方法,用于生成形状,现在我们简单的只用arc来画圆
      Point.prototype.draw = function() {
        context.beginPath();
        context.strokeStyle = "#ffffff";
        context.arc(this.cx, this.cy, r, 0, 2 * Math.PI);
        context.stroke();
      };

      // 初始化所有定位点,放在points数组
      let points = [];
      function initPoints() {
        for (let y = 0; y < canvas.height; y += span) {
          for (let x = 0; x < canvas.width; x += span) {
            // 这里可能造成绘制的条形超过canvas边界,x < canvas.height即使在canvas的倒数第一行也成立,也会继续向下
            points.push(
              new Point({
                cx: x + r,
                cy: y + r
              })
            );
          }
        }
      }

      // 每一次requestAnimationFrame都调用的方法,重绘画布
      function draw() {
        context.clearRect(0, 0, canvas.width, canvas.height); // 清空画布
        
        // 调用每一个point的draw方法
        for (let i of points) {
          i.draw();
        }
      }

      let id; // 动画id
      function animate() {
        draw();

        id = requestAnimationFrame(animate);
      }

      initPoints();
      animate();

      // 返回canvas与stop方法用于停止动画
      return {
        canvas,
        stop() {
          cancelAnimationFrame(id);
        }
      };
    };

    // 运行
    wave({
      dom: document.querySelector(".main")
    });
  </script>
</html>

运行后你应该会看到下面的样子↓

引入噪声

首先我们需要引入噪声库,请自行复制然后script引入 raw.githubusercontent.com/josephg/noi…

对于每个point,我们需要用它的cx与cy属性来映射到噪声立方体中的坐标,但是一个是平面一个是三维怎么映射?用三维切一个面不就是二维了,我们把noise.simplex3(x, y, z)中的z直接设置为0noise.simplex3(x, y, 0)就变成了一个二维噪声。

先在函数的参数中多加一个scale,也就是前面说的与噪声立方体的映射比

const wave = function({
      dom, // 挂载在哪个dom上
      span = 50, // 单个元素的大小
      scale = 1000, // ----------------- 前面说的与噪声立方体的映射比
      zIndex = -999 // canvas的z-index
    })

改造上面的Point.prototype.draw

  Point.prototype.draw = function() {
    context.beginPath();
    let seed = 0.5 * (noise.simplex3(this.cx / scale, this.cy / scale, 0) + 1); // seed范围0-1
    context.strokeStyle = `rgba(255, 255, 255, ${seed})`; // 以seed作为alpha值
    context.arc(this.cx, this.cy, r, 0, 2 * Math.PI);
    context.stroke();
  }

重新运行你应该会看到下面这样的东西↓

圆圈的透明度变得“随机且连续”了。

让它动起来

在上面我们虽然写了animate()函数,但是因为图形的属性并不会有改变,所以看起来是静止的。

还记得我们之前将噪声函数的第三个参数置零了吗↓

noise.simplex3(this.cx / scale, this.cy / scale, 0)

而现在,我们将它重新启用,用什么来作为它的值呢?时间

将上面的let seed = 0.5 * (noise.simplex3(this.cx / scale, this.cy / scale, 0) + 1);变为let seed = 0.5 * (noise.simplex3(this.cx / scale, this.cy / scale, time) + 1);

我们需要定义一个全局的变量time,与一个控制时间快慢的参数speed,并在animate中time += speed;

const wave = function({
      dom, // 挂载在哪个dom上
      span = 50, // 单个元素的大小
      scale = 1000, // 前面说与噪声立方体的映射比
      speed = 0.01, // -----------------speed参数
      zIndex = -999 // canvas的z-index
    })
  let id; // 动画id
  let time = 0; // -------------初始化time为0
  function animate() {
    draw();
    time += speed;
    id = requestAnimationFrame(animate);
  }

然后你就会发现你的图动起来了↓

到这里所有的基本代码就已经完成了,接下来就是对Point.prototype.draw的各种改造了,我们来实现一个线条动画

  Point.prototype.draw = function() {
    context.beginPath();
    let s = noise.simplex3(this.cx / scale, this.cy / scale, time);
    let sa = Math.abs(s); // 噪声的绝对值,用来生成alpha值与lineWidth
    context.strokeStyle = `rgba(255, 255, 255, ${sa})`;
    context.lineWidth = Math.abs(s) * 8;
    let a = Math.PI * 2 * s; // 角度
    let ap = Math.PI + a; // 角度 + 180度
    
    context.moveTo(this.cx + Math.cos(a) * r, this.cy);
    context.lineTo(this.cx + Math.cos(ap) * r, this.cy + Math.sin(ap) * r);

    context.stroke();
  };

context.moveTo(this.cx + Math.cos(a) * r, this.cy);这一句可能会有人比较疑惑,为什么不用context.moveTo(this.cx + Math.cos(a) * r, this.cy + Math.sin(a));,各位可以将前者换成后者后试验一下,效果会没趣不少,因为那就相当于只是从圆的一头连接到它的另一头了。

通过调整speedscalespan等参数就能获得很多有趣的效果↓

接下来发挥你的想象力去改造Point.prototype.draw

此示例的完整代码,记得更改噪声库的src↓

<!DOCTYPE html>
<html>
  <head>
    <style>
      body {
        margin: 0;
      }

      .main {
        background: #000;
        height: 100vh;
      }
    </style>
  </head>

  <body>
    <div class="main"></div>
  </body>
  <script src="./perlin.js"></script>
  <script>
    const wave = function({
      dom,
      span = 50,
      scale = 1000,
      speed = 0.01,
      zIndex = -999
    }) {
      dom.style.position = "relative";
      dom.style.zIndex = 0;
      dom.style.overflow = "hidden";

      const canvas = document.createElement("canvas");
      const context = canvas.getContext("2d");

      canvas.style.position = "absolute";
      canvas.style.zIndex = zIndex;
      canvas.height = parseInt(getComputedStyle(dom)["height"]);
      canvas.width = parseInt(getComputedStyle(dom)["width"]);

      dom.appendChild(canvas);

      let r = span / 2;

      function Point({ cx, cy }) {
        this.cx = cx;
        this.cy = cy;
      }

      Point.prototype.draw = function() {
        context.beginPath();
        let s = noise.simplex3(this.cx / scale, this.cy / scale, time);
        let sa = Math.abs(s);
        context.strokeStyle = `rgba(255, 255, 255, ${sa})`;
        context.lineWidth = Math.abs(s) * 8;
        let a = Math.PI * 2 * s;
        let ap = Math.PI + a;

        context.moveTo(this.cx, this.cx);
        context.lineTo(this.cx + Math.cos(ap) * r, this.cy + Math.sin(ap) * r);

        context.stroke();
      };

      let points = [];
      function initPoints() {
        for (let y = 0; y < canvas.height; y += span) {
          for (let x = 0; x < canvas.width; x += span) {
            points.push(
              new Point({
                cx: x + r,
                cy: y + r
              })
            );
          }
        }
      }

      function draw() {
        context.clearRect(0, 0, canvas.width, canvas.height);

        for (let i of points) {
          i.draw();
        }
      }

      let id;
      let time = 0;
      function animate() {
        draw();
        time += speed;
        id = requestAnimationFrame(animate);
      }

      initPoints();
      animate();

      return {
        canvas,
        stop() {
          cancelAnimationFrame(id);
        }
      };
    };

    wave({
      dom: document.querySelector(".main"),
      scale: 5000,
      speed: 0.002,
      span: 100
    });
  </script>
</html>

颜色渐变

接下来我们要实现点击一下dom颜色就变换一下,并且能自定义颜色转换的颜色数组,颜色转换的时间。首先来看一下新的参数:

 const wave = function({
      dom,
      span = 50,
      scale = 1000,
      speed = 0.01,
      zIndex = -999,
      duration = 2000, // -------------- 颜色转换时间
      colors = [
        [212, 192, 255],
        [192, 255, 244],
        [255, 192, 203]
      ] // ------------- 颜色数组
    })

渐变

首先我们要实现渐变,并且还需要能自定义渐变的时间,写一个通用的线性渐变函数↓

const copy = target => {
  let re = [];
  for (let i in target) {
    re[i] = target[i];
  }

  return re;
};

const move = (
  origin,
  target,
  duration,
  after, // 每次数值更改后的回调
  fn = pro => {
    return Math.sqrt(pro, 2);
  } // 缓动函数
) => {
  if (fn(1) != 1) throw '[moaline-move] The fn must satisfy "fn (1) == 1"'; // 当参数为1时,对应的值也一定要为1

  let st, sp;
  st = performance.now(); // 保存开始时间
  sp = copy(origin); // 保存源属性
  let d = {}; // 源与目标之间每一项的距离
  for (let i in origin) {
    d[i] = target[i] - origin[i];
  }

  let frame = t => {
    let pro = (t - st) / duration; // 当前进程
    if (pro >= 1) {
      return;
    }

    for (let i in origin) {
      origin[i] = sp[i] + fn(pro) * d[i]; // fn(pro)得出当前时间对应的缓动函数的距离百分比,再乘以总距离
    }

    if(after) after(copy(origin), pro);
    requestAnimationFrame(frame);
  };

  frame(st);
};

绑定事件

接着我们在dom上绑定一个事件,让它点击一下就能进行渐变。

  let ci = 0;
  const color = [...colors[ci]];
  function clickE() {
    let target = [...colors[++ci % colors.length]];
    move(color, target, duration);
  }
  dom.addEventListener("click", clickE);

数值绑定

既然color能渐变了,就把它绑定到Point.prototype.draw中颜色相关的地方吧。

  Point.prototype.draw = function() {
    context.beginPath();
    let s = noise.simplex3(this.cx / scale, this.cy / scale, time);
    let sa = Math.abs(s);
    context.strokeStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${sa})`;
    context.lineWidth = Math.abs(s) * 8;
    let a = Math.PI * 2 * s;
    let ap = Math.PI + a;

    context.moveTo(this.cx + Math.sin(a) * r, this.cy + Math.cos(a) * r);
    context.lineTo(this.cx + Math.cos(ap) * r, this.cy + Math.sin(ap) * r);

    context.stroke();
  };

接着把body的背景色去掉

  .main {
    /* background: #000; */
    height: 100vh;
  }

完整代码如下(记得自行改变noise库的src)

<!DOCTYPE html>
<html>
  <head>
    <style>
      body {
        margin: 0;
      }

      .main {
        /* background: #000; */
        height: 100vh;
      }
    </style>
  </head>

  <body>
    <div class="wrapper"></div>
    <div class="main"></div>
  </body>
  <script src="./perlin.js"></script>
  <script>
    const wave = function({
      dom,
      span = 50,
      scale = 1000,
      speed = 0.01,
      zIndex = -999,
      duration = 2000,
      colors = [
        [212, 192, 255],
        [192, 255, 244],
        [255, 192, 203]
      ]
    }) {
      dom.style.position = "relative";
      dom.style.zIndex = 0;
      dom.style.overflow = "hidden";

      const canvas = document.createElement("canvas");
      const context = canvas.getContext("2d");

      canvas.style.position = "absolute";
      canvas.style.zIndex = zIndex;
      canvas.height = parseInt(getComputedStyle(dom)["height"]);
      canvas.width = parseInt(getComputedStyle(dom)["width"]);

      dom.appendChild(canvas);

      let r = span / 2;

      function Point({ cx, cy }) {
        this.cx = cx;
        this.cy = cy;
      }

      Point.prototype.draw = function() {
        context.beginPath();
        let s = noise.simplex3(this.cx / scale, this.cy / scale, time);
        let sa = Math.abs(s);
        context.strokeStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${sa})`;
        context.lineWidth = Math.abs(s) * 8;
        let a = Math.PI * 2 * s;
        let ap = Math.PI + a;

        context.moveTo(this.cx + Math.sin(a) * r, this.cy + Math.cos(a) * r);
        context.lineTo(this.cx + Math.cos(ap) * r, this.cy + Math.sin(ap) * r);

        context.stroke();
      };

      let points = [];
      function initPoints() {
        for (let y = 0; y < canvas.height; y += span) {
          for (let x = 0; x < canvas.width; x += span) {
            points.push(
              new Point({
                cx: x + r,
                cy: y + r
              })
            );
          }
        }
      }

      function draw() {
        context.clearRect(0, 0, canvas.width, canvas.height);

        for (let i of points) {
          i.draw();
        }
      }

      let id;
      let time = 0;
      function animate() {
        draw();
        time += speed;
        id = requestAnimationFrame(animate);
      }

      let ci = 0;
      const color = [...colors[ci]];
      function clickE() {
        let target = [...colors[++ci % colors.length]];
        move(color, target, duration);
      }
      dom.addEventListener("click", clickE);

    const copy = target => {
      let re = [];
      for (let i in target) {
        re[i] = target[i];
      }
    
      return re;
    };
    
    const move = (
      origin,
      target,
      duration,
      after, // 每次数值更改后的回调
      fn = pro => {
        return Math.sqrt(pro, 2);
      } // 缓动函数
    ) => {
      if (fn(1) != 1) throw '[moaline-move] The fn must satisfy "fn (1) == 1"'; // 当参数为1时,对应的值也一定要为1
    
      let st, sp;
      st = performance.now(); // 保存开始时间
      sp = copy(origin); // 保存源属性
      let d = {}; // 源与目标之间每一项的距离
      for (let i in origin) {
        d[i] = target[i] - origin[i];
      }
    
      let frame = t => {
        let pro = (t - st) / duration; // 当前进程
        if (pro >= 1) {
          return;
        }
    
        for (let i in origin) {
          origin[i] = sp[i] + fn(pro) * d[i]; // fn(pro)得出当前时间对应的缓动函数的距离百分比,再乘以总距离
        }
    
        if(after) after(copy(origin), pro);
        requestAnimationFrame(frame);
      };
    
      frame(st);
    };


      initPoints();
      animate();

      return {
        canvas,
        stop() {
          cancelAnimationFrame(id);
          dom.removeEventListener('click', clickE);
        }
      };
    };

    wave({
      dom: document.querySelector(".main"),
      scale: 1000,
      speed: 0.0006,
      span: 200,
      duration: 1000
    });
  </script>
</html>

最后

发挥想象,将噪声的生成值灵活运用,或者添加一些交互,一定会有更加厉害的效果。但是记得不需要时一定要调用返回的stop()方法停止动画。

还可多个canvas叠加。