Loading...

量子力学 Canvas 动画

2025-03-16 · 14min

前几天浏览 Anthony Fu 的博客中,在 Async, Sync, in Between 这篇文章中,发现了这么一个很有意思的量子力学动画。

image-20250316123358079

默认状态下,粒子红蓝两色粒子会在一个圆的范围内进行无规则运动。仔细观察的话会发现,这些粒子除了有圆的边界约束,还有进行碰撞检测的,但动画效果非常流畅。

当鼠标从画布左边进入,蓝色粒子会在画布中心汇聚成 sync ,红色粒子会一边汇聚消失;

image-20250316123840838

当鼠标从画布右边进入,红色粒子会在画布中心汇聚成 async ,蓝色粒子会一边汇聚消失;

image-20250316123953314

观察完动画效果后,于是就开始思考是如何实现的。

构建粒子类 Particle

因为在前司的时候会经常去隔壁部门帮忙看看一些交互动画有没有办法实现,粒子方面的还是蛮多的,所以我这里打算构建一个可以扩展的粒子类,不仅仅是用来构建一个粒子动画,而是可以让其他粒子动画也基于这个类通过扩展的方式去实现。

基本类型

首先确定粒子的基本属性:

  • 当前粒子位置
  • 粒子大小
  • 粒子颜色
  • 粒子运动方向
  • 粒子运动速度

粒子类基本方法:

  • 粒子运动
  • 粒子绘制

那么基本粒子类可以确定如下:

import type { ColorInstance } from "color";
import Color from "color";

export interface ParticleConstructOption {
  /**
   * @description id
   */
  id: string;
  /**
   * @description x坐标
   */
  x: number;
  /**
   * @description y坐标
   */
  y: number;
  /**
   * @description 粒子半径
   */
  r: number;
  /**
   * @description 颜色
   */
  color: string;
  /**
   * @description X方向速度
   */
  speedX: number;
  /**
   * @description Y方向速度
   */
  speedY: number;
}
/**
 * @description 粒子类
 */
export class Particle {
  /**
   * @description id
   */
  id: string;
  /**
   * @description x坐标
   */
  x: number;
  /**
   * @description y坐标
   */
  y: number;
  /**
   * @description 粒子半径
   */
  r: number;
  /**
   * @description 颜色
   */
  color: ColorInstance;
  /**
   * @description X方向速度
   */
  speedX: number;
  /**
   * @description Y方向速度
   */
  speedY: number;

  constructor({ x, y, r, color, id, speedX, speedY }: ParticleConstructOption) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.r = r;
    this.color = Color(color);
    this.speedX = speedX;
    this.speedY = speedY;
  }

  public draw(ctx: CanvasRenderingContext2D) {
    ctx.restore();
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
    ctx.fillStyle = this.color.hexa();
    ctx.fill();
    ctx.closePath();
  }

  /**
   * @description 移动方法
   */
  public move() {
    this.x += this.speedX;
    this.y += this.speedY;
  }
}

这里我引入 color 库来对颜色进行更加方便的控制,讲运动方向与运动速度通过 speedX 和 speedY 来进行管理。

扩展属性

基本的粒子类已经定义好了,现在需要对粒子类进行改造,允许我根据需求自行扩展属性。

首先想到的是,通过一个 extraProperty 属性,接收扩展属性,这就需要将粒子类改成一个泛型类,同时这个泛型参数是可选:

export interface ParticleConstructOption<T extends object = {}> {
  /**
   * @description id
   */
  id: string;
  /**
   * @description x坐标
   */
  x: number;
  /**
   * @description y坐标
   */
  y: number;
  /**
   * @description 粒子半径
   */
  r: number;
  /**
   * @description 颜色
   */
  color: string;
  /**
   * @description 额外属性
   */
  extraProperty?: T;
  /**
   * @description X方向速度
   */
  speedX: number;
  /**
   * @description Y方向速度
   */
  speedY: number;
}

/**
 * @description 粒子类
 */
export class Particle<T extends object = {}> {
  /**
   * @description id
   */
  id: string;
  /**
   * @description x坐标
   */
  x: number;
  /**
   * @description y坐标
   */
  y: number;
  /**
   * @description 粒子半径
   */
  r: number;
  /**
   * @description 颜色
   */
  color: ColorInstance;
  /**
   * @description 额外属性
   */
  extraProperty: T;
  /**
   * @description X方向速度
   */
  speedX: number;
  /**
   * @description Y方向速度
   */
  speedY: number;

  constructor({ x, y, r, color, extraProperty, id, speedX, speedY }: ParticleConstructOption<T>) {
    this.id = id;
    this.x = x;
    this.y = y;
    this.r = r;
    this.color = Color(color);
    this.extraProperty = extraProperty ?? ({} as T);
    this.speedX = speedX;
    this.speedY = speedY;
  }

