Canvas星空类特效

594 阅读3分钟

star-sky.gif

思路

  1. 绘制单个星星
  2. 在画布批量随机绘制星星
  3. 添加星星移动动画
  4. 页面resize处理

Vanilla JavaScript实现

  1. 初始化一个工程
pnpm create vite@latest

# 输入工程名后,类型选择Vanilla

cd <工程目录> pnpm install pnpm dev # 运行本地服务
body {
  background-color: black;
  overflow: hidden;
}
"use strict";
import './style.css';

document.querySelector('#app').innerHTML = `
  <canvas id="canvas"></canvas>
`;
// 后续代码全部在main.js下添加即可
  1. 绘制单个星星

image.png

const hue = 220; // 颜色hue值可以根据自己喜好调整
// 离屏canvas不需要写入到静态html里,所以用createElement
const offscreenCanvas = document.createElement("canvas"); 
const offscreenCtx = offscreenCanvas.getContext("2d");
offscreenCanvas.width = 100;
offscreenCanvas.height = 100;
const half = offscreenCanvas.width / 2;
const middle = half;
// 设定径向渐变的范围,从画布中心到画布边缘
const gradient = offscreenCtx.createRadialGradient(
  middle,
  middle,
  0,
  middle,
  middle,
  half
);
// 添加多级颜色过渡,可以根据自己喜好调整
gradient.addColorStop(0.01, "#fff");
gradient.addColorStop(0.1, `hsl(${hue}, 61%, 33%)`);
gradient.addColorStop(0.5, `hsl(${hue}, 64%, 6%)`);
gradient.addColorStop(1, "transparent");

// 基于渐变填充色,在画布中心为原点绘制一个圆形
offscreenCtx.fillStyle = gradient;
offscreenCtx.beginPath();
offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
offscreenCtx.fill();

参考链接:

hsl() - CSS:层叠样式表 | MDN

  1. 在画布批量绘制星星

其实要绘制星星,我们只需要在画布上基于离屏画布来在指定位置将离屏画布渲染成图片即可,但是批量绘制以及后续的动画需要我们能记录每颗星星的位置、状态和行驶轨迹,所以可以考虑创建一个星星的类。

// 声明存放星星数据的数组,以及最大星星数量
const stars = [];
const maxStars = 1000;

// 用于提供随机值,不用每次都Math.random()
const random = (min, max) => {
  if (!max) {
    max = min;
    min = 0;
  }
  if (min > max) {
    [min, max] = [max, min];
  }
  return Math.floor(Math.random() * (max - min + 1)) + min;
};
// 用于计算当前以画布为中心的环绕半径
const maxOrbit = (_w, _h) => {
  const max = Math.max(_w, _h);
  const diameter = Math.round(Math.sqrt(max * max + max * max));
  return diameter / 2;
};

class Star {
  constructor(_ctx, _w, _h) {
    this.ctx = _ctx;
    // 最大轨道半径
    this.maxOrbitRadius = maxOrbit(_w, _h);
    // 轨道半径
    this.orbitRadius = random(this.maxOrbitRadius);
    // 星星大小(半径)
    this.radius = random(60, this.orbitRadius) / 12;
    // 环绕轨道中心,即画布中心点
    this.orbitX = _w / 2;
    this.orbitY = _h / 2;
    // 随机时间,用于动画
    this.elapsedTime = random(0, maxStars);
    // 移动速度
    this.speed = random(this.orbitRadius) / 500000;
    // 透明度
    this.alpha = random(2, 10) / 10;
  }
  // 星星的绘制方法
  draw() {
    // 计算星星坐标[x, y],使用sin和cos函数使星星围绕轨道中心做圆周运动
    const x = Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX;
    const y = Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY;

    // 基于随机数调整星星的透明度
    const spark = Math.random();
    if (spark < 0.5 && this.alpha > 0) {
      this.alpha -= 0.05;
    } else if (spark > 0.5 && this.alpha < 1) {
      this.alpha += 0.05;
    }

    // 调整全局绘制透明度,使后续绘制都基于这个透明度绘制,也就是绘制当前星星
    // 因为动画里会遍历每一个星星进行绘制,所以透明度会来回改变
    this.ctx.globalAlpha = this.alpha;
    // 在星星所在的位置基于离屏canvas绘制一张星星的图片
    this.ctx.drawImage(offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
    // 时间基于星星的移动速度递增,为下一帧绘制做准备
    this.elapsedTime += this.speed;
  }
}

获取当前画布,批量添加星星

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let w = canvas.width = window.innerWidth;
let h = canvas.height = window.innerHeight;

for (let i = 0; i < maxStars; i++) {
  stars.push(new Star(ctx, w, h));
}
  1. 添加星星的移动动画
function animation() {
  // 绘制一个矩形作为背景覆盖整个画布,'source-over'是用绘制的新图案覆盖原有图像
  ctx.globalCompositeOperation = 'source-over';
  ctx.globalAlpha = 0.8;
  ctx.fillStyle = `hsla(${hue} , 64%, 6%, 1)`;
  ctx.fillRect(0, 0, w, h);
  // 绘制星星,'lighter'可以使动画过程中重叠的星星有叠加效果
  ctx.globalCompositeOperation = 'lighter';
  stars.forEach(star => {
    star.draw();
  });
  window.requestAnimationFrame(animation);
}
// 调用动画
animation();

这样星星就动起来了。

