33 · 弹球反射与表面反弹
从镜面反射到有能量损失的碰撞
镜面反射将速度的法向分量反号,切向分量保持不变。单位法线 n 下:
这就是 Vector3.reflect(n)。若法线不是单位向量,该接口不会替你归一化,结果会错误。真实弹跳常用恢复系数 e,表示法向离开速度与接近速度之比:
仅当 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 检出穿透;薄墙、有限三角形和移动物体则可能被高速球完全穿过。减少时间步不是通用保证,严格处理需要连续碰撞检测或扫掠求首次接触时间。切向速度未衰减,因此没有摩擦,球会持续水平滑动。球体旋转、角动量、多物体接触应使用成熟物理引擎。
练习(暂不提供答案)
- 记录连续反弹高度,比较实测比例与 e²。
- 添加倾斜平面,用有符号距离代替 y 高度检测。
- 加入切向阻尼,但只在接触时应用。
- 用反射公式证明理想反射保持向量长度。