  public draw(ctx: CanvasRenderingContext2D) {
    ctx.restore();
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
    ctx.fillStyle = this.color.hexa();
    ctx.fill();
    ctx.closePath();
  }

  /**
   * @description 移动方法
   */
  public move() {
    this.x += this.speedX;
    this.y += this.speedY;
  }
}

这样一个可以扩展属性的粒子类就完成了。

以这次量子力学动画为例,除了上述的基本属性外,还需要粒子类记录以下属性:

  • 汇集坐标
  • 约束中心坐标
  • 约束半径
  • 绘制文本
  • 运动时透明度
  • 透明度过渡量

本次我们构建的粒子类如下:

/**
 * @description 额外属性
 */
interface ExtraProperty {
  /**
   * @description 目标点X坐标
   */
  targetX: number;
  /**
   * @description 目标点Y坐标
   */
  targetY: number;
  /**
   * @description 约束中心X坐标
   */
  centerX: number;
  /**
   * @description 约束中心Y坐标
   */
  centerY: number;
  /**
   * @description 约束半径
   */
  maxDistance: number;
  /**
   * @description 绘制文本
   */
  text: string;
  /**
   * @description 原始透明度
   */
  opacity: number;
  /**
   * @description 透明度过渡变量
   */
  opacityOffset: number;
}

type ParticleType = Particle<ExtraProperty>;

生成粒子

粒子最终是要组成文字的,所以我们这边通过 canvas 离屏渲染先绘制出粒子汇集成文字的内容,然后通过 getImageData 获取画布中的非透明的像素点,来生成我们所需的粒子类:

/**
 * @description 约束半径
 */
const maxDistance = 100;

/**
 *
 * @description 获取粒子
 * @param text 绘制文字
 * @param color 颜色
 */
const handleGetParticles = (text: string, color: string) => {
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  if (ctx) {
    canvas.width = width;
    canvas.height = height;
    ctx.clearRect(0, 0, width, height);
    ctx.fillStyle = color;
    ctx.font = "48px Helvetica";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(text, width / 2, height / 2);

    const data = ctx.getImageData(0, 0, width, height).data;
    const particles: ParticleType[] = [];

    for (let targetY = 0; targetY < height; targetY++) {
      for (let targetX = 0; targetX < width; targetX++) {
        const opacity =
          Number(((data[(targetY * width + targetX) * 4 + 3] * 100) / 255).toFixed(0)) / 100;
        if (opacity > 0) {
          const { x, y } = getRandomPointInCircle();
          const r = data[(targetY * width + targetX) * 4 + 0];
          const g = data[(targetY * width + targetX) * 4 + 1];
          const b = data[(targetY * width + targetX) * 4 + 2];

          const distX = x - width / 2;
          const distY = y - height / 2;
          const dist = Math.sqrt(distX * distX + distY * distY);
          // 运动角度
          const toCenterAngle = Math.atan2(height / 2 - y, width / 2 - x);
          // 随机偏移角度
          const angleOffset = (Math.random() - 0.5) * Math.PI * 1.8;
          const angel = toCenterAngle + angleOffset + Math.PI;

          // 速度与约束中心距离成反比
          const speed = 1 * (1 - (maxDistance - dist) / maxDistance) + 0.1;

          particles.push(
            new Particle<ExtraProperty>({
              id: `${targetX}-${targetY}`,
              x,
              y,
              r: 1,
              speedX: speed * Math.cos(angel),
              speedY: speed * Math.sin(angel),
              color: `rgba(${r}, ${g}, ${b}, ${opacity})`,
              extraProperty: {
                targetX,
                targetY,
                centerX: width / 2,
                centerY: height / 2,
                maxDistance,
                text,
                opacity,
                opacityOffset: opacity / 20,
              },
            })
          );
        }
      }
    }

    return particles;
  }
};

// 生成圆内随机坐标(均匀分布)
const getRandomPointInCircle = () => {
  const angle = Math.random() * Math.PI * 2;
  const radius = maxDistance * Math.sqrt(Math.random());

  return {
    x: width / 2 + radius * Math.cos(angle),
    y: height / 2 + radius * Math.sin(angle),
  };
};

粒子运动

粒子运动分2种状态:

  • 默认状态下:粒子在约束范围内进行运动。
  • 汇集状态:粒子向目标点汇集,根据 text 字段控制粒子透明度变化。
/**
 * @description 单个粒子移动
 * @param particle 粒子
 */
