
1. 物理与动画在引擎架构中的真实定位很多人第一次翻《游戏引擎架构》这类书翻到物理和动画这两章时容易产生一种错觉物理就是碰撞检测加刚体求解动画就是关键帧插值加骨骼蒙皮两件事各管各的中间隔着一道墙。但真正在引擎里写过这两套系统的人会告诉你它们之间的关系远比教科书上画的模块框图要纠缠得多。物理系统每帧输出的变换矩阵是动画系统做布娃娃、做物理驱动骨骼、做载具悬挂的直接输入而动画系统里那些带根运动的动作又反过来要把位移喂给物理系统做胶囊体的移动。这两套系统在运行时是互相咬合的谁也不是谁的附属。这篇文章想聊的就是物理系统和动画系统在引擎架构层面到底是怎么设计的它们各自内部的核心模块怎么划分数据怎么流动以及在实际工程中那些文档里不会写、但踩过一次就忘不掉的坑。适合已经有一定引擎使用经验、想往底层架构方向深入的开发者也适合正在自己动手写小引擎、卡在物理和动画衔接处的朋友。我不会只讲概念会尽量把每个设计决策背后的“为什么”讲清楚因为架构这东西光知道长什么样没用得知道它为什么长这样。先给一个整体的认知框架。物理系统在引擎里通常分成三层碰撞检测层负责回答“谁和谁碰上了”约束求解层负责回答“碰上了之后怎么分开、怎么传力”场景查询层负责回答“从A点到B点中间有没有东西”。动画系统则分成数据层骨骼、蒙皮、动画曲线、求值层采样、混合、状态机、应用层蒙皮矩阵计算、根运动提取、与物理的交互。这两套系统都遵循一个共同的设计哲学把计算密集的部分做成数据导向的、可并行的把逻辑控制的部分做成可扩展的、事件驱动的。理解了这个哲学后面所有的模块划分就都顺了。提示如果你正在自己写引擎不要一上来就想着把物理和动画做成通用框架。先把一条最短路径跑通——一个胶囊体加一个地面一个骨骼加一段动画让它们能正确显示和碰撞再逐步往外扩。架构是在迭代中长出来的不是一开始就设计完整的。2. 碰撞检测的宽相与窄相为什么不能只做一步2.1 宽相的本质是“用便宜的计算排除掉绝大多数不可能碰撞的对象”假设场景里有一千个物体如果每两个都做一次精确的碰撞检测那就是接近五十万次配对。每次配对哪怕只花一微秒一帧光碰撞检测就要半秒这显然不可接受。宽相Broad Phase要解决的就是这个问题用非常廉价的计算快速排除掉那些明显不可能碰撞的配对只留下少量“疑似碰撞”的候选对交给窄相去精确判断。宽相最常用的数据结构是动态AABB树Dynamic AABB Tree。每个物体用一个轴对齐包围盒AABB包住这些AABB组成一棵二叉树父节点的AABB是子节点AABB的并集。查询时从根节点往下遍历如果某个节点的AABB和查询AABB不相交整棵子树直接跳过。这个结构的妙处在于它不需要每帧重建物体移动时只需要更新对应叶子节点的AABB然后做局部的树旋转来保持平衡。实测下来一千个动态物体的场景宽相每帧的耗时通常在零点几毫秒量级完全在预算内。另一种常见方案是空间哈希Spatial Hashing把空间划分成固定大小的格子每个物体根据其AABB覆盖的格子注册到对应的桶里。查询时只需要检查同一个桶里的物体。空间哈希的优点是实现简单、插入删除快缺点是格子大小需要根据场景调调不好要么桶里物体太多失去意义要么物体跨太多桶导致重复注册。我个人的经验是物体大小比较均匀的场景用空间哈希很舒服物体大小差异悬殊的场景还是AABB树更稳。2.2 窄相的核心是GJK和EPA但工程上往往用更简单的方案窄相Narrow Phase要回答的是精确的碰撞问题两个凸体到底有没有相交如果相交穿透深度和碰撞法线是什么。理论上最通用的方案是GJK算法Gilbert-Johnson-Keerthi配合EPA算法Expanding Polytope Algorithm。GJK用来判断两个凸体是否相交它通过在闵可夫斯基差空间里迭代寻找原点是否被包含来判断如果相交EPA再从这个结果出发扩展成一个多面体来求最小穿透向量。但工程上很多引擎并不会对所有形状都用GJK。球体对球体、球体对胶囊体、胶囊体对胶囊体这些常见组合都有解析解直接套公式算比GJK快得多而且数值更稳定。GJK主要留给凸包对凸包、凸包对胶囊体这类没有简单解析解的组合。这种“特例走解析、通用走GJK”的混合策略是实际引擎里非常常见的做法。形状组合推荐方案原因球-球解析解距离比较一次开方球-胶囊解析解点到线段距离胶囊-胶囊解析解线段到线段最近点凸包-凸包GJKEPA无简单解析解凸包-球GJK或解析视凸包顶点数而定网格-任意BVH三角形测试网格非凸需分解2.3 碰撞过滤不是所有碰上的都要处理一个容易被忽略但极其重要的机制是碰撞过滤。场景里两个物体在几何上相交了不代表它们应该产生物理响应。比如角色的胶囊体和它自己持有的武器比如载具的底盘和车轮比如布娃娃的相邻骨骼。这些都需要通过过滤机制排除掉。过滤通常分两层层级过滤Layer Filtering和组过滤Group Filtering。层级过滤是粗粒度的比如“玩家层”和“敌人层”碰“玩家层”和“装饰层”不碰。组过滤是细粒度的用位掩码控制每个物体有一个“属于哪些组”的掩码和一个“和哪些组碰”的掩码两个物体只有在彼此的掩码匹配时才做碰撞。这套机制在物理引擎的API里通常表现为collisionFilterGroup和collisionFilterMask两个参数用的时候一定要想清楚不然会出现角色被自己的子弹推着走这种诡异现象。注意碰撞过滤是在宽相之后、窄相之前做的。也就是说被过滤掉的配对不会进入窄相这是性能优化的重要一环。但过滤本身也有开销如果过滤条件太复杂反而得不偿失。建议把最常用的过滤条件放在最前面。3. 约束求解器物理系统里最像“玄学”的部分3.1 约束求解的本质是解一个大型的线性互补问题刚体物理的核心方程是牛顿-欧拉方程但加上碰撞约束、关节约束之后问题就变成了在满足所有约束的前提下求每个物体的速度和位置。数学上这是一个线性互补问题LCP规模等于约束的数量乘以每个约束的维度。直接求解LCP在实时场景里是不现实的所以实际引擎都用迭代法。最常用的迭代法是序列脉冲Sequential Impulses也叫投影高斯-赛德尔Projected Gauss-Seidel。它的思路很直观一个一个约束地处理每次处理一个约束时计算它需要的冲量来满足这个约束然后立即把这个冲量应用到相关物体上再处理下一个约束。这样一轮下来所有约束都被处理了一遍但先处理的约束可能被后处理的约束破坏所以需要多迭代几轮。通常八到十轮迭代就能得到视觉上可接受的结果。序列脉冲的优点是实现简单、内存占用小、天然支持关节和碰撞的统一处理。缺点是收敛速度依赖迭代顺序而且对于质量比悬殊的物体比如一个大铁球压一个小木块收敛会很慢。工程上的应对办法是热启动Warm Starting把上一帧的冲量缓存下来这一帧作为初始值能显著加快收敛。这个技巧几乎是现代物理引擎的标配但很多自己写引擎的人不知道导致同样的迭代次数下效果差很多。3.2 关节约束和碰撞约束在求解器里是统一的很多人以为关节是关节碰撞是碰撞两套东西分开算。但在序列脉冲框架下它们其实是统一的都是一个约束都有雅可比矩阵都需要计算有效质量都需要施加冲量。区别只在于约束的维度不同——碰撞约束通常是三维的法线方向加两个摩擦方向球关节是三维的三个旋转自由度铰链关节是五维的去掉一个旋转自由度。这种统一带来的好处是求解器只需要写一套迭代逻辑关节和碰撞可以混合在一起迭代互相影响。比如一个布娃娃它的骨骼之间是关节约束骨骼和地面之间是碰撞约束在求解器里它们一起迭代布娃娃倒地时的姿态才会自然。如果分开算先解关节再解碰撞就会出现骨骼穿地或者关节被拉断的现象。3.3 摩擦力的处理是物理真实感的关键摩擦力在约束求解里通常用库仑摩擦模型摩擦力的大小不超过法向力乘以摩擦系数方向与相对滑动趋势相反。在序列脉冲里摩擦约束和法向约束是分开处理的先解法向约束确定法向冲量再根据法向冲量计算摩擦锥的上界然后解摩擦约束。这里有个细节摩擦约束是二维的两个切向方向但这两个方向不是独立的它们共同受一个摩擦锥的约束。简单的做法是分别对两个切向做一维约束然后钳制到摩擦锥内。更精确的做法是用一个二维的锥约束但计算量更大。实测下来对于大多数游戏场景分别处理两个切向已经足够只有在需要精确模拟物体在斜面上缓慢滑动时才会看出差别。提示摩擦系数不要设成零。很多新手为了“让物体滑得顺”把摩擦设成零结果物体永远停不下来还会出现数值抖动。哪怕想要很滑的效果也建议给一个很小的值比如0.01让求解器有东西可以收敛。4. 动画系统的数据管线从美术资产到屏幕像素4.1 骨骼层级和蒙皮矩阵的计算顺序不能乱动画系统的数据源头是美术在DCC工具里做的骨骼和蒙皮。导出到引擎时通常是一棵骨骼树每个骨骼有一个局部变换相对于父骨骼以及每个顶点对应的骨骼索引和权重。运行时动画系统要做的事情是根据当前动画时间采样出每个骨骼的局部变换然后从根骨骼开始逐级乘以父骨骼的世界变换得到每个骨骼的世界变换最后用骨骼的世界变换和绑定姿势的逆变换计算出蒙皮矩阵。这个顺序不能乱。蒙皮矩阵的公式是SkinMatrix BoneWorld * InverseBindPose其中InverseBindPose是绑定姿势下骨骼世界变换的逆矩阵。这个矩阵在导出时就计算好存下来了运行时只需要做一次矩阵乘法。但BoneWorld的计算必须从根往下逐级进行因为子骨骼的世界变换依赖父骨骼。如果骨骼树很深这个逐级计算就是一条长长的依赖链没法并行。优化办法是把骨骼树按深度分层同一层的骨骼可以并行计算但层与层之间还是要串行。4.2 动画混合不是简单的插值单个动画的采样很简单就是在关键帧之间做插值。但游戏里角色通常同时播放多个动画跑步动画加一个上半身的射击动画再加一个面部的表情动画。这些动画需要混合在一起。混合的方式有很多种最基础的是线性混合Linear Blend按权重把多个动画的骨骼变换加权平均。但线性混合有个问题当两个动画的旋转差异很大时直接对四元数做线性插值会导致体积收缩看起来像骨骼被压扁了。正确的做法是用球面线性插值Slerp或者归一化线性插值Nlerp。Nlerp比Slerp快在大多数情况下视觉差异可以忽略所以很多引擎默认用Nlerp。更复杂的混合是分层混合Layered Blending比如上半身和下半身分别用不同的动画中间有一个混合权重控制过渡。还有遮罩混合Masked Blending只让动画影响特定的骨骼子集。这些混合方式在状态机里组合起来才能实现复杂的角色动作。4.3 状态机是动画逻辑的骨架动画状态机负责管理“当前应该播放哪个动画”这个决策。它由状态、过渡和条件组成。状态就是一个动画或一个混合树过渡定义了从一个状态到另一个状态的条件和过渡时间条件通常是参数比较比如速度大于某个值、是否按下跳跃键等。状态机的设计难点在于过渡的管理。一个状态可能有多条出边每条出边有不同的条件运行时需要按优先级依次检查。如果两条出边的条件同时满足应该走哪条通常的做法是给过渡设优先级或者按声明顺序检查。另外过渡期间两个动画同时播放并按权重混合过渡时间太短会显得突兀太长会显得迟钝。经验值是动作过渡用0.1到0.2秒表情过渡用0.3到0.5秒具体还要看动画本身的节奏。过渡类型典型时长适用场景即时切换0秒死亡、受击快速过渡0.1-0.2秒走跑切换、跳跃中速过渡0.2-0.4秒站蹲切换、武器切换慢速过渡0.5-1.0秒情绪变化、疲劳状态5. 物理与动画的咬合点布娃娃、根运动和物理骨骼5.1 布娃娃是物理和动画最典型的交界布娃娃Ragdoll的本质是把角色的骨骼替换成物理刚体骨骼之间的关节替换成物理关节然后让物理系统去驱动这些刚体。当角色死亡或失去意识时动画系统停止驱动骨骼物理系统接管角色就会像布娃娃一样倒下。实现布娃娃的关键在于动画到物理的切换。切换的瞬间需要把当前动画姿势下每个骨骼的世界变换转换成物理刚体的初始位置和旋转同时把动画的速度如果有根运动的话转换成刚体的初始速度。这个转换如果做得不好切换时角色会突然跳一下或者瘫软得不自然。经验做法是在切换前几帧就开始把动画权重逐渐降低物理权重逐渐升高做一个平滑的过渡。布娃娃的另一个难点是关节限制。人的关节不是球关节有活动范围。肘关节只能往一个方向弯膝关节不能反折。这些限制在物理关节里通过角度限制来实现。限制设得太松布娃娃会摆出人类不可能做到的姿势设得太紧布娃娃会显得僵硬。通常需要根据角色体型调几轮才能找到合适的值。5.2 根运动让动画驱动位移但和物理的配合需要小心根运动Root Motion是指动画本身包含根骨骼的位移角色的实际移动由动画驱动而不是由代码控制。这样做的好处是移动和动画完全同步不会出现滑步。但根运动和物理系统的配合需要小心如果角色撞到墙物理系统会阻止角色移动但动画还在播放根运动还在往前推就会导致角色贴着墙原地跑步。解决办法通常是在根运动应用到角色位置之前先做一次物理碰撞检测如果前方有障碍就把根运动的位移截断或者投影到障碍表面。这个处理在引擎里通常叫根运动重定向Root Motion Redirection。实现方式是在每帧的根运动位移上做一次胶囊体扫描Capsule Sweep如果扫描到碰撞就把位移调整到碰撞点之前。5.3 物理骨骼和动画骨骼的权重混合有些效果需要动画和物理同时驱动骨骼比如角色的头发、披风、尾巴。这些部位通常用物理骨骼Physics Bones来实现骨骼的主体运动由动画驱动但在动画结果之上叠加一层物理模拟让头发和披风有惯性、有摆动。实现方式通常是在动画求值之后、蒙皮矩阵计算之前插入一个物理更新步骤。这个步骤读取动画输出的骨骼世界变换把它作为物理模拟的目标或者约束然后运行物理迭代最后把物理结果写回骨骼变换。权重控制物理影响的程度权重为零时完全跟动画权重为一时完全跟物理。这种混合方式在实现上要注意顺序物理更新必须在动画求值之后否则物理会用上一帧的动画数据导致延迟。注意物理骨骼的模拟频率最好和动画求值频率一致。如果物理以固定频率运行而动画以可变频率运行两者之间需要做插值否则会出现抖动。很多引擎的物理默认以固定时间步长运行而渲染帧率是可变的这个同步问题在物理骨骼上尤其明显。6. 多线程与性能物理和动画的并行化实践6.1 物理的并行化主要在宽相和窄相求解器很难并行物理系统的并行化有个天然的边界宽相和窄相可以并行因为每个碰撞对的处理是独立的但约束求解器很难并行因为序列脉冲本身就是串行迭代每个约束的处理依赖前一个约束的结果。宽相的并行化通常是把场景分成多个区域每个区域一个线程做AABB树查询或者空间哈希查询最后合并结果。窄相的并行化是把候选碰撞对分给多个线程每个线程独立做精确碰撞检测输出接触点。这两步的并行效率通常很高因为任务之间没有依赖。求解器的并行化是个难题。一种思路是用雅可比迭代代替高斯-赛德尔迭代雅可比迭代中所有约束同时更新天然可并行但收敛速度比高斯-赛德尔慢需要更多迭代次数。另一种思路是图着色把约束按依赖关系分组同一组内的约束没有共享刚体可以并行处理不同组之间串行。这种方法在约束数量多、刚体数量多的时候效果不错但实现复杂度高。6.2 动画的并行化在求值层蒙皮计算可以放到GPU动画系统的并行化相对直接。骨骼树的逐级计算可以按深度分层并行同一层的骨骼互不依赖。动画混合也可以并行每个动画的采样独立进行最后合并。状态机的逻辑通常在主线程做因为涉及游戏逻辑参数但状态机输出的动画权重可以传给工作线程做实际采样。蒙皮计算是动画系统里计算量最大的部分每个顶点要做四次矩阵乘加假设每个顶点受四根骨骼影响。这个计算天然适合GPU把骨骼矩阵传到常量缓冲区或者纹理顶点着色器里直接算。现代引擎基本都把蒙皮放在GPU上做CPU只负责准备骨骼矩阵。这样CPU的动画开销就只剩下采样、混合和状态机通常每帧不到一毫秒。6.3 物理和动画的线程划分要避免数据竞争物理和动画如果放在不同的线程最大的风险是数据竞争。动画系统输出的骨骼变换物理系统可能要读做布娃娃或者物理骨骼物理系统输出的刚体变换动画系统可能要读做根运动或者物理驱动动画。如果两个线程同时读写同一块数据就会出现撕裂或者不一致。常见的做法是双缓冲物理和动画各自维护一份数据每帧结束时交换。动画线程写骨骼变换到缓冲区A物理线程从缓冲区B读上一帧的骨骼变换下一帧交换。这样读写分离没有竞争。代价是有一帧的延迟但对于大多数效果来说一帧延迟是可以接受的。另一种做法是任务图把物理和动画的各个步骤拆成任务用依赖关系组织成图由任务调度器决定哪些任务可以并行。这种方式的灵活性最高但实现复杂度也最高通常只有大型引擎才会这么做。并行方案优点缺点适用规模单线程简单无竞争性能瓶颈小型项目双缓冲无竞争实现简单一帧延迟中型项目任务图灵活利用率高实现复杂大型项目完全并行理论性能最高同步开销大特定场景7. 那些文档里不会写的踩坑记录7.1 物理时间步长和渲染帧率不一致导致的抖动这是我早期做物理时踩过的最大的坑。物理引擎通常要求固定时间步长比如每秒60次因为可变步长会破坏求解器的稳定性。但渲染帧率是可变的可能每秒120帧也可能每秒30帧。如果物理和渲染用同一个循环物理的步长就会随帧率变化导致同样的场景在不同机器上表现不一样甚至出现抖动。正确的做法是固定步长加插值物理以固定步长运行比如每16.67毫秒一次渲染时根据当前时间在两次物理状态之间做插值得到平滑的显示效果。这个插值只影响显示不影响物理状态。实现上物理维护一个累加器每帧把渲染时间加到累加器上当累加器超过步长时执行一次物理更新并从累加器里减去步长。渲染时用累加器剩余值作为插值系数。这个方案听起来简单但有个细节插值需要保存上一帧的物理状态。对于刚体来说就是上一帧的位置和旋转。插值位置直接线性插值插值旋转用四元数的Nlerp。如果不保存上一帧状态插值就无从谈起。很多自己写引擎的人忘了这一步结果物理看起来一顿一顿的。7.2 动画事件的触发时机和物理帧不对齐动画事件是指在动画的特定时间点触发游戏逻辑比如脚步声、攻击判定、特效生成。这些事件通常是在动画采样时根据时间判断的。但动画采样是在渲染帧做的而物理是在固定步长做的两者时间不对齐。如果攻击判定在动画事件里触发而物理碰撞检测在另一个时间点做就可能出现判定丢失或者重复判定。解决办法是把动画事件的时间点转换成物理时间轴上的事件在物理更新时检查。或者更简单粗暴动画事件触发时记录一个待处理标记在下一个物理更新时统一处理。这样虽然有一帧延迟但保证了判定和物理状态的一致性。对于大多数游戏来说一帧延迟是可以接受的。7.3 骨骼数量超过常量缓冲区限制蒙皮计算放到GPU时骨骼矩阵通常通过常量缓冲区传递。但常量缓冲区有大小限制比如DirectX 11保证至少64KB。每个骨骼矩阵是64字节4x4浮点矩阵64KB只能放1024个骨骼。如果角色骨骼超过这个数就需要用纹理来传骨骼矩阵或者把骨骼分组多次绘制。这个问题在移动端尤其突出因为移动GPU的常量缓冲区限制更小。解决办法是用骨骼纹理把骨骼矩阵存到一张浮点纹理里顶点着色器里根据骨骼索引采样纹理。这样骨骼数量只受纹理大小限制通常能支持到几千根骨骼。代价是采样纹理比读常量缓冲区慢一点但现代GPU上差异很小。7.4 布娃娃的初始速度没有正确传递布娃娃切换时如果角色正在移动刚体应该有初始速度。这个速度来自动画的根运动速度或者角色的移动速度。如果忘了传布娃娃会从静止开始看起来像角色突然刹车然后瘫倒。正确的做法是在切换时把角色的当前速度赋给布娃娃的根刚体其他刚体的速度根据根刚体的速度和关节关系推算。这个推算不需要很精确因为布娃娃很快就会被物理接管初始速度只影响前几帧的表现。但就是这几帧决定了布娃娃看起来是“倒下去”还是“瘫下去”。经验做法是根刚体速度等于角色速度其他刚体的速度等于根刚体速度加上一个随机的角速度扰动让布娃娃的倒下有一点随机性不至于每次都一样。8. 从架构角度看物理和动画的未来演进物理和动画这两套系统在架构上正在经历一些变化。物理方面GPU物理是一个明显的趋势。碰撞检测的宽相和窄相天然适合GPU因为它们是数据并行的。约束求解器虽然难并行但也有一些研究在尝试用GPU做大规模迭代。不过GPU物理的瓶颈在数据传输如果物理状态每帧都要从GPU读回CPU做游戏逻辑那传输开销可能抵消计算收益。所以GPU物理更适合那些不需要频繁读回的场景比如纯视觉的粒子物理或者布料模拟。动画方面机器学习驱动的动画正在兴起。传统动画状态机需要手动设计状态和过渡工作量大且难以覆盖所有情况。用神经网络学习运动数据可以直接根据输入参数生成动画省去了状态机的设计。但这种方案的可控性是个问题游戏需要精确控制角色动作时神经网络的输出不一定可靠。目前的实践是混合方案关键动作用手工动画过渡和细节用神经网络补全。从架构层面看物理和动画的边界正在模糊。物理骨骼、布娃娃、主动布娃娃这些技术本质上都是在物理和动画之间做混合。未来的引擎可能会把这两套系统统一成一个“运动系统”统一处理刚体、骨骼、约束和混合。这种统一在架构上更优雅但实现难度也更大因为物理和动画的数学基础不同物理是微分方程动画是插值和混合要统一需要一套新的抽象。我个人在实际项目中的体会是不要追求架构的完美统一而是追求接口的清晰。物理系统对外暴露的应该是“施加力”“查询状态”“创建约束”这些操作动画系统对外暴露的应该是“播放动画”“设置参数”“混合权重”这些操作。两者之间的交互通过一个明确的中间层来做比如“物理骨骼组件”或者“布娃娃控制器”。这样即使内部实现变化接口不变上层逻辑就不用改。架构的价值不在于内部多优雅而在于变化时影响的范围有多小。