跳球物理模拟实战:从碰撞检测到参数调优的完整指南

发布时间:2026/10/15 15:58:00
跳球物理模拟实战:从碰撞检测到参数调优的完整指南
1. 从“跳球”两个字能读出什么一个被低估的物理交互原型“跳球”这个词第一次看到的人大概率会愣一下。它不像“弹球”“弹跳球”那样直白也不像“物理引擎演示”那样技术味十足。但恰恰是这种模糊感给了它很大的解读空间。我最初接触这个标题时脑子里蹦出来的第一个画面是小时候玩的那种橡胶弹力球——往地上一砸它能弹到天花板上再落下来再弹直到能量耗尽。后来做交互开发久了再看到“跳球”我想到的是一套完整的物理模拟与实时交互系统一个球体在重力场中运动与地面、墙壁、障碍物发生碰撞每次碰撞都伴随着能量损耗、形变反馈和轨迹变化。这个项目标题虽然只有两个字但它指向的核心领域非常明确基于物理的运动模拟与交互反馈。它可能是一个小游戏可能是一个教学演示也可能是一个用来验证碰撞检测算法的实验性项目。不管具体形态是什么它的底层逻辑是共通的——用代码复现真实世界里的弹跳行为并且让用户能直观地感受到“这个球是活的”。为什么值得花时间聊这个因为“跳球”看起来简单实际上是一个极佳的物理引擎入门切口。很多开发者学物理模拟一上来就搞刚体、关节、布料结果被各种约束求解器绕得头晕。而一个球体的弹跳麻雀虽小五脏俱全重力、速度、加速度、碰撞检测、碰撞响应、能量衰减、边界处理这些核心概念一个不少。把它吃透了再去看更复杂的物理场景会发现底层逻辑是相通的。这篇文章适合谁看如果你是刚接触物理模拟的开发者想找一个不那么吓人的起点那“跳球”就是很好的练手项目。如果你是有经验的工程师想回顾一下碰撞响应的数学细节或者想看看别人在实现类似功能时踩过哪些坑这里也有不少可以对照的内容。我会从物理原理拆解开始讲到碰撞检测的具体实现再到参数调优和实际开发中那些文档里不会写的经验。全程用从业者的视角不堆公式尽量说人话。2. 弹跳背后的物理账本重力、速度与能量衰减的三角关系2.1 为什么球会越弹越低能量守恒的“不完美”现实理想情况下一个球从高处落下碰撞地面后应该弹回原来的高度然后永远弹下去。但现实里球每次弹起的高度都在降低最终停下来。这个现象的背后是能量转换与损耗。球在高处时拥有重力势能公式是 ( E_p mgh )其中 ( m ) 是质量( g ) 是重力加速度( h ) 是高度。下落过程中势能转化为动能( E_k \frac{1}{2}mv^2 )。到达地面时如果碰撞是完全弹性的动能会全部转化回势能球弹回原高度。但真实世界和大多数游戏里碰撞是非完全弹性的一部分动能会在碰撞瞬间转化为热能、声能和形变能散失掉了。在代码里这个损耗通常用一个恢复系数coefficient of restitution来表示一般记作 ( e )。它的取值范围是 0 到 1。( e 1 ) 表示完全弹性碰撞球永远弹下去( e 0 ) 表示完全非弹性碰撞球落地就停。真实橡胶球的 ( e ) 大概在 0.7 到 0.9 之间篮球在 0.75 左右网球在 0.6 到 0.7 之间。碰撞后的速度计算很简单[ v_{\text{after}} -e \cdot v_{\text{before}} ]负号表示方向反转。比如球落地时速度是向下 10 m/s( e 0.8 )那弹起速度就是向上 8 m/s。这个公式是“跳球”最核心的一行代码没有之一。2.2 重力该怎么设不是随便填个 9.8 就完事很多教程一上来就说“重力加速度设为 9.8”这是物理课本上的标准值单位是 m/s²。但在实际项目里直接填 9.8 往往效果不对。原因在于像素与米的映射关系。假设你的画布高度是 600 像素球从顶部落下。如果按真实比例一个球从 1 米高落下在 600 像素里可能只占几十个像素的位移看起来慢吞吞的完全没有“跳球”的爽快感。所以实际开发中重力值需要根据视觉节奏来调。我的经验是先确定你希望球从屏幕顶部落到底部需要多少秒。假设你希望是 1 秒屏幕高度 600 像素那根据自由落体公式 ( h \frac{1}{2}gt^2 )代入 ( h 600 )( t 1 )得到 ( g 1200 ) 像素/秒²。这个值看起来很大但在像素坐标系里是合理的。如果你用的是基于时间的物理更新每帧按 deltaTime 推进那重力单位就是“像素/秒²”。如果你用的是固定时间步长比如每帧固定 1/60 秒那重力单位可以简化为“像素/帧²”数值会小很多。两种方式都可以关键是单位要统一不要混用。注意重力值调大球的下落和弹跳节奏会变快视觉上更“脆”重力值调小球会显得轻飘飘的像在月球上。没有绝对正确的值只有适合你项目节奏的值。2.3 速度积分为什么显式欧拉法在跳球里够用了物理模拟里位置和速度的更新方式叫积分方法。最常见的是显式欧拉法velocity gravity * dt position velocity * dt这两行代码的意思是先根据重力更新速度再根据速度更新位置。简单粗暴但在“跳球”这种场景里完全够用。为什么因为跳球的运动相对简单没有复杂的约束和高速旋转。显式欧拉法的误差在短时间内可以忽略而且它计算量小代码直观。如果你用更高阶的方法比如 Verlet 积分或 Runge-Kutta精度会更高但代码复杂度也上去了对于跳球来说属于杀鸡用牛刀。不过有一个坑要注意如果 dt 太大显式欧拉法会导致能量不守恒球可能会越弹越高或者直接穿透地面。解决办法是固定时间步长比如每帧固定按 1/60 秒更新或者用子步sub-stepping把一帧拆成多个小步来算。我一般会把物理更新和渲染更新分开物理固定 60Hz渲染按显示器刷新率走这样既稳定又流畅。3. 碰撞检测与响应球和地面到底什么时候“碰”上了3.1 最简单的碰撞检测边界判断就够了跳球场景里最常见的碰撞对象是地面、墙壁和天花板。如果球是圆形的边界是矩形的碰撞检测可以简化成圆心到边界的距离判断。以地面为例假设地面在 y groundY球的圆心在 (x, y)半径是 r。球和地面接触的条件是y r groundY也就是球的最低点碰到了地面。这个判断只需要一次加法和一次比较性能开销几乎为零。墙壁和天花板的判断类似左墙x - r 0右墙x r canvasWidth天花板y - r 0这些判断放在每一帧的物理更新之后执行一旦检测到碰撞就进行位置修正和速度反转。3.2 位置修正为什么不能只反转速度很多初学者会这样写碰撞响应if (y r groundY) { velocityY -velocityY * restitution; }看起来没问题但实际跑起来会发现球会“陷”进地面里或者在地面附近抖动。原因是速度反转了但位置没有修正。球在碰撞帧的位置已经穿过了地面如果不把它拉回来下一帧它还在穿模状态就会反复触发碰撞。正确的做法是先修正位置再反转速度if (y r groundY) { y groundY - r; // 把球拉回刚好接触地面的位置 velocityY -velocityY * restitution; }这一步叫穿透修正penetration resolution。对于简单的轴对齐边界直接设置位置就行。如果碰撞面是斜的就需要沿法线方向修正计算会复杂一些但跳球场景里通常不需要。3.3 能量衰减的多种实现方式恢复系数不是唯一选择除了恢复系数还有几种常见的能量衰减方式各有适用场景衰减方式实现方式适用场景特点恢复系数碰撞后速度乘以 e通用弹跳物理意义明确参数直观速度阈值速度低于阈值时归零球最终停止避免无限小弹跳线性阻尼每帧速度乘以 0.99空气阻力模拟影响所有运动不只是碰撞摩擦系数切向速度衰减斜面或地面滚动控制水平滑动实际项目里我通常会组合使用。比如恢复系数控制垂直弹跳线性阻尼模拟空气阻力速度阈值让球最终停下来。这样调出来的效果最自然。速度阈值的逻辑是这样的if (Math.abs(velocityY) threshold y r groundY - 1) { velocityY 0; y groundY - r; }阈值一般设得很小比如 0.5 像素/帧 或 10 像素/秒。太小了球会抖很久太大了球会突然停住看起来不自然。我一般会试几个值找到视觉上最舒服的那个。4. 从代码到手感参数调优与视觉反馈的实战经验4.1 恢复系数怎么调不同材质的“性格”差异恢复系数直接决定了球的“弹跳性格”。我整理了一份常见材质的参考值这些值是我在实际项目中反复试出来的可以直接拿来用材质恢复系数视觉效果橡胶弹力球0.85 ~ 0.92弹得很高次数多活泼篮球0.75 ~ 0.80标准弹跳稳重网球0.65 ~ 0.72弹跳偏低略带沉闷铅球0.10 ~ 0.20几乎不弹落地就停理想弹性球1.0永远弹下去不现实但有趣调参的时候不要只盯着一个值。恢复系数和重力是联动的。重力大球下落快碰撞时速度大弹起也高重力小整体节奏慢恢复系数的影响会被放大。我的习惯是先定重力再调恢复系数最后用速度阈值收尾。4.2 视觉反馈让球看起来“有重量”纯物理计算出来的运动有时候看起来会有点“飘”。问题不在于物理不对而在于缺少视觉反馈。真实世界里球落地时会形变会发出声音会扬起灰尘。这些细节在代码里加上手感会立刻不一样。最简单的做法是挤压与拉伸squash and stretch。球落地瞬间把球在垂直方向压扁水平方向拉宽然后快速恢复。这个效果不需要物理引擎支持只需要在渲染时根据速度或碰撞状态改变缩放比例if (isColliding) { scaleX 1.2; scaleY 0.8; } else { scaleX 1.0; scaleY 1.0; }配合一个短暂的过渡动画效果就很自然。注意不要压得太夸张1.2 和 0.8 已经很明显了再大就变成卡通风格了。另一个技巧是拖尾。球快速移动时在身后留下几个半透明的残影能增强速度感。实现方式很简单每帧记录球的位置画几个透明度递减的圆。残影数量不要太多3 到 5 个就够了太多会显得糊。4.3 帧率无关的物理更新为什么你的球在不同设备上表现不一样这是一个非常经典的坑。如果你的物理更新直接写在渲染循环里用requestAnimationFrame驱动那在 60Hz 屏幕和 144Hz 屏幕上球的运动速度会不一样。因为 144Hz 屏幕每秒执行更多次更新如果每次更新的位移相同球就会跑得更快。解决办法是用时间差驱动物理更新let lastTime performance.now(); function loop(currentTime) { const dt (currentTime - lastTime) / 1000; // 转成秒 lastTime currentTime; updatePhysics(dt); render(); requestAnimationFrame(loop); }这样无论帧率多少物理更新的时间总量是一致的。但前面提到过dt 太大会导致数值不稳定。所以更稳妥的做法是固定时间步长 累加器const FIXED_DT 1 / 60; let accumulator 0; function loop(currentTime) { const frameTime (currentTime - lastTime) / 1000; lastTime currentTime; accumulator frameTime; while (accumulator FIXED_DT) { updatePhysics(FIXED_DT); accumulator - FIXED_DT; } render(); requestAnimationFrame(loop); }这个模式在游戏开发里非常常见能保证物理模拟的稳定性和一致性。我强烈建议跳球项目从一开始就用这个结构后面扩展成更复杂的场景时不用重构。5. 跳球项目的扩展方向从单球到多球、从弹跳到交互5.1 多球碰撞球与球之间的检测与响应单球跳地面很简单但一旦加入第二个球复杂度就上来了。球与球之间的碰撞检测是圆心距离判断const dx ball2.x - ball1.x; const dy ball2.y - ball1.y; const distance Math.sqrt(dx * dx dy * dy); if (distance ball1.radius ball2.radius) { // 发生碰撞 }碰撞响应比地面碰撞复杂因为需要沿碰撞法线方向分解速度。简化版的做法是交换两球在法线方向上的速度分量再乘以恢复系数。如果两球质量相同公式可以简化为const nx dx / distance; const ny dy / distance; const p 2 * (ball1.vx * nx ball1.vy * ny - ball2.vx * nx - ball2.vy * ny) / 2; ball1.vx - p * nx; ball1.vy - p * ny; ball2.vx p * nx; ball2.vy p * ny;这段代码看起来有点绕但核心思想就是动量守恒。实际写的时候建议先用两个球测试确认碰撞后速度方向正确再增加球的数量。球多了之后还需要考虑碰撞顺序和多次碰撞的问题简单做法是每帧只处理一次碰撞复杂场景可能需要迭代求解。5.2 加入用户交互点击、拖拽与力度控制让用户能“玩”起来跳球才从一个演示变成一个小游戏。最简单的交互是点击屏幕给球一个向上的冲量canvas.addEventListener(click, () { ball.vy -jumpForce; });jumpForce的大小决定了球能跳多高。根据能量守恒如果希望球跳到高度 h需要的初速度是 ( v \sqrt{2gh} )。假设 g 1200 像素/秒²h 300 像素那 v 大约是 849 像素/秒。这个计算可以帮助你快速设定合理的力度值。更高级一点的交互是拖拽发射用户按住球往某个方向拖松手后球沿反方向飞出。这需要记录拖拽起点和终点计算方向和力度。力度可以跟拖拽距离成正比方向就是拖拽的反方向。这种交互在弹射类游戏里很常见手感好的关键是力度曲线——不要用线性映射用平方或平方根会让控制更细腻。5.3 性能优化什么时候该担心性能单球跳地面的性能开销几乎可以忽略不计。但如果你扩展到几百个球或者加入复杂的碰撞检测就需要考虑优化了。第一个优化点是空间划分。不要每帧对所有球做两两碰撞检测那样复杂度是 O(n²)。可以用网格划分或四叉树把空间切分只检测相邻区域内的球。对于跳球这种场景网格划分更简单实现起来也够用。第二个优化点是休眠机制。当球的速度低于阈值且已经静止一段时间后把它标记为“休眠”跳过物理更新和碰撞检测。这样静止的球不消耗计算资源只有被其他球撞到或者用户交互时才唤醒。第三个优化点是渲染批处理。如果球很多不要一个一个画尽量用批量绘制或者离屏画布。不过对于跳球这种小项目通常不需要走到这一步。提示优化之前先测量。用performance.now()记录每帧的物理更新耗时确认瓶颈在哪里再动手。我见过太多人凭感觉优化结果改了一堆代码性能没提升多少反而引入了 bug。6. 那些文档不会告诉你的踩坑记录6.1 球在地面抖动一个让我调了一下午的 bug第一次做跳球的时候球落地后在地面附近高频抖动看起来像在“抽搐”。我检查了碰撞检测和速度反转逻辑都没问题。后来把每帧的速度和位置打印出来才发现问题出在速度阈值和位置修正的顺序上。当时的代码是这样的if (y r groundY) { y groundY - r; velocityY -velocityY * restitution; } if (Math.abs(velocityY) threshold) { velocityY 0; }问题在于当速度很小时先反转速度速度变成了一个很小的向上值然后阈值判断把它归零。但下一帧重力又给了一个向下的速度球又往下掉再次触发碰撞再次反转、归零。如此反复就抖起来了。修复方法是把阈值判断放在碰撞检测之前并且加上位置条件if (Math.abs(velocityY) threshold y r groundY - 1) { velocityY 0; y groundY - r; } else if (y r groundY) { y groundY - r; velocityY -velocityY * restitution; }这样当球速度足够小且已经在地面附近时直接让它停住不再触发碰撞反转。抖动问题就解决了。6.2 高速穿透球直接穿过地面的原因当球的下落速度很快时一帧内移动的距离可能超过球的直径导致球在一帧之前在地面上方下一帧已经在地面下方碰撞检测直接漏掉了。这叫穿透tunneling。解决办法有两种。第一种是限制最大速度让球每帧移动距离不超过半径const maxSpeed r / dt; velocityY Math.min(velocityY, maxSpeed);第二种是连续碰撞检测CCD在球移动路径上做射线检测看是否穿过地面。对于跳球这种简单场景限制最大速度就够了实现简单效果也够用。我一般会把最大速度设为r / FIXED_DT这样球每帧最多移动一个半径的距离不会穿透。代价是球的下落速度有上限但对于跳球来说这个上限通常不会影响视觉效果。6.3 不同屏幕尺寸的适配从手机到桌面的统一体验跳球项目如果要在不同设备上跑屏幕尺寸适配是绕不开的。我的做法是用相对坐标所有位置和速度都基于画布尺寸的百分比而不是绝对像素。比如地面位置设为画布高度的 90%球的半径设为画布宽度的 3%重力设为画布高度的 2 倍每秒平方。这样无论画布多大球的运动比例都是一致的。const groundY canvas.height * 0.9; const ballRadius canvas.width * 0.03; const gravity canvas.height * 2;渲染的时候再把这些相对值转成绝对像素。这样在手机竖屏和桌面横屏上跳球的视觉节奏基本一致不会出现手机上球小得看不清、桌面上球大得离谱的情况。7. 写在最后跳球教会我的事做跳球这个项目最大的收获不是学会了怎么写碰撞检测而是理解了物理模拟的本质是近似。真实世界的物理太复杂了代码里不可能完全复现。我们能做的是用最简单的公式和参数去逼近那个让眼睛觉得“对”的效果。恢复系数 0.8 和 0.82 在数学上差别很小但在视觉上可能就是“活泼”和“稳重”的区别。重力 1200 和 1500 在数值上只差 25%但球的节奏感完全不同。这些参数没有标准答案只能靠反复试、反复看直到手感对了为止。如果你也在做类似的项目我的建议是先把单球跳地面做到满意再考虑加球、加交互、加特效。基础不牢后面加的东西越多越乱。单球调好了整个物理系统的骨架就立住了剩下的都是在这个骨架上添砖加瓦。最后分享一个我常用的调试技巧在屏幕上实时显示球的速度、位置和碰撞状态。不用很复杂几行文字就行。调参的时候盯着这些数字比凭感觉猜要快得多。尤其是速度阈值和恢复系数看着数字变化很快就能找到合适的值。