Skip to content
Canvas 碰撞小球——从 DPR 适配到弹性碰撞检测的物理模拟分析
概述
该示例使用原生 JavaScript 与 Canvas 实现了点击生成小球、小球受重力下落、碰壁反弹变色以及球间弹性碰撞等交互效果。以下从 DPR 适配、弹性碰撞的向量计算、重力数值积分和碰撞检测性能等方面说明实现要点。
设备像素比(DPR)适配
Canvas 的显示由两个尺寸共同决定:
- CSS 尺寸(通过
canvas.style.width/canvas.style.height设置),控制页面布局中的显示大小; - 绘图表面尺寸(通过
canvas.width/canvas.height属性设置),决定实际可用于绘制的像素分辨率。
在高 DPR(如 Retina 屏,devicePixelRatio 为 2 或 3)的设备上,如果绘图表面尺寸与 CSS 尺寸相同,则每个 CSS 像素仅对应一个物理像素,浏览器会将画布内容拉伸至实际的物理像素网格上,导致画面模糊。
适配思路是将 canvas.width 和 canvas.height 设置为窗口尺寸乘以 devicePixelRatio 的值,然后通过 ctx.scale() 将绘图上下文进行缩放,使所有绘图指令仍然使用 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);
}, 200);注意点
- 重新赋值
canvas.width会立即清空画布并重置渲染上下文状态(包括已有的变换、样式等),因此每次 resize 都需要重新调用ctx.scale(dpr, dpr),不可将其视为一次性初始化操作。 - 经过
ctx.scale(dpr, dpr)后,开发者可以继续使用 CSS 像素坐标进行绘制。例如绘制一个半径为50的圆,实际会渲染为100个物理像素(DPR 为 2 时),无需在每次绘图调用时手动乘以 DPR。
throttle 将 resize 回调的执行频率限制为每 200ms 一次。因为窗口拖拽等操作可能以极高频率触发 resize 事件,而重置 canvas.width 会带来画布清空与上下文重建的开销,过于密集的重绘会引发性能抖动。
弹性碰撞中的向量运算
在两个质量相同的小球发生弹性碰撞时,需要将速度沿碰撞法线方向分解为主分量,然后在法线方向上进行速度交换(等价于一维弹性碰撞),最后再还原到原坐标系。操作中要用到向量旋转。
向量旋转函数
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),
};
}该函数实现的是顺时针旋转(与常规的逆时针旋转矩阵符号相反)。对于弹性碰撞来说,旋转方向不影响最终结果,因为后续仅在法线方向上交换速度分量,切线分量保持不变,对称性使得顺时针与逆时针旋转等效。
碰撞处理流程
- 计算两球心连线的方向角:
angle = Math.atan2(dy, dx); - 将两个球的速度向量分别旋转
-angle(或直接用上述函数传入angle),使碰撞的法线方向对齐到 X 轴; - 在法线方向上,质量相同的两球直接交换法线速度分量(即
vx'),切线速度分量保持不变; - 将速度向量旋转回原始坐标系。
防止粘连
碰撞检测会在两球球心距离小于半径之和(即发生重叠)时触发。如果只交换速度而不调整位置,则下一帧两球仍可能处于重叠状态,导致反复触发碰撞并产生“粘在一起”的视觉效果。因此每次碰撞后需要沿法线方向将两球推开一个微小偏移:
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);其中的 +1 是为了确保两球被彻底分离,避免因浮点数精度导致的微小重叠残留。
若两球质量不同,步骤 3 需要采用质量加权的速度交换公式,这里不再展开。
重力模拟与数值积分
每个小球在每帧按半隐式欧拉方法更新速度和位置:
js
this.vy += GRAVITY;
this.x += this.vx;
this.y += this.vy;该方法先更新速度,再使用新速度更新位置。与先更新位置再更新速度的显式欧拉方法相比,半隐式欧拉在处理简谐振动等振荡系统时能更好地保持能量守恒,数值稳定性更好。
GRAVITY = 0.1 的单位是“像素/帧²”。在 requestAnimationFrame 稳定以 60 fps 执行时,这约等于 360 像素/秒² 的加速度。该值仅为了在视觉上产生合适的“下落感”,并非物理意义上的重力常数。
当主线程繁忙导致帧率下降时,固定步长累加会使物理效果弱化(物体移动减慢)。更健壮的方式是引入与帧时间增量(delta time)相关的计算:
js
const dt = (timestamp - lastTimestamp) / 16.67; // 相对于 60fps 的比例
this.vy += GRAVITY * dt;
this.x += this.vx * dt;
this.y += this.vy * dt;在绝大多数设备上 requestAnimationFrame 可稳定维持 60 fps,此时固定步长与 delta time 的差异并不明显,因此本示例保留了固定步长方式。
碰撞检测的时间复杂度
示例采用双重循环检测所有球对之间的碰撞:
js
for (let i = 0; i < balls.length; i++) {
for (let j = i + 1; j < balls.length; j++) {
// 距离判断与碰撞响应
}
}该方式的时间复杂度为 O(n²)。在不同小球数量下,每帧的碰撞检查次数和大约耗时如下(以 60 fps 为参考):
| 小球数量 | 每帧碰撞检查次数 | 每帧耗时(估算) |
|---|---|---|
| 10 | 45 | < 0.1ms |
| 50 | 1,225 | ~0.3ms |
| 100 | 4,950 | ~1ms |
| 200 | 19,900 | ~5ms |
| 500 | 124,750 | ~30ms ❌ |
当小球数量超过 200 时,碰撞检测将占据可观的帧预算,可能导致帧率下降。对于更大规模的粒子系统,通常使用空间哈希或四叉树等空间划分方法将平均检测复杂度优化至接近 O(n)。
空间哈希的基本思路:将画布划分为固定大小的网格(单元格),每个小球根据坐标落入一个单元格。碰撞检测时,对每个小球仅检查其所在单元格及相邻单元格内的其他球。一个简化示例:
js
const grid = new Map();
balls.forEach(ball => {
const key = `${Math.floor(ball.x / cellSize)},${Math.floor(ball.y / cellSize)}`;
if (!grid.has(key)) grid.set(key, []);
grid.get(key).push(ball);
});
// 对每个球,获取其所在单元格及相邻单元格的球列表进行检测
balls.forEach(ball => {
const cx = Math.floor(ball.x / cellSize);
const cy = Math.floor(ball.y / cellSize);
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const neighborKey = `${cx + dx},${cy + dy}`;
const neighbors = grid.get(neighborKey);
if (neighbors) {
neighbors.forEach(other => {
if (other !== ball && /* 未重复检测 */) {
// 执行碰撞检测与响应
}
});
}
}
}
});当单元格大小与小球直径相匹配时,每个小球仅需与周围几十个邻居进行比较,从而使整体计算量随小球数量线性增长。
