33 · 弹球反射与表面反弹

返回系列目录

从镜面反射到有能量损失的碰撞

镜面反射将速度的法向分量反号,切向分量保持不变。单位法线 n 下:

v′=v−2(v⋅n)n. v'=v-2(v\cdot n)n.

这就是 Vector3.reflect(n)。若法线不是单位向量,该接口不会替你归一化,结果会错误。真实弹跳常用恢复系数 e,表示法向离开速度与接近速度之比:

v′=v−(1+e)(v⋅n)n,0≤e≤1. v'=v-(1+e)(v\cdot n)n,\qquad 0\le e\le1.

仅当 v·n < 0 时才执行反弹,避免把已经离开的物体重新弹回来。位置穿透修正与速度反射是两项独立工作。

完整核心代码

import { createDemo } from './runtime.js';

const { THREE, scene, onFrame } = createDemo();
scene.add(new THREE.GridHelper(20, 20));
const radius = 0.3;
const ball = new THREE.Mesh(new THREE.SphereGeometry(radius), new THREE.MeshNormalMaterial());
ball.position.set(-3, 4, 0);
scene.add(ball);
const velocity = new THREE.Vector3(1, 0, 0.2);
const gravity = new THREE.Vector3(0, -9.8, 0);
const normal = new THREE.Vector3(0, 1, 0);
const restitution = 0.7;
const fixedDt = 1 / 120;
let accumulator = 0;

function step(dt) {
  velocity.addScaledVector(gravity, dt);
  ball.position.addScaledVector(velocity, dt);
  if (ball.position.y < radius) {
    ball.position.y = radius;
    const incoming = velocity.dot(normal);
    if (incoming < 0) velocity.addScaledVector(normal, -(1 + restitution) * incoming);
    // 低速接触使用阈值消除不断抖动的小弹跳。
    if (Math.abs(velocity.y) < 0.1) velocity.y = 0;
  }
}

onFrame((dt) => {
  accumulator += dt;
  let steps = 0;
  while (accumulator >= fixedDt && steps < 8) {
    step(fixedDt);
    accumulator -= fixedDt;
    steps++;
  }
  // 持续严重超时时丢弃积压,防止补帧工作无限增长。
  if (steps === 8) accumulator = 0;
});

半隐式欧拉先更新速度,再更新位置,适合这个简单重力示例。固定时间步让数值结果不直接依赖屏幕刷新率,但渲染运动可能产生细微离散感;可保存前后物理状态并进行渲染插值。

验证、局限与替代方案

设 e = 1 检查无重力单次反射前后速度长度是否相同。e = 0 时球落地后不应继续弹起。理论上忽略空气阻力时,相邻反弹高度比例约为 e²,数值积分会有偏差。

本例的地面是无限平面,越过平面后仍能通过 y 检出穿透;薄墙、有限三角形和移动物体则可能被高速球完全穿过。减少时间步不是通用保证,严格处理需要连续碰撞检测或扫掠求首次接触时间。切向速度未衰减,因此没有摩擦,球会持续水平滑动。球体旋转、角动量、多物体接触应使用成熟物理引擎。

练习(暂不提供答案)

  1. 记录连续反弹高度,比较实测比例与 e²。
  2. 添加倾斜平面,用有符号距离代替 y 高度检测。
  3. 加入切向阻尼,但只在接触时应用。
  4. 用反射公式证明理想反射保持向量长度。