作为一个既教物理又折腾独立游戏的人,我经常在FPS游戏里被朋友问:"这游戏的枪为什么打人会有击退效果?""为什么我跳起来撞到墙角会被卡住?"其实这些看似细枝末节的游戏手感,背后全是物理里碰撞分类的基本功。你平时在教科书上看到的是"弹性碰撞、非弹性碰撞"几个干巴巴的名词,但它在射击游戏里却直接决定了子弹打中敌人时是"噗"一下嵌入、还是"砰"一声弹开、还是整个人被掀飞出去。
这篇文章我不打算讲枯燥的公式推导,而是用尽量生活化的类比,把这三种碰撞类型(弹性碰撞、完全非弹性碰撞、一般非弹性碰撞)彻底讲透,再带着你把它们套到射击游戏的枪械反馈、物理子弹、爆炸冲击、人物碰撞体这些真实场景里,看看游戏引擎是怎么用这些物理规则营造手感的。不管你是中学物理还没忘完的普通玩家,还是正在做自己的射击游戏Demo的开发新手,这篇文章都应该能让你收获一点"原来如此"的感觉。
1. 先搞懂碰撞的分类底层逻辑
1.1 碰撞到底是什么——从动量守恒说起
要理解碰撞分类,第一个绕不开的概念是动量。物理课本里说的动量是物体质量和速度的乘积,公式写成 (p = mv),它描述的是一个物体"运动的强度"。两个物体碰到一起,如果不考虑地面摩擦力、空气阻力这些外部干扰,那么碰撞前后它们的总动量是守恒的,这就是动量守恒定律。
这个公式本身很简单,但它是个很可怕的约束——不管两个物体撞成什么样、碎成几块,只要没有外力参与,碰撞前后的总动量必须一模一样。这就是碰撞分类的根基:动量守恒是所有碰撞都必须遵守的底线,而碰撞分类则取决于在满足这个底线之外,还有没有额外多守住一条"动能守恒"。
很多人把"动能"和"动量"搞混,这里用一个生活化类比说明:动量像一个奔跑中的大胖子,动能像他那股"撞上去能把人怼飞"的冲击力。两个物体相撞,动量必然守恒,这是物理世界不允许讨论的规则;但动能可不一定——如果碰撞过程中有声音、有热量、有形变,那动能就有一部分被"偷走"变成了内能或声能。碰撞分类,本质上就是看"动能有没有被偷走、偷走了多少"。
1.2 为什么碰撞分类应该看成一条连续谱
教科书里的分类是:弹性碰撞、非弹性碰撞、完全非弹性碰撞。但真正做游戏物理、或者做物理题的时候,我更喜欢把它理解成一条连续谱,而不是三个互不相干的抽屉。
这条谱的两端是理想状态。一端是弹性碰撞,动能完全守恒,碰撞前后总动能一样,现实里几乎不存在,只是某些情况下非常接近——比如两颗台球对撞、牛顿摆里的钢球互撞。另一端是完全非弹性碰撞,两个物体碰完直接粘在一起,动能损失最大,比如一颗子弹射进沙袋里卡住不动。
而现实世界里绝大多数碰撞都落在两端之间,叫一般非弹性碰撞,动能有损失但没损失到"粘在一起"的程度。比如你玩碰碰车,两车撞完会弹开一些,但不会有台球那种干脆利落的弹性——一部分动能被车的形变、轮胎摩擦和碰撞时"砰"的噪音吃掉了。
物理学里有个概念叫恢复系数,公式是 (e = \frac{v_2' - v_1'}{v_1 - v_2})。看不懂没关系,你可以粗暴理解成"碰完之后分开的猛烈程度"。(e=1) 是理想弹性碰撞,(e=0) 是完全非弹性碰撞,游戏物理引擎里的Bounciness参数本质就是干这个的。
2. 弹性碰撞与非弹性碰撞的物理细节拆解
2.1 弹性碰撞:能量守恒的完美世界
弹性碰撞最神的性质不是动量守恒,而是它额外守住了动能守恒。以"一个运动小球撞另一个静止小球"这个最简单场景为例,设运动小球质量为 (m_1),速度为 (v_1),静止小球质量为 (m_2),碰后速度分别为 (v_1') 和 (v_2')。
动量守恒给出一个方程:
[ m_1 v_1 = m_1 v_1' + m_2 v_2' ]
动能守恒再给出一个方程:
[ \frac{1}{2} m_1 v_1^2 = \frac{1}{2} m_1 v_1'^2 + \frac{1}{2} m_2 v_2'^2 ]
两个方程联立求解,就能得到碰后的速度:
[ v_1' = \frac{(m_1 - m_2)}{m_1 + m_2} v_1 ]
[ v_2' = \frac{2 m_1}{m_1 + m_2} v_1 ]
这两个公式看起来吓人,但背后藏着特别反直觉的结论:如果 (m_1 = m_2),也就是两个球质量相等,你会发现碰后运动球的速度完全变成0,原来静止的球则完全获得了碰撞前的速度——这就是台球桌上经常看到的"定球"效果,也是牛顿摆最让人着迷的地方。质量差距越大,轻球越容易被弹飞,重球几乎纹丝不动。
2.2 非弹性碰撞:现实世界的主旋律
如果是非弹性碰撞,那动力学方程里就得损失掉一部分动能。现实里这种损失主要是三块:形变消耗(物体凹进去再弹回来但回不到原样)、热能消耗(摩擦生热)和声能消耗(碰撞发出的响声)。
因为动能不守恒,我们不能再写动能守恒方程,只能靠恢复系数 (e) 来做补充。碰后相对速度等于原相对速度乘以 (e),所以 (e=0.6) 就意味着"碰完以后互相分开的速度只有撞之前靠近速度的60%"。
一般非弹性碰撞算起来很麻烦,但搞懂完全非弹性碰撞就够了。完全非弹性碰撞的定义极端简单:碰完两个物体以同一个速度一起运动。既然粘在一起了,恢复系数就是0。拿子弹射沙包举例,设子弹质量 (m)、速度 (v),沙包质量 (M)、初始静止,碰撞后黏在一起的速度 (V) 可以直接算:
[ m v = (m + M) V ]
[ V = \frac{m}{m + M} v ]
子弹质量越小、沙包质量越大,最后共同速度就越小。这一个公式,其实就是射击游戏里"击退效果"的物理原型。
2.3 三个碰撞类型快速对比表
| 碰撞类型 | 恢复系数e | 动量守恒 | 动能守恒 | 现实/游戏典型例子 |
|---|---|---|---|---|
| 弹性碰撞 | e = 1 | 守恒 | 完全守恒 | 台球对撞、牛顿摆、理想化粒子 |
| 一般非弹性碰撞 | 0 < e < 1 | 守恒 | 部分损失 | 篮球落地弹起、汽车追尾、Ragdoll撞墙 |
| 完全非弹性碰撞 | e = 0 | 守恒 | 损失最大 | 子弹射入沙包、两个泥团相撞、黏弹粘合 |
这张表看起来简单,但它背后有个特别值得新手记住的点:在物理引擎里头,所有碰撞类型的代码"底子"都是一样的——先保证动量守恒,再用恢复系数 (e) 去调整分离速度。你肉眼看到的"弹开"还是"黏住",其实只是同一个公式在不同参数下的表现。
3. 射击游戏引擎里碰撞是怎么跑的
3.1 从物理公式到游戏引擎:碰撞体的思想
游戏引擎当然不会像物理课本那样解方程,但原理是一脉相承的。在Unity、Unreal这些引擎里,一个可碰撞的对象通常由三件套组成:碰撞体(Collider,定义物体的形状范围)、刚体(Rigidbody,决定物体是否受物理影响)和物理材质(Physics Material,提供摩擦力与弹力系数)。
这里面物理材质的Bounciness参数,就是物理世界恢复系数 (e) 在主流的游戏引擎里的直译。调成1,物体撞啥都像打了鸡血一样弹个没完;调成0,碰撞后基本就"卸力"了。我们在FPS里踩墙跳、手雷弹墙、敌人中弹后飞出去,背后全靠这个数值。
一个常见的误区是:很多人觉得游戏里的碰撞就应该100%还原现实物理。真那样做,FPS根本没法玩——真实子弹动能一枪能掀翻成年人吗?游戏需要的是"看起来像那么回事、打起来很爽"的伪物理。所以引擎里几乎所有碰撞都是经过艺术化调参后的结果。
3.2 子弹命中目标:典型的完全非弹性碰撞案例
先说最核心的应用:子弹击中人体。现实里子弹射进肉体,绝大多数能量被组织吸收、子弹留在体内或穿出,这是典型的完全非弹性碰撞。在游戏里,这种"非弹性"体现在两个地方:一是伤害结算,二是受击反馈。
伤害结算用物理公式其实是"杀鸡用牛刀",游戏一般直接按命中部位和距离查表。但受击反馈就纯粹靠物理了。当子弹命中角色时,客户端会计算一个冲击力,施加到角色的Rigidbody上,让角色产生轻微后仰或位移。这个冲击力可以直接从动量定理推出来:子弹动量的变化量乘以一个修正系数,再除以受击角色质量,就能估算出角色被推倒的速度。
我在自己的FPS Demo里写过这样一段子弹命中后的受击逻辑:
Vector3 bulletVelocity = bulletRigidbody.velocity; float bulletMass = bulletRigidbody.mass; // 子弹命中瞬间的动量变化,模拟完全非弹性碰撞 Vector3 impactImpulse = bulletVelocity * bulletMass * hitReactionScale; // 把冲击力加到目标刚体上 targetRigidbody.AddForceAtPosition(impactImpulse, hitPoint, ForceMode.Impulse); // 同时给一个极短时间的HitStop,让枪感更清脆 StartCoroutine(FreezeFrames(hitStopDuration));这里有个细节很多人不知道:为什么要用AddForceAtPosition而不是直接在Rigidbody上AddForce?因为冲击力施加位置不同,角色受力后的运动状态完全不同——打在胸口,角色是整体后退;打在头部,角色会向后仰甚至旋转倒地。AddForceAtPosition能精确控制"撞击点",实现出更自然的受击动画。
3.3 人物移动和跳跃:弹性碰撞的"偷懒"版本
人物与场景的碰撞看起来和"弹性"不太沾边,因为我们不希望角色被墙壁弹得乱飞。但跳床和弹跳板的设计就要反过来用它。
正常角色控制器(CharacterController)里的碰撞其实是"挡住就停"的逻辑,几乎没有弹性恢复,这相当于把地面和墙壁的恢复系数强行压到很低的水平。但如果你做一张跳床,或者想让玩家从高处落地时有一个"缓冲弹起"的手感,就要单独给那块碰撞面设置一个高Bounciness的物理材质,再配合速度衰减公式:
[ v_{bounce} = e \cdot v_{impact} ]
假如你从高处落地,撞击瞬间速度为20米/秒,地面物理材质的Bounciness设为0.5,那弹起速度就是10米/秒。这个公式和你在物理课上学过的恢复系数完全是一回事,但游戏里的地面往往还会给弹起方向加一个操控系数,让玩家能微调落点。
这里有一个无数人踩过的坑:如果你把角色整个当成普通刚体放到跳床上,碰上Bounciness=1的材质,它会无限反弹、越弹越高,玩家完全没法操控,撞两次摄像头都能晃晕。正确的做法是给角色加一个"最高弹跳速度"限制,或者在角色落地弹起瞬间覆盖恢复系数。
3.4 爆炸冲击波与Ragdoll:真实物理在游戏中的艺术化
爆炸伤害是射击游戏里最有画面感的物理时刻。手雷爆开时,会对爆炸半径内的所有刚体施加径向冲量,这个冲量的大小遵循类似平方反比的衰减公式:
[ F = \frac{k}{r^2} \cdot m ]
其中 (r) 是物体到爆炸中心的距离,(k) 是爆炸强度系数。距离越远,受力越小,这是从点爆炸能量扩散规律简化来的。而物理引擎里实际做的时候,会用球形范围检测加Falloff曲线来逼近,不会真的做一次平方反比——因为在每秒几十帧的游戏里,平方反比意味着近距离冲量无穷大,很容易把物体炸出物理边界。
Ragdoll(布娃娃物理)系统则是另一个经典应用。角色死亡后,骨骼切换成一个由多个刚体关节组成的布娃娃模型,这些刚体之间的碰撞就是典型的非弹性碰撞——身体部位碰到地面会反弹一点点,但因为肌肉张力消失,几乎不会产生真正的回弹。这个"刚刚好"的效果,就是靠把每个关节的物理材质Bounciness压到0.1到0.2之间实现的。
4. 游戏物理中的碰撞参数调优与实战避坑
4.1 物理材质Bounciness设置的实战经验
在FPS项目里,给不同表面设置Bounciness是门手艺活。我试过几套参数,直接贴出来供参考:
| 表面类型 | Bounciness | Friction | 典型用途 |
|---|---|---|---|
| 混凝土/地面 | 0.1 | 1.0 | 角色落地、手雷触地不弹起 |
| 金属墙壁 | 0.3 | 0.2 | 手雷反弹、子弹弹射 |
| 跳床/弹性板 | 0.8-1.0 | 0 | 弹跳玩法 |
| 尸体/布娃娃 | 0.1 | 0.5 | 死亡动画自然倒地 |
| 冰块 | 0.3 | 0 | 滑行、打滑 |
这里有个很微妙的问题:手雷的Bounciness设计。真实手雷落地基本不弹,但游戏里如果Bounciness设成0,手雷落地就像泥巴一样瘫在地上,视觉效果非常死板。我一般设0.25到0.35,让它有明显但克制的两三次弹跳,给玩家留出反应时间,同时手感和真实感都能兼顾。
4.2 碰撞体和刚体常见问题
物理做多了,你会发现真正折磨人的不是公式,而是引擎里那些"物理发疯"的瞬间。我遇到的三个典型问题:
第一个是抖动(Jitter)。刚体物体紧贴地面时,会因为重力不断被压进碰撞体再弹出来,产生高频震动。解决办法,一个是把物体的Solver Iterations调高一点,另一个是干脆不要让它当纯刚体,用"运动学刚体+插值"来处理。
第二个是隧道效应(Tunneling)。子弹飞太快,一帧之内穿透了一面薄墙,这在子弹射击游戏里特别致命。引擎的解决办法是Continuous Collision Detection(连续碰撞检测),原理是把物体这一帧的运动轨迹当成一条"扫掠体"来做检测,而不是只检测终点位置。代价是性能开销大,所以一般只给子弹、高速导弹启用。
| 问题现象 | 常见原因 | 推荐排查方案 |
|---|---|---|
| 物体抖动/抖动漂移 | 物理材质摩擦设置过高、刚体睡眠判定异常 | 降低摩擦,调整SleepThreshold,开启刚体插值 |
| 高速子弹穿透薄墙 | 默认离散碰撞检测只查末位置 | 使用Continuous或ContinuousSpeculative模式 |
| 人物被碰撞体卡住 | 碰撞体边缘过薄、多个碰撞体叠加 | 给人物加Capsule碰撞体,避免使用复杂Mesh Collider |
| 网络延迟导致位置跳变 | 客户端预测误差,服务器回滚不一致 | 用快照插值加延迟补偿,或在服务器端做HitValidation |
| 手雷弹飞方向随机 | 地面存在网格接缝,碰撞法线突变 | 在爆炸点做射线检测,手工修正初速度方向 |
第三个是网络同步。这是射击游戏物理里最坑的地方。本地玩家丢一个手雷,手雷在客户端A有物理模拟,但在客户端B要看到同样的弹跳轨迹,就需要服务器协调。现在市面上主流的做法是"服务器权威+客户端预测"——物理计算在服务器跑,客户端本地预测路径,出现偏差时做平滑修正。物理参数稍微偏一点,两个端的手雷轨迹就会天差地别,所以打包时物理材质和初始速度必须做成可同步的参数。
4.3 性能优化思路
物理碰撞看着普通,实际是射击游戏性能的大坑。一个手雷爆炸要检测周围30个刚体,一发霰弹枪7颗子弹、每颗都要做碰撞检测,几十人混战的时候物理计算量能直接把CPU拖垮。我的优化思路有几条:
碰撞分层是必须的。把子弹、角色、场景做成不同的Layer,只让该碰撞的层做检测,比如子弹层只检测角色层和场景层,绝不检测其他子弹。引擎里每个物理每帧都在"广播"自己的位置,分层之后广播数量呈指数级下降。
另一个技巧是动态调整物理更新频率。远处的敌人和近处的敌人没有必要每帧都跑物理,可以分成两个或多个"物理调度等级":近距离每秒60次物理更新,中距离30次,远距离10次。这个方案在《绝地求生》这类大地图射击游戏里很常见,因为远处中弹的敌人根本没有必要做高精度的物理反馈——反正玩家也看不清。
还有一个容易被忽视的点是碰撞体的形状选择。很多初学者喜欢用Mesh Collider去精准拟合场景模型,但Mesh Collider的计算量是Box和Sphere的十几倍。射击游戏里的墙壁其实不需要那么精确的碰撞形状,用几个Box Collider拼出来,玩家完全感觉不到区别,但性能提升是肉眼可见的。
5. 我个人在处理碰撞时的几个心得
聊到这儿,基本的分类和游戏应用都过了一遍。最后分享几个我实际做项目中总结出来的经验,希望对你有直接的参考价值。
第一个心得是:做游戏物理前,先把物理公式写清楚,哪怕最后引擎一个公式都没用上。我在做碰撞系统时,把动量守恒、恢复系数、完全非弹性碰撞公式全部推导了一遍,结果写代码时发现所有逻辑都能对号入座——子弹击退用的是动量定理,手雷爆炸用的是类平方反比衰减,跳床弹跳用的就是恢复系数公式。公式是物理世界的地图,引擎只是交通工具,没有地图,你连该调哪个参数都不知道。
第二个心得是:精心调过的Bounciness参数一定要全项目统一保存。我见过太多项目里同一个地面,在这个场景Bounciness是0.1,换个场景又变成0.3,结果手雷弹跳手感完全不一致,玩家会觉得"这游戏物理有问题"。做一个物理参数总表,把所有表面材质统一管理,是一个成熟项目的基本功。
第三个心得是:物理手感不是越真实越好,而是越"符合预期"越好。射击游戏玩家不会去管真实子弹击中人体能不能把人击飞,他只关心"这一枪打中了、反馈清晰、玩起来爽"。所以我做受击反馈时,会刻意放大击退效果,把真实的冲击力乘以1.2到1.5的系数,让中弹后的位移更明显。这不算欺骗——这是游戏设计与物理的合理结合。
如果你想试着做自己的FPS物理玩法,建议从最简单的手雷弹跳开始:给一个球体设置刚体,调整Bounciness为0.3,丢出去观察两次弹跳衰减;然后把恢复系数改成0.8,观察"越弹越欢"的失控感。亲手把玩过这两个极端,你就能深刻理解恢复系数这条连续谱两端到底意味着什么,也就能明白射击游戏里的每一个"砰"背后,其实都是物理规则在替你讲故事。