  1. 页面resize处理

其实只需要在resize事件触发时重新设定画布的大小即可

window.addEventListener('resize', () => {
  w = canvas.width = window.innerWidth;
  h = canvas.height = window.innerHeight;
});

但是有一个问题,就是星星的运行轨迹并没有按比例变化,所以需要添加两处变化

// 在Star类里添加一个update方法
class Star {
  constructor(_ctx, _w, _h) {//...//}
  //添加部分
  update(_w, _h) {
    // 计算当前的最大轨道半径和类之前保存的最大轨道半径的比例
    const ratio = maxOrbit(_w, _h) / this.maxOrbitRadius;
    // 因为每帧动画都会调用这个方法,但比例没变化时不需要按比例改变移动轨道,所以加个判断
    if (ratio !== 1) {
      // 重新计算最大轨道半径
      this.maxOrbitRadius = maxOrbit(_w, _h);
      // 按比例缩放轨道半径和星星的半径
      this.orbitRadius = this.orbitRadius * ratio;
      this.radius = this.radius * ratio;
      // 重新设置轨道中心点
      this.orbitX = _w / 2;
      this.orbitY = _h / 2;
    }
  }

  draw() {//...//}
}

// 在animation函数里调用update
function animation() {
  // ...
  stars.forEach(star => {
    star.update(w, h);
    star.draw();
  });
  // ...
}

React实现

react实现主要需要注意resize事件的处理,怎样避免重绘时对星星数据初始化,当前思路是使用多个useEffect

import React, { useEffect, useRef, useState } from 'react';

const HUE = 217;
const MAX_STARS = 1000;

const random = (min: number, max?: number) => {
  if (!max) {
    max = min;
    min = 0;
  }
  if (min > max) {
    [min, max] = [max, min];
  }
  return Math.floor(Math.random() * (max - min + 1)) + min;
};

const maxOrbit = (_w: number, _h: number) => {
  const max = Math.max(_w, _h);
  const diameter = Math.round(Math.sqrt(max * max + max * max));
  return diameter / 2;
};

// 离屏canvas只需要执行一次,但是直接在函数外部使用document.createElement会出问题
const getOffscreenCanvas = () => {
  const offscreenCanvas = document.createElement('canvas');
  const offscreenCtx = offscreenCanvas.getContext('2d')!;
  offscreenCanvas.width = 100;
  offscreenCanvas.height = 100;
  const half = offscreenCanvas.width / 2;
  const middle = half;
  const gradient = offscreenCtx.createRadialGradient(middle, middle, 0, middle, middle, half);
  gradient.addColorStop(0.01, '#fff');
  gradient.addColorStop(0.1, `hsl(${HUE}, 61%, 33%)`);
  gradient.addColorStop(0.5, `hsl(${HUE}, 64%, 6%)`);
  gradient.addColorStop(1, 'transparent');

  offscreenCtx.fillStyle = gradient;
  offscreenCtx.beginPath();
  offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
  offscreenCtx.fill();
  return offscreenCanvas;
};

class OffscreenCanvas {
  static instance: HTMLCanvasElement = getOffscreenCanvas();
}

class Star {
  orbitRadius!: number;
  maxOrbitRadius!: number;
  radius!: number;
  orbitX!: number;
  orbitY!: number;
  elapsedTime!: number;
  speed!: number;
  alpha!: number;
  ratio = 1;
  offscreenCanvas = OffscreenCanvas.instance;
  constructor(
    private ctx: CanvasRenderingContext2D,
    private canvasSize: { w: number, h: number; },
  ) {
    this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
    this.orbitRadius = random(this.maxOrbitRadius);
    this.radius = random(60, this.orbitRadius) / 12;
    this.orbitX = this.canvasSize.w / 2;
    this.orbitY = this.canvasSize.h / 2;
    this.elapsedTime = random(0, MAX_STARS);
    this.speed = random(this.orbitRadius) / 500000;
    this.alpha = random(2, 10) / 10;
  }

  update(size: { w: number, h: number; }) {
    this.canvasSize = size;
    this.ratio = maxOrbit(this.canvasSize.w, this.canvasSize.h) / this.maxOrbitRadius;
    if (this.ratio !== 1) {
      this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
      this.orbitRadius = this.orbitRadius * this.ratio;
      this.radius = this.radius * this.ratio;
      this.orbitX = this.canvasSize.w / 2;
      this.orbitY = this.canvasSize.h / 2;
    }
  }

  draw() {
    const x = (Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX);
    const y = (Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY);
    const spark = Math.random();

    if (spark < 0.5 && this.alpha > 0) {
      this.alpha -= 0.05;
    } else if (spark > 0.5 && this.alpha < 1) {
      this.alpha += 0.05;
    }

    this.ctx.globalAlpha = this.alpha;
    this.ctx.drawImage(this.offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
    this.elapsedTime += this.speed;
  }
}

const StarField = () => {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const animationRef = useRef<number | null>(null);
  const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
  const [initiated, setInitiated] = useState(false);
  const [stars, setStars] = useState<Star[]>([]);

  // 这里会在画布准备好之后初始化星星,理论上只会执行一次
  useEffect(() => {
    if (canvasRef.current && canvasSize.w !== 0 && canvasSize.h !== 0 && !initiated) {
      const ctx = canvasRef.current!.getContext('2d')!;
      const _stars = Array.from({ length: MAX_STARS }, () => new Star(ctx, canvasSize));
      setStars(_stars);
      setInitiated(true);
    }
  }, [canvasSize.w, canvasSize.h]);
  // 这里用于处理resize事件,并重新设置画布的宽高
  useEffect(() => {
    if (canvasRef.current) {
      const resizeHandler = () => {
        const { clientWidth, clientHeight } = canvasRef.current!.parentElement!;
        setCanvasSize({ w: clientWidth, h: clientHeight });
      };
      resizeHandler();
      addEventListener('resize', resizeHandler);
      return () => {
        removeEventListener('resize', resizeHandler);
      };
    }
  }, []);
  // 这里用于渲染动画,每次画布有变化时都会触发,星星初始化完成时也会触发一次
  useEffect(() => {
    if (canvasRef.current) {
      const ctx = canvasRef.current.getContext('2d')!;
      canvasRef.current!.width = canvasSize.w;
      canvasRef.current!.height = canvasSize.h;
      const animation = () => {
        ctx.globalCompositeOperation = 'source-over';
        ctx.globalAlpha = 0.8;
        ctx.fillStyle = `hsla(${HUE} , 64%, 6%, 1)`;
        ctx.fillRect(0, 0, canvasSize.w, canvasSize.h);

        ctx.globalCompositeOperation = 'lighter';
        stars.forEach((star) => {
          if (star) {
            star.update(canvasSize);
            star.draw();
          }
        });

        animationRef.current = requestAnimationFrame(animation);
      };

      animation();
      return () => {
        cancelAnimationFrame(animationRef.current!);
      };
    }
  }, [canvasSize.w, canvasSize.h, stars]);
  return (
    <canvas ref={canvasRef}></canvas>
  );
};

export default StarField;