
1. 物理与动画系统在游戏引擎中的定位与整体设计1.1 为什么物理和动画是引擎架构里最难啃的两块骨头做引擎开发的人都有一个共识渲染管线可以靠堆人力优化脚本层可以靠热重载提升迭代速度唯独物理和动画这两块一旦架构设计出了问题后期几乎不可能靠打补丁救回来。原因很简单——它们都是强状态、强时序、强耦合的子系统。物理系统每一帧要处理成百上千个刚体的碰撞检测、约束求解、积分更新任何一个环节的精度损失都会在几十帧后放大成肉眼可见的穿模或抖动。动画系统则要在毫秒级时间内完成骨骼层级变换、蒙皮矩阵计算、状态机切换、动画混合同时还要和物理驱动的布娃娃系统、IK 反向动力学做数据交换。这两个系统之间还存在双向依赖物理可以驱动动画比如角色被击飞时的布娃娃效果动画也可以反过来影响物理比如动画驱动的碰撞体位置更新。我在实际项目里踩过最典型的一个坑就是早期把物理更新和动画更新放在同一个线程里串行执行结果角色移动速度一快动画的根骨骼位移和物理胶囊体的位置就对不上出现角色滑步的现象。后来把物理更新拆到固定时间步长的独立循环里动画更新保持在渲染帧率上做插值问题才彻底解决。这个经历让我深刻理解了一件事物理和动画的更新频率、时间基准、数据流向必须在架构设计的第一天就定清楚。1.2 物理系统的核心架构分层一个成熟的物理系统在架构上通常分为四层从下往上依次是数学基础层向量、矩阵、四元数、AABB/OBB 包围盒、射线、平面等几何图元。这一层看起来简单但精度问题往往就出在这里。比如用 float 存储世界坐标时当坐标值超过 10000 之后浮点精度会下降到 0.001 左右对于高速运动的物体来说这就是穿模的根源。碰撞检测层分为粗检测Broad Phase和精检测Narrow Phase。粗检测用空间划分结构快速筛掉不可能碰撞的物体对精检测对候选对做精确的相交测试并生成接触点。约束求解层把接触点、关节、马达等统一抽象为约束用迭代求解器通常是序列脉冲求解器或投影高斯-赛德尔求解器计算出每个刚体应该受到的冲量。积分更新层根据求解出的冲量更新速度再根据速度更新位置。这里涉及到积分器的选择半隐式欧拉是最常用的因为它在稳定性和计算量之间取得了很好的平衡。注意很多新手会跳过粗检测直接做两两碰撞测试物体数量到 200 个以上时帧率就会崩掉。粗检测不是可选项是必选项。1.3 动画系统的架构演进路线动画系统的架构经历了从简单到复杂的演进。最早期的引擎就是直接播放骨骼动画片段没有任何混合逻辑。后来出现了动画状态机用节点图的方式管理动画切换。再往后发展出动画图Animation Graph支持分层混合、遮罩、骨骼过滤等高级功能。现代引擎的动画架构通常包含以下几个核心模块动画数据层存储关键帧数据、曲线数据、压缩后的骨骼变换。这里的关键是压缩策略原始的关键帧数据量非常大一个 30 骨骼的角色跑 10 秒动画未压缩数据可能超过 5MB。动画采样层根据当前时间从关键帧中插值出骨骼变换。插值方式有线性插值、球面线性插值用于旋转、三次样条插值等。动画混合层把多个动画片段的采样结果按权重混合。混合可以在局部空间做也可以在世界空间做各有优劣。动画状态管理层决定当前应该播放哪些动画、权重是多少、何时切换。这是游戏逻辑和动画数据之间的桥梁。骨骼变换层把混合后的局部变换转换成世界变换再计算蒙皮矩阵传给 GPU。1.4 物理与动画的耦合设计原则物理和动画的耦合是架构设计中最容易出问题的地方。我的经验是遵循三条原则第一数据流向要单向清晰。要么物理驱动动画要么动画驱动物理不要双向同时驱动否则会出现反馈震荡。如果确实需要双向必须加阻尼或约束。第二时间步长要解耦。物理用固定时间步长通常是 1/60 秒或 1/120 秒动画用可变时间步长跟随渲染帧率。两者之间通过插值或外推来同步。第三碰撞体和骨骼要分离。不要让物理引擎直接操作骨骼也不要让动画系统直接操作碰撞体。中间应该有一层映射关系把骨骼的动画姿态转换成物理碰撞体的目标位置再由物理引擎去求解。2. 物理系统核心细节与实操要点2.1 碰撞检测的粗检测结构选型粗检测的核心是空间划分结构常用的有四种均匀网格、四叉树/八叉树、BVH包围体层次结构、空间哈希。均匀网格适合物体分布均匀且大小相近的场景实现简单查询速度快。但如果物体大小差异很大小物体在大网格里会浪费大量空间大物体又会跨越多个网格。我一般只在 2D 游戏或者物体尺寸统一的场景里用。四叉树/八叉树适合静态场景或者变化不频繁的场景。构建成本较高但查询效率好。动态物体频繁移动时树的更新会成为瓶颈。BVH 是目前 3D 引擎里最常用的结构。它把物体按层次组织成树每个节点是一个包围盒。BVH 的优点是适应性强物体大小差异大也没问题而且支持增量更新。缺点是树的平衡性依赖构建算法不好的构建算法会导致查询效率下降。空间哈希适合物体数量多但分布稀疏的场景。它把空间划分成固定大小的格子用哈希表存储每个格子里的物体。优点是插入和删除都是 O(1)缺点是格子大小需要调参而且哈希冲突会影响性能。我在一个开放世界项目里做过对比测试场景里有 5000 个动态物体分布在一个 2km x 2km 的区域内。均匀网格的粗检测耗时约 2.3ms四叉树约 1.8msBVH 约 1.2ms空间哈希约 1.5ms。最终选了 BVH因为它的性能最稳定不会因为物体分布变化而剧烈波动。2.2 精检测的算法选择与精度控制精检测阶段要处理具体的图元对球-球、球-胶囊、胶囊-胶囊、凸包-凸包等。最复杂的是凸包-凸包通常用 GJK 算法加 EPA 算法来求解。GJK 算法用来判断两个凸包是否相交它的核心思想是在闵可夫斯基差空间里寻找原点。如果原点在差空间内说明两个凸包相交。EPA 算法则在 GJK 的基础上继续扩展找到最小穿透向量也就是把两个物体分开所需的最短距离和方向。这里有一个实操中很容易忽略的点GJK 的迭代次数上限。默认情况下 GJK 可能迭代 20 次就停止但对于一些特殊形状比如非常扁的盒子20 次可能不够导致检测结果不稳定。我的做法是把上限提高到 64 次同时加一个收敛阈值当两次迭代的结果差异小于阈值时就提前退出。另一个坑是接触点的生成。两个物体相交时接触点可能是一个点、一条线或者一个面。如果只生成一个接触点物体会绕着这个点旋转产生抖动。正确的做法是生成多个接触点通常 4 个就够分布在接触面上。这样约束求解器才能计算出稳定的冲量。2.3 约束求解器的参数调优约束求解器是物理系统的心脏。目前主流的是序列脉冲求解器Sequential Impulse Solver它的核心思想是逐个约束地迭代求解每次迭代只处理一个约束但多次迭代后整体会收敛。求解器的关键参数有三个迭代次数默认通常是 8-10 次。迭代次数越多约束越稳定但计算量也越大。对于堆叠的物体比如一摞箱子迭代次数需要提高到 20 次以上才能稳定。松弛因子控制每次迭代的修正幅度。太大会震荡太小会收敛慢。通常设在 0.1-0.3 之间。穿透容差允许物体之间有多少穿透量。设得太小会导致物体被弹开设得太大又会出现明显的穿模。一般设在 0.01-0.05 米之间。我调过一个堆叠场景20 个箱子叠在一起初始参数是迭代 10 次、松弛因子 0.2、穿透容差 0.02。结果箱子会缓慢下沉10 秒后下沉了约 0.5 米。把迭代次数提高到 25 次松弛因子降到 0.15下沉速度明显减缓但仍有约 0.1 米的下沉。最后加了位置修正Position Correction才彻底解决位置修正会在速度求解之后额外做一次位置调整把穿透的物体推回正确位置。2.4 物理材质与碰撞过滤物理材质定义了摩擦系数和恢复系数弹性。摩擦系数决定了物体在表面上滑动时的阻力恢复系数决定了碰撞后的反弹程度。这里有一个常见的误区很多人以为摩擦系数就是越粗糙越大但实际上静摩擦和动摩擦是分开的。静摩擦是物体开始滑动前需要克服的力动摩擦是滑动过程中的阻力。静摩擦通常大于动摩擦这就是为什么推一个重箱子时刚开始推不动一旦推动了就轻松了。碰撞过滤则是控制哪些物体之间会发生碰撞。通常用位掩码Bitmask来实现每个物体有一个碰撞组和一个碰撞掩码只有当两个物体的组和掩码互相匹配时才会碰撞。这个机制在游戏里非常有用比如角色的胶囊体不应该和自身的武器碰撞但应该和敌人的武器碰撞。提示碰撞过滤的位掩码不要超过 32 位因为大多数引擎用 32 位整数存储。如果需要更多分组可以用多层过滤或者自定义过滤回调。3. 动画系统核心细节与实操要点3.1 骨骼动画的数据压缩策略骨骼动画的数据量是很大的。一个角色如果有 60 根骨骼每根骨骼有位置、旋转、缩放三个通道每个通道用 4 个 float 存储一帧就是 60 x 3 x 4 x 4 2880 字节。如果动画有 30 帧每秒时长 10 秒那就是 2880 x 30 x 10 864KB。一个游戏里如果有 100 个这样的动画数据量就接近 90MB。压缩策略主要有三种第一种是关键帧抽稀。不是每一帧都存而是只存关键帧中间用插值补出来。抽稀的阈值需要根据动画的曲率来定曲率大的地方多存曲率小的地方少存。第二种是量化压缩。把 float 量化成 16 位整数甚至 8 位整数。旋转用四元数存储时可以只存三个分量第四个分量通过归一化计算出来。这样旋转数据可以从 16 字节压缩到 6 字节。第三种是曲线拟合。用多项式或者样条曲线拟合动画数据只存曲线参数。这种方法压缩率最高但拟合误差也最大适合对精度要求不高的动画。我在项目里用的是量化压缩加关键帧抽稀的组合方案。旋转用 16 位量化位置用 16 位量化缩放通常不变所以只存一个值。抽稀阈值设为 0.5 度低于这个角度的变化不存关键帧。最终数据量压缩到了原来的 15% 左右肉眼几乎看不出差异。3.2 动画混合的数学原理与实现动画混合的本质是在两个或多个姿态之间做插值。最简单的线性混合就是加权平均最终姿态 权重1 * 姿态1 权重2 * 姿态2 ... 权重N * 姿态N但这里有一个问题旋转不能用线性插值。两个四元数直接做线性插值再归一化虽然能得到一个合法的旋转但角速度不均匀会出现加速-减速的现象。正确的做法是用球面线性插值Slerp它保证角速度均匀。Slerp 的公式是Slerp(q1, q2, t) (sin((1-t)*θ) / sinθ) * q1 (sin(t*θ) / sinθ) * q2其中 θ 是两个四元数之间的夹角。这个公式计算量比线性插值大但效果明显更好。对于多个动画的混合通常用累加混合Additive Blending。先选一个基础动画然后把其他动画作为增量叠加到基础动画上。增量动画存储的是相对于参考姿态的偏移量这样混合时只需要把偏移量加权累加即可。3.3 动画状态机的设计模式动画状态机是管理动画切换的核心模块。最简单的实现是一个有限状态机每个状态对应一个动画状态之间有转换条件。但实际项目里简单的有限状态机会很快变得难以维护。一个角色可能有待机、行走、跑步、跳跃、攻击、受击、死亡等状态状态之间的转换条件可能涉及十几个参数。这时候就需要更高级的设计模式。我常用的是分层状态机加混合树的组合。分层状态机把动画分成几个层基础层移动、上半身层攻击、施法、表情层面部动画。每层独立管理自己的状态层与层之间通过遮罩Mask来控制影响范围。比如上半身层只影响上半身的骨骼不影响腿部的行走动画。混合树则用来处理同一状态内的动画变化。比如行走状态速度从 0 到 5 米每秒对应从走到跑的过渡。混合树可以根据速度参数自动混合走路和跑步动画不需要手动切换状态。3.4 反向动力学IK的实操要点IK 是用来解决手要放在某个位置但手臂骨骼怎么旋转这类问题的。最常见的应用是脚部 IK让角色的脚在不同坡度的地面上都能正确贴合。IK 的求解算法有两类解析法和迭代法。解析法适用于两骨骼链比如大腿和小腿可以直接用三角函数算出关节角度。迭代法适用于多骨骼链常用的是 CCD循环坐标下降和 FABRIK。CCD 的思路是从末端骨骼开始依次旋转每根骨骼让末端尽可能靠近目标点。迭代几次后就能收敛。FABRIK 则是先调整骨骼位置再反推旋转收敛速度更快。我在做脚部 IK 时踩过一个坑直接对脚部做 IK结果膝盖会向外翻。原因是 IK 只考虑了位置约束没有考虑关节的角度限制。后来加了膝盖的极向量约束Pole Vector指定膝盖应该朝向的方向问题才解决。注意IK 不要每帧都从头求解可以用上一帧的结果作为初始值这样迭代次数可以减少一半以上。4. 物理与动画的协同实战4.1 布娃娃系统的实现细节布娃娃系统是物理和动画协同的典型案例。角色死亡时从动画驱动切换到物理驱动让身体各部位自然倒下。实现布娃娃的关键是骨骼到刚体的映射。每根骨骼对应一个刚体刚体之间用关节连接。关节的角度限制要参考人体关节的活动范围比如肘关节只能单向弯曲膝关节不能反向弯曲。切换时有一个难点动画姿态和物理姿态的衔接。如果直接切换物理刚体会从动画姿态的当前位置开始模拟但动画姿态可能不满足物理约束比如关节角度超出了限制导致刚体突然弹开。正确的做法是在切换时先做一次约束求解把物理姿态调整到满足约束的状态再开始模拟。另一个难点是布娃娃的稳定性。布娃娃的刚体数量多关节链长很容易出现抖动或者爆炸。我的经验是降低布娃娃的求解精度要求增加阻尼限制最大速度。布娃娃不需要像角色控制器那样精确看起来自然就行。4.2 物理驱动动画的根骨骼匹配角色移动时动画的根骨骼位移和物理胶囊体的位移需要匹配。如果不匹配就会出现滑步或者脚陷进地面的现象。匹配的方法有两种一种是动画驱动物理即动画的根骨骼位移直接设置物理胶囊体的位置。这种方法简单但物理碰撞的反馈无法影响动画角色撞墙时动画还在往前走。另一种是物理驱动动画即物理胶囊体根据输入和碰撞计算出实际位移然后把位移传给动画系统动画系统用这个位移来调整根骨骼。这种方法更真实但需要动画系统支持根骨骼偏移。我通常用混合方案物理胶囊体负责碰撞检测和实际位移计算动画系统根据物理位移和动画原始位移的差值来调整根骨骼。这样既保证了碰撞的正确性又保留了动画的自然感。4.3 性能优化物理和动画的并行化物理和动画都是计算密集型任务在多核 CPU 上并行化是必然选择。物理的并行化相对容易因为刚体之间的计算是独立的。可以把刚体分组每组在一个线程上做碰撞检测和积分更新。但约束求解是串行的因为约束之间会相互影响。常用的做法是图着色算法把不相关的约束分到同一组组内并行组间串行。动画的并行化更复杂因为骨骼之间有层级依赖。子骨骼的世界变换依赖父骨骼的世界变换不能简单地并行。但可以用拓扑排序把骨骼分层同一层的骨骼可以并行计算。对于 60 根骨骼的角色通常能分成 8-10 层并行度还是不错的。我在一个项目里做过测试物理和动画都并行化之后在 8 核 CPU 上整体耗时从 6.2ms 降到了 2.8ms提升了一倍多。但要注意线程同步的开销如果任务粒度太小同步开销会抵消并行收益。4.4 常见问题速查表问题现象可能原因排查方法解决方案物体抖动约束求解迭代次数不足增加迭代次数观察是否改善提高迭代次数到 20 以上物体穿模时间步长过大或碰撞检测遗漏减小时间步长测试启用连续碰撞检测CCD角色滑步动画根骨骼与物理位移不匹配对比动画位移和物理位移启用根骨骼匹配动画切换突兀混合时间太短增加混合时间测试设置 0.2-0.3 秒的混合过渡布娃娃爆炸关节约束不满足或速度过大检查关节角度限制增加阻尼限制最大速度IK 膝盖外翻缺少极向量约束观察膝盖朝向添加极向量约束物理帧率波动粗检测结构不适应物体分布统计粗检测耗时换用 BVH 或调整网格大小5. 架构选型的经验与建议5.1 自研还是用现成物理引擎这是每个引擎团队都会面临的问题。我的建议是除非你的游戏有非常特殊的物理需求比如大规模破坏、流体模拟否则不要自研物理引擎。自研物理引擎的坑太多了数值稳定性、碰撞检测的边界情况、约束求解的收敛性、多线程安全……每一个都需要大量的测试和调优。Bullet、PhysX、Jolt 这些成熟的物理引擎都是经过十几年迭代的代码质量和稳定性远超一般团队的自研水平。但用现成引擎也有代价性能特征不可控、调试困难、定制化受限。我的做法是核心物理用成熟引擎但在外面包一层适配层把引擎的 API 隔离起来。这样将来如果要换引擎只需要改适配层不需要改游戏逻辑。5.2 动画系统的架构选择动画系统的选择相对灵活一些。如果项目规模不大用引擎自带的动画系统就够了。如果项目有特殊的动画需求比如大量角色同屏、复杂的动画图可能需要自研或者深度定制。自研动画系统的核心是数据管线和运行时的分离。数据管线负责把美术做的动画数据转换成引擎可用的格式运行时负责采样、混合、状态管理。这两部分应该解耦数据管线的改动不应该影响运行时。我在项目里用的方案是数据管线用 Python 脚本实现运行时用 C 实现。数据管线输出的格式是自定义的二进制格式运行时直接读取。这样美术可以在不重新编译引擎的情况下更新动画数据。5.3 物理和动画的调试工具调试工具的重要性怎么强调都不为过。物理和动画的问题往往很难复现没有好的调试工具排查效率会极低。物理调试工具需要能可视化碰撞体、接触点、约束、受力方向。最好还能暂停、单步、回放。我习惯在引擎里加一个物理调试模式按快捷键就能切换。动画调试工具需要能显示骨骼层级、当前播放的动画、混合权重、状态机状态。如果能实时调整参数并立即看到效果调试效率会高很多。提示调试工具不要等到项目后期才做应该在物理和动画系统开发的第一天就开始做。前期投入的时间后期会十倍地省回来。5.4 跨平台适配的注意事项物理和动画系统在不同平台上的表现可能有差异。浮点精度是最常见的问题x86 和 ARM 的浮点运算结果可能不同导致物理模拟的结果不一致。如果游戏有回放或者联网同步的需求这个问题必须解决。解决方案有两种一是用定点数代替浮点数保证所有平台的计算结果完全一致。二是用浮点数但在关键计算后做量化把差异控制在可接受范围内。定点数的精度有限适合物理模拟浮点数量化适合动画采样。我在一个联网项目里用的是定点数物理精度设为 1/1024 米。这个精度对于大多数游戏场景足够了而且所有平台的计算结果完全一致回放和同步都没有问题。