
做引擎架构的人基本都会遇到同一个困惑物理系统和动画系统名字里都带一个“动”字业务上天天粘在一起可真要往游戏引擎架构图里画又是两条八竿子打不着的技术栈。前两篇把引擎主循环、资源与渲染链路拆得差不多了这篇就把最后两块硬骨头放在一起盘一盘。不管你是要改商业引擎里某个子模块还是想自己搭一个轻量框架又或者只是被角色动作、碰撞穿模折磨的客户端开发这篇都能给你一张可以直接照着落地的地图。需要先说清楚一个前提这篇文章聊的是“架构”不是“算法大全”。物理引擎里的连续碰撞检测怎么写、动画曲线怎么压缩这些可以单独写一整本书我这里只把架构上必须做对、做扎实的骨架讲透顺带把容易踩的坑点出来。1. 物理和动画到底在各自解决什么问题1.1 两条完全不同的运动路径物理和动画看起来都在“移动物体”但它们的世界观是相反的。动画系统是数据驱动的。它的核心输入是一段录制好的、或者美术手 K 出来的关键帧数据然后按照时间轴去采样得到某一时刻的骨骼姿态。动画不关心你脚下的地面是否是斜坡也不关心头顶是否有一根横梁它只知道“这个角色在第 3.2 秒应该把左腿抬到某个角度”。说白了动画是先有结果再倒推过程。物理系统是因果驱动的。它的输入是力、速度、碰撞形状、约束条件然后通过积分运算去推算物体下一个时刻的位置和朝向。物理不关心这个角色是不是“看起来走得帅”它只关心“这坨质量 80kg 的胶囊体在受到 500N 的推力后到底会被推多远、会不会撞上墙”。这两条路径最终都会汇合到同一个地方改变某个节点在场景中的 Transform。动画通过骨骼矩阵驱动视觉网格物理通过刚体变换驱动碰撞体而角色这类对象往往是“动画给一个目标位移物理做一次碰撞修正再把修正后的结果喂回来”。理解了这个循环后面所有架构设计都有了锚点。1.2 共享的底座变换、时间与对象生命周期两条路径完全不同但它们对引擎底层有同样的依赖这四样东西必须在架构里先准备好场景变换树物理和动画最终都要写 Transform引擎必须提供一个统一的、线程安全的节点树访问接口。最常用的做法是 ECS 里放TransformComponent物理和动画都只改这个组件里的数据而不是各自维护一套坐标。时间系统动画需要“自上次采样以来过了多久”物理需要“这次的积分步长是多少”但动画通常跟渲染帧走物理必须用固定步长。架构上要提供两套时钟一个变步长的渲染时钟一个固定步长的物理时钟然后再做一个对齐机制。对象生命周期物理世界里的 body 和动画系统里的 skeleton生命周期一般比游戏逻辑对象要长而且它们创建/销毁的时机往往开在后台线程。如果直接把业务对象指针塞给物理或者动画求值器等到并发出问题、对象已经被回收时你会查得很痛苦。正确姿势是用entityId做映射。数据版本号物理变换从物理线程写回、动画骨骼矩阵从动画线程写回渲染线程要读取。如果两个写入方交叉覆盖同一块内存偶发闪一下就没了。每帧写数据时带一个单调递增的版本号读方先读版本号、再读数据、再读一次版本号校验是成本很低的防撕裂手段。2. 物理系统的标准分层架构从空间索引到求解器2.1 物理世界与游戏世界的“双世界”镜像商业引擎里物理部分几乎都是外接库PhysX、Bullet、Jolt或者自研的轻量碰撞库。无论底层是谁物理系统在架构上都表现为一个与游戏世界“镜像”的平行世界。游戏逻辑世界里有一个Character对象对应物理世界里一个CapsuleBody游戏逻辑世界里有一堵可被砍碎的墙对应物理世界里一个由几块凸包拼起来的CompoundBody。两边通过id互相引用但数据不共用。这个镜像关系必须一开始就设计清楚否则后面会犯一个经典错误为了让物理引擎少一次同步直接把游戏对象的指针塞进物理 body 的userData。单线程时没事一旦物理引擎跑在独立线程上或者你开启了异步模拟游戏对象在物理回调触发时可能已经被销毁了轻则读到野指针重则直接崩溃。安全做法是userData里只放一个uint32_t entityId需要访问游戏对象时回到主线程通过实体管理器查。另一个容易忽略的点是物理 world 的同步时机。我们通常不让物理引擎每帧直接读写游戏场景中的 Transform而是用一个SyncBuffer做批处理模拟开始前把所有“动态刚体”的最新位置统一写进物理世界模拟结束后把物理世界算出的新位置统一读回场景树。这样物理引擎对场景的访问是紧凑的、批量的cache 友好度远远好于逐对象散弹式访问。对于移动端这种缓存敏感的平台这个设计带来的收益非常明显。2.2 Broadphase 与 Narrowphase不要让精确计算压垮 CPU物理引擎在做碰撞检测时内部是分阶段的。架构上最核心的分层是 Broadphase 和 Narrowphase。**Broadphase粗检测**的任务是“快速排除显然不可能碰撞的物体对”。常见算法是 Sweep and Prune按某一个轴排序的滑动窗口、Dynamic BVH 树、或者场景很大的时候用格子哈希。粗检测的输出是一堆“可能碰撞的候选对”。它的核心指标是漏报率必须为 0——漏掉一对就会出现物体互相穿透这在玩法上是不可接受的。**Narrowphase精检测**的任务是针对候选对做真正的形状相交测试。球-球、球-盒、胶囊-胶囊、凸包-凸包各有不同的算法。凸体之间通常用 GJK 判断是否相交再用 EPA 计算穿透深度和接触法线。在架构层面Narrowphase 有一个必须留出来的扩展点自定义形状。游戏里永远会出现标准形状表达不了的东西比如一面弯曲的墙、一张被撕裂的布料、一段动态修改的地形。如果碰撞库只支持内置球/盒/胶囊/凸包后面要加东西就会很痛苦。好的架构在 Narrowphase 阶段抽象出Shape接口允许注册自定义的相交回调而不是把形状类型写死在一串switch case里。这两个阶段对 CPU 的开销差异极大所以做性能剖析时要分开统计。我见过不少项目优化半天物理性能最后定位到是 Broadphase 里某个动态对象的 AABB 更新太频繁导致树结构反复重排——这种问题只看整体耗时根本看不出来。2.3 迭代求解器、固定步长与约束守恒检测出碰撞之后物理引擎要回答一个最关键的问题两个物体相互挤压时各自应该以多大的速度弹开这一步由约束求解器完成。主流的求解思路是 PGSProjected Gauss-Seidel投影高斯-赛德尔迭代也就是把接触点、关节约束都转成一组不等式/等式约束然后迭代求解多次每次迭代不断修正速度直到误差收敛。迭代次数直接决定了物理的“硬度”迭代太少物体会显得软绵绵、堆叠的箱子会不停抖动迭代太多CPU 开销成倍上涨。架构上要把迭代次数做成可配置参数不同平台用不同档位而不是写死在代码里。物理模拟还有一个硬性要求固定时间步长。这是数值积分稳定性决定的——半隐式欧拉这类积分器只有在步长固定的前提下误差才是可控的。如果步长随渲染帧率波动30 帧和 144 帧下同一段物理模拟会走出完全不同的结果联机同步更是不用想了。“固定步长”具体到代码上最常见的是一个 accumulator 循环const float fixedDt 1.0f / 60.0f; float accumulator 0.0f; while (true) { float frameTime getFrameDeltaTime(); accumulator frameTime; // 限制单帧最大追赶次数防止“死亡螺旋” int maxSteps 5; while (accumulator fixedDt maxSteps-- 0) { physicsWorld.step(fixedDt); accumulator - fixedDt; } // 如果仍然有残余时间说明物理追赶不上直接丢弃这一小段 if (maxSteps 0) { accumulator 0.0f; } }maxSteps限制非常重要。有些设备突然卡一下帧间隔飙到 200ms如果不限制循环次数一帧内要补几十次物理步进后面每帧都会持续追赶整个游戏会雪崩成幻灯片——这个现象有一个专门的名字叫 spiral of death死亡螺旋。宁可丢弃一点时间差也要保证实时性。3. 动画系统的三层管线资源、求值与姿态驱动3.1 数据层骨架、动画片段与曲线压缩动画系统的第一层是数据层负责把美术制作的内容转成引擎能高效消费的二进制资源。骨架Skeleton描述的是骨骼层级结构每个骨骼的父节点索引、局部绑定姿势T-Pose、世界逆绑定姿势IBind。蒙皮阶段需要用 IBind 矩阵把顶点从模型空间转到骨骼空间再乘骨骼当前的世界矩阵所以骨架数据一旦发布IBind 矩阵就必须冻结。后面会专门讲这个坑。动画片段AnimationClip的原始形态是一大堆曲线哪根骨骼在哪个时间点旋转了多少、位移了多少。曲线存在两种存储方向按通道存储Channel-based每根骨骼的所有关键帧连续排列适合采样时一次把一根骨骼全部读完按时间采样存储Frame-based每个时间点的所有骨骼数据连续排列适合 GPU 蒙皮直接拉取。架构上两种格式都用加载时根据用途转换。平时 CPU 动画求值用 channel-basedGPU 蒙皮时打包成 frame-based 的骨骼纹理。数据层还有一个不能省的东西压缩。一段 10 秒的 60fps 动捕片段30 根骨骼、每根骨骼一个旋转加一个位移原始数据量轻松上兆字节。工程上常见的压缩手段包括关键帧裁剪对曲率变化小的区间直接抽掉中间帧量化旋转的四元数每个分量用 16bit 或 8bit 定点存储通道复用两根骨骼动画完全一样时共享数据。压缩和解压都在加载时完成运行时只保留解压后的采样缓冲区。动画占内存的大头往往不是贴图而是这些看起来不起眼的 Clip压不压缩能差出 3~5 倍的内存占用。3.2 求值层动画状态机与混合数据层准备好之后下一层是求值层。这一层回答的问题是“在某个输入条件下当前应该播放哪段动画、以及各段动画应该按什么权重混合。”业界标准的做法是动画状态机Animation State Machine。它的架构核心是三个概念状态State)绑定到一个或多个动画 Clip可以配置播放速度、循环方式、是否播放完自动切换转换Transition从状态 A 到状态 B 的条件通常由布尔/浮点参数触发转换过程一般做一段 Crossfade交叉淡化参数Parameter暴露给游戏逻辑层的控制接口比如isMoving、speed、turnAngle。这里最重要的架构决策是游戏逻辑永远不直接告诉动画系统“播放哪段动画”而是只改参数由状态机自己决定怎么过渡。这样做的原因是直接指定动画会绕过所有混合和转换规则导致角色在突然切换动作时出现“瞬移式”滑动非常出戏。混合层面还需要支持分层动画。典型的场景是角色下半身走路上半身同时举枪瞄准或者开门。实现上就是把骨骼分成几个插槽下半身、上半身、头部分别跑独立的子状态机最后按权重把各层 pose 合并到同一块姿态缓冲区。这个“姿态缓冲区Pose Buffer”可以理解成一块固定大小的骨骼变换数组所有系统都在这个数组上做读写它是动画系统的中央数据交换区。3.3 应用层骨骼变换发布与蒙皮求值层的产出是一堆局部空间下骨骼变换它们此时还只是“一堆数字”必须经过应用层才能变成屏幕上会动的东西。应用层要做三件事把局部骨骼变换沿着骨架层级做一次自顶向下的世界变换传播得到每一根骨骼的世界矩阵把世界骨骼矩阵交给渲染层用于蒙皮SkinnedMesh计算把关键骨骼的世界矩阵发布给其他系统物理胶囊体的跟随位置、IK 的参考点、特效的挂点比如枪口 muzzle 位置。蒙皮本身有两种常见架构CPU 蒙皮和 GPU 蒙皮。CPU 蒙皮把顶点变换放在动画线程里做好处是任何平台能跑坏处是顶点量一大就顶不住。GPU 蒙皮把骨骼矩阵上传成纹理/Uniform 数组顶点着色器里做矩阵变换性能好得多但代价是要处理“CPU 端也需要骨骼矩阵”的矛盾。我的建议是动画求值线程永远保留一份 CPU 端骨骼矩阵给物理、IK、逻辑查询用渲染用的那份 GPU 数据由渲染线程自己拷贝上传。不要让渲染线程反过来读动画线程的 pose buffer否则每一帧都要做同步等待架构上就输了。4. 物理与动画的交叉地带根运动、布娃娃与程序化混合4.1 根运动把动捕位移交给物理检测角色动画系统里有一个特别的通道叫根运动Root Motion。动捕数据里角色整体在场景中的位移和旋转也存在根骨骼通常是臀部或双脚中间的曲线上。播放这段动画时角色要么“原地踏步跑”位移靠代码控制要么“按动画轨迹跑”位移直接读动捕。工程上更自然的做法是动画求值层输出根运动增量物理层做碰撞解析。也就是说动画说“这一帧角色应该朝前方移动 0.3 米”物理系统拿这个增量去检测前方有没有墙有墙就把实际位移截断到碰撞点之前。截断后的实际位移再回写动画状态机作为根骨骼的补偿让脚步动画看起来是贴合地面的。在这个环节最容易犯的错是直接让动画根骨骼的世界位移盖掉物理修正结果。一旦角色踩到台阶、坡道根运动的垂直分量会把角色带进地里或者抬到空中出现经典的“滑步月球漫步”。正确架构一定分两层根骨骼位移 动画驱动视觉层角色控制器位置 物理修正后的实际位置逻辑层视觉上再通过偏移动画根骨骼把两者差值慢慢收敛掉。4.2 受击/倒地时的布娃娃接管逻辑角色死亡或受到重击时需要从“播放动画”无缝切换到“被物理支配”这就是布娃娃Ragdoll系统。架构上布娃娃不是一股脑把角色所有骨骼都交给物理而是先做一张“动画骨骼 → 物理刚性体”的映射表骨盆对应一个大刚体四肢各段对应几个胶囊体关节用球窝约束连起来。接管的瞬间有一个关键细节把动画当前每一根骨骼的速度直接赋给对应的物理刚体。不做这一步角色倒地的瞬间会先“僵住”然后才开始受重力下落观感上就像撞上了什么透明的屏障。正确的做法是“热切换”动画与物理并行跑一小段时间然后把动画权重逐渐衰减物理权重逐渐接管。即使在布娃娃模式下也经常需要在手部或头部保留一小块动画驱动区域用来维持角色最后的“表达”——比如倒下后头还要看向敌人实现方法就是在物理骨上面叠加一层局部动画。4.3 表层混合与物理骨毛发、布料的局部自治游戏里除了角色躯干还有头发、裙摆、尾巴这类“既要有动画参考又要有自然物理效果”的对象。它们的解决方案是表层物理模拟动画先算出一个基础姿态然后在上边叠加一个物理模拟层。这类模拟在架构上有一个好听的名字Post-Animation Physics。它的数据流是动画状态机产出基础骨骼姿态物理线程读取基础姿态把需要模拟的骨骼当作“受约束的粒子系统”处理——发梢骨骼既要逼近动画给的参考位置又受重力、惯性、碰撞排斥影响物理线程把修正后的骨骼写回姿态缓冲区渲染层照常蒙皮完全不感知这套机制的存在。好处是动画师不需要手动 K 出头发摆动的每一帧代价是需要为模拟骨骼额外预留数据结构。好在模拟骨骼的数量通常只有十几根CPU 开销可以忽略不计。这个方案比纯物理头发稳定得多也是目前大世界游戏最主流的做法。5. 帧循环里的调度秩序动画、物理、渲染的节奏分工5.1 动画用渲染帧物理用固定步长两个时钟怎么对齐前面说过动画采样通常跟着渲染帧走——渲染帧是变步长的60 帧和 144 帧都正常跑物理则是固定步长。这带来一个矛盾动画已经跑到第 3.012 秒了物理世界刚刚完成第 180 次固定步进两个系统看到的“当前时间”不一样。解决这个问题的关键是插值。物理每算完一个固定步长就把刚体位置写到“上一帧/当前帧”两个快照里渲染线程读取时根据当前渲染时间在快照间做一次插值。这样物理模拟的低频更新比如 60Hz也能支撑渲染的高帧率比如 144Hz的视觉平滑度。动画则不需要插值因为它是按渲染帧采样的天然对齐。但动画求值产生的根运动增量消费时要留意它对应的是一段“变步长”的时间跨度后面喂给物理做碰撞解析时不能直接当作物理固定步长的速度输入。常见做法是在状态机层面把 root motion delta 除以帧时间转成瞬时速度再交给物理层。5.2 任务依赖与多线程缓冲现代引擎的帧循环不是线性执行而是一张任务依赖图Job Graph。物理与动画相关的依赖链通常是这样的输入采集 → 游戏逻辑更新写动画参数 → 动画求值可并行 → IK/程序化后处理 → 物理模拟提交角色速度可并行 → 物理线程跑完 → 读取物理结果 → 回写场景树 → 渲染线程打包需要注意的依赖点是动画求值依赖游戏逻辑参数所以它排在逻辑之后布娃娃或物理骨依赖动画姿态输出所以它排在动画之后角色控制器的位移更新依赖动画根运动增量和物理碰撞结果所以它横跨两个阶段。多线程架构上有一个常用的基础设施叫做双缓冲Double Buffer物理线程写 buffer A渲染线程读 buffer B下一帧交换。写入方永远不需要等待读取方读取方最多看到一帧前的旧数据——这在大多数游戏里完全可接受尤其是角色蒙皮这种“晚一帧根本看不出来”的场景。顺带说一句物理引擎的回调比如碰撞开始、关闭触发器不应该在物理线程里直接调用游戏逻辑函数。正确姿势是物理线程只把事件写进一个无锁队列主线程在固定步长结束后统一消费。否则你会发现同样的碰撞事件有时触发有时不触发甚至游戏对象已经被销毁还在回调里被访问——这类 bug 几乎都是线程模型没分清楚造成的。6. 容易被低估的三个架构坑个人实测经验6.1 单位制1 米和 100 厘米的物理参数幻觉物理引擎里单位制是默认“米、千克、秒”的。如果美术或者关卡设计师习惯了把 1 单位当作 1 厘米——这在很多建模软件里很常见——角色就会变成一个 200 米高的巨人。更隐蔽的问题是数值范围失控小物体比如一颗子弹、一颗螺丝在米制单位下只有 0.01 的尺度Broadphase 的 AABB 容差、Narrowphase 的接触阈值都要跟着调整否则会出现明明看到物体穿模了物理引擎却报告“没有碰撞”的诡异现象。架构上的兜底措施是在引擎核心设置一个全局单位换算常量物理引擎一律换算成标准米制游戏逻辑层对外暴露的接口用项目自定义单位。不要指望每个程序都记得手动换算中心化换算比约定自律可靠得多。6.2 碰撞回调不是“函数调用”是“事件消息”很多项目第一次接入物理引擎都会把碰撞回调直接写成“碰撞了就执行一段逻辑”。早期人少、场景小没事等到大地图、大量动态物的时候就开始连续崩。问题出在几个层面回调时机发生在物理模拟中间此时场景的物理状态可能仍然处于“中间状态”读取游戏逻辑数据可能读到旧值回调所在线程不是主线程访问 UI、动画、网络接口都不安全同一帧内物理引擎可能回调同一个碰撞对多次如果不做去重加血、扣血、播放音效这类逻辑会执行多次。架构上强烈建议把物理回调设计成事件流物理线程填充一个环形缓冲区的CollisionEvent {entityA, entityB, phase, impulse}主线程在逻辑 Update 的入口统一处理并且用(entityA, entityB)加阶段做一个去重表。框架感更强也更好调试——你可以在事件流上打断点一帧一帧检查“到底谁碰了谁”。6.3 动画绑定姿势与胶囊体骨骼绑定动画系统最常见的重构级事故是在项目中期改了美术资源的 T-Pose 或者骨骼层级。哪怕只是给骨架中间加了一根新的虚拟骨骼所有蒙皮顶点、动画 Clip、武器挂点的本地变换全部会错位因为 IBind 矩阵变了。这个坑的根治方法不是在编辑器里写脚本批量重导而是在架构上约定骨架资源一旦发布就冻结绑定姿势严禁修改。要加骨骼不做“改原有骨架”而是新建一个骨架把旧 Clip 做一次重定向Retargeting映射映射逻辑基于骨骼名字而不是索引。骨骼索引在运行时可以做成紧凑的boneId但在资产层面必须保留名字通道这是唯一经得起迭代的方案。另外角色胶囊体跟随骨骼的绑定建议挂在“骨盆”或者“根骨骼”上而不是 Head、Chest 这种容易因为动画偏移产生视觉抖动的部位。胶囊体的位置不应该直接等于骨骼世界坐标而是用骨骼坐标加一个可配置的偏移量——不同体型、不同动画集下调试这个偏移量是日常高频操作把它暴露成可调参数会省很多事。我自己在项目里踩得最深的一次就是所有物理、动画、渲染的模块都做完了结果因为美术需要在腰上挂一把动态刀鞘团队直接在原骨架上多插了一根骨骼导致全身蒙皮错位回滚了整整一个迭代。从那以后“冻结绑定姿势”这几个字就根深蒂固地长在了我的架构原则里。7. 让“动”这件事变得可控物理与动画的架构设计归根结底是在回答一个问题怎么让“物体动起来”这件事变得可控、可预测、可扩展。物理追求的是确定性——固定步长、迭代次数、单位制、线程模型每一个决策都是为了让它算出的结果稳定可复现。动画追求的是表现力——状态机、分层混合、根运动、布娃娃每一个机制都是为了让它产出的姿态平滑、可信、可表达复杂语义。两者在一套引擎里共存靠的不是算法多高级而是数据边界划得足够清楚、调度时序足够严格。如果你正在设计或者重构这一类系统我的建议很简单先画清楚数据流图把“谁产生数据、谁消费数据、谁在哪条线程上读写数据”标出来再去纠结算法和参数。这个顺序从来没有错过。