Skip to content

Canvas 碰撞小球

这里记录一段用原生 JS + Canvas 实现的粒子碰撞模拟:点击产生小球,小球受重力下落,碰壁反弹变色,球与球之间发生弹性碰撞。实现本身不复杂,但其中几个技术点值得单独拆开。


DPR 适配:为什么 Canvas 需要手动处理

Canvas 有两个尺寸概念:CSS 尺寸(style.width)和绘图表面尺寸(canvas.width)。CSS 尺寸控制页面布局中的显示大小,绘图表面尺寸控制实际像素分辨率。

在高 DPR 屏幕(Retina,devicePixelRatio = 23)上,如果不适配 DPR,Canvas 会显示模糊——绘图表面只有 CSS 尺寸对应的像素数,被硬件拉伸到物理像素。

js
const resizeCanvas = throttle(function () {
  const dpr = window.devicePixelRatio || 1;
  canvas.width = window.innerWidth * dpr;   // 物理像素
  canvas.height = window.innerHeight * dpr;
  ctx.scale(dpr, dpr);                       // 坐标系按 DPR 缩放
}, 200);

ctx.scale(dpr, dpr) 将所有绘图操作按 DPR 比例放大,效果是:你在 CSS 坐标系中画一个 (100, 100) 的圆,引擎在物理像素上画 (200, 200)。两端坐标系一致,不需要在每个绘图调用中手动乘 DPR。

throttle 将 resize 处理频率限制为 200ms。窗口拖拽时 resize 事件可能以 60fps+ 触发,每次 resize 都会重建整个 Canvas 绘图表面(canvas.width = ... 会清空画布并重置渲染上下文),触发过密时容易出现性能抖动。


弹性碰撞的向量运算

两个小球碰撞后的速度计算是物理模拟的核心。简化的一维弹性碰撞公式(等质量):

text
v1' = v2
v2' = v1

但这是在一维(两个球沿一条直线对撞)下的结果。在二维空间中,需要将速度分解到碰撞法线方向:

js
function rotate(velocity, angle) {
  return {
    x: velocity.x * Math.cos(angle) + velocity.y * Math.sin(angle),
    y: -velocity.x * Math.sin(angle) + velocity.y * Math.cos(angle),
  };
}

碰撞处理步骤:

  1. 计算碰撞法线角度:atan2(dy, dx)——两球心连线的方向
  2. 将两个球的速度旋转到法线坐标系(法线方向 + 切线方向)
  3. 在法线方向上执行一维弹性碰撞(交换法线速度分量)
  4. 旋转回原始坐标系

等质量假设简化了计算——如果质量不同,步骤 3 需要质量加权的速度交换公式。

防止粘连: 碰撞检测在两个球重叠(dist < r1 + r2)时触发。如果只交换速度而不修正位置,两个球可能在下一帧仍然重叠,导致反复碰撞——视觉上表现为"粘在一起"。解决方案是碰撞后将两个球沿法线方向推开一个微小的偏移量:

js
const overlap = 0.5 * (a.radius + b.radius - dist + 1);
a.x -= overlap * Math.cos(angle);
a.y -= overlap * Math.sin(angle);
b.x += overlap * Math.cos(angle);
b.y += overlap * Math.sin(angle);

重力模拟与数值积分

js
this.vy += GRAVITY;  // 每帧累加重力加速度
this.x += this.vx;
this.y += this.vy;

这是一个半隐式欧拉方法(Semi-implicit Euler)——先更新速度,再用新速度更新位置。与显式欧拉(先更新位置再更新速度)相比,半隐式在振荡系统中的能量守恒更好。

GRAVITY = 0.1 的单位是"像素/帧²"。由于 requestAnimationFrame 通常以 60fps 运行,这等价于 360 像素/秒² 的加速度。在真实物理中重力加速度是 9.8 m/s²——这里不需要模拟真实重力,只需要视觉上合适的"下落感"。

帧率不稳定的问题:如果 requestAnimationFrame 因主线程繁忙降到 30fps,重力效果会减半(每帧累加相同的 GRAVITY,但帧数少了一半)。解决方案是用时间增量(delta time):

js
// 帧率无关的重力(未在 demo 中实现)
const dt = (timestamp - lastTimestamp) / 16.67; // 相对于 60fps 的比例
this.vy += GRAVITY * dt;
this.x += this.vx * dt;
this.y += this.vy * dt;

但在稳定的 60fps 动画里,固定步长和 delta time 的差异通常并不明显,这个 demo 保留固定步长实现即可。


O(n²) 碰撞检测的性能上限

js
for (let i = 0; i < balls.length; i++) {
  for (let j = i + 1; j < balls.length; j++) { ... }
}

这是 O(n²) 的双重循环。每个小球都要与其他所有小球检查碰撞。在 60fps 下:

小球数量每帧碰撞检查次数每帧耗时(估算)
1045< 0.1ms
501,225~0.3ms
1004,950~1ms
20019,900~5ms
500124,750~30ms ❌

超过 200 个小球时,碰撞检测开始消耗帧预算的显著比例。生产级的粒子系统使用**空间哈希(Spatial Hashing)四叉树(Quadtree)**将碰撞检测从 O(n²) 优化到接近 O(n)——将画布划分为网格,每个小球只检查所在网格和相邻网格中的其他小球。

js
// 空间哈希的简化思路
const grid = new Map(); // key: "gridX,gridY", value: [ball1, ball2, ...]
balls.forEach(ball => {
  const key = `${Math.floor(ball.x / cellSize)},${Math.floor(ball.y / cellSize)}`;
  grid.get(key)?.push(ball) ?? grid.set(key, [ball]);
});
// 碰撞检测只在同一或相邻 cell 内的球之间进行