
物理和动画这两块在游戏引擎里属于那种平时不显山露水一出问题就是灾难级的模块。我做过几个中小型项目也啃过Unity和Unreal的部分源码最大的感受是很多人对物理系统的理解停留在加个Rigidbody、调调重力的层面对动画系统的理解停留在调个Animator状态机的层面但一旦遇到穿透、抖动、动画融合穿帮、骨骼数量暴涨导致性能雪崩就完全不知道从哪下手。这篇内容就是把我这些年在这两个系统上踩过的坑、读过的源码逻辑、以及实际项目中的调优经验系统地梳理一遍。不管你是刚入行想搞明白引擎底层怎么运转的还是已经做了几年想补上架构认知短板的应该都能从中找到对自己有用的东西。1. 物理系统到底在引擎里扮演什么角色1.1 物理引擎不是做物理仿真那么简单很多人一提物理系统脑子里第一反应就是让物体掉下来。这个理解不能说错但太窄了。在游戏引擎架构里物理系统承担的职责远比模拟重力复杂得多。它本质上是一套空间查询与碰撞响应框架重力只是它众多功能中的一个默认配置项。我习惯把物理系统的职责拆成三层来看碰撞检测层负责判断两个物体是否相交、相交在哪个点、法线方向是什么。这一层是纯几何计算不涉及任何物理概念。约束求解层负责处理刚体之间的约束关系比如关节、弹簧、摩擦、恢复系数等。这一层才是真正体现物理的地方。场景查询层负责射线检测、球形检测、胶囊体扫描等空间查询操作。这一层在游戏逻辑里用得极其频繁比如射击游戏的弹道判定、AI的视野检测、角色脚下的地面检测。这三层在架构上是解耦的但共享同一套碰撞体数据。理解这一点非常关键因为很多性能问题就出在把碰撞检测层和约束求解层的需求混在一起。举个例子你做一款FPS游戏子弹的弹道判定用的是射线检测场景查询层但你给子弹也挂了一个Rigidbody约束求解层。这就造成了不必要的开销——子弹根本不需要参与物理仿真它只需要做一次射线查询就够了。我见过不少项目在子弹上挂Rigidbody结果同屏几百发子弹直接把物理线程打满。1.2 物理世界的Tick与游戏逻辑Tick的关系这是架构层面一个非常容易被忽视的问题。物理系统的更新频率和游戏逻辑的更新频率不一定是同一个频率。在大多数引擎里物理系统有一个固定的Tick频率比如Unity默认是50Hz也就是每20ms一次而游戏逻辑的帧率是浮动的可能60fps、可能144fps。这就意味着在一帧游戏逻辑里物理系统可能Tick了0次、1次或多次。为什么物理系统要用固定频率因为可变步长的物理仿真会导致不确定性。同样的初始条件在不同帧率下可能得到不同的结果。对于需要网络同步的游戏来说这是致命的。固定步长保证了物理仿真的确定性和可复现性。但这也带来了一个经典问题物理插值。如果物理Tick频率低于渲染帧率物体会看起来一顿一顿的。解决办法是在两个物理帧之间做插值把物理状态平滑地过渡到渲染帧上。Unity的Rigidbody有一个interpolation选项就是干这个的Unreal也有类似的机制。注意物理插值只影响渲染表现不影响实际的物理状态。如果你在物理Tick的回调里读取位置拿到的永远是物理帧的离散值不是插值后的值。1.3 碰撞体的层级设计与性能预算碰撞体的设计直接决定了物理系统的性能上限。我总结了几条实战中验证过的原则第一能用简单碰撞体就不用复杂碰撞体。盒体Box最快球体Sphere次之胶囊体Capsule再次凸包Convex Hull较慢三角网格Triangle Mesh最慢。一个角色用胶囊体做碰撞比用三角网格做碰撞性能差距可能是几十倍。第二静态碰撞体和动态碰撞体要严格区分。静态碰撞体比如地面、墙壁可以预先构建空间加速结构如BVH树查询效率极高。动态碰撞体每帧都要更新加速结构开销大得多。如果你把一个从不移动的物体标记为动态那就是在白白浪费性能。第三碰撞矩阵要精细配置。大多数引擎都支持碰撞层Layer和碰撞矩阵Collision Matrix。默认情况下所有层之间都互相检测但实际上很多层之间根本不需要检测。比如子弹层和子弹层之间不需要检测装饰物层和装饰物层之间也不需要检测。把碰撞矩阵配置好能省下大量无效的碰撞检测计算。下面这张表是我在一个中型项目里实测的碰撞体类型性能对比同屏500个物体单位ms/帧碰撞体类型仅碰撞检测含约束求解内存占用Sphere0.81.2低Box0.91.4低Capsule1.32.1中Convex Hull3.55.8中高Triangle Mesh12.718.3高这组数据不是说三角网格不能用而是说用的时候要知道代价。角色碰撞用胶囊体场景静态碰撞用三角网格这是最常见的组合。2. 碰撞检测的底层逻辑与常见误区2.1 宽阶段与窄阶段的分工碰撞检测在架构上分为两个阶段宽阶段Broad Phase和窄阶段Narrow Phase。这个划分是物理引擎性能的核心。宽阶段的目的是快速排除明显不相交的物体对。如果场景里有1000个物体两两组合有近50万对逐一做精确碰撞检测是不现实的。宽阶段用简单的包围盒AABB或空间划分结构如网格、四叉树、BVH来快速筛选出可能相交的物体对通常能把候选对从50万降到几百对。窄阶段则对候选对做精确的碰撞检测计算接触点、法线、穿透深度等信息。这一阶段用的是GJK、SAT等算法计算量比宽阶段大得多但因为候选对数量已经被大幅削减总体开销可控。我见过一个典型的性能问题某个项目的场景里有大量小物体掉落物、碎片宽阶段用的是最简单的暴力遍历O(n²)结果物体数量一多帧率直接崩盘。后来换成基于空间哈希的宽阶段性能提升了将近20倍。所以宽阶段的数据结构选型对物理性能的影响是数量级的。2.2 穿透问题的根因与解决思路穿透Tunneling是物理系统里最常见的问题之一一个快速移动的物体直接穿过了另一个物体没有产生碰撞。根本原因是离散碰撞检测的时间采样不够密。假设一颗子弹以100m/s的速度飞行物理Tick频率是50Hz那么每个物理帧子弹移动2米。如果墙壁厚度只有0.5米子弹在上一帧还在墙前面下一帧就已经在墙后面了中间的过程被完全跳过碰撞检测自然就漏掉了。解决办法有几种连续碰撞检测CCD在物体的运动路径上做扫掠检测而不是只在离散的时间点上做检测。大多数引擎都支持CCD但开销较大通常只对高速物体开启。射线检测替代对于子弹这类物体直接用射线检测代替物理碰撞效率更高精度也够用。增加物理Tick频率治标不治本而且会增加整体开销。限制最大速度简单粗暴但在很多场景下不适用。我的经验是子弹、投掷物这类高速小物体直接用射线检测不要走物理系统。角色、载具这类物体开启CCD就够了。碎片、掉落物这类物体速度不会太快不需要特殊处理。2.3 物理材质与摩擦力的调参经验物理材质Physics Material控制着摩擦力和弹性。这两个参数看起来简单但调起来非常容易出问题。摩擦力分为静摩擦和动摩擦。静摩擦是物体开始移动前需要克服的阻力动摩擦是物体移动过程中受到的阻力。大多数引擎只暴露一个摩擦力参数内部会同时用于静摩擦和动摩擦。摩擦力设得太高角色会卡在斜坡上设得太低角色会在斜坡上滑下来。弹性Bounciness控制碰撞后的反弹程度。设为0表示完全不反弹设为1表示完全弹性碰撞。但实际项目中弹性很少设为1因为完全弹性碰撞在数值积分下会引入能量导致物体越弹越高。通常弹性设在0.3到0.6之间比较合理。还有一个容易被忽视的参数是摩擦力组合模式Friction Combine和弹性组合模式Bounce Combine。当两个物体碰撞时它们的摩擦力和弹性如何组合常见的有取最小值、取最大值、取平均值、相乘等模式。默认通常是取平均值但在某些场景下需要手动调整。比如冰面低摩擦和普通地面高摩擦碰撞时如果取平均值冰面就不够滑如果取最小值冰面就会很滑。实操建议角色控制器不要依赖物理材质来防止滑落。用代码控制角色在斜坡上的行为比调物理材质可靠得多。3. 约束求解器物理系统里最复杂的部分3.1 约束求解的基本原理约束求解器是物理引擎里最复杂的模块。它的任务是在满足所有约束条件的前提下计算出每个刚体的速度和位置。常见的约束包括接触约束两个物体不能互相穿透。关节约束两个物体之间通过关节连接比如铰链、滑块、球窝等。摩擦约束接触面上的摩擦力不能超过最大静摩擦力。这些约束通常是不等式约束比如穿透深度 0求解起来比等式约束复杂得多。主流物理引擎PhysX、Bullet、Havok用的是迭代求解方法比如序列脉冲Sequential Impulses或投影高斯-赛德尔Projected Gauss-Seidel。迭代求解的核心思想是每次迭代只处理一个约束逐步逼近满足所有约束的解。迭代次数越多结果越精确但开销也越大。大多数引擎默认迭代次数在4到10次之间。3.2 约束求解的稳定性问题约束求解最容易出的问题是抖动和爆炸。抖动通常发生在物体堆叠的场景中。比如一摞箱子每个箱子都在微调自己的位置来满足接触约束导致整摞箱子持续微微颤动。解决办法包括增加迭代次数、引入松弛因子Relaxation、使用位置修正Position Correction等。爆炸则是数值发散的表现物体突然飞到无穷远或者变成NaN。常见原因包括质量比过大比如一个质量1的物体和一个质量10000的物体碰撞、约束冲突比如两个约束互相矛盾、时间步长过大等。我在项目里遇到过一次典型的爆炸问题一个角色踩在一个质量极小的平台上角色的质量是80平台的质量是0.01。碰撞时平台受到巨大的冲量瞬间飞出去然后角色也跟着飞出去。解决办法是把平台的质量设为一个合理值比如和角色同量级或者把平台设为静态物体。经验法则动态刚体之间的质量比不要超过10:1。如果确实需要质量差异很大的物体交互考虑把大质量物体设为静态或者用运动学Kinematic刚体代替。3.3 角色控制器为什么不用物理系统这是一个在架构设计上非常关键的决策点。大多数游戏的角色控制器不使用物理系统的约束求解而是用自定义的运动逻辑。原因很简单物理系统追求的是物理正确但游戏角色需要的是手感正确。物理正确的角色会在斜坡上滑下来、会被小台阶卡住、会被墙壁弹开这些都不是玩家想要的体验。角色控制器的典型实现方式是用胶囊体或圆柱体表示角色的碰撞体积。每帧根据输入计算期望的移动方向。用扫掠检测Sweep Test判断移动路径上是否有障碍物。如果有障碍物沿障碍物表面滑动Slide。处理台阶攀爬Step Offset和斜坡限制Slope Limit。这套逻辑完全绕过了物理系统的约束求解只用了物理系统的场景查询功能。Unity的CharacterController和Unreal的CharacterMovementComponent都是这个思路。4. 动画系统的架构分层4.1 动画系统的四层架构动画系统在架构上通常分为四层动画数据层存储骨骼动画的原始数据包括关键帧、曲线、事件等。这一层是离线的在导入时就已经处理好。动画采样层根据当前时间从动画数据中采样出骨骼的局部变换位置、旋转、缩放。动画混合层把多个动画的采样结果混合在一起比如跑步和射击的上半身混合、待机和行走的过渡混合。动画应用层把混合后的骨骼变换应用到场景中的骨骼节点上并处理蒙皮Skinning和渲染。这四层在架构上是流水线式的每一层的输出是下一层的输入。理解这个分层对于定位动画问题非常关键。比如动画播放速度不对问题在采样层动画过渡穿帮问题在混合层动画性能差问题可能在应用层的蒙皮计算。4.2 骨骼动画的数据结构骨骼动画的核心数据结构是骨骼层级和关键帧曲线。骨骼层级是一棵树每个骨骼节点有一个父节点根骨骼除外以及相对于父节点的局部变换。最终的世界变换是通过从根节点到叶节点的矩阵连乘得到的。关键帧曲线存储的是每个骨骼在每个时间点上的变换值。但直接存储每个骨骼的完整变换矩阵太浪费空间了所以通常只存储旋转用四元数表示和位移用向量表示缩放通常不存储默认是1。采样时根据当前时间在关键帧之间做插值。旋转用球面线性插值Slerp位移用线性插值Lerp。Slerp比Lerp慢但能保证旋转的平滑性和一致性。这里有一个性能优化点如果某个骨骼在整段动画中没有旋转变化就不要存储旋转曲线。很多动画文件里大量骨骼的旋转曲线是常量存储这些常量曲线纯属浪费。导入时做一次曲线压缩能显著减少内存占用和采样开销。4.3 动画状态机与混合树动画状态机Animation State Machine是游戏逻辑和动画系统之间的桥梁。它定义了动画状态之间的转换条件以及转换时的混合规则。状态机的核心概念包括状态State一个动画或一个混合树。转换Transition从一个状态到另一个状态的路径包含转换条件和混合时长。参数Parameter驱动状态转换的变量比如速度、方向、是否着地等。混合树Blend Tree是状态机里的一种特殊状态它可以根据参数在多个动画之间连续混合。最常见的是一维混合树比如根据速度在待机、行走、跑步之间混合和二维混合树比如根据速度和方向在八个方向的行走动画之间混合。我在项目里踩过的一个坑是混合树的参数范围没有对齐。比如待机动画的速度是0行走动画的速度是1.5跑步动画的速度是4但混合树的参数范围设成了0到1。结果就是速度超过1之后混合树一直输出跑步动画行走动画完全被跳过了。这种问题在编辑器里不容易发现因为编辑器里可以手动拖参数预览但运行时参数是代码驱动的很容易超出预期范围。5. 动画混合与骨骼蒙皮的性能陷阱5.1 动画混合的计算开销动画混合看起来简单就是几个动画的加权平均但实际开销比想象中大。假设一个角色有100根骨骼每个骨骼有旋转和位移两条曲线每条曲线有4个分量四元数4个分量位移3个分量但通常补齐到4个。那么一个动画的采样结果就是100 × 4 × 2 800个浮点数。如果有4个动画参与混合就是3200个浮点数的加权运算。这还只是一个角色同屏有20个角色就是64000个浮点运算。虽然现代CPU处理这个量级不在话下但如果骨骼数量更多、混合层数更深开销就会迅速累积。优化手段包括减少骨骼数量不是所有骨骼都需要参与动画。手指骨骼、面部骨骼在远景角色上可以完全跳过。减少混合层数不是所有动画都需要多层混合。远景角色的动画可以直接切换不需要过渡混合。LODLevel of Detail根据角色距离摄像机的远近使用不同精度的动画。远景角色可以用更少的骨骼、更低的采样频率。骨骼合并把一些不重要的骨骼合并到父骨骼上减少骨骼总数。5.2 蒙皮计算的GPU与CPU分工蒙皮Skinning是把骨骼变换应用到网格顶点上的过程。每个顶点根据其绑定的骨骼权重计算出最终的顶点位置。蒙皮计算可以在CPU上做也可以在GPU上做。CPU蒙皮的好处是灵活可以做各种自定义效果GPU蒙皮的好处是并行度高适合大量顶点的情况。现代引擎基本都用GPU蒙皮。顶点着色器里读取骨骼矩阵纹理Bone Matrix Texture根据顶点权重计算出最终的顶点位置。这种方式把蒙皮计算从CPU卸载到了GPUCPU只需要更新骨骼矩阵纹理就行了。但GPU蒙皮也有代价骨骼矩阵纹理的更新需要从CPU传到GPU如果骨骼数量多、角色数量多这个传输开销也不小。而且GPU蒙皮的结果在CPU端不可见如果需要在CPU端做碰撞检测或其他逻辑就需要额外的回读操作开销很大。实操经验如果你的游戏需要精确的骨骼碰撞检测比如格斗游戏的打击判定考虑在关键骨骼上挂载碰撞体而不是依赖蒙皮后的网格。5.3 动画事件与根运动动画事件Animation Event是在动画时间轴上的特定时间点触发回调的机制。比如跑步动画的某个时间点触发脚步声攻击动画的某个时间点触发伤害判定。动画事件的实现方式通常是在动画数据里存储事件时间点和事件名称采样时检查当前时间是否越过了事件时间点如果越过就触发回调。这里有一个容易出问题的地方如果动画被跳帧或快进可能会跳过某些事件。解决办法是在采样时检查时间区间内是否有事件而不是只检查当前时间点。根运动Root Motion是把动画中的位移应用到角色实际位置上的机制。比如一个翻滚动画动画本身包含了角色向前移动的位移根运动就是把这个位移提取出来应用到角色的Transform上。根运动的好处是动画和位移完全同步不会出现动画在翻滚但角色没动的穿帮。但根运动也有代价它把动画和游戏逻辑耦合在了一起网络同步时需要考虑根运动的同步问题。而且根运动提取的位移是动画作者预设的如果游戏逻辑需要动态调整位移比如被击退就需要额外的处理。6. 物理与动画的协同那些容易翻车的地方6.1 布娃娃系统与动画的切换布娃娃Ragdoll系统是物理和动画协同的典型案例。角色死亡时从动画驱动的骨骼切换到物理驱动的刚体让角色自然地倒下。这个切换过程有几个关键点骨骼到刚体的映射每根参与布娃娃的骨骼需要对应一个刚体刚体的位置和旋转从骨骼的当前变换初始化。关节的配置刚体之间需要配置关节限制它们的相对运动范围。关节的角度限制要合理太松会像面条太紧会像木棍。切换时的速度继承如果角色在奔跑中死亡布娃娃应该继承角色的速度否则会突然停下来看起来很假。布娃娃的休眠布娃娃稳定后应该进入休眠状态停止物理计算节省性能。我遇到过一个典型问题角色死亡切换到布娃娃后脚部穿进了地面。原因是布娃娃的刚体碰撞体和角色原来的胶囊体碰撞体尺寸不一致脚部刚体比胶囊体底部更低。解决办法是调整布娃娃刚体的碰撞体尺寸或者在切换时把角色稍微抬高一点。6.2 物理驱动的动画与动画驱动的物理物理和动画的协同有两种模式物理驱动动画物理系统计算刚体的位置和旋转然后把这些变换应用到骨骼上。布娃娃、物理绳索、物理布料都属于这种模式。动画驱动物理动画系统计算骨骼的位置和旋转然后把这些变换应用到物理刚体上通常是运动学刚体。角色控制器、移动平台都属于这种模式。这两种模式的混合使用是最容易出问题的地方。比如一个角色站在一个移动平台上平台是动画驱动的运动学刚体角色是物理驱动的动态刚体。平台移动时角色需要跟随平台移动但物理系统的摩擦力计算可能不够精确导致角色在平台上滑动。解决办法通常是在角色控制器里检测脚下的平台如果平台在移动就把平台的速度叠加到角色的移动上。这个逻辑需要自己写引擎不会自动处理。6.3 网络同步中的物理与动画网络同步是物理和动画协同的另一个难点。物理仿真的不确定性使得网络同步非常困难。主流方案是状态同步服务器计算物理状态把状态同步给客户端客户端做插值和平滑。但状态同步需要同步的数据量很大每个刚体的位置、旋转、速度带宽压力大。另一种方案是帧同步所有客户端运行相同的物理仿真只同步输入。但帧同步要求物理仿真完全确定性浮点数的精度问题、不同平台的差异都会导致仿真结果不一致。我的经验是对于物理交互不频繁的游戏用状态同步对于物理交互频繁的游戏尽量减少物理仿真的范围。比如格斗游戏角色的移动和碰撞可以用自定义逻辑不用物理系统这样网络同步就简单得多。动画的网络同步相对简单一些因为动画是确定性的给定时间和参数输出是确定的。只需要同步动画状态机的参数和当前时间客户端就能重建出相同的动画。但根运动是个例外根运动的位移需要同步否则不同客户端的角色位置会不一致。7. 性能调优的实战清单7.1 物理性能调优的检查项物理性能调优我通常按以下顺序排查碰撞体类型是否有可以用简单碰撞体替代的复杂碰撞体碰撞矩阵是否有不必要的碰撞层对刚体数量是否有可以设为静态或运动学的刚体物理Tick频率是否可以降低物理Tick频率约束迭代次数是否可以减少迭代次数CCD开启范围是否只对必要物体开启了CCD休眠机制是否启用了刚体休眠下面这张表是我在一个项目中做物理调优前后的对比调优项调优前调优后收益碰撞体类型混合使用统一为简单碰撞体减少40%检测开销碰撞矩阵全开精细配置减少60%无效检测物理Tick60Hz30Hz减少50%物理开销迭代次数106减少30%求解开销休眠机制关闭开启减少20%整体开销7.2 动画性能调优的检查项动画性能调优的检查项骨骼数量是否有可以合并或删除的骨骼曲线数量是否有可以压缩的常量曲线混合层数是否有可以简化的混合层动画LOD是否根据距离使用了不同精度的动画蒙皮方式是否使用了GPU蒙皮动画事件是否有不必要的事件回调根运动是否所有动画都需要根运动一个容易被忽视的点动画采样频率不需要和渲染帧率一致。远景角色的动画可以每两帧采样一次甚至每四帧采样一次视觉上几乎看不出差别但性能收益很明显。7.3 物理与动画协同的性能陷阱物理和动画协同的性能陷阱主要有两个第一布娃娃的数量。每个布娃娃有十几到二十几个刚体如果同屏有多个布娃娃物理开销会急剧上升。解决办法是限制同时活跃的布娃娃数量远处的布娃娃直接切换到静态姿势。第二动画驱动的运动学刚体。运动学刚体虽然不参与约束求解但仍然参与碰撞检测。如果场景里有大量动画驱动的运动学刚体比如移动平台、旋转门碰撞检测的开销会很大。解决办法是给这些物体配置简单的碰撞体并且只在必要时开启碰撞检测。8. 一些踩坑之后的个人体会物理和动画这两个系统单独看都不算特别复杂但一旦它们开始协同工作问题就会成倍增加。我最大的体会是架构设计阶段就要想清楚哪些东西走物理、哪些东西走动画、哪些东西走自定义逻辑。如果一开始没有想清楚后期改起来非常痛苦。另一个体会是不要迷信引擎的默认配置。Unity的默认物理Tick是50Hz默认迭代次数是6默认碰撞矩阵是全开。这些默认值在小型项目里可能没问题但在中型以上项目里几乎肯定需要调整。花半天时间把物理和动画的配置过一遍可能比花一周时间做代码优化效果更好。还有一个很实用的技巧用Profiler定位问题不要靠猜。物理和动画的性能问题往往不是线性的物体数量翻倍开销可能翻三倍。只有用Profiler看到具体的数据才能知道瓶颈在哪里。Unity的Profiler可以看物理和动画的详细开销Unreal的Stat命令也能看到各个模块的耗时。养成用数据说话的习惯比凭经验猜要靠谱得多。最后说一个关于动画混合的小技巧混合时长不要设得太短。很多人为了让动画响应更快把混合时长设成0.05秒甚至更短结果动画切换非常生硬。实际上0.1到0.2秒的混合时长在大多数情况下既能保证响应速度又能保证视觉平滑。如果觉得响应不够快应该优化的是状态机的转换条件而不是缩短混合时长。