const handleMove = (particle: ParticleType) => {
  const {
    x,
    y,
    speedX,
    speedY,
    color,
    extraProperty: { targetX, targetY, text, opacity, opacityOffset },
  } = particle;

  const alpha = color.alpha();
  if (isHover.value) {
    // 向目标点移动
    const moveX = (targetX - x) * 0.05;
    const moveY = (targetY - y) * 0.05;

    particle.x += moveX;
    particle.y += moveY;
    // 改变透明度
    if (activeText.value !== text && alpha > 0) {
      particle.color = color.alpha(Math.max(alpha - opacityOffset, 0));
    }
  } else {
    // 自然运动
    const dx = particle.x - width / 2;
    const dy = particle.y - height / 2;
    const sqDistance = dx * dx + dy * dy;

    if (sqDistance > maxDistance * maxDistance) {
      const distance = Math.sqrt(sqDistance);
      const penetration = distance - maxDistance;

      // 计算法线方向(无需归一化)
      const nx = dx / distance;
      const ny = dy / distance;

      // 精确位置修正
      particle.x -= nx * penetration;
      particle.y -= ny * penetration;

      // 速度反射(完全弹性)
      const dot = speedX * nx + speedY * ny;

      // 仅当向外的速度分量才需要反弹
      if (dot > 0) {
        particle.speedX -= 2 * dot * nx;
        particle.speedY -= 2 * dot * ny;
      }
    }

    particle.x += particle.speedX;
    particle.y += particle.speedY;

    // 颜色恢复
    if (alpha !== opacity) {
      particle.color = color.alpha(Math.min(alpha + opacityOffset, opacity));
    }
  }
};

此时效果如下:

particles_no_collisions

碰撞检测

现在基本的粒子动画效果已经有了,但是并没有对粒子进行碰撞检测,粒子仅仅是根据初始的随机位置和随机运动方向,不断在约束范围内进行弹射运动。

当前粒子数量有3087个,常规的碰撞检测需要进行3087 * 3087次,即便限制碰撞检测频率,依然会导致动画卡顿不流畅的。

尝试了网格划分的方法进行碰撞检测,但即便我把检测频率限制在每60帧1次,动画效果任然会出现卡顿,我最终选择了 quadtree-ts 用四叉树方案进行碰撞检测。

改造粒子类

quadtree-ts 提供了 Circle 、Line、Rectangle 等基本类来进行碰撞检测,也允许用户添加自定义数据类型,或者用户修改自己的类来满足碰撞检测的条件。

由于前面我已经自己写了粒子类了,我选择用后者的方式修改自己的粒子类,只需要在类中添加 qtIndex 方法即可。

/**
 *
 * @description 碰撞检测
 * @param other 其他粒子
 */
public checkCollision(other: Particle<T>): boolean {
  const dx = this.x - other.x;
  const dy = this.y - other.y;
  const distance = Math.sqrt(dx * dx + dy * dy);
  return distance < this.r + other.r;
}

public qtIndex(node: NodeGeometry) {
  return Circle.prototype.qtIndex.call(
    {
      x: this.x,
      y: this.y,
      r: this.r,
    },
    node
  );
}

四叉树碰撞检测 & 碰撞处理

准备工作做好后,就可以进行碰撞检测了

const tree = new Quadtree({
    width,
    height,
    maxObjects: 10,
});

/**
 * @description 碰撞处理逻辑
 */
const resolveCollision = (a: ParticleType, b: ParticleType) => {
  // 简化的弹性碰撞处理
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  const minDistance = a.r + b.r;

  if (dx * dx + dy * dy < minDistance * minDistance) {
    const distance = Math.sqrt(dx * dx + dy * dy);
    const overlap = a.r + b.r - distance;
    const angle = Math.atan2(dy, dx);
    const moveX = (overlap / 2) * Math.cos(angle);
    const moveY = (overlap / 2) * Math.sin(angle);

    a.speedX = -moveX;
    a.speedY = -moveY;
    b.speedX = moveX;
    b.speedY = moveY;
  }
}

/**
 * @description 四叉树碰撞检测
 */
const detectCollisions = () => {
  tree.clear();
  particles.forEach((p) => tree.insert(p));

  particles.forEach((p) => {
    const candidates = tree.retrieve(p);
    candidates.forEach((other) => {
      if (p !== other && p.checkCollision(other)) {
        resolveCollision(p, other);
      }
    });
  });
}

/**
 * @description 动画帧处理
 */
const handleAnimation = () => {
  if (canvas.value) {
    const ctx = canvas.value.getContext("2d");
    if (ctx) {
      ctx.clearRect(0, 0, width, height);
      // 碰撞检测20帧检测1次
      if (!isHover.value && frameCount++ % 20 === 0) {
        detectCollisions();
      }
      particles.forEach((particle) => {
        particle.move();
        particle.draw(ctx);
      });
    }

    animationId = requestAnimationFrame(handleAnimation);
  }
};

实测下来3087个粒子,碰撞频率即便在每3帧检测一次情况下也不会卡顿。但由于检测频率太高,会有可能导致初始化时成堆粒子在反复碰撞困在一个区域中,没有形成混乱无规则的效果,所以我这里把碰撞频率缩小到了每20帧检测1次。

最终效果