游戏引擎架构深度解析(三):物理与动画系统
一位技术架构师视角下的游戏引擎系统设计与工程实践
前言
在数字内容产业蓬勃发展的今天,游戏引擎已经从单纯的"游戏开发工具"演变为横跨影视、建筑、医疗、教育、工业仿真等多个领域的实时交互内容创作平台。Unreal Engine、Unity等商业引擎的迭代速度不断加快,开源引擎生态也日益繁荣。然而,对于大多数开发者而言,游戏引擎内部的工作原理仍然是一个"黑盒"——我们习惯于使用上层的API和编辑器工具,却很少有机会系统性地理解其底层架构设计。
本文将从技术架构师的视角出发,系统性地拆解游戏引擎的核心架构。我们不会停留在"如何使用"的层面,而是深入探讨"为什么这样设计"、"这样设计的权衡是什么"、"在不同场景下应该如何选择技术方案"等深层问题。文章将理论与实践紧密结合:上篇深入讲解游戏引擎各个子系统的架构原理,下篇以Unreal Engine 5为例,剖析其C++架构设计的工程实践。
目标读者
本文主要面向以下读者群体:
- 中级游戏开发者:有一定的游戏开发经验,希望突破API使用层面,深入理解引擎内部原理
- 技术架构师/技术负责人:需要对游戏引擎架构有全局性理解,以做出正确的技术选型和架构决策
- 引擎开发工程师:正在或有志于从事引擎底层开发,希望建立系统化的知识体系
- 图形/物理/动画等专项工程师:希望了解自己所在的子系统如何与引擎其他部分协同工作
阅读本文需要具备以下基础:
- 扎实的C/C++编程基础
- 基本的计算机图形学知识
- 一定的线性代数和微积分基础
- 对游戏开发有基本概念性了解
本册为《游戏引擎架构深度解析》第三册,涵盖第三篇至第四篇,聚焦物理与碰撞系统、动画系统架构。
目录
第三篇:物理与碰撞系统
第四篇:动画系统架构
第三篇:物理与碰撞系统
第11章 物理引擎架构总览
11.1 物理引擎在游戏中的角色:从碰撞检测到动力学模拟
物理引擎是游戏引擎中负责模拟现实世界物理规律的核心子系统。它的职责远不止"让物体掉下来"这么简单——从最基础的碰撞检测,到复杂的刚体动力学、布料模拟、流体效果,再到角色运动的手感调校,物理引擎贯穿了游戏体验的方方面面。
从架构视角看,物理引擎在游戏引擎中扮演着"状态演化器"的角色。渲染系统负责"世界看起来什么样",而物理系统负责"世界如何运动和交互"。每一帧,物理引擎接收来自游戏逻辑的输入(施加的力、设置的速度、创建的约束),基于物理定律计算出下一时刻所有物体的位置、旋转和速度,然后将结果反馈给渲染系统和游戏逻辑系统。
物理引擎的发展经历了三个明显的阶段:
第一阶段:纯碰撞检测时代(1990年代)。早期游戏如《DOOM》和《Quake》只需要判断玩家是否撞到墙壁、子弹是否击中敌人。物理计算本质上是几何相交测试,没有动力学概念。角色的移动完全由逻辑层控制,物理系统只回答"是否碰撞"这个二元问题。
第二阶段:刚体动力学普及(2000-2010年代)。随着Havok和PhysX等商业物理引擎的兴起,刚体动力学成为3A游戏的标配。《Half-Life 2》的Source引擎首次将物理交互作为核心玩法元素——玩家可以拾取物体、堆叠箱子、利用物理机关解谜。这个阶段的物理引擎开始处理质量、惯性、力、扭矩等真实物理量。
第三阶段:多物理域融合(2010年代至今)。现代物理引擎不再局限于刚体,而是涵盖了布料、柔体、流体、破坏、毛发等多种物理现象。Unreal Engine的Chaos和Unity的Physics Stack都在朝"统一物理框架"的方向演进,试图用一套统一的求解器架构处理不同类型的物理模拟。
物理引擎在游戏循环中的位置:
游戏逻辑输入
│
▼
┌─────────────┐
│ 物理引擎 │
│ ┌─────────┐│
│ │ 碰撞检测││
│ └─────────┘│
│ ┌─────────┐│
│ │ 动力学 ││
│ └─────────┘│
│ ┌─────────┐│
│ │ 约束求解││
│ └─────────┘│
└─────────────┘
│
▼
渲染/逻辑输出
从性能角度看,物理引擎通常是游戏中仅次于渲染的第二大计算开销来源。一个开放世界游戏中可能同时存在数千个物理对象,每个对象都需要进行碰撞检测和动力学积分。因此,物理引擎的架构设计直接决定了游戏的性能上限和可玩性边界。
11.2 主流物理引擎对比:PhysX vs Bullet vs Box2D vs Jolt
NVIDIA PhysX 是目前行业应用最广泛的商业物理引擎。它最初由AGEIA公司开发,后被NVIDIA收购并开源。PhysX的优势在于:
- GPU加速支持成熟,可利用CUDA核心进行大规模刚体和粒子模拟
- 与Unreal Engine和Unity深度集成,工具链完善
- 支持丰富的高级特性:布料(Cloth)、柔体(Soft Body)、粒子流体(FLIP)、破坏(Destruction)
- 车辆物理系统成熟,被大量赛车游戏采用
PhysX的架构采用了"场景图 + 行为组件"的模式。PxScene是物理世界的容器,PxActor及其子类(PxRigidStatic、PxRigidDynamic)代表物理对象,PxShape定义碰撞几何,PxJoint定义约束关系。这种面向对象的设计使得扩展和定制相对容易。
Bullet Physics 是最具影响力的开源物理引擎。它完全免费、可商用,被广泛应用于独立游戏和科研领域。Bullet的特点是:
- 完全开源且无依赖,可在任何平台编译运行
- 架构设计清晰,代码质量高,是学习物理引擎的绝佳参考
- 支持刚体、柔体、流体等多种物理类型
- 提供了丰富的碰撞形状和约束类型
Bullet的架构可以说是现代物理引擎的"教科书实现"。它的btCollisionWorld、btDynamicsWorld、btRigidBody等类名几乎成了物理引擎的标准命名。许多商业引擎的物理系统都直接参考或基于Bullet构建。
Box2D 是专为2D游戏设计的轻量级物理引擎。由Erin Catar开发,是2D物理领域的事实标准。Box2D的设计哲学是"小而美":
- 专注于2D刚体动力学,不涉及3D或高级物理效果
- API简洁优雅,学习曲线平缓
- 性能极高,可在移动设备上流畅运行数百个物体
- 代码量小(约15000行),便于完全理解和定制
Box2D的影响力远超其代码规模。它的许多设计模式——特别是接触约束的求解方法和岛屿(Island)概念——被3D物理引擎广泛借鉴。Erin Catar在GDC上的演讲《Physics for Game Programmers》系列更是物理程序员的必读经典。
Jolt Physics 是近年来崛起的新星。它最初为《地平线:西之绝境》开发,后由Guerrilla Games开源。Jolt的设计目标是解决开放世界游戏的物理性能问题:
- 针对多核CPU深度优化,采用任务并行架构
- 支持大量(10万+)物理对象的广域场景
- 确定性模拟,支持网络游戏状态同步
- 内存占用低,对象生命周期管理高效
Jolt最引人注目的架构创新是其"Body Manager + BroadPhase + NarrowPhase + Constraint Solver"的四层解耦设计。每层都可以独立替换和扩展,物理对象(Body)和碰撞体(Shape)的内存布局经过精心优化,充分利用CPU缓存。
| 特性 | PhysX | Bullet | Box2D | Jolt |
|---|---|---|---|---|
| 维度 | 3D为主 | 2D+3D | 纯2D | 3D为主 |
| 许可证 | BSD-3 | zlib | MIT | MIT |
| GPU加速 | 支持CUDA | 实验性 | 无 | 无 |
| 并行架构 | 多线程 | 基础支持 | 单线程 | 任务级并行 |
| 布料/柔体 | 完善 | 有 | 无 | 基础 |
| 车辆物理 | 成熟 | 有 | 无 | 有 |
| 学习曲线 | 中等 | 较陡 | 平缓 | 中等 |
11.3 物理引擎的核心模块:碰撞检测、动力学、约束求解
一个典型的物理引擎可以划分为三个核心模块,它们以流水线方式协作:
模块一:碰撞检测(Collision Detection)
碰撞检测模块回答"哪些物体发生了接触"以及"接触的详细几何信息是什么"。这是物理引擎的基础——没有碰撞信息,动力学就无从谈起。
碰撞检测又分为两个阶段(详见第12章):
- Broad Phase(宽相):快速找出可能碰撞的物体对,使用空间加速结构(BVH、SAP等)将复杂度从O(n²)降低到接近O(n)
- Narrow Phase(窄相):对宽相筛选出的候选对进行精确的几何相交测试,计算碰撞点、碰撞法线、穿透深度等信息
碰撞检测流水线:
物体集合 → [Broad Phase] → 候选碰撞对 → [Narrow Phase] → 接触流(Contact Manifold)
模块二:动力学(Dynamics)
动力学模块负责根据力和速度更新物体的运动状态。这是物理引擎的"运动学心脏"。
刚体动力学的核心方程非常简洁:
F = m * a (牛顿第二定律)
τ = I * α (转动定律)
v = v₀ + a * dt (速度更新)
x = x₀ + v * dt (位置更新)
但在实际实现中,事情远没有这么简单。积分器的选择(显式欧拉 vs 半隐式欧拉 vs Verlet vs RK4)直接影响模拟的稳定性和精度。此外,还需要处理重力、阻尼、作用力、冲量等多种力的类型。
动力学模块的核心数据结构是刚体(Rigid Body),它包含以下状态量:
- 位置(Position):Vector3
- 朝向(Orientation):Quaternion
- 线速度(Linear Velocity):Vector3
- 角速度(Angular Velocity):Vector3
- 质量(Mass):标量
- 惯性张量(Inertia Tensor):Matrix3x3
模块三:约束求解(Constraint Solver)
约束求解是物理引擎中最复杂、最微妙的部分。它的任务是确保所有约束条件得到满足——物体不能互相穿透、关节不能被拉开、绳子不能超过长度限制。
约束求解的本质是一个优化问题:在满足所有约束条件的前提下,找到一组速度(或位置)修正量,使得系统的动能变化最小。
主流的求解方法包括:
- Sequential Impulse(顺序冲量法):PhysX和Box2D采用的方法。迭代遍历每个约束,逐个施加冲量修正。简单、稳定、易于调试。
- Projected Gauss-Seidel(投影高斯-赛德尔):Bullet采用的方法。将约束转化为线性互补问题(LCP),使用迭代法求解。数学上更严谨。
- Direct Solver(直接求解法):将所有约束组装成一个大型矩阵,直接求逆。精度最高,但复杂度为O(n³),只适合小规模场景。
约束求解过程:
初始速度
│
▼
┌───────────────┐
│ 迭代1:约束1 │
│ 迭代1:约束2 │
│ 迭代1:约束3 │
│ ... │
└───────────────┘
│
▼
┌───────────────┐
│ 迭代2:约束1 │
│ 迭代2:约束2 │
│ ... │
└───────────────┘
│
▼
最终速度
三个模块之间的协作流程可以概括为:
- Broad Phase:找出潜在碰撞对
- Narrow Phase:生成接触约束
- Integrate Velocities:根据力更新速度
- Constraint Solver:求解约束,修正速度
- Integrate Positions:根据速度更新位置
- Position Correction:位置级别的穿透修正
11.4 物理引擎与游戏引擎的集成架构
物理引擎很少独立存在,它必须与游戏引擎的其他子系统紧密协作。从架构视角看,物理集成有几个关键设计决策。
集成模式:内联 vs 插件
物理引擎与游戏引擎的集成有两种典型模式:
内联模式(Monolithic):物理系统作为引擎的核心模块,与引擎其他部分深度耦合。优点是性能最优、工具链统一;缺点是难以替换物理后端,引擎升级成本高。早期的Unreal Engine(UDK时代)和很多自研引擎采用这种模式。
插件模式(Plugin):物理引擎作为独立的中间件,通过抽象层与游戏引擎交互。游戏引擎定义一套物理接口(如PhysicsInterface),具体的物理引擎(PhysX、Bullet等)以插件形式实现这些接口。Unity和Unreal Engine 4/5都采用这种架构。
插件模式的架构优势非常明显:
- 可替换性:项目可以根据需求选择不同的物理后端
- 并行开发:引擎团队和物理中间件团队可以独立迭代
- 风险隔离:物理引擎的bug不会导致整个引擎崩溃
代价是一层抽象带来的性能开销和功能限制。抽象层往往只能提供"最大公约数"级别的功能,物理引擎的高级特性可能无法通过抽象层暴露。
数据同步:Transform的归属
物理引擎和渲染引擎都需要物体的位置和旋转信息。这个数据"归谁所有"是一个架构层面的重要问题。
方案一:物理为源(Physics Authoritative)。物理引擎是Transform的唯一真相来源。每帧物理模拟完成后,将结果同步到渲染组件。这种模式适合物理驱动的游戏(如《Angry Birds》、《Human: Fall Flat》)。
方案二:逻辑为源(Logic Authoritative)。游戏逻辑层是Transform的拥有者,物理引擎只是一个"碰撞查询服务"。每帧逻辑层把位置告诉物理引擎,物理引擎只做碰撞检测并返回结果。这种模式适合以逻辑为主的游戏(如MOBA、回合制策略)。
方案三:双轨制(Dual Track)。逻辑对象和物理对象各自维护Transform,通过事件同步。这是最灵活但也最复杂的方案。角色控制器通常采用这种模式——逻辑上角色移动了,但物理可能把它推回来,两者需要协商出最终结果。
线程模型:同步 vs 异步
物理模拟的计算量很大,如何安排它在多线程架构中的位置是另一个关键决策。
同步物理:物理模拟在主线程的固定阶段执行。游戏循环是"逻辑→物理→渲染"的串行流水线。优点是简单、确定性好、容易调试;缺点是物理成为性能瓶颈,无法利用多核。
异步物理:物理模拟在独立的物理线程中运行,与游戏逻辑和渲染并行。主线程提交物理命令,物理线程在后台执行,下一帧或几帧后取回结果。这种模式能充分利用多核,但带来了延迟和同步问题。
异步物理的时间线:
帧N: 逻辑提交命令 → 渲染帧N
帧N+1: 逻辑提交命令 → 物理执行帧N → 渲染帧N+1
帧N+2: 逻辑提交命令 → 物理执行帧N+1 → 渲染帧N+2
异步物理的核心挑战是一帧延迟。当逻辑层施加一个力时,结果要到下一帧才能看到。这对需要即时反馈的操作(如跳跃、射击)影响很大。解决方案通常是混合模式:关键的即时操作在主线程做预测性计算,其余的交给物理线程异步处理。
11.5 架构师视角:物理精度与性能的权衡
作为架构师,设计物理系统时最核心的权衡是精度与性能的永恒博弈。物理模拟的精度越高,计算成本就越高——而且往往是超线性增长。我们需要在多个维度上做出明智的取舍。
时间步长:固定步长 vs 可变步长
物理模拟的时间步长(Time Step)是最基础的精度旋钮。步长越小,模拟越精确,但每帧需要执行的步数越多。
固定步长(Fixed Timestep)是行业标准做法。物理引擎以固定的频率(通常是60Hz,即16.67ms)运行,与渲染帧率解耦。如果渲染帧率高于物理频率,就重复使用上一帧的物理结果;如果低于物理频率,就在一帧内执行多次物理步进。
固定步长的累加器模式:
accumulator += deltaTime;
while (accumulator >= fixedStep) {
stepPhysics(fixedStep);
accumulator -= fixedStep;
}
// 使用插值平滑渲染
interpolationAlpha = accumulator / fixedStep;
可变步长(Variable Timestep)则让物理步长跟随渲染帧率。实现简单,但会导致模拟结果依赖帧率——帧率波动时物体会出现抖动、穿透甚至行为不一致。这对于网络游戏和需要确定性的场景是不可接受的。
架构师建议:几乎所有情况下都应该使用固定步长。可变步长只适合对物理精度要求极低的原型或2D休闲游戏。
求解器迭代次数
约束求解器的迭代次数是另一个关键的精度/性能旋钮。迭代次数越多,约束满足得越好,物体越不容易穿透,但计算成本线性增长。
典型的迭代次数范围:
- 4-6次:休闲游戏,简单堆叠
- 8-10次:动作游戏,一般物理交互
- 15-20次:模拟游戏,复杂堆叠和精密机械
- 30+次:物理仿真,高精度需求
架构师需要考虑的问题是:迭代次数应该是全局统一的,还是可以按岛屿(Island)分配?Jolt Physics的做法是根据岛屿的复杂度动态调整迭代次数——堆叠的物体需要更多迭代,自由飞行的物体可以少迭代甚至跳过。
碰撞精度:连续碰撞检测(CCD)
快速运动的物体会出现"穿模"问题——一帧之内从障碍物的一边飞到另一边,碰撞检测完全失效。连续碰撞检测(CCD)通过计算物体运动的时间-空间轨迹来解决这个问题,但计算成本很高。
架构决策:
- 全局开启CCD:最精确,但性能代价巨大
- 仅对快速物体启用CCD:根据速度阈值判断,是常用的折中方案
- 子步长(Sub-stepping):将物理步长进一步细分,用多次离散检测近似连续效果
- 投射检测(Sweep/CAST):在移动前先做一次形状投射,预判碰撞
睡眠(Sleeping)机制
大部分物理对象在大部分时间里是静止的。如果对每个物体都执行完整的物理计算,就是巨大的浪费。睡眠机制的思想是:当物体的速度低于某个阈值且持续一段时间后,将其标记为"睡眠"状态,跳过后续的物理计算,直到有外力作用或碰撞发生。
睡眠机制看似简单,实则有很多架构考量:
- 阈值如何设定?太低会导致物体"假死",太高会导致不必要的计算
- 唤醒机制如何设计?碰撞唤醒、力唤醒、邻接唤醒
- 睡眠是按物体还是按岛屿?按岛屿更高效,因为接触的物体应该同睡同醒
层级化精度(LOD for Physics)
借鉴渲染中的LOD(Level of Detail)思想,物理系统也可以引入层级化精度策略:
- 近距离:高精度碰撞体(凸包、多边形)、完整动力学
- 中距离:简化碰撞体(胶囊体、包围盒)、减少求解迭代
- 远距离:仅Broad Phase,跳过Narrow Phase和动力学
- 极远距离:完全不参与物理模拟
这种策略对开放世界游戏尤为重要。玩家不会注意到远处一个箱子的物理模拟是否精确,但会注意到帧率是否流畅。
架构师决策清单
在设计物理系统架构时,需要回答以下问题:
- 游戏类型决定了物理的"角色定位"——是核心玩法、辅助效果还是背景装饰?
- 目标平台的CPU核心数和性能预算是多少?物理能分到多少算力?
- 确定性要求有多高?是否需要支持网络游戏的状态同步?
- 工具链的优先级如何?是追求可视化编辑效率,还是运行时性能?
- 物理引擎是自研还是采用中间件?如果是中间件,抽象层的边界在哪里?
这些问题没有标准答案,每一个选择都是在特定约束条件下的权衡。优秀的架构师不是追求"最精确"的物理,而是在给定的性能预算内,提供"足够好且恰到好处"的物理体验。
第12章 碰撞检测系统架构
12.1 碰撞检测的两阶段:Broad Phase vs Narrow Phase
碰撞检测是物理引擎的基石。如果不能准确、高效地判断物体是否相交以及如何相交,后续的动力学模拟和约束求解就无从谈起。
碰撞检测面临的核心挑战是规模问题。如果场景中有n个物体,朴素的两两比较(O(n²))在n超过几百时就会变得不可接受。一个开放世界场景可能有上万个物理对象,O(n²)的复杂度意味着上亿次检测——这在任何硬件上都是不现实的。
解决方案是将碰撞检测拆分为两个阶段:Broad Phase(宽相/粗检测)和Narrow Phase(窄相/精检测)。这是一种经典的"过滤-精确"架构模式,在计算机科学的许多领域都能看到类似的设计。
Broad Phase:快速筛选候选对
宽相的目标是"快速排除不可能碰撞的物体对"。它不关心碰撞的精确几何信息,只回答一个问题:"这两个物体有没有可能碰撞?"
宽相利用空间一致性和包围体(Bounding Volume)来加速检测。每个物体都用一个简单的包围体(通常是AABB,即轴对齐包围盒)包裹,如果两个物体的包围体都不相交,那么它们的精细几何形状更不可能相交。
宽相的输出是一个"潜在碰撞对"列表(Potential Collision Pairs, PCP)。理想情况下,这个列表应该既不遗漏真正的碰撞(不漏报),也不包含太多不可能的碰撞(不误报)。但宽相本质上是保守的——误报可以接受(窄相会排除),漏报不能接受(会导致穿模)。
Narrow Phase:精确计算碰撞信息
窄相接收宽相输出的候选对,对每一对进行精确的几何相交测试。它的输出是接触流形(Contact Manifold)——包含碰撞点、碰撞法线、穿透深度等详细信息的数据结构。
窄相的计算成本远高于宽相。两个复杂网格的相交测试可能需要数万个基本操作。因此,宽相的筛选效率直接决定了整个碰撞检测系统的性能。
两阶段碰撞检测架构:
n个物体
│
▼
┌─────────────┐
│ Broad Phase │ O(n) ~ O(n log n)
│ 空间加速 │
└─────────────┘
│
│ m个候选对 (m << n²)
▼
┌─────────────┐
│ Narrow Phase│ O(m) * 单次检测成本
│ 精确几何 │
└─────────────┘
│
▼
接触流形
两阶段设计的精髓在于将计算量的大头从昂贵的精确检测转移到廉价的粗略检测。只要宽相足够高效,即使场景中有上万个物体,最终进入窄相的候选对也可能只有几百个。
12.2 Broad Phase算法:Sweep and Prune、SAP、BVH
宽相算法的选择对物理引擎的整体性能有决定性影响。以下是几种主流算法的深度分析。
算法一:Sweep and Prune(扫描修剪法)
Sweep and Prune(简称SaP)是最经典的宽相算法之一。它的核心思想非常巧妙:如果两个物体的AABB在所有三个轴上的投影都重叠,它们才可能碰撞。
算法步骤:
- 为每个物体的AABB在x轴上记录两个事件:起点(min-x)和终点(max-x)
- 将所有事件按x坐标排序
- 沿x轴扫描,遇到起点就将物体加入"活跃集合",遇到终点就移除
- 每次加入新物体时,与活跃集合中的所有物体比较,记录x轴上重叠的对
- 对y轴和z轴重复同样的过程
- 在三个轴上都重叠的对就是潜在碰撞对
SaP的时间复杂度主要由排序决定。如果物体移动幅度不大,排序可以利用时间连贯性(Temporal Coherence)——上一帧已经排好序的数组,这一帧只需做少量的相邻交换(冒泡排序的思路)。在理想情况下,复杂度接近O(n)。
Box2D的宽相就是基于SaP的优化版本。它只在x轴和y轴上做扫描(2D情况下),并且使用了"轴选择"启发式——总是选择物体分布最均匀的轴作为主扫描轴。
SaP的优点:
- 实现简单,代码量小
- 利用时间连贯性,物体缓慢移动时效率极高
- 内存访问模式友好,缓存命中率高
SaP的缺点:
- 当物体瞬间传送(Teleport)或场景剧烈变化时,排序成本飙升到O(n log n)
- 对于大量静止物体堆叠的场景,活跃集合可能很大,导致内层比较增多
- 不支持高效的区域查询(需要额外的数据结构)
算法二:Spatial Hashing(空间哈希)
空间哈希的思路是把空间划分为均匀大小的网格,每个网格单元对应哈希表中的一个桶。物体根据其AABB覆盖的网格单元被放入相应的桶中。同一桶中的物体就是潜在碰撞对。
空间哈希的关键参数是网格大小(Cell Size)。网格太大,每个桶里物体太多,筛选效率低;网格太小,每个物体跨越太多网格,内存开销大。
空间哈希的优点:
- 实现极其简单
- 插入和删除都是O(1)(平均情况)
- 适合物体分布稀疏的场景
- 区域查询效率高
空间哈希的缺点:
- 网格大小的选择非常敏感,没有"万能"的最优值
- 物体大小差异大时性能急剧下降(大物体占据太多网格)
- 空间分布不均匀时,哈希冲突严重
空间哈希在粒子系统和布料模拟中应用广泛,因为粒子大小均匀且数量巨大。但在通用物理引擎中,它通常不是首选。
算法三:BVH(Bounding Volume Hierarchy,包围体层次树)
BVH是目前最主流的宽相算法。PhysX和Jolt都采用BVH作为宽相的核心数据结构。
BVH的基本思想是将物体的包围体组织成一棵树形结构。每个中间节点代表一个较大的包围体,包含其所有子节点。叶子节点是实际物体的包围体。
查询两个物体是否可能碰撞时,从根节点开始递归遍历:
- 如果当前节点的包围体不相交,直接返回空
- 如果当前节点是叶子节点,返回这一对
- 否则,递归检查子节点的组合
BVH的时间复杂度是O(log n)级别的查询——比SaP的O(n)优秀得多,尤其是在大规模场景中。
BVH结构示意:
Root AABB
/ \
Node A Node B
/ \ / \
Obj1 Obj2 Obj3 Node C
/ \
Obj4 Obj5
BVH的关键挑战是树的平衡与更新。如果物体不断移动,BVH可能变得不平衡,查询效率下降。解决方案包括:
- 自底向上更新:物体移动后,从叶子节点向上更新所有祖先节点的包围体
- 旋转优化(Rotation):定期检查兄弟节点,通过交换子树来减小总包围体体积
- 重构(Refit)vs 重建(Rebuild):增量更新 vs 定期全量重建
- SAH(Surface Area Heuristic):基于表面积启发式构建最优BVH
PhysX的宽相实现采用了一种称为"Dynamic BVH"的结构,支持高效的动态更新。Jolt则进一步优化,采用了"两阶段BVH"——第一阶段是静态物体的预构建BVH(几乎不变),第二阶段是动态物体的动态BVH(每帧更新)。
算法对比与选型建议
| 算法 | 平均查询 | 最坏情况 | 空间连贯性 | 动态更新 | 区域查询 | 适用场景 |
|---|---|---|---|---|---|---|
| Sweep and Prune | O(n) | O(n log n) | 极好 | 好 | 差 | 物体缓慢移动的2D游戏 |
| 空间哈希 | O(1) | O(n) | 一般 | 极好 | 好 | 粒子系统、均匀大小物体 |
| BVH | O(log n) | O(n) | 好 | 中等 | 好 | 通用3D引擎、大规模场景 |
架构师视角:BVH是当前最均衡的选择,但SaP在特定场景下(特别是2D)仍有优势。一个成熟的物理引擎应该支持多种宽相算法,或至少将宽相设计为可替换的模块。
12.3 Narrow Phase算法:GJK、EPA、SAT
窄相的任务是给定两个碰撞体的几何形状和位置,精确判断它们是否相交,如果相交则计算接触信息(碰撞点、法线、穿透深度)。
窄相算法的选择取决于碰撞体的类型。不同形状组合(球-球、球-盒、盒-盒、凸包-凸包等)有不同的最优算法。
算法一:SAT(Separating Axis Theorem,分离轴定理)
分离轴定理是凸多面体碰撞检测的经典算法。它的原理是:两个凸体不相交,当且仅当存在一个轴,使得两个凸体在该轴上的投影不重叠。
对于凸多面体,只需要检查有限个候选轴——每个物体的每条边的法向量方向。如果在所有这些轴上的投影都重叠,则两个物体相交。
SAT的优点:
- 数学优雅,原理简单易懂
- 对于面数较少的凸体(盒子、棱锥)效率极高
- 能同时得到碰撞法线(投影重叠量最小的那个轴)
SAT的缺点:
- 只适用于凸体
- 对于高面数凸包,候选轴数量是O(m*n)(m和n是两个物体的边数),效率下降
- 计算碰撞点需要额外的处理(参考面-参考边、Clipping)
SAT在2D物理引擎(如Box2D)中应用广泛,因为2D情况下候选轴数量很少(每个多边形只需要检查其边法线方向)。
算法二:GJK(Gilbert-Johnson-Keerthi算法)
GJK是一种迭代算法,用于判断两个凸体是否相交。它的核心思想基于Minkowski差(Minkowski Difference):两个凸体A和B相交,当且仅当A - B(Minkowski差)包含原点。
GJK不直接构造Minkowski差(那将是O(m*n)的复杂度),而是通过一个巧妙的支撑函数(Support Function)来迭代地构建一个单纯形(Simplex),试图"包住"原点。
算法步骤(简化版):
- 选择一个初始方向d
- 计算两个物体在方向d上的支撑点之差(Minkowski差的一个顶点)
- 将该点加入单纯形
- 检查单纯形是否包含原点。如果是,返回相交
- 如果不是,找到离原点最近的子单纯形,并更新方向d
- 回到步骤2,直到找到碰撞或确定不相交
GJK的优点:
- 时间复杂度接近O(log n),对高面数凸包效率远高于SAT
- 适用于任何凸形状,只要能实现支撑函数
- 代码结构简洁,核心算法只有几十行
- 早期终止:在迭代过程中如果发现原点不可能在Minkowski差内,可以立即返回不相交
GJK的缺点:
- 只能判断是否相交,不能直接得到碰撞法线和穿透深度
- 数值稳定性需要精心处理,特别是接近平行的面
- 对于退化情况(点-面、边-边接触)需要特殊处理
算法三:EPA(Expanding Polytope Algorithm,膨胀多面体算法)
EPA是GJK的"搭档"算法。当GJK检测到两个物体相交后,EPA用来计算精确的穿透深度和碰撞法线。
EPA的基本思想是:从GJK结束时得到的单纯形出发,不断找到离原点最近的面,在该面的法线方向上找到Minkowski差的新顶点,将其加入多面体,直到收敛。最终,离原点最近的面的法线就是碰撞法线,原点到该面的距离就是穿透深度。
EPA的优点:
- 与GJK无缝衔接,共享支撑函数
- 精度可控,可以通过迭代次数调节
- 适用于任意凸形状
EPA的缺点:
- 迭代过程中需要维护一个多面体结构,有一定内存开销
- 收敛速度受初始形状影响
- 深度穿透情况下可能需要较多迭代
GJK + EPA是目前3D物理引擎中凸体碰撞检测的主流组合。Bullet、PhysX(内部)、Jolt都采用这一算法栈。
GJK + EPA 协作流程:
两个凸体
│
▼
┌───────┐
│ GJK │ → 不相交 → 结束
└───────┘
│ 相交
▼
┌───────┐
│ EPA │ → 穿透深度 + 碰撞法线
└───────┘
│
▼
接触流形构建
接触流形(Contact Manifold)
需要特别指出的是,窄相的输出不是"一个碰撞点",而是一个接触流形——通常包含1-4个接触点,形成一个接触面。这是因为单个接触点在约束求解中不稳定,物体容易绕着接触点旋转。多个接触点形成的流形可以提供更稳定的支撑。
接触流形的构建是一个独立的问题。常见方法包括:
- 增量式流形更新:保留上一帧的接触点,添加新的接触点,删除过时的点
- Clipping方法:找到参考面和入射面,用多边形裁剪得到接触多边形
- Manifold Warping:根据物体的运动对上一帧的流形进行变换和修正
12.4 碰撞体类型:Primitive、Mesh、Heightfield的选型
碰撞体(Collision Shape)是碰撞检测的基本单元。选择合适的碰撞体类型是性能和精度之间的重要权衡。
Primitive(基本体)
基本体是最简单的碰撞形状,包括:
- Sphere(球体):由球心和半径定义。碰撞检测最简单,球-球检测只需比较距离平方。
- Box(盒子):OBB(有向包围盒),由中心、朝向和半尺寸定义。
- Capsule(胶囊体):由两个半球和一个圆柱组成。常用于角色碰撞。
- Cylinder(圆柱体):圆柱形状。
- Cone(圆锥体):圆锥形状。
- Plane(平面):无限大平面。常用于地面。
基本体的优点:
- 碰撞检测速度极快(通常是常数时间)
- 内存占用极小
- 数值稳定性好
基本体的缺点:
- 形状有限,无法精确表示复杂物体
- 美术制作的模型往往需要"凑"形状
Convex Hull(凸包)
凸包是包含给定点集的最小凸多面体。它是对任意凸形状的通用近似。
凸包的质量(面数和顶点数)可以根据需求调整:
- 低面数凸包(4-20个顶点):性能接近基本体,但精度有限
- 中面数凸包(20-100个顶点):精度和性能的平衡
- 高面数凸包(100+顶点):接近原始模型的精度,但检测成本高
凸包的一个重要特性是分解(Decomposition)。任何凹形状都可以分解为多个凸包的并集。V-HACD(Volumetric Hierarchical Approximate Convex Decomposition)是目前最流行的凸分解算法。
Triangle Mesh(三角网格)
三角网格直接使用渲染模型的三角形作为碰撞几何。它可以精确表示任意形状,包括凹面。
但三角网格有一个致命的限制:通常只能作为静态碰撞体使用。两个动态三角网格之间的碰撞检测极其昂贵(O(m*n)复杂度),而且难以得到稳定的接触流形。
三角网格的碰撞检测通常使用"凸体 vs 网格"的方式——一个凸形状(如球、盒、凸包)与三角网格进行测试。算法利用BVH加速:先找到网格中可能与凸体相交的三角形,然后逐个进行精确测试。
Heightfield(高度场)
高度场是专门用于地形的碰撞表示。它用一个2D高度数组来表示地面的起伏。
高度场的碰撞检测效率很高:给定一个水平位置,可以直接通过采样找到地面高度。对于凸体 vs 高度场的检测,可以先在高度场上找到物体覆盖的区域,然后提取出相关的三角形进行测试。
选型策略
架构师在设计碰撞体方案时,应该遵循"从简到繁"的原则:
- 优先使用基本体:只要基本体能满足需求,就不要用更复杂的形状
- 其次考虑凸包:基本体不够用时,用尽量少面数的凸包
- 凸分解用于凹刚体:需要凹形状的动态物体,用V-HACD分解为凸包组合
- 三角网格仅用于静态:静态场景几何体用三角网格,确保精度
- 专用类型用于专用场景:地形用Heightfield,水面对用Plane,布料用Particle System
碰撞体精度/性能金字塔:
▲
/ \
/ \ 高精度
/ Mesh\ 高成本
/-------\
/ Convex \
/ Hull \
/-------------\
/ Primitive \ 低精度
----------------- 低成本
一个常见的最佳实践是碰撞体与渲染体分离。渲染用高精度模型,物理用简化的碰撞体。美术资源的制作流程中应该包含"碰撞体生成"这一环节,由TA或物理美术师负责调整碰撞体的形状和精度。
12.5 架构设计:碰撞检测系统的可扩展性与精度控制
一个优秀的碰撞检测系统不只是算法的堆砌,更需要精心的架构设计,以支持可扩展性、可配置性和高效的性能控制。
分层架构设计
碰撞检测系统应该采用分层架构,每一层对上层提供服务,对下层隐藏实现细节:
碰撞检测系统分层架构:
┌─────────────────────────────┐
│ 碰撞查询接口(Query API) │ Raycast、Sweep、Overlap
└─────────────────────────────┘
┌─────────────────────────────┐
│ 碰撞体抽象层(Shape层) │ 统一的形状接口
└─────────────────────────────┘
┌─────────────────────────────┐
│ Broad Phase 层 │ 空间加速结构
└─────────────────────────────┘
┌─────────────────────────────┐
│ Narrow Phase 层 │ 精确碰撞检测算法
└─────────────────────────────┘
层一:碰撞查询接口
对外暴露的是一组高层查询接口,而非底层算法细节。典型的查询类型包括:
- Raycast(射线检测):射线是否击中某个物体
- Sweep/CAST(形状投射):沿路径移动一个形状,检测是否碰撞
- Overlap(重叠检测):给定区域内有哪些物体
- Contact(接触查询):两个物体的详细接触信息
这种设计的好处是,上层业务逻辑不需要了解宽相/窄相的实现细节,只需要调用语义清晰的查询函数。
层二:碰撞体抽象层
所有碰撞形状都实现统一的IShape接口,提供以下核心功能:
- 计算AABB(用于Broad Phase)
- 计算质量和惯性张量(用于动力学)
- 接受访问者(Visitor)进行碰撞检测(双分派模式)
双分派(Double Dispatch)是碰撞检测中的经典设计模式。由于不同形状组合需要不同的检测算法(球-球、球-盒、盒-凸包等),通过两次虚函数调用可以在运行时确定正确的算法:
// 伪代码:双分派实现碰撞检测
class Shape {
public:
virtual bool collide(const Shape& other) const = 0;
virtual bool collideSphere(const SphereShape& sphere) const = 0;
virtual bool collideBox(const BoxShape& box) const = 0;
// ...
};
class SphereShape : public Shape {
public:
bool collide(const Shape& other) const override {
return other.collideSphere(*this);
}
bool collideSphere(const SphereShape& sphere) const override {
// 球-球碰撞检测实现
}
bool collideBox(const BoxShape& box) const override {
// 球-盒碰撞检测实现
}
};
可扩展性设计
碰撞检测系统的可扩展性体现在三个维度:
-
新形状类型的扩展:添加新的碰撞形状(如胶囊体、高度场)应该只需实现IShape接口和相应的碰撞函数,不需要修改现有代码。
-
新查询类型的扩展:添加新的查询类型(如体积查询、距离查询)应该能在不修改形状类的前提下实现。访问者模式(Visitor Pattern)在这里非常有用。
-
新Broad Phase算法的扩展:宽相算法应该是可插拔的。项目可以根据场景特点选择最合适的宽相实现。
精度控制机制
碰撞精度的控制不应该是"全局一刀切",而应该是细粒度的、可配置的。以下是一些关键的精度控制旋钮:
- 碰撞检测层(Collision Layer):定义哪些层之间需要检测碰撞。玩家的武器不需要与场景中不可见的触发器碰撞。
- 碰撞回调过滤:通过回调函数动态决定是否需要处理某个碰撞。可以基于距离、重要性、优先级等进行过滤。
- 连续碰撞检测(CCD)阈值:仅对速度超过阈值的物体启用CCD。
- 接触偏移(Contact Offset):在物体表面外一点点就生成接触,避免深度穿透导致的不稳定。
- 皮肤宽度(Skin Width):给物体套一层"皮肤",允许轻微穿透以换取稳定性。
架构师视角的设计原则
- 保守优于精确:宽相宁可误报不可漏报;碰撞检测宁可提前检测不可事后补救。
- 分层优于混合:每个层次职责清晰,边界明确,不要为了性能在层之间"抄近道"。
- 可配置优于硬编码:所有的精度参数、阈值、迭代次数都应该可配置,最好是按物体级别可配置。
- 可观测性优先:提供调试绘制功能(显示AABB、显示碰撞对、显示接触点),性能分析功能(各阶段耗时统计)。
- 确定性设计:碰撞检测的结果应该是确定性的——相同的输入总是产生相同的输出。这对网络游戏和回放系统至关重要。
碰撞检测系统是物理引擎的基石,它的架构质量直接影响整个物理系统的性能上限和可维护性。一个好的碰撞检测架构应该像一把精密的手术刀——既能高效地处理大规模场景,又能精确地控制每一处细节。
第13章 刚体动力学与约束求解
13.1 刚体动力学基础:线性运动与角运动
刚体动力学是物理引擎的数学核心。所谓"刚体",是指在运动过程中形状和大小保持不变、内部各点相对位置不变的物体。这是一个理想化的物理模型——现实世界中不存在绝对的刚体,但对于大多数游戏场景,刚体假设既足够精确又计算高效。
刚体的运动可以分解为两个独立的部分:线性运动(平动)和角运动(转动)。这两种运动遵循相似的物理规律,但数学表达有所不同。
线性运动的基本方程
线性运动描述刚体质心的位置变化。核心方程是牛顿第二定律:
F = m · a
其中 F 是合外力,m 是质量,a 是线加速度。
将加速度积分得到速度,再积分得到位置:
v(t) = v₀ + ∫ a(t) dt = v₀ + (F/m) · t
x(t) = x₀ + ∫ v(t) dt
在离散时间步长的模拟中,我们需要用数值积分来近似这些连续方程。具体的积分方法将在下一节详细讨论。
角运动的基本方程
角运动描述刚体的朝向变化,比线性运动复杂得多。核心方程是转动定律:
τ = I · α
其中 τ 是合外力矩(Torque),I 是惯性张量(Inertia Tensor),α 是角加速度。
这里的关键概念是惯性张量。它不是一个简单的标量,而是一个3×3的对称矩阵,描述物体的质量分布如何影响旋转。惯性张量取决于:
- 物体的总质量
- 质量相对于质心的分布
- 参考坐标系的选择(通常在物体的局部坐标系中计算)
对于简单形状,惯性张量有解析公式:
- 球体:I = (2/5) · m · r²(各向同性,标量即可)
- 长方体:I_xx = (1/12) · m · (h² + d²), I_yy = (1/12) · m · (w² + d²), I_zz = (1/12) · m · (w² + h²)
对于复杂形状,惯性张量通常通过数值积分或凸分解近似计算。
角运动的另一个复杂之处在于朝向的表示。位置是三维向量,直观且易于插值。但朝向的表示有多种选择,各有优劣:
- 欧拉角(Euler Angles):直观(横滚-俯仰-偏航),但存在万向节锁(Gimbal Lock)问题,不适合平滑插值
- 旋转矩阵(Rotation Matrix):3×3矩阵,无奇异点,但冗余(9个参数表示3个自由度),插值困难
- 四元数(Quaternion):4个参数,无奇异点,可平滑插值,是游戏引擎的标准选择
四元数的运动学方程为:
q̇ = (1/2) · ω * q
其中 ω 是角速度(用四元数形式表示),* 是四元数乘法。
刚体的状态向量
综合线性和角运动,一个刚体在任意时刻的完整运动状态可以用以下变量描述:
位置: x ∈ ℝ³
朝向: q ∈ ℍ(单位四元数)
线速度: v ∈ ℝ³
角速度: ω ∈ ℝ³
质量: m ∈ ℝ(标量,不变量)
惯性张量:I ∈ ℝ³ˣ³(在局部坐标系中不变,世界坐标系中随朝向变化)
注意质量和惯性张量在模拟过程中是不变的(假设物体不发生断裂或形变),因此它们是"属性"而非"状态"。
力与冲量
在物理模拟中,改变物体运动状态的方式有两种:
力(Force):持续作用一段时间,效果取决于作用时长。例如重力、推力、弹簧力。力通过 F = m·a 影响加速度,再通过积分影响速度和位置。
冲量(Impulse):瞬时作用,直接改变速度。例如碰撞反弹、跳跃、爆炸冲击。冲量 J 直接加到速度上:v = v₀ + J/m。
力和冲量的选择是物理引擎API设计的重要考量。力更"物理正确",但需要知道持续时间。冲量更直接,适合游戏中瞬间发生的事件。大多数物理引擎同时提供两种接口。
13.2 积分器:显式欧拉、半隐式欧拉、Verlet、RK4
积分器是物理引擎的"时间推进器"。它的任务是:给定当前时刻的状态和加速度,计算下一时刻的状态。由于微分方程通常没有解析解,我们必须使用数值积分——这会引入误差。积分器的选择直接决定了模拟的精度、稳定性和性能。
显式欧拉(Explicit Euler)
显式欧拉是最简单的积分方法,也是精度最低的:
v(t + dt) = v(t) + a(t) · dt
x(t + dt) = x(t) + v(t) · dt
特点:
- 使用当前时刻的导数来预测下一时刻的状态
- 实现极其简单,计算量最小
- 精度为O(dt)(一阶方法)
- 稳定性差,能量会不断增加(系统越来越"热")
显式欧拉在游戏物理中几乎从不单独使用。它的不稳定性会导致物体在堆叠时弹跳、发散,最终爆炸。
半隐式欧拉(Semi-implicit Euler / Symplectic Euler)
半隐式欧拉是显式欧拉的一个微小改动,但效果截然不同:
v(t + dt) = v(t) + a(t) · dt
x(t + dt) = x(t) + v(t + dt) · dt
注意关键区别:位置更新使用的是新的速度v(t+dt),而不是旧的速度v(t)。
这个改动带来的好处:
- 仍然是一阶精度,但具有辛几何性质(Symplectic),能量有界而非持续增长
- 实现几乎和显式欧拉一样简单
- 对于周期性运动(如弹簧振子),能量在平均值附近振荡而非发散
- 稳定性远优于显式欧拉
半隐式欧拉是绝大多数游戏物理引擎的默认积分器。它在简单性、稳定性和精度之间取得了最佳平衡。Box2D、PhysX、Bullet都使用半隐式欧拉作为主积分器。
Verlet积分
Verlet积分的思路不同——它直接积分位置,不存储速度:
x(t + dt) = 2·x(t) - x(t - dt) + a(t) · dt²
速度如果需要,可以从位置差推导:v(t) ≈ (x(t) - x(t - dt)) / dt。
Verlet的特点:
- 二阶精度(O(dt²))
- 稳定性好,适合约束求解(位置约束可以直接投影到位置上)
- 速度是派生量,施加冲量不太方便
- 初始条件设置需要特殊处理(需要知道前一帧的位置)
Verlet积分在布料模拟、粒子系统和基于位置的动力学(PBD)中应用广泛,因为这些场景中约束通常直接作用在位置上。
RK4(四阶龙格-库塔)
RK4是经典的高精度积分方法,通过计算四个中间斜率来估计状态变化:
k1 = a(t, x)
k2 = a(t + dt/2, x + k1·dt/2)
k3 = a(t + dt/2, x + k2·dt/2)
k4 = a(t + dt, x + k3·dt)
x(t + dt) = x(t) + (k1 + 2k2 + 2k3 + k4) · dt / 6
特点:
- 四阶精度(O(dt⁴)),精度远高于前面的方法
- 稳定性好
- 计算量大——需要四次力/加速度计算
- 不具有辛性质,长时间模拟能量会缓慢漂移
RK4在科学计算和物理仿真中很常见,但在游戏中使用较少。主要原因是:
- 游戏物理的瓶颈通常在碰撞检测和约束求解,而不是积分精度
- RK4需要多次计算力,而力的计算可能涉及碰撞检测(代价高昂)
- 游戏的物理精度要求通常不需要四阶精度
积分器对比与选型
| 积分器 | 精度阶数 | 稳定性 | 计算量 | 适用场景 |
|---|---|---|---|---|
| 显式欧拉 | 一阶 | 差 | 最低 | 教学演示,精度要求极低 |
| 半隐式欧拉 | 一阶 | 好 | 最低 | 通用游戏物理(默认选择) |
| Verlet | 二阶 | 好 | 低 | 布料、粒子、PBD |
| RK4 | 四阶 | 很好 | 高 | 高精度模拟、弹道计算 |
架构师视角:积分器的选择策略
对于大多数游戏,半隐式欧拉是最佳选择。但在以下情况可以考虑其他方案:
- 布料/柔体模拟:使用Verlet或PBD,因为位置约束的求解更直接
- 弹道/投射物:可以使用RK4或解析解,因为轨迹精度直接影响游戏体验
- 车辆物理:可能需要更高阶的积分器,因为车辆的动力学方程更刚性(Stiff)
- 确定性需求:选择解析解或固定步长的辛积分器,确保跨平台结果一致
一个成熟的物理引擎应该将积分器设计为可替换的策略(Strategy Pattern),不同类型的物体可以使用不同的积分器。
13.3 约束求解:Sequential Impulse、Projected Gauss-Seidel
约束求解是物理引擎中最核心也最复杂的部分。碰撞检测告诉我们物体接触了,约束求解负责计算需要施加多大的力/冲量,才能让物体"正确地"接触——不穿透、不分离、符合物理规律。
约束的数学表达
一个约束可以表示为一个等式(或不等式)条件:
C(x) = 0
其中 x 是系统的状态向量(所有物体的位置和速度)。例如:
- 接触约束:两个物体不能穿透 → C(x) = 穿透深度 ≥ 0(不等式约束)
- 距离约束:两个质点间距固定 → C(x) = |x₁ - x₂| - L = 0(等式约束)
- 铰链约束:两个刚体绕轴旋转 → 多个等式约束组合
在速度层面,约束可以表示为:
Ċ(x, v) = J · v = 0
其中 J 是约束的雅可比矩阵(Jacobian),v 是系统的速度向量。雅可比矩阵描述了状态变化如何影响约束函数的变化率。
约束求解的目标是找到一个速度修正量 Δv,使得约束条件得到满足:
J · (v + Δv) = 0
而速度修正量 Δv 来自于约束冲量 λ:
M · Δv = Jᵀ · λ
其中 M 是质量矩阵(对角矩阵,对角线上是各物体的质量和惯性张量)。
将两式联立,得到约束方程:
J · M⁻¹ · Jᵀ · λ = -J · v
这是一个线性方程组,形式为 A·λ = b,其中 A = J·M⁻¹·Jᵀ 是有效质量矩阵。
如果有n个约束,这就是一个n×n的线性方程组。理论上可以直接求逆得到λ,但n很大时(例如上百个约束),直接求解的代价是O(n³),不可接受。
因此,游戏物理引擎都采用迭代法来近似求解。
方法一:Sequential Impulse(顺序冲量法)
Sequential Impulse是Erin Catar在Box2D中推广的约束求解方法,现在被PhysX等主流引擎采用。它的思想非常直观:
- 遍历每个约束
- 计算当前约束的冲量修正量
- 将冲量施加到相关物体上
- 重复以上步骤若干次(迭代)
每次迭代,约束被逐个满足。由于修正一个约束可能会破坏已经满足的其他约束,所以需要多轮迭代,让解逐渐收敛。
算法伪代码:
for iteration in 0..numIterations:
for constraint in allConstraints:
// 计算当前速度下的约束误差
velocityError = J * v
// 计算需要的冲量变化
lambda = (desiredVelocity - velocityError) / (J * M_inv * J_transpose)
// 累积冲量(用于不等式约束的钳位)
oldLambda = accumulatedLambda[constraint]
newLambda = clamp(oldLambda + lambda, lowerBound, upperBound)
deltaLambda = newLambda - oldLambda
accumulatedLambda[constraint] = newLambda
// 施加冲量
v += M_inv * J_transpose * deltaLambda
Sequential Impulse的优点:
- 概念简单,易于理解和实现
- 天然支持不等式约束(通过累积冲量的钳位)
- 每次迭代的复杂度是O(n)(n为约束数)
- 可以利用空间局部性优化(接触约束只涉及两个物体)
- 易于调试——可以逐约束观察冲量的变化
Sequential Impulse的缺点:
- 收敛速度依赖约束顺序和系统复杂度
- 对于大规模堆叠或刚性约束链,需要较多迭代次数
- 数学上等价于Projected Gauss-Seidel,但物理直觉更强
方法二:Projected Gauss-Seidel(投影高斯-赛德尔)
PGS是基于线性互补问题(LCP)的求解方法。Bullet物理引擎主要采用这种方法。
PGS将约束求解问题形式化为一个混合线性互补问题(MLCP):
A·λ + b ≥ 0
λ ≥ 0
λᵀ·(A·λ + b) = 0
其中 λ 是约束冲量,不等式约束对应 λ ≥ 0 的情况(如接触约束,只能推不能拉)。
Gauss-Seidel迭代的思想是:每次只求解一个变量(一个约束的冲量),假设其他变量已知。第i个约束的解为:
λ_i = (b_i - Σ_{j≠i} A_ij · λ_j) / A_ii
然后用投影(Projection)操作处理不等式约束:
λ_i = max(0, λ_i) // 对于单边约束(接触)
PGS和Sequential Impulse在数学上是等价的。它们的区别更多在于推导路径和实现风格:
- Sequential Impulse从物理直觉(冲量叠加)出发
- PGS从数学形式(LCP求解)出发
求解器的性能与精度控制
迭代求解器有两个关键的精度旋钮:
-
迭代次数:最直接的控制参数。迭代次数越多,解越精确,但计算成本线性增长。典型范围是4-20次。
-
求解顺序:约束的处理顺序影响收敛速度。一般来说,按岛屿分组、从稳定到不稳定的顺序处理有助于收敛。
还有一些高级优化技术:
-
Warm Starting(热启动):用上一帧的冲量作为初始猜测。如果物体运动连续,上一帧的解通常很接近当前帧的解,可以大幅减少所需迭代次数。几乎所有现代物理引擎都使用热启动。
-
Position Correction(位置修正):速度级的求解不能完全消除位置误差(穿透)。有两种常用的位置修正方法:
- Baumgarte Stabilization:在速度约束中加入位置误差的反馈项,相当于"弹簧"一样把物体推回去
- Position Solver(NGSK/PGS位置求解):额外执行位置级的求解迭代,直接修正位置
-
Sleeping(睡眠):当物体静止时,跳过求解(详见第11章)。
约束求解器完整流程:
1. 建立约束(接触、关节等)
2. 初始化约束数据(计算雅可比、有效质量)
3. Warm Start:用上一帧的冲量初始化速度
4. 速度求解循环(迭代N次):
a. 接触约束求解
b. 关节约束求解
5. 位置求解循环(迭代M次):
a. 接触位置修正
b. 关节位置修正
6. 保存冲量,用于下一帧Warm Start
13.4 关节类型:Fixed、Hinge、Ball-Socket、Slider
关节(Joint)是连接两个刚体的约束集合,限制它们之间的相对运动。关节是构建复杂机械系统(门、车辆、机器人、布娃娃)的基础。
从约束的角度看,一个关节就是一组约束方程的集合。在6个自由度(3个平移 + 3个旋转)中,关节限制其中的一部分,保留其余的自由度。
Fixed Joint(固定关节)
固定关节将两个刚体完全"粘"在一起,不允许任何相对运动。
约束数:6个(3个平移 + 3个旋转) 自由度:0
典型用途:
- 临时粘合物体(如角色拾取物品)
- 构建复合刚体(虽然通常更建议直接创建一个多形状的刚体)
- 破坏系统的"可断裂"连接
固定关节看起来简单,但实现中有一个微妙的问题:如果两个物体质量差异很大,求解器可能难以收敛。这时需要提高迭代次数或使用特殊的质量加权策略。
Hinge Joint(铰链关节/ revolute joint)
铰链关节只允许两个物体绕一个共同的轴旋转,就像门的合页。
约束数:5个(3个平移 + 2个旋转) 自由度:1(绕轴旋转)
铰链关节通常还可以加限制(Limit)和马达(Motor):
- 角度限制:限制旋转角度的范围,如门只能开90度
- 旋转马达:驱动关节以指定速度旋转,或施加指定扭矩
典型用途:门、车轮、风扇、机器人关节
Ball-Socket Joint(球窝关节)
球窝关节允许两个物体在三个轴上自由旋转,但不能平移,就像人的肩关节。
约束数:3个(平移) 自由度:3(旋转)
球窝关节也可以加锥形限制(Cone Limit)——限制旋转轴在一个圆锥范围内,模拟关节的活动范围。
典型用途:角色骨骼连接(布娃娃)、链条、绳索
Slider Joint(滑动关节/Prismatic Joint)
滑动关节只允许两个物体沿一个轴相对移动,不允许旋转和其他方向的平移。
约束数:5个(2个平移 + 3个旋转) 自由度:1(沿轴移动)
同样可以加线性限制和线性马达。
典型用途:电梯、活塞、伸缩装置
其他常见关节类型
- Cylindrical Joint(圆柱关节):允许沿轴平移和绕轴旋转(2个自由度)。类似螺栓和螺母的关系。
- Distance Joint(距离关节):保持两个锚点之间的距离固定(1个约束)。允许其他方向自由运动。
- D6 Joint(6自由度关节):通用关节,每个轴的运动都可以独立配置为自由、受限或锁定。PhysX和Bullet都提供这种"瑞士军刀"式的关节。
- Gear Joint(齿轮关节):模拟两个齿轮的啮合关系,约束两个铰链关节的角速度比。
- Rope/Cable Joint(绳索关节):距离关节的变体,只有最大距离限制(可以松弛,不能超过)。
关节的实现要点
从架构视角看,关节系统的设计有几个关键考量:
-
关节的存储与组织:关节是独立对象还是存储在关联的刚体上?大多数引擎采用独立对象的方式,通过指针引用两个刚体。
-
关节破坏(Breakable Joints):当作用在关节上的力超过阈值时,关节自动断裂。这是实现破坏效果的基础。架构上需要在约束求解后检查每个关节的受力,决定是否断开。
-
关节马达(Motor):马达是关节的重要功能,但马达的实现方式影响求解器的稳定性。常见的实现有速度马达(维持目标速度)和位置马达(弹簧-阻尼系统,趋向目标位置)。
-
关节层级:复杂的机械系统(如车辆、机器人)包含多个关节,关节之间有耦合关系。求解这些关节的顺序和策略会影响整体稳定性。
13.5 架构师视角:求解器迭代次数与稳定性的权衡
约束求解器是物理引擎中性能消耗最大的部分之一,也是物理手感的决定性因素。作为架构师,需要在迭代次数、稳定性、性能之间找到最优平衡点。
迭代次数的影响曲线
迭代次数与求解精度的关系不是线性的。前几次迭代的收益最大,之后收益递减:
精度
▲
│ ╭─
│ ╱
│ ╱
│ ╱
│ ╱
│╱
└──────────► 迭代次数
1 5 10 20
- 第1-2次迭代:消除大部分穿透,基本稳定
- 第4-6次迭代:大部分约束满足良好,堆叠不明显下沉
- 第10-15次迭代:高精度,几乎没有可见的误差
- 第20次以上:收益微乎其微,浪费算力
影响迭代次数需求的因素
不是所有场景都需要相同的迭代次数。以下因素会增加迭代需求:
-
质量比悬殊:一个很重的物体压在一个很轻的物体上,轻物体需要多次迭代才能"撑住"重物体。质量比超过10:1时问题开始显现,100:1以上会很困难。
-
长约束链:一串由关节连接的物体(如链条、布娃娃的脊柱),误差会沿链条累积。链条越长,需要的迭代越多。
-
深度堆叠:一摞箱子,底层的箱子承受上方所有箱子的重量,接触约束之间有强耦合。堆叠越高,迭代需求越大。
-
刚性约束:有些约束本身就"难满足",比如摩擦约束、带马达的关节。
架构优化策略
优秀的架构师不会简单地"把迭代次数调大",而是采用多种策略组合优化:
策略一:按岛屿分配迭代次数
不同的岛屿(相互接触/约束的物体群)复杂度不同。一个独立飞行的物体不需要迭代,一个两层堆叠的岛屿4次迭代足够,一个十层堆叠的岛屿可能需要15次。按岛屿动态分配迭代次数,可以在不影响整体精度的前提下大幅节省算力。
Jolt Physics就是这样做的——它根据岛屿的大小和复杂度估计所需迭代次数,而不是全局统一。
策略二:分阶段求解
不是所有约束都需要在每次迭代中处理。可以采用分阶段策略:
- 阶段1(快速):只处理接触约束的法向部分,快速消除穿透
- 阶段2(标准):处理所有接触约束(包括摩擦)
- 阶段3(精细):处理关节约束和其他高级约束
这样,即使总迭代次数不多,最重要的约束也能得到充分满足。
策略三:子步长(Sub-stepping)
当遇到特别困难的场景(如快速运动、高质量比)时,增加迭代次数可能效果有限,因为问题的根源是"单步时间内状态变化太大"。
子步长的思路是:将一个物理步长进一步细分,每个子步都做完整的碰撞检测和约束求解。虽然总计算量增加了,但稳定性的提升往往比单纯增加迭代次数更显著。
子步长 vs 增加迭代:
标准步长(dt,10次迭代)
├───────┼───────┤
子步长(dt/2,每个子步6次迭代)
├───┼───┤───┼───┤
总迭代次数相同,但稳定性更好
策略四:质量缩放(Mass Scaling)
对于质量比悬殊的情况,可以在求解时临时调整质量,让求解器更容易收敛。代价是物理精确性略有下降,但对于游戏来说通常不可察觉。
常见的质量缩放策略:
- 给轻物体增加有效质量(让它"看起来"更重)
- 给重物体减小有效质量(让它"看起来"更轻)
- 限制最大质量比(超过10:1就截断)
策略五:求解器类型选择
对于特定的难题,通用的迭代求解器可能不是最优选择:
- 直接求解器:对于小规模的高精度需求(如车辆物理、精密机械),可以使用直接法(如LCP的Dantzig法或Baraff法),代价是O(n³)的复杂度。
- 专用求解器:某些类型的约束有更高效的专用求解器。例如布料模拟用PBD,流体用SPH或FLIP。
架构师的决策框架
在设计求解器架构时,建议遵循以下决策路径:
- 确定物理在游戏中的优先级:是核心玩法(如物理益智游戏)还是辅助效果(如RPG中的杂物)?
- 设定性能预算:物理系统在一帧中能占用多少毫秒?多少CPU核心?
- 确定典型场景的复杂度:最多有多少个同时活动的物体?堆叠有多高?约束链有多长?
- 从保守配置开始:先用较低的迭代次数(如6次),然后在需要的地方逐步增加。
- 优先使用架构级优化:睡眠、岛屿、LOD、子步长,这些比单纯加迭代次数更高效。
- 提供分层的精度控制:全局默认值 + 岛屿级调整 + 单物体覆盖。
约束求解器的设计最能体现物理引擎架构师的功力。它不是简单的数学问题,而是在性能预算下,通过分层、分治、启发式等手段,让玩家感觉"物理很真实"的艺术。
第14章 角色物理与角色控制器
14.1 角色物理的特殊性:非物理真实但要"手感好"
在游戏物理的所有领域中,角色物理是最特殊的一个。它的目标不是"物理正确",而是"手感好"——让玩家觉得角色的移动响应灵敏、跳跃有力度、碰撞合理、与环境交互自然。
这是一个深刻的设计矛盾:真实的物理模拟往往"手感不好",而手感好的角色移动通常不满足物理定律。
为什么真实物理不适合角色控制?
让我们用一个简单的例子来说明。假设你在玩一个平台跳跃游戏,角色从平台边缘走出去。在真实物理中:
- 角色的质心一旦越过边缘,就会开始下落
- 下落的加速度是恒定的重力加速度g
- 角色在空中无法改变水平速度(因为没有受力)
- 落地时会受到冲击力,可能摔倒或弹跳
但玩家期望的行为是:
- 角色可以"跑出去"一点再跳(Coyote Time——郊狼时间)
- 跳跃高度可以通过按键时长控制(Variable Jump Height)
- 空中可以改变方向和速度(Air Control)
- 落地时稳稳站住,不会弹跳或滑动
这些都不符合真实物理,但它们让游戏更好玩。
角色物理的本质是玩家意图的物理表达。玩家按下方向键,期望角色立即响应;玩家按下跳跃键,期望角色准确地跳到目标位置。物理系统在这里不是"模拟真实",而是"翻译玩家输入"。
角色物理的设计原则
-
响应优先:角色的移动必须即时响应输入。延迟超过50ms就能被玩家感知,超过100ms就会感到"迟滞"。
-
可预测性:玩家应该能预测角色的运动轨迹。如果物理效果太随机或太复杂,玩家会感到失控。
-
容错性:角色物理应该对玩家的不精确操作给予宽容。例如,差一点跳到平台边缘时,应该"抓"上去而不是掉下去。
-
一致性:相同的输入应该产生相同的结果。确定性对于竞技游戏尤为重要。
这些原则往往与物理真实感相冲突。架构师的任务就是在两者之间找到适合游戏类型的平衡点。
14.2 角色控制器设计:Kinematic vs Dynamic
角色控制器(Character Controller)是处理角色运动的专用组件。它位于游戏逻辑和物理引擎之间,负责将玩家输入转化为角色的运动,同时处理与环境的碰撞。
角色控制器有两种基本架构:运动学(Kinematic)和动力学(Dynamic)。
Kinematic Character Controller(运动学角色控制器)
运动学角色控制器是一个"不受物理力影响"的特殊刚体。它的运动完全由代码控制——你告诉它移动多少,它就移动多少,同时处理碰撞(不能穿墙)。
核心特点:
- 角色的速度和位置直接由逻辑设置,不通过力/冲量
- 物理引擎只负责碰撞检测和响应(阻止穿透)
- 角色不受重力影响?不,重力通常由控制器自己模拟
- 角色不会被其他物体推动(除非你专门处理)
实现方式通常是形状投射 + 滑动(Sweep and Slide):
移动算法(Sweep and Slide):
remainingMove = desiredMovement
currentPosition = startPosition
for i in 0..maxBounces: // 通常3-4次反弹足够
// 投射碰撞体,检测路径上的碰撞
hit = sweepShape(currentPosition, remainingMove)
if no hit:
// 没有碰撞,直接移动到目标
currentPosition += remainingMove
break
// 移动到碰撞点前一点
currentPosition += hit.distance * moveDirection
// 将剩余移动量投影到碰撞平面上(滑动)
remainingMove = projectOntoPlane(remainingMove - hit.distance * moveDirection, hit.normal)
// 如果剩余移动量很小,结束
if remainingMove.length < epsilon:
break
return currentPosition
这种算法的优点是:
- 完全可控,角色行为精确可预测
- 不会出现抖动、穿透、被卡住等物理模拟常见问题
- 性能开销小
- 容易实现特殊效果(如斜坡滑行、台阶攀爬)
缺点是:
- 与物理世界的交互有限——角色推不动箱子,箱子也推不动角色
- 布娃娃效果切换复杂
- 需要手动处理很多物理细节(重力、惯性、动量)
Unreal Engine的Character Movement Component和Unity的Character Controller都属于运动学类型(尽管Unity的那个基于PhysX的Kinematic Controller)。
Dynamic Character Controller(动力学角色控制器)
动力学角色控制器就是一个普通的刚体,通过施加力和冲量来控制运动。物理引擎负责处理所有的碰撞和动力学。
核心特点:
- 角色的运动通过AddForce、AddImpulse等物理API控制
- 角色自然地受到重力、摩擦力、惯性的影响
- 可以与其他物理对象自然交互(推箱子、被撞击)
- 可以无缝切换到布娃娃
实现方式通常是速度控制:
// 每帧设置目标水平速度
targetVelocity = inputDirection * moveSpeed
// 计算需要的加速度
currentVelocity = rigidBody.linearVelocity
velocityDelta = targetVelocity - currentVelocity
velocityDelta.y = 0 // 不改变垂直速度
// 施加力来达到目标速度
force = velocityDelta * rigidBody.mass / deltaTime
// 但通常会限制最大力,让加速更平缓
force = clampMagnitude(force, maxForce)
rigidBody.addForce(force)
// 跳跃是一个冲量
if jumpPressed and isGrounded:
rigidBody.addImpulse(Vector3.up * jumpImpulse)
动力学控制器的优点:
- 与物理世界的交互自然、一致
- 实现简单(理论上),很多物理细节引擎自动处理
- 容易实现复杂效果(被爆炸击飞、被绳索拉动)
- 布娃娃切换自然
缺点是:
- 手感难以调校。物理参数(质量、摩擦力、阻尼)对"手感"影响很大,需要大量调试
- 墙角卡滞、斜坡抖动、高速穿透等问题常见
- 响应不够直接——施加力后需要时间加速,玩家会感到"迟滞"
- 跳跃高度受帧率和物理步长影响,难以精确控制
架构选型:何时用哪种?
| 游戏类型 | 推荐方案 | 原因 |
|---|---|---|
| 平台跳跃 | Kinematic | 需要精确的跳跃控制和快速响应 |
| FPS/TPS | Kinematic为主 | 需要精确的移动和射击手感 |
| 格斗游戏 | Kinematic | 需要确定性和精确的碰撞判定 |
| 物理益智 | Dynamic | 物理交互是核心玩法 |
| 沙盒游戏 | 混合方案 | 正常移动用Kinematic,特殊情况切Dynamic |
| 生存/恐怖 | 混合方案 | 平时Kinematic,受攻击时切布娃娃 |
大多数3A游戏采用的是混合架构:
- 正常状态下是Kinematic Controller,提供精确的移动控制
- 角色死亡或受击时切换到Dynamic Ragdoll,实现物理驱动的动画
- 某些特殊状态(如被爆炸击飞)也会临时切换到Dynamic
状态切换的时机和方式是架构设计的重点。切换时需要处理速度传递、碰撞体切换、动画过渡等问题,否则会出现明显的"跳变"。
14.3 角色碰撞:Capsule vs Character Controller
角色碰撞体的选择对移动手感有显著影响。不同的碰撞形状有不同的优缺点。
Capsule(胶囊体)
胶囊体是角色碰撞的标准选择。它由上下两个半球和中间的圆柱组成,整体光滑。
为什么胶囊体如此受欢迎?
-
平滑滑动:胶囊体的曲面在遇到障碍物时会自然地滑过去,不会像盒子那样被小凸起卡住。角色在墙角、楼梯、斜坡上的移动更流畅。
-
合适的近似:对于人形角色,胶囊体是一个很好的几何近似——身体是圆柱,头和脚是半球。它提供了合理的碰撞体积。
-
碰撞检测高效:胶囊体 vs 其他形状的碰撞检测有高效的算法。胶囊体 vs 三角网格的检测也比凸包快。
-
朝向无关:胶囊体绕竖直轴旋转对称,角色转身时碰撞体不需要更新(竖直朝向不变的前提下)。
胶囊体的缺点:
- 不能精确表示角色的形状。手臂、武器、头部的碰撞无法用胶囊体准确表达
- 蹲下、趴下等动作需要调整胶囊体的高度和半径
- 与环境的交互可能不精确(角色模型看起来离墙有距离,但碰撞体已经接触)
Character Controller的专用碰撞处理
除了基本的碰撞体形状,角色控制器还需要处理很多特殊的碰撞场景:
1. 台阶攀爬(Step Climbing)
角色遇到楼梯或小台阶时,应该能直接走上去,而不是被挡住。纯物理碰撞是做不到的——台阶的水平面会挡住胶囊体。
解决方案:
- 检测到碰撞后,尝试先向上移动一个"台阶高度",再向前移动,最后向下移动
- 如果这样能成功通过,说明是可攀爬的台阶
- 台阶高度是可配置参数,通常是30-50cm
台阶攀爬算法示意:
初始位置 尝试向上 向前移动 向下贴合
║ ║ ║ ║
───╔═╝─── ───╔═╝─── ─── ╔═╝─── ─── ╔═╝───
██ ██ ██ ██
───┴─── ───┴─── ───────┴─── ───────┴───
2. 斜坡滑行(Slope Handling)
角色站在斜坡上时,纯物理会让角色滑下去(因为重力在斜面方向有分量)。但玩家期望角色能稳稳站在斜坡上,除非斜坡太陡。
解决方案:
- 判断地面法线,如果坡度小于"可站立角度"(通常45-60度),就阻止滑动
- 可以通过在垂直于法线方向施加力,或直接投影速度来实现
- 超过最大坡度的斜坡,角色应该滑下去(或无法上去)
3. 墙角卡滞(Corner Hanging)
角色在两个墙的夹角处移动时,有时会被卡住。这是因为碰撞响应将角色向两个墙的法线方向推,但两个方向的合力可能与移动方向相反。
解决方案:
- 使用多次反弹的滑动算法(如前所述的Sweep and Slide)
- 检测是否处于"卡住"状态(移动输入但速度为零),并尝试微小的位置修正
- 调整胶囊体半径,避免过于贴近墙面
4. 天花板检测
角色跳跃时,头部撞到天花板应该立即停止上升,而不是穿透后再弹回来。这需要精确的向上碰撞检测。
多层碰撞体方案
对于复杂的角色,单一碰撞体往往不够用。常见的方案是多层碰撞体:
- 运动层:一个大的胶囊体,用于角色移动和环境碰撞
- 命中检测层:多个小碰撞体(头、胸、四肢),用于射击和攻击的命中判定
- 物理交互层:简化的凸包或刚体组,用于与物理对象的交互
这种分层设计的好处是各层各司其职:运动层追求流畅性,命中层追求精确性,交互层追求物理真实性。
14.4 布料、柔体与破坏物理
角色物理不仅仅是角色控制器。现代游戏中的角色还涉及布料(衣服、披风)、柔体(肌肉、脂肪)、破坏(断肢、粉碎)等高级物理效果。这些效果虽然不是核心玩法的一部分,但对视觉品质的提升非常显著。
布料模拟(Cloth Simulation)
布料模拟的目标是让角色的衣服、披风、裙子等织物自然地随角色运动和环境力而飘动。
主流的布料模拟方法:
质点-弹簧模型(Mass-Spring Model):最经典的方法。将布料离散化为由弹簧连接的质点网格。
- 结构弹簧(Structural Springs):连接相邻质点,维持布料的结构
- 剪切弹簧(Shear Springs):连接对角质点,抵抗剪切变形
- 弯曲弹簧(Bend Springs):跨过一个质点连接,抵抗弯曲
每帧更新时:
- 对每个质点施加力(重力、风力、惯性力)
- 更新质点位置(积分)
- 约束求解:确保弹簧长度不超过限制(通常用Verlet积分 + 位置修正)
优点:实现简单,直观易懂,性能可调节 缺点:弹簧参数调校困难,容易出现超弹(Super-elastic)问题
基于位置的动力学(PBD - Position-Based Dynamics):这是目前游戏中最主流的布料模拟方法。PBD直接操作位置,而不是力和加速度。
每帧更新:
- 预测位置(根据速度和外力)
- 依次满足所有约束(距离约束、弯曲约束、碰撞约束等)
- 从位置变化推导速度
优点:稳定性极好,无条件稳定(步长多大都不会爆炸),参数直观 缺点:精度略低,约束满足的顺序影响结果
PhysX的布料模块和Unity的Cloth组件都基于PBD方法。
布料的架构考量:
-
模拟精度与性能的平衡:布料的顶点数(分辨率)直接决定了视觉质量和计算成本。角色衣服通常用1000-5000个顶点,披风可能需要更多。
-
与角色动画的耦合:布料不是独立模拟的——它的运动来源于角色动画。需要确定哪些点被动画驱动(固定点),哪些点由物理模拟(自由点)。常见的做法是蒙皮驱动 + 物理修正:先用骨骼动画计算布料顶点的位置,再用物理模拟添加次级运动。
-
碰撞处理:布料需要与角色身体和环境碰撞。布料-身体碰撞通常用简化的碰撞体(胶囊体、球体)表示身体,避免高复杂度的网格-网格碰撞。
-
LOD策略:远距离的角色可以降低布料模拟的精度,甚至直接用动画代替物理模拟。
柔体模拟(Soft Body Simulation)
柔体模拟用于模拟可变形的固体,如果冻、肌肉、脂肪、橡胶等。在角色物理中,柔体主要用于次级动画——比如角色跑动时胸部和臀部的晃动、面部表情的肉感。
柔体的模拟方法与布料类似,但多了一个维度:
- 布料是2D曲面在3D空间中的变形
- 柔体是3D体积的变形
柔体的额外挑战:
- 体积守恒约束:柔体被挤压时体积应该保持不变(不可压缩性)
- 形状匹配:柔体应该有一个"静止形状",变形后有恢复的趋势
- 四面体网格:柔体通常用四面体网格离散化,比布料的三角形网格更复杂
由于计算成本较高,游戏中的柔体通常只用于小规模的视觉效果,而不是大规模的物理交互。
破坏物理(Destruction)
破坏物理让角色可以破坏环境——打碎墙壁、炸掉桥梁、撕裂布料。破坏物理是一个复杂的系统,涉及多个子系统的协作:
-
预破碎(Pre-fracturing):美术预先将模型切割成多个碎片。运行时,当物体受到足够大的冲击力时,将其分裂为碎片并释放物理模拟。
-
实时破碎(Real-time Fracturing):根据冲击力的位置和大小,实时计算破碎的模式。更真实但计算量巨大,通常只用于小规模物体。
-
分层破坏:物体有不同的破坏层级——从裂纹到破碎到粉碎。根据伤害程度触发不同层级的破坏效果。
-
支撑结构分析:建筑被破坏后,应该根据结构完整性判断哪些部分会坍塌。这需要类似结构力学的分析。
PhysX的Apex Destruction和Unreal的Chaos Destruction都是成熟的破坏物理解决方案。
破坏物理的架构挑战:
- 性能:破碎后产生大量碎片,可能导致物理计算量激增
- 渲染:碎片需要特殊的渲染处理(内部面、破碎纹理)
- 网络同步:破坏状态需要在多人间同步,数据量可能很大
- 游戏逻辑:破坏可能影响关卡设计和路径规划
14.5 架构权衡:物理真实感与游戏手感的平衡
角色物理的设计是一场持续的拉锯战——一边是物理真实感,一边是游戏手感。作为架构师,需要有意识地在两者之间做出选择,并理解每个选择的代价。
真实感与手感的矛盾矩阵
| 设计选择 | 物理真实感 | 游戏手感 | 典型场景 |
|---|---|---|---|
| 空中无法改变方向 | 高 | 低 | 模拟游戏 |
| 空中可以微调方向 | 中 | 中 | 动作游戏 |
| 空中完全可控 | 低 | 高 | 平台跳跃 |
| 精确的跳跃物理 | 高 | 低 | 物理游戏 |
| 可变跳跃高度 | 低 | 高 | 平台跳跃 |
| 落地有弹跳 | 高 | 低 | 物理沙盒 |
| 落地完全静止 | 低 | 高 | FPS游戏 |
| 动量守恒 | 高 | 低 | 太空游戏 |
| 即时加速 | 低 | 高 | 大多数游戏 |
手感优先的设计策略
对于大多数游戏,手感优先于物理真实感。以下是常见的"反物理"但"好手感"的设计:
-
郊狼时间(Coyote Time):角色走出平台边缘后,仍然有一小段时间(约0.1秒)可以跳跃。这给了玩家容错空间,即使晚按一点跳跃键也不会掉下去。
-
跳跃缓冲(Jump Buffering):在角色落地前的一小段时间内按跳跃键,落地后会立即起跳。这样玩家不需要精确地在落地瞬间按键,节奏感更好。
-
最大重力(Variable Gravity):角色上升时重力较小(跳得更高更飘逸),下降时重力较大(落地更快更干脆)。或者更极端地:松开跳跃键时立即增加重力,实现可变跳跃高度。
-
空气控制(Air Control):角色在空中可以改变水平速度。真实物理中这是不可能的(没有受力点),但游戏中几乎是标配。
-
斜坡辅助(Ramp Assist):角色跑上斜坡时自动施加额外的力,防止速度下降太多。保持移动的流畅感。
-
磁铁效应(Magnet Effect):角色靠近平台边缘时,会被"吸"到平台上。或者跳跃时,稍微偏向平台的方向会有修正。
架构设计:可调节的参数系统
优秀的角色控制器架构应该将所有这些"手感参数"暴露出来,让设计师可以灵活调节,而不需要改代码。
关键参数类别:
移动参数:
- 最大行走速度
- 最大奔跑速度
- 加速度
- 减速度
- 空中控制系数
- 地面摩擦力
跳跃参数:
- 跳跃高度(或跳跃初速度)
- 可变跳跃:松开键后的额外重力
- 郊狼时间
- 跳跃缓冲时间
- 最大跳跃次数(二段跳、三段跳)
碰撞参数:
- 角色半径
- 角色高度
- 可站立最大角度
- 可攀爬最大台阶高度
- 步高偏移
这些参数应该按角色配置——玩家角色、NPC、Boss可能有完全不同的参数组。
状态机驱动的角色物理
角色不是处于单一的运动状态。行走、奔跑、跳跃、落地、攀爬、滑行——每种状态下的物理行为都不同。
因此,角色控制器通常是一个状态机:
┌───────┐
┌───►│ 站立 │◄───┐
│ └───────┘ │
│ │ 移动 │
│ ▼ │
│ ┌───────┐ │ 停止
│ │ 行走 │────┘
│ └───────┘
│ │ 奔跑键
│ ▼
│ ┌───────┐
│ │ 奔跑 │────┐
│ └───────┘ │
│ │ 跳跃 │
│ ▼ │
跳跃 │ ┌───────┐ │ 落地
落地 │ │ 空中 │────┘
│ └───────┘
│ ▲
│ │ 跳跃键
└──────┘
每个状态有自己的物理参数和行为逻辑。状态转换时需要处理速度过渡、动画过渡、碰撞体变化等。
架构师的角色物理设计清单
- 明确游戏类型对手感的要求:是精确平台跳跃?还是拟真生存?还是爽快动作?
- 选择控制器类型:Kinematic、Dynamic还是混合?切换逻辑如何设计?
- 定义碰撞层级:运动碰撞、命中检测、物理交互各用什么形状?
- 确定特殊运动能力:跳跃、二段跳、冲刺、滑铲、攀爬、游泳……
- 设计参数系统:哪些参数需要暴露给设计师?参数粒度如何?
- 规划调试工具:如何可视化角色的碰撞体、速度、接触点?如何快速调节参数并测试?
- 考虑与动画系统的集成:根运动如何处理?动画驱动的运动如何与物理协调?
角色物理是游戏体验中最"体感"的部分。玩家可能不会注意到精确的光影效果,但一定会注意到角色移动的手感是否舒服。从架构角度看,角色物理系统的核心不是物理技术有多先进,而是能否为设计师提供足够的控制力,让他们调出"恰到好处"的手感。
第四篇:动画系统架构
第15章 动画系统架构总览
15.1 动画系统在游戏引擎中的位置与作用
如果说渲染系统决定了游戏"长什么样",物理系统决定了游戏"怎么运动",那么动画系统就是连接两者的桥梁——它决定了角色"如何表达"。一个没有动画的3D角色只是一具僵硬的雕塑,而优秀的动画能赋予角色灵魂,让玩家感受到角色的情绪、重量和生命力。
动画系统在游戏引擎中的地位正在不断提升。从早期的"播放预录制动画",到今天的程序化动画、物理动画、机器学习驱动动画,动画系统已经从一个"播放组件"演变为一个复杂的"动作生成系统"。它不再是渲染的附属,而是与物理、AI、游戏逻辑深度融合的核心子系统。
从架构视角看,动画系统的核心职责可以概括为三个层面:
数据层:存储与管理动画资源
动画系统需要高效地存储和管理大量的动画数据。一段骨骼动画可能包含数十根骨骼、数千帧关键帧。一个3A游戏角色的动画资源可能达到数百兆甚至上千兆字节。如何压缩、索引、缓存这些数据,是动画系统的基础挑战。
逻辑层:决定播放什么动画
动画系统需要根据游戏状态决定角色当前应该做什么动作。是行走还是奔跑?是攻击还是受击?转身朝向哪个方向?这涉及复杂的状态管理、过渡逻辑和混合计算。现代动画系统已经发展出动画蓝图、状态机、混合空间等复杂的逻辑编排机制。
求解层:生成最终骨骼姿态
这是动画系统的"计算核心"。给定一组动画输入(当前播放的动画片段、混合权重、IK目标等),计算出每根骨骼最终的位置和旋转。这个过程可能涉及动画采样、混合、叠加、IK求解、物理修正等多个步骤。
动画系统三层架构:
游戏逻辑/AI输入
│
▼
┌──────────┐
│ 逻辑层 │ 状态机、混合空间、动画蓝图
│ 播什么? │
└──────────┘
│
▼
┌──────────┐
│ 求解层 │ 采样、混合、IK、修正
│ 怎么算? │
└──────────┘
│
▼
┌──────────┐
│ 数据层 │ 动画片段、曲线、压缩格式
│ 存什么? │
└──────────┘
│
▼
骨骼姿态 → 渲染/物理
动画系统的输出是骨骼姿态(Pose)——一帧中所有骨骼的位置和旋转。这个姿态一方面送给渲染系统做蒙皮(Skinning),生成最终的角色画面;另一方面送给物理系统,用于角色碰撞、布娃娃等物理计算。
15.2 动画技术演进:逐帧动画→骨骼动画→动作捕捉→程序化动画
动画技术的发展经历了数次范式转移,每一次都深刻改变了游戏的视觉表现力和开发方式。
第一代:逐帧动画(Frame-by-Frame Animation)
最早的游戏动画是逐帧绘制的——每一帧都是一张独立的图片。《超级马里奥》、《魂斗罗》等经典游戏都采用这种方式。动画师需要手绘角色的每一个动作帧。
特点:
- 技术简单,就是按顺序播放图片
- 美术工作量巨大,每增加一个动作都要画一套帧
- 无法动态修改——角色不能换衣服、不能改变动作
- 内存占用与动画长度成正比
逐帧动画虽然古老,但在像素风游戏和2D独立游戏中仍然广泛使用。它的独特艺术魅力是3D动画无法替代的。
第二代:精灵动画与2D骨骼动画
随着硬件性能提升,2D游戏开始使用更高效的动画方式:
- 精灵动画(Sprite Animation):将角色的各个部位(头、身体、四肢)拆分为独立的精灵,通过代码控制各部分的位置和旋转来组成动画。大大减少了美术工作量。
- 2D骨骼动画:进一步引入骨骼层级和蒙皮的概念(Spine、DragonBones等工具)。原理与3D骨骼动画类似,只是在2D平面上。这是2D游戏动画的现代标准。
第三代:3D骨骼动画(Skeletal Animation)
3D游戏兴起后,骨骼动画成为了标准。它的核心思想是:用一个骨骼层级(Skeleton)来驱动模型的变形。动画数据只记录骨骼的运动,而不是每个顶点的运动。
骨骼动画的优势是革命性的:
- 数据量小:一个有50根骨骼的角色,动画数据量只与骨骼数相关,与模型顶点数无关
- 可复用:同一套动画可以复用于不同的角色模型(只要骨骼结构兼容)
- 可混合:不同动画可以按权重混合,产生平滑的过渡效果
- 可程序化控制:可以在运行时修改骨骼(如IK、表情)
骨骼动画至今仍是3D游戏动画的基础。现代动画系统的所有高级特性都是建立在骨骼动画之上的。
第四代:动作捕捉(Motion Capture)
随着角色动画品质要求的提升,手工关键帧动画越来越难以满足对真实感的需求。动作捕捉技术通过记录真人演员的运动,生成高度真实的动画数据。
动捕技术经历了几个阶段:
- 光学动捕:在演员身上贴反光标记点,用多台摄像机捕捉三维位置。精度高,是行业主流(如Vicon、OptiTrack)。
- 惯性动捕(IMU):演员穿戴内置惯性传感器的服装。无需摄像机,使用灵活,但精度稍低(如Xsens、Perception Neuron)。
- 视频动捕:纯视觉方案,通过普通摄像机和AI算法估计人体姿态。成本低,适合快速原型。
动捕不仅改变了动画的生产方式,也改变了动画系统的架构。动捕数据的特点是:量大(每秒60-120帧)、噪声大(需要清理和光顺)、细节丰富(包含许多微妙的次级运动)。动画系统需要有能力高效地处理这些大数据量的动画资源。
第五代:程序化动画(Procedural Animation)
最新的趋势是程序化动画——不依赖预录制的动画片段,而是在运行时实时生成动作。这包括:
- IK(反向运动学):给定末端目标(如手要抓到某个位置),反求整个骨骼链的姿态。
- 物理动画:用物理模拟生成动画,如布料、头发、尾巴的摆动。
- 步态生成(Locomotion):根据速度和方向,程序化生成行走/奔跑动画。
- 姿势匹配(Pose Matching):从动画库中检索最接近当前状态的姿势,平滑过渡。
- 机器学习驱动:用神经网络生成动画(如深度学习角色动画、Motion Matching的进阶版本)。
程序化动画的兴起,本质上是对"预录制动画灵活性不足"的回应。预录制动画无论做多少组,都无法覆盖游戏中所有可能的情况。程序化动画则可以根据环境和状态实时生成合适的动作。
但程序化动画也有局限:生成质量不稳定、美术控制力弱、计算成本高。因此,当前的主流是混合方案——以预录制动画为基础,用程序化动画做补充和修正。
15.3 动画系统的核心模块:数据层、逻辑层、求解层
让我们深入了解动画系统三个核心层次的具体构成。
数据层:动画资源的存储与管理
数据层是动画系统的基础。它负责管理所有的动画资源,包括:
-
动画片段(Animation Clip):最基本的动画单元,包含一段完整的动作(如"行走"、"出拳"、"跳跃")。每个Clip记录了一组骨骼在一段时间内的运动轨迹。
-
动画曲线(Animation Curve):除了骨骼变换,动画还可以驱动其他参数,如材质参数、物体可见性、音效触发等。这些由浮点曲线驱动。
-
骨架(Skeleton):骨骼的层级结构定义,包括骨骼名称、父子关系、初始姿态(Bind Pose / Reference Pose)。
-
蒙皮数据(Skinning Data):网格顶点受哪些骨骼影响,以及每块骨骼的权重(Weight)。
-
元数据:动画通知(Notify)、根运动轨迹、混合空间采样点等辅助数据。
数据层的核心挑战是压缩。未经压缩的动画数据量非常大——一根骨骼一帧需要7个浮点数(位置3 + 旋转4,四元数),100根骨骼、30fps、10秒的动画就是 100 × 7 × 30 × 10 × 4字节 = 840KB。一个角色如果有几百个动画片段,就是数百兆。
常见的动画压缩技术:
- 关键帧压缩:只存储有变化的关键帧,而非每一帧都存
- 曲线简化:移除对视觉影响小的关键帧
- 量化压缩:用更少的位数表示浮点数(如用16位定点数代替32位浮点数)
- 分段压缩:不同骨骼、不同时间段用不同精度
- 小波压缩:类似图像压缩的频域压缩方法
逻辑层:动画状态的决策与编排
逻辑层决定了角色当前的动画状态。它是游戏逻辑与动画系统之间的桥梁。
逻辑层的核心组件包括:
-
动画状态机(Animation State Machine):用状态和状态转移来组织动画。每个状态对应一个动画片段,转移条件定义了何时从一个状态切换到另一个状态。
-
混合空间(Blend Space):根据连续的输入参数(如速度、方向),在多个动画样本之间进行连续插值。例如根据移动速度在"走-跑-冲刺"之间平滑过渡。
-
动画蓝图(Animation Blueprint):Unreal Engine首创的可视化动画编程系统。用节点图的方式组织动画逻辑,包括状态机、混合节点、IK节点、参数节点等。
-
动画层(Animation Layer):将动画逻辑分层,每层处理身体的一部分(如上身层、下身层、面部层),各层独立计算后合成。
逻辑层的复杂度直接决定了动画的表现力。简单的游戏可能只有几个状态(待机、行走、攻击、受击、死亡),而3A游戏的动画蓝图可能包含数百个状态、数十个混合空间、多层级的动画逻辑。
求解层:骨骼姿态的计算与合成
求解层是动画系统的计算核心。它接收逻辑层的输入(当前播放什么、混合权重是多少),计算出最终的骨骼姿态。
求解层的典型计算流程:
求解层数据流:
动画Clip 1 ──┐
动画Clip 2 ──┤
... ├─→ [采样] → [混合] → [叠加] → [IK] → [修正] → 最终Pose
动画Clip N ──┘
-
采样(Sampling):根据当前时间,从每个动画片段中采样出当前帧的骨骼姿态。对于关键帧动画,需要在相邻关键帧之间插值。
-
混合(Blending):将多个动画的姿态按权重混合。最简单的是线性混合(Lerp),高级的有叠加混合(Additive)、差量混合(Difference)等。
-
叠加(Additive):在基础动画之上叠加额外的动画。例如在行走动画上叠加挥手动画,上半身做动作而下半身继续走路。
-
IK求解(Inverse Kinematics):根据目标位置修正骨骼链的姿态。例如让脚贴合地面、让手抓到物体。
-
物理修正:用物理模拟修正部分骨骼,如头发、布料、胸部的晃动。
-
姿态修正(Post-Processing):最后的调整步骤,如扭曲修正、拉伸补偿等。
求解层是性能优化的重点。角色数量多时,动画求解可能成为CPU瓶颈。优化技术包括:
- 多线程并行求解(不同角色在不同线程计算)
- SIMD优化(一次计算多根骨骼)
- GPU动画(将动画计算放到GPU上)
- LOD(远距离角色降低动画精度)
15.4 动画与其他系统的交互:物理、渲染、AI
动画系统不是孤立存在的。它需要与游戏引擎的许多其他子系统紧密协作。
动画与物理的交互
动画和物理是天然的搭档,也是经常产生冲突的两个系统。动画说"手应该在这里",物理说"手穿模了"。如何协调两者的关系是架构设计的重要问题。
交互模式一:动画驱动物理(Animation Driving Physics)
动画是主导,物理是跟随。角色的运动由动画决定,物理系统只是被动地接收动画结果。例如:
- 角色的碰撞体跟随动画移动
- 布娃娃系统在角色死亡前是运动学的,由动画驱动
这种模式的优点是动画完全可控,缺点是物理交互不真实(角色推不动箱子,因为没有力的传递)。
交互模式二:物理驱动动画(Physics Driving Animation)
物理是主导,动画是结果。角色的运动完全由物理模拟决定。例如:
- 布娃娃系统
- 物理驱动的布料和头发
- 完全物理驱动的角色(如《Gang Beasts》)
这种模式的优点是物理真实感强,缺点是动画不可控,很难调出"好看"的效果。
交互模式三:双向耦合(Two-way Coupling)
动画和物理互相影响。动画提供基础的运动趋势,物理在此基础上进行修正和补充。这是最复杂也最强大的模式。
典型例子:
- 动画驱动 + IK修正:动画给出大致姿态,IK让脚贴合地面
- Ragdoll Animation:部分身体由物理驱动,部分由动画驱动
- Projection / Matching:物理模拟后,将姿态"拉回"到接近动画的位置
Unreal Engine的Animation Blueprint + Physics Asset架构就是这种双向耦合的实践。动画蓝图计算基础姿态,物理Asset(由约束连接的骨骼刚体)在其上做物理模拟,最终结果是两者的融合。
动画与物理的双向耦合:
动画姿态 ──► 物理模拟 ──► 融合/投影 ──► 最终姿态
▲ │
│ │
└──────────── 速度/位置反馈 ──────────────────┘
动画与渲染的交互
动画系统的输出直接服务于渲染系统。两者之间的交互主要是数据传递:
-
骨骼矩阵传递:动画系统计算出每根骨骼的最终矩阵,传给渲染系统做蒙皮。这是最基本的数据通路。
-
蒙皮计算位置:蒙皮计算(顶点位置 = Σ权重 × 骨骼矩阵 × 初始顶点位置)可以在CPU上做,也可以在GPU上做。现代引擎普遍采用GPU蒙皮(Hardware Skinning),将骨骼矩阵上传到GPU,在顶点着色器中计算。
-
动画驱动的渲染参数:动画曲线可以驱动材质参数(如眼神变化、脸红)、粒子系统发射、贴花显示等。
-
渲染反馈动画:某些渲染效果也会影响动画,如运动模糊需要速度信息,而速度可以从动画的帧间差分得到。
动画与AI的交互
AI系统决定角色"做什么",动画系统决定角色"怎么做"。两者的交互点包括:
-
运动学同步:AI告诉动画系统角色的移动速度和方向,动画系统播放相应的行走/奔跑动画。理想情况下,动画的脚步应该与实际移动速度匹配(Foot IK和Root Motion帮助实现这一点)。
-
动作选择:AI决定播放什么动作(攻击、防御、施法),动画系统负责执行和过渡。
-
动画事件回调:动画中的特定时刻(如"拳头到达最远点")触发事件,通知AI和游戏逻辑执行相应的逻辑(如伤害判定)。
-
环境感知:动画系统可以根据环境信息调整动画,如靠近墙壁时转身、上下楼梯时调整脚步。这些调整需要AI提供环境信息。
15.5 架构师视角:动画系统的复杂度与表现力权衡
动画系统是游戏引擎中复杂度增长最快的子系统之一。每提升一级表现力,复杂度可能增加数倍。作为架构师,需要在表现力、开发效率、运行时性能之间找到平衡点。
表现力的层次模型
我们可以将动画系统的表现力分为几个层级,每一层都建立在下层的基础上:
Layer 5: 机器学习动画 / 完全程序化
↑ 最高表现力,最高复杂度
Layer 4: 物理动画 + IK + 程序化修正
↑
Layer 3: 状态机 + 混合空间 + 动画蓝图
↑
Layer 2: 简单状态机 + 线性混合
↑
Layer 1: 单动画播放 + 硬切换
↑ 最低表现力,最低复杂度
每提升一个层级,都需要投入相应的开发成本和运行时成本。架构师的任务是根据项目需求选择合适的层级,而不是盲目追求"最先进"。
复杂度的来源分析
动画系统的复杂度来自多个维度:
-
状态爆炸:角色的状态组合是指数级的。行走+持枪+左移+受伤+上坡……如果每种组合都做一个动画,数量会爆炸。混合空间和程序化动画是应对状态爆炸的手段。
-
过渡复杂度:任意两个状态之间的过渡都需要考虑。n个状态就有n²种过渡组合。状态机设计的艺术在于用结构化的方式管理这些过渡。
-
数据量膨胀:更多的动画片段意味着更多的内存占用、更长的加载时间、更高的磁盘空间需求。压缩技术只能缓解,不能消除。
-
调试困难:动画是高度视觉化的,bug往往是"看起来不对"而不是"程序崩溃"。调试动画问题需要专门的工具和经验。
-
多系统耦合:动画与物理、AI、逻辑的耦合越深,系统间的依赖越复杂,bug越难追踪。
架构设计的权衡策略
策略一:数据驱动 vs 代码驱动
动画逻辑应该用数据(可视化蓝图、配置文件)还是代码(C++/C#)来写?
- 数据驱动的优点:美术和动画师可以直接调整,不需要程序员介入,迭代速度快
- 数据驱动的缺点:复杂逻辑用图表表达很痛苦,性能不如代码,版本管理困难(二进制文件diff麻烦)
- 代码驱动的优点:表达力强,性能好,易于调试和版本管理
- 代码驱动的缺点:每次调整都需要程序员,迭代慢
最佳实践是混合模式:基础的、频繁变化的用数据驱动(如状态机、混合参数),复杂的、稳定的用代码实现(如自定义IK、特殊的混合逻辑)。
策略二:预录制 vs 程序化
- 预录制动画质量高、可控,但灵活性差、数据量大
- 程序化动画灵活、数据量小,但质量不稳定、难以美术控制
架构趋势是以预录制为基础,程序化做补充。例如:
- 基础的行走、跑步、攻击用预录制动画,保证质量
- 脚部贴合地面用IK做程序化修正,适应不同地形
- 布料、头发用物理模拟,增加真实感
- 受击反应用程序扰动,减少动画制作量
策略三:集中式 vs 分布式
动画逻辑是集中在一个大的动画蓝图里,还是分散到各个组件中?
- 集中式:逻辑集中,容易理解和调试,但蓝图会变得巨大且难以维护
- 分布式:模块化好,可复用性高,但逻辑分散,理解整体流程困难
Unreal Engine的Animation Layer系统是一种折中——在集中的蓝图中使用分层结构,每层可以相对独立地开发和维护。
架构师的动画系统设计清单
- 确定动画品质目标:项目需要达到什么水准?是独立游戏的简单动画,还是3A的电影级动画?
- 评估团队产能:动画师有多少人?能产出多少动画资源?这决定了能否走"堆动画数量"的路线。
- 选择技术栈:用什么动画工具链(Maya / MotionBuilder / Houdini)?引擎用什么方案(状态机 / 动画蓝图 / Motion Matching)?
- 定义性能预算:同屏多少角色?每个角色多少根骨骼?动画帧率多少?CPU/GPU各分配多少预算?
- 规划工具链:动画导入、压缩、预览、调试的工具链是否完善?
- 预留扩展空间:未来是否需要加入IK、物理动画、程序化动画?架构是否支持平滑演进?
动画系统的架构设计,本质上是在"美术能做多少"和"程序能自动多少"之间找到最优分工。优秀的动画架构师不仅要懂技术,还要懂美术流程和创作规律,才能设计出既高效又富有表现力的系统。
第16章 骨骼动画原理与实现
16.1 骨骼层级与蒙皮:Skeleton、Bone、Skinning
骨骼动画是现代3D游戏动画的基石。理解骨骼动画的原理,是掌握动画系统架构的起点。
骨骼(Bone)与骨架(Skeleton)
骨骼动画的核心概念是"骨骼"——但这里的骨骼不是生物意义上的骨头,而是一个抽象的变换节点。每块骨骼包含:
- 一个变换(位置 + 旋转 + 缩放)
- 一个父骨骼指针(根骨骼除外)
- 一个名字(用于标识和查找)
多块骨骼通过父子关系连接在一起,形成一个树形结构,称为骨架(Skeleton)。
典型的人形角色骨架包含50-100块骨骼:
- 根骨骼(Root):整个骨架的原点
- 脊柱(Spine):1-3节,从骨盆到胸口
- 头部(Head):脖子 + 头 + 面部骨骼
- 手臂(Arms):锁骨 + 上臂 + 前臂 + 手 + 手指
- 腿部(Legs):大腿 + 小腿 + 脚 + 脚趾
人形骨骼层级示意(简化版):
Root
│
Pelvis
/ \
Spine_0 L_Thigh
│ │
Spine_1 L_Calf
│ │
Spine_2 L_Foot
│
Neck
│
Head
/ \
L_Clavicle R_Clavicle
│ │
L_UpperArm R_UpperArm
│ │
L_Forearm R_Forearm
│ │
L_Hand R_Hand
骨骼变换有两种表示方式:
- 局部变换(Local Transform):相对于父骨骼的变换。动画数据通常以局部变换存储,因为这样骨骼的运动是解耦的——移动父骨骼时,子骨骼自然跟随。
- 世界变换(Global / World Transform):相对于世界坐标系的变换。这是最终用于蒙皮计算的变换。
从局部变换计算世界变换需要沿骨骼链向上累积:
worldMatrix(bone) = worldMatrix(parent(bone)) * localMatrix(bone)
这个过程称为正向运动学(Forward Kinematics, FK)——从根向末端逐节计算。
绑定姿态(Bind Pose / Reference Pose)
骨架有一个特殊的初始姿态,称为绑定姿态(或参考姿态、T-Pose)。这是模型建模时的姿态,也是蒙皮权重的计算基准。
每块骨骼在绑定姿态下的世界变换矩阵的逆矩阵,称为偏移矩阵(Offset Matrix / Inverse Bind Pose Matrix)。它的作用是将顶点从模型空间变换到骨骼的局部空间。
蒙皮(Skinning)
有了骨架和模型,还需要一种机制让骨骼的运动传递到模型表面。这就是蒙皮(Skinning)。
蒙皮的基本思想是:每个顶点受一块或多块骨骼的影响,顶点的最终位置是所有影响骨骼变换的加权和。
数学表达:
v_final = Σ (w_i × M_i × v_bind)
其中:
- v_bind 是顶点在绑定姿态下的位置(模型空间)
- M_i 是第i块骨骼的最终变换矩阵(模型空间)
- w_i 是第i块骨骼对该顶点的权重,所有权重之和为1
- v_final 是顶点的最终位置
权重(Weight)是蒙皮的关键数据。它决定了骨骼运动对顶点的影响程度。典型的权重分布:
- 关节附近的顶点受两块骨骼共同影响(如肘部顶点同时受上臂和前臂影响)
- 权重平滑过渡,避免出现明显的折痕
- 大部分顶点受2-4块骨骼影响
蒙皮权重通常由美术在DCC工具(如Maya、Blender)中手工绘制或自动计算(如热图扩散、体素方法),然后进行微调。
骨骼动画的完整流程
综合以上概念,一帧骨骼动画的完整计算流程如下:
1. 采样动画数据 → 得到各骨骼的局部变换(位置+旋转+缩放)
2. 正向运动学(FK)→ 从根到末端,计算各骨骼的世界矩阵
3. 蒙皮计算 → 对每个顶点,按权重混合骨骼变换,得到最终位置
4. 渲染 → 将顶点数据送入渲染管线
这个流程每帧都要执行。对于有上万个顶点、几十上百根骨骼的角色,蒙皮计算的工作量是相当大的。
16.2 关键帧动画:插值算法、曲线压缩
关键帧动画是骨骼动画最基本的存储和播放方式。它的思想很简单:在重要的时间点(关键帧)记录骨骼的变换,中间的时刻通过插值计算得到。
为什么用关键帧而不是逐帧存储?
逐帧存储的问题是数据量大且冗余。一段30fps、10秒的动画有300帧,但骨骼的运动通常是平滑连续的,很多帧之间的差异很小,不需要每帧都存。关键帧只存储运动发生显著变化的时刻,大大减少了数据量。
关键帧的类型
每块骨骼的变换可以分解为三个分量,每个分量独立存储关键帧:
-
位置关键帧(Position Keys):记录骨骼的位置随时间的变化。通常关键帧较少,因为位置变化相对缓慢。
-
旋转关键帧(Rotation Keys):记录骨骼的旋转随时间的变化。旋转是动画中最活跃的分量,关键帧通常最多。
-
缩放关键帧(Scale Keys):记录骨骼的缩放随时间的变化。在角色动画中很少使用,通常只有恒等缩放。
这种分解存储的好处是每个通道可以有不同的关键帧数量——变化频繁的通道多存关键帧,变化缓慢的少存,进一步节省空间。
插值算法
有了关键帧,如何计算任意时刻的值?这取决于插值算法的选择。
1. 线性插值(Linear Interpolation, Lerp)
最简单的插值方法。在两个相邻关键帧之间按时间线性过渡。
value(t) = value_a + (t - t_a) / (t_b - t_a) × (value_b - value_a)
优点:计算简单,速度快 缺点:过渡生硬,关键帧处有突变(一阶不连续),视觉上有卡顿感
线性插值适合位置和缩放,但不适合旋转——因为旋转的线性插值不是最短路径,而且没有单位长度保证。
2. 球面线性插值(Spherical Linear Interpolation, Slerp)
专门用于四元数旋转插值的方法。Slerp在四元数所在的四维球面上沿大圆弧插值,保证了旋转角速度恒定,且路径最短。
slerp(q_a, q_b, t) = sin((1-t)·θ)/sin(θ) · q_a + sin(t·θ)/sin(θ) · q_b
其中 θ = arccos(q_a · q_b)
优点:旋转过渡平滑,角速度均匀 缺点:计算量较大(涉及三角函数)
Slerp是旋转插值的标准方法,但在游戏中,更常用的是Nlerp(Normalized Lerp)——先做线性插值,再归一化。Nlerp比Slerp快很多,效果也足够好(角速度不是恒定的,但差异在大多数情况下可接受)。
3. 三次样条插值(Cubic Spline / Hermite / Bezier)
对于更高质量的动画,线性插值不够用——关键帧处的突变会导致明显的"机械感"。三次样条插值保证了一阶(速度)甚至二阶(加速度)连续性,运动更平滑自然。
常见的三次插值方法:
- Hermite插值:给定两端点的位置和切线,构造三次曲线
- Bezier插值:用控制点定义曲线形状
- Catmull-Rom样条:经过所有控制点的平滑曲线,切线由相邻点自动计算
三次插值的优点是动画质量高,缺点是:
- 计算量比线性插值大
- 需要额外存储切线/控制点信息
- 采样时需要查找更多关键帧(前后各两个)
采样的性能优化
动画采样是每帧都要做的操作,性能很重要。以下是常见的优化手段:
-
二分查找:在关键帧数组中查找当前时间所在的区间,用二分查找,时间复杂度O(log n)。
-
缓存提示(Cache Hint):利用时间连贯性,上一帧的关键帧索引很可能接近当前帧的。从上次的位置开始顺序查找,通常只需1-2次比较。
-
均匀采样:如果动画是均匀采样的(每帧都有关键帧),可以直接用时间除以帧率得到索引,O(1)复杂度。
-
动画帧率与渲染帧率解耦:动画不需要每帧都重新采样。可以以固定频率(如30Hz)采样动画,渲染帧率高于动画帧率时,在相邻两帧动画结果之间插值。这可以大幅减少采样计算量。
16.3 蒙皮算法:Linear Blend Skinning vs Dual Quaternion Skinning
蒙皮算法是骨骼动画中最核心的计算步骤。算法的选择直接影响视觉质量和计算性能。
Linear Blend Skinning(LBS,线性混合蒙皮)
线性混合蒙皮是最经典、最常用的蒙皮算法。它的原理就是前面介绍的加权平均:
v_final = Σ (w_i × M_i × v_bind)
其中 M_i 是4×4的变换矩阵。
LBS的优点:
- 实现简单,理解容易
- 计算效率高,易于GPU并行化
- 硬件支持好,几乎所有GPU都支持硬件蒙皮
LBS的缺点也很明显,最突出的两个问题:
问题一:糖果包装效应(Candy Wrapper Effect)
当骨骼沿长轴旋转较大角度时,皮肤会出现扭曲,就像拧糖果包装纸一样。这是因为矩阵的线性混合不保持旋转的几何特性——平均两个旋转矩阵,得到的不再是一个纯粹的旋转矩阵(会有切变)。
例如,上臂旋转180度,肘部顶点同时受上臂和前臂影响。两块骨骼的旋转差180度,矩阵混合后会导致顶点被压扁、扭曲。
问题二:体积损失(Volume Loss)
关节弯曲时,皮肤会出现"塌陷",看起来像被捏扁了。弯曲角度越大,体积损失越明显。这也是线性混合的固有问题——两个旋转矩阵的加权平均,其缩放分量小于1。
这些问题在质量要求高的游戏中尤其明显,特别是角色的肩部、髋部、手腕等大角度关节。
Dual Quaternion Skinning(DQS,双四元数蒙皮)
为了解决LBS的扭曲问题,研究者提出了双四元数蒙皮。它的核心思想是:不用矩阵表示变换,而是用双四元数(Dual Quaternion)。
双四元数是一种可以同时表示旋转和平移的数学工具。它的性质是:双四元数的线性混合(然后重新归一化)仍然是一个刚性变换(旋转+平移,无切变、无缩放)。
这完美解决了LBS的扭曲问题——因为混合后仍然是合法的旋转,不会产生切变。
DQS的算法步骤:
1. 将每块骨骼的变换矩阵转换为双四元数 q_i
2. 对双四元数进行加权混合:q_blend = Σ(w_i × q_i)
3. 归一化双四元数:q_normalized = q_blend / |q_blend|
4. 将双四元数转换回矩阵,或直接用于顶点变换
5. v_final = q_normalized × v_bind
DQS的优点:
- 完美解决糖果包装效应,关节旋转时不会扭曲
- 体积保持更好,弯曲时的塌陷更少
- 计算量只比LBS略大(双四元数运算比矩阵运算稍复杂)
DQS的缺点:
- 仍然存在体积损失问题(虽然比LBS好)
- 缩放处理麻烦(双四元数不直接支持非均匀缩放)
- 实现稍复杂,需要处理一些特殊情况(如零权重、接近180度旋转)
其他蒙皮算法
除了LBS和DQS,还有一些更高级的蒙皮算法:
- Delta Mush:先做LBS,然后将顶点位置"推回"到原始网格的平滑变形版本。可以有效减少LBS的 artifacts,实现简单但需要额外的预处理数据。
- Pose Space Deformation(PSD):在姿态空间中进行变形插值。将常见姿态(如手臂各种角度)的目标变形预存,运行时根据当前姿态插值。质量最高,但需要大量的美术工作和数据存储。
- Cage-based Deformation:用一个简化的"笼"网格控制高模变形。常用于面部动画。
架构选型建议
| 算法 | 质量 | 性能 | 适用场景 |
|---|---|---|---|
| LBS | 一般 | 最好 | 低质量要求、大量NPC、移动端 |
| DQS | 较好 | 好 | 中高质量、主角、次世代游戏 |
| LBS + Delta Mush | 好 | 中 | 高质量主角、面部动画 |
| PSD | 最好 | 差 | 过场动画、电影级角色 |
对于大多数项目,DQS是性价比最高的选择。它在只增加少量计算开销的情况下,显著提升了视觉质量。Unreal Engine和Unity的最新版本都已经内置了DQS支持。
一个实用的架构是可配置的蒙皮管线:
- 主角用DQS + Delta Mush,追求最高质量
- 重要NPC用DQS,平衡质量和性能
- 普通NPC和远景用LBS,追求性能
- 可以在运行时根据画质设置动态切换
16.4 动画数据格式与压缩:关键帧压缩、曲线简化
动画数据是游戏中最大的资源类型之一。一个3A游戏的角色动画资源可能达到数GB。动画压缩不仅关乎内存占用,还影响加载速度、磁盘空间和运行时性能。
动画数据的冗余分析
原始动画数据中存在大量冗余:
- 时间冗余:相邻帧之间的差异很小,运动是连续的。
- 空间冗余:相邻骨骼的运动有相关性(如手指骨骼通常一起运动)。
- 感知冗余:人眼对某些运动不敏感(如远离摄像机的骨骼、微小的抖动)。
- 语义冗余:有些骨骼在某些时间段完全不动。
压缩算法的目标就是尽可能去除这些冗余,同时保持视觉质量。
压缩技术一览
1. 关键帧压缩(Keyframe Reduction)
最基本的压缩方法。从均匀采样的逐帧数据中,移除那些对曲线形状影响小的关键帧,只保留必要的。
常用算法:
- Douglas-Peucker算法:从整条曲线开始,不断找到偏离直线最远的点加入,直到所有点的误差都在阈值内。
- 误差度量:可以是位置误差、旋转误差、或综合的视觉误差。
关键帧压缩的压缩比取决于动画的复杂度。缓慢平滑的动画可以达到10:1甚至更高,快速复杂的动作可能只有2:1。
2. 量化压缩(Quantization)
用更少的位数表示浮点数。例如用16位定点数代替32位浮点数,可以减少一半的数据量。
旋转的量化有更高效的方法。因为单位四元数的四个分量满足x²+y²+z²+w²=1,所以只需要存储其中三个,第四个可以推导出来。这就是**"小端三点"(Smallest Three)**压缩法——存储绝对值最小的三个分量,用一个2位标志位表示省略了哪个。
进一步,每个分量用16位或12位存储。12位量化的旋转误差通常在视觉上不可察觉,但数据量减少到原来的 3×12 / 4×32 = 28%。
3. 轨道压缩(Track Compression / 分段压缩)
不同的骨骼、不同的时间段可以用不同的压缩精度。
例如:
- 根骨骼和主要骨骼(脊柱、头、大腿)用高精度
- 手指、脚趾等次要骨骼用较低精度
- 静止的时间段可以用常数表示(零关键帧)
- 运动剧烈的时间段保留更多关键帧
这种方法的思想是"将比特用在刀刃上"——把精度预算分配给最容易被注意到的地方。
4. 频域压缩
类似图像的JPEG压缩,将动画曲线变换到频域(如小波变换、傅里叶变换),丢弃不重要的高频分量。
这种方法可以达到很高的压缩比(20:1以上),但可能引入"振铃"等伪影,对动画质量的影响也更难控制。通常用于要求不高的NPC动画。
5. 动画重定向(Retargeting)优化
这不是传统意义上的压缩,但能大幅减少动画资源总量。通过动画重定向技术,一套动画可以复用于多个不同比例的角色。这样就不需要为每个角色都做一套动画了。
压缩管线架构
一个完整的动画压缩管线通常是多阶段的:
原始动画(DCC导出)
│
▼
[预处理] 清理、光顺、重采样
│
▼
[关键帧约简] Douglas-Peucker等
│
▼
[量化] 降低数值精度
│
▼
[排序/打包] 优化内存布局
│
▼
压缩动画数据
压缩质量的评估是一个难题。客观指标(如位置误差、旋转误差)不一定与主观视觉质量对应。工业界通常采用"黄金动画对比"的方法——用一组标准动画作为基准,比较压缩前后的视觉差异,调整压缩参数直到不可察觉。
16.5 架构设计:动画数据的内存布局与性能优化
动画数据的内存布局直接影响运行时性能。一个好的布局应该充分利用CPU缓存、减少内存带宽、便于并行计算。
数据布局:SoA vs AoS
动画数据有两种典型的内存组织方式:
AoS(Array of Structures):每块骨骼的所有数据(位置、旋转、时间)存在一起,组成一个结构体数组。
struct BoneKeyframes {
vector<Vec3> positions;
vector<Quat> rotations;
vector<float> times;
};
BoneKeyframes bones[NUM_BONES];
优点:逻辑清晰,单块骨骼的数据连续 缺点:采样时如果只需要旋转(常见情况),位置数据也会被加载到缓存,造成浪费
SoA(Structure of Arrays):所有骨骼的位置数据存在一起,旋转数据存在一起,时间数据存在一起。
struct AnimationData {
vector<Vec3> allPositions; // 所有骨骼的所有位置关键帧
vector<Quat> allRotations; // 所有骨骼的所有旋转关键帧
vector<float> allTimes; // 所有时间点
};
优点:需要什么数据就访问什么数组,缓存效率高 缺点:同一骨骼的数据不连续,逻辑上稍复杂
对于动画采样来说,SoA通常更高效,因为采样时往往是逐通道处理的——先处理所有骨骼的位置,再处理旋转。而且很多时候只需要旋转(位置不变)。
流式加载与即时解压
对于大型开放世界游戏,动画数据不可能全部驻留内存。需要流式加载(Streaming)机制:
- 按角色打包:每个角色的动画数据打包成一个资源包,角色进入视野时加载,离开时卸载。
- 按优先级分层:基础动画(待机、行走、奔跑)常驻内存,稀有动画(特殊动作、过场)按需加载。
- 即时解压:动画数据以压缩形式存储在磁盘上,加载到内存后即时解压。或者更进一步,在运行时部分解压,需要哪部分解哪部分。
GPU动画(Animation on GPU)
随着角色数量和骨骼数量的增加,CPU动画计算逐渐成为瓶颈。将动画计算转移到GPU是一个重要趋势。
GPU动画的基本思路:
- 将动画数据(关键帧、骨骼层级)存储在GPU显存中(纹理缓冲区或结构化缓冲区)
- 在顶点着色器(或计算着色器)中执行动画采样和蒙皮计算
- 结果直接用于渲染,不需要回传到CPU
GPU动画的优势:
- 巨大的并行计算能力,轻松处理上百个角色
- 减少CPU-GPU数据传输(骨骼矩阵不需要每帧上传)
- 可以与渲染管线深度集成
GPU动画的挑战:
- 动画逻辑(状态机、混合)仍然需要CPU计算,然后把混合权重传给GPU
- 复杂的动画效果(IK、物理修正)难以在GPU上实现
- 调试困难
动画LOD
借鉴渲染的LOD思想,动画也可以有LOD策略:
- LOD0(最近):全骨骼、全帧率、高质量蒙皮(DQS)
- LOD1(中距离):减少骨骼数(移除手指、面部等次要骨骼)、降低动画帧率
- LOD2(远距离):极少的骨骼、最简单的蒙皮、最低的帧率
- LOD3(极远):完全关闭动画,只用静止姿态或广告牌
动画LOD的切换比渲染LOD更隐蔽——玩家不容易注意到远处角色的手指有没有动,但很容易注意到帧率波动。
架构师的性能优化清单
- 数据压缩:使用合适的压缩算法和参数,在质量可接受的前提下尽量减小数据量
- 内存布局:使用SoA布局,提高缓存命中率
- 动画帧率:动画不需要60fps,30fps甚至20fps通常足够
- 骨骼裁剪:权重为零的骨骼不需要参与计算
- GPU蒙皮:将蒙皮计算转移到GPU,释放CPU
- 动画LOD:根据距离和重要性调整动画质量
- 实例化动画:相同角色的相同动画可以共享采样结果
- 睡眠机制:不可见或静止的角色跳过动画计算
动画系统的性能优化是一个系统工程,需要在数据、算法、硬件利用等多个层面同时努力。优秀的动画架构师应该对"每一块字节的开销"和"每一次计算的代价"都有清晰的认识。
第17章 动画蓝图与状态机系统
17.1 动画状态机:从简单状态到复杂状态机
动画状态机(Animation State Machine, ASM)是组织和管理角色动画的核心工具。它的基本思想来自计算机科学中的有限状态机(FSM):角色在任意时刻处于一个动画状态,当满足特定条件时,从一个状态过渡到另一个状态。
为什么需要状态机?
在最简单的情况下,角色只有几个动画(待机、行走、攻击、死亡),直接用if-else或switch语句就能控制。但随着动画数量增长到几十个、上百个,硬编码的逻辑会变得混乱不堪——状态组合爆炸、过渡逻辑重复、修改困难。
状态机提供了一种结构化的方式来管理动画:
- 状态(State):角色当前在做什么动作(如"待机"、"行走"、"跳跃")
- 过渡(Transition):从一个状态切换到另一个状态的规则
- 条件(Condition):触发过渡的判断条件(如"速度 > 0"、"isJumping == true")
- 参数(Parameter):驱动状态机的输入变量(如速度、方向、布尔标志)
简单状态机
最简单的状态机只有几个状态和线性的过渡关系。例如:
速度>0 跳跃按下
[待机]──────►[行走]─────────►[跳跃]
▲ │ │
│速度==0 │速度==0 │落地
└───────────┘ │
▼
[落地]
│
│动画结束
▼
[待机]
这种状态机对于简单角色足够了,但很快会遇到问题:
- 如果行走和待机都能过渡到攻击,每个都要连一条线
- 如果有10个状态,每个都能到其他状态,就需要90条过渡线
- 状态越多,图越复杂,维护越困难
分层状态机(Hierarchical State Machine)
为了应对状态爆炸,分层状态机将状态组织成树形结构。父状态包含子状态,子状态继承父状态的过渡规则。
例如,可以将所有地面状态(待机、行走、奔跑)归为一个"地面"父状态,将所有空中状态(跳跃上升、下落、落地)归为一个"空中"父状态。地面和空中之间的过渡只需要在父状态级别定义一次,所有子状态自动继承。
┌───────────────┐
│ 地面(父) │
│ ┌─────────┐ │
│ │ 待机 │ │
│ └─────────┘ │
│ ┌─────────┐ │
│ │ 行走 │ │
│ └─────────┘ │
│ ┌─────────┐ │
│ │ 奔跑 │ │
│ └─────────┘ │
└───────┬───────┘
│
离地 / 跳跃
│
▼
┌───────────────┐
│ 空中(父) │
│ ┌─────────┐ │
│ │ 上升 │ │
│ └─────────┘ │
│ ┌─────────┐ │
│ │ 下落 │ │
│ └─────────┘ │
└───────────────┘
分层状态机的优势是结构清晰、过渡复用。相关的状态被组织在一起,公共的过渡只需要定义一次。
但分层状态机也有局限:
- 层级深度增加后,理解和调试变得困难
- 某些状态可能不属于任何清晰的分类(如"受击"可以从任何状态触发)
- 跨层过渡的优先级和行为需要仔细设计
状态机的执行模型
状态机每帧的执行流程:
1. 更新参数(速度、方向、标志位等)
2. 检查当前状态的过渡条件
3. 如果满足过渡条件:
a. 开始过渡(设置过渡时间、混合曲线)
b. 进入过渡状态
4. 如果在过渡中:
a. 更新过渡进度
b. 混合源状态和目标状态的动画
c. 过渡结束后,切换到目标状态
5. 输出当前动画姿态
这里有一个重要的设计决策:过渡是在状态机内部处理,还是作为独立的混合节点处理?
在Unreal Engine中,过渡是状态机的内置功能——状态之间的过渡自动处理混合。而在一些更底层的动画系统中,过渡只是一种特殊的混合节点,状态机只负责状态切换,混合由上层统一处理。
两种方案各有优劣:内置过渡更方便使用,独立混合更灵活可控。
17.2 动画蓝图(Animation Blueprint)架构设计
动画蓝图(Animation Blueprint)是Unreal Engine首创的可视化动画编程范式,它将状态机、混合节点、IK节点、逻辑运算等以节点图的形式组织起来,形成一个完整的动画求解图。
动画蓝图的出现深刻改变了动画开发流程——在此之前,动画逻辑需要程序员写代码实现,动画师只能被动等待。有了动画蓝图,动画师和技术美术可以直接在编辑器中创建和调试动画逻辑,大大加快了迭代速度。
动画蓝图的核心构成
一个动画蓝图由以下部分组成:
-
事件图(Event Graph):用于处理输入事件、更新参数、调用游戏逻辑。本质上是一个简化版的脚本系统。
-
动画图(Animation Graph):动画计算的主图。数据从左到右流动——左侧是输入(动画片段、参数),中间是各种处理节点(混合、IK、修改骨骼),右侧是输出(最终Pose)。
-
状态机节点(State Machine Node):动画图中的一种特殊节点,内部包含完整的状态机逻辑。状态机可以嵌套——一个状态内部可以再有状态机。
-
变量(Variables):动画蓝图的参数,由外部(游戏逻辑、AI)设置,内部读取使用。如速度、方向、是否跳跃等。
动画蓝图整体结构:
┌─────────────────────────────────────────┐
│ 动画蓝图 │
│ │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ 事件图 │───►│ 动画图 │ │
│ │ (逻辑) │ │ ┌─────┐ │ │
│ └──────────┘ │ │状态机│───┐ │ │
│ │ └─────┘ │ │ │
│ │ 混合节点 │ │ │
│ │ IK节点 │ │ │
│ │ ... │ │ │
│ │ ▼ │ │
│ │ 输出Pose │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────┘
动画图的数据流动模型
动画图采用的是**数据流(Data Flow)**模型。每个节点接收输入Pose,进行处理,输出新的Pose。整个图是一个有向无环图(DAG),数据从源节点流向输出节点。
典型的节点类型:
- 序列播放器(Sequence Player):播放一个动画片段,输出当前帧的Pose。
- 混合节点(Blend Node):接收两个输入Pose和一个混合权重,输出混合后的Pose。
- 分层混合(Layered Blend Per Bone):按骨骼分层混合,下半身用A动画,上半身用B动画。
- 叠加混合(Apply Additive):将叠加动画应用到基础动画上。
- 状态机(State Machine):内部是状态机,输出当前状态的Pose。
- IK节点(IK Node):接收基础Pose和IK目标,输出IK修正后的Pose。
- 修改骨骼(Modify Bone):直接修改某块骨骼的变换。
- 插槽(Slot):占位节点,用于动画蒙太奇(Montage)插入动画。
动画蓝图的执行模型
动画蓝图的计算是按需驱动的。每帧,系统从输出节点开始,反向递归计算所有依赖的节点。这种拉模式(Pull Model)保证了只计算真正需要的部分。
计算流程:
- 游戏逻辑更新动画蓝图的参数变量
- 渲染系统请求新的Pose
- 动画蓝图从输出节点开始求值
- 每个节点请求其输入节点的结果
- 递归直到叶子节点(动画片段播放器)
- 结果逐层返回,最终得到输出Pose
这个模型的优点是:
- 天然的惰性计算——不需要的分支不会被计算
- 依赖关系清晰,易于理解
- 可以做各种优化(缓存、并行计算)
动画蓝图的架构优势与挑战
优势:
- 可视化:直观易懂,非程序员也能使用
- 迭代快:修改后即时预览,不需要编译
- 模块化:节点可以复用,函数和宏可以抽象公共逻辑
- 调试方便:可以逐节点查看中间结果
挑战:
- 性能:节点图的解释执行比原生代码慢。每帧都要遍历节点图,有一定开销。
- 复杂度控制:大型动画蓝图可能包含数百个节点,变得难以理解和维护。
- 版本管理:蓝图是二进制文件(或复杂的文本格式),diff和merge困难。
- 调试:虽然有可视化调试工具,但复杂逻辑的bug仍然难以定位。
架构师视角:动画蓝图的定位
动画蓝图不是万能的。它最适合的是以数据为中心的动画逻辑——状态机、混合空间、参数混合等。对于复杂的算法逻辑(如复杂的IK求解、自定义的物理模拟),用蓝图实现效率低且难以调试。
最佳实践是混合架构:
- 基础的动画编排(状态机、混合)用动画蓝图,发挥可视化和快速迭代的优势
- 复杂的算法逻辑(自定义节点、高级IK)用C++实现,封装成蓝图节点
- 性能关键路径用C++优化,提供蓝图接口
17.3 状态过渡与混合空间(Blend Space)
状态之间的切换不是瞬间完成的——从行走到奔跑、从站立到跳跃,都需要平滑的过渡。过渡的质量直接影响动画的流畅度和自然感。
过渡(Transition)的基本参数
一个状态过渡包含以下关键参数:
-
过渡时间(Transition Duration):从开始到完全过渡到目标状态需要多长时间。时间太短会显得突兀,太长会显得迟钝。典型值是0.1-0.5秒。
-
过渡曲线(Transition Curve / Blend Profile):过渡过程中的权重变化曲线。线性过渡最简单,但往往不够自然。常用的有Ease-In-Ease-Out(慢进慢出)、先快后慢等。
-
中断规则(Interruption Rule):过渡过程中如果触发了新的过渡,该如何处理?是等当前过渡完成,还是立即中断?不同的规则会导致不同的手感。
-
同步(Sync):源状态和目标状态的动画是否需要同步播放?例如从行走过渡到奔跑,脚步应该对齐,否则会出现"滑步"。
混合空间(Blend Space)
状态机适合处理离散的状态切换(待机→行走→奔跑),但现实中的运动是连续的——速度可以是从0到最大速度之间的任意值,方向可以是360度的任意方向。如果用离散状态来逼近连续运动,需要大量的状态和过渡,而且效果不够平滑。
混合空间(Blend Space)就是为了解决这个问题。它是一个连续的参数空间,空间中的每个点对应一个动画样本。给定输入参数,在样本之间插值,得到平滑的混合结果。
最简单的混合空间是一维的:
一维混合空间(速度驱动的走-跑过渡):
动画: 待机 行走 奔跑
速度: 0 2m/s 6m/s
●──────●──────●
▲
当前速度 3.5m/s
→ 行走和奔跑按 5:3 混合
更常见的是二维混合空间,用于方向和速度的组合:
二维混合空间(八方向移动):
前左 前 前右
○ ● ○
\ | /
○— ● —● — ● —○
左 左前 右前 右
/ | \
○ ● ○
后左 后 后右
输入:(方向角度, 移动速度)
输出:混合后的Pose
混合空间的样本点不要求均匀分布,可以根据需要在重要的区域加密采样。
混合空间的插值方法
混合空间的核心是多点插值——给定若干样本点和当前参数点,计算每个样本的权重。
常见方法:
-
最近邻(Nearest Neighbor):只用最近的那个样本。最简单,但效果是跳变的,不连续。
-
重心坐标插值(Barycentric Interpolation):将空间三角化(Delaunay三角剖分),找到当前点所在的三角形,用三个顶点的重心坐标作为权重。保证了连续性,但在三角形边界处导数不连续。
-
径向基函数(Radial Basis Function, RBF):每个样本点的权重是距离的函数(如高斯函数),所有样本都参与混合,归一化后得到最终权重。效果最平滑,但计算量稍大。
-
双线性/双三次插值:对于规则网格的样本,使用网格插值。质量高,但要求样本是网格布局。
混合空间的高级形式
- 叠加混合空间(Additive Blend Space):混合空间输出的不是完整Pose,而是叠加动画。常用于上半身动作的混合(如不同方向的瞄准)。
- 目标偏移混合空间(Aim Offset):专门用于瞄准偏移的混合空间,本质上是叠加混合空间的一种应用。
- 动态混合空间:样本点可以在运行时动态添加和移除。这是Motion Matching等技术的基础。
状态机 + 混合空间的组合模式
在实际项目中,状态机和混合空间通常配合使用:
[待机] ← 速度 == 0
│
│ 速度 > 0
▼
[移动混合空间] ← 速度、方向作为输入
│
│ 速度 == 0
▼
[待机]
状态机管理大的状态类别(地面、空中、受击、死亡等),混合空间处理每个类别内的连续变化(速度、方向)。这种组合兼顾了结构化和连续性。
17.4 动画通知(Notify)与事件系统
动画不只是视觉表现,它还需要与游戏逻辑交互。例如:
- 攻击动画到某个时刻才产生伤害
- 脚步落地时播放音效和产生灰尘粒子
- 角色蹲下时碰撞体高度变化
- 动画结束时触发下一个动作
这些都需要一种机制,让动画在特定时刻"通知"游戏逻辑。这就是动画通知(Animation Notify / AnimNotify)。
动画通知的类型
根据触发时机和持续时间,动画通知可以分为几类:
-
单点通知(Point Notify / Event Notify):在动画的特定时间点触发一次事件。例如"出拳"、"落地"、"开火"。
-
持续通知(State Notify / Duration Notify):在一段时间内持续生效,有开始和结束两个事件。例如"无敌帧"、"攻击判定"、"声音持续"。
-
曲线驱动通知(Curve-driven Notify):由动画曲线的值变化触发。例如当某个参数超过阈值时触发事件。
通知的实现机制
从架构角度看,动画通知的实现有两种典型方式:
方式一:采样时触发(Sampling-based)
在动画采样过程中,当时间跨过通知点时,触发事件。
优点:
- 实现简单,与动画播放天然同步
- 可以精确控制触发时机
缺点:
- 如果动画跳帧或被混合,通知可能被跳过或重复
- 通知的逻辑和动画播放耦合紧密
方式二:时间轴扫描(Timeline Scanning)
每帧扫描当前帧和上一帧之间的时间段,找出所有经过的通知点,依次触发。
优点:
- 即使动画跳变,也不会错过通知
- 可以处理动画速度变化、反向播放等情况
- 与混合解耦——通知属于动画片段本身,不受混合的影响
缺点:
- 实现稍复杂
- 需要维护上一帧的时间
Unreal Engine的动画通知采用的是类似方式二的机制。每个动画片段有一个通知轨道,在动画播放时,系统会追踪时间变化并触发相应的通知。
动画蒙太奇(Animation Montage)
动画蒙太奇是一个更高级的概念。它允许在运行时动态地"插入"动画到状态机中,而不需要修改状态机本身。
蒙太奇的典型用途:
- 攻击动画:角色可以在任何状态下播放攻击动画,播放完后回到原来的状态
- 受击动画:被击中时播放受击动画,打断当前动作
- 技能动画:释放技能时播放特殊动画
蒙太奇的工作原理:
- 蒙太奇是一个独立的动画资源,包含一段或多段动画
- 蒙太奇通过"插槽(Slot)"注入到动画图中
- 播放蒙太奇时,它的输出会覆盖插槽位置的原有动画
- 蒙太奇有自己的播放逻辑(可以有多个段落、分支)
- 播放结束后,控制权交还给原来的状态机
蒙太奇是一种非常灵活的架构设计——它将"一次性动作"从状态机中解耦出来,避免了状态机被无数个攻击/受击/技能状态淹没。
通知与事件系统的架构设计
一个完善的动画事件系统应该具有以下特性:
- 类型安全:不同类型的通知有不同的数据结构,避免类型转换错误。
- 低耦合:动画系统只负责触发事件,不关心谁处理事件。使用观察者模式或委托机制。
- 可扩展:项目可以方便地添加自定义通知类型。
- 调试支持:可以可视化通知在时间轴上的位置,可以单步调试通知触发。
- 确定性:相同的动画播放序列应该产生相同的通知序列(对网络游戏重要)。
17.5 架构师视角:可视化编辑与运行时性能的权衡
动画蓝图等可视化编辑工具极大地提升了开发效率,但也带来了运行时性能的代价。作为架构师,需要在开发效率和运行时性能之间找到平衡。
可视化编辑的性能代价
动画蓝图的性能开销来自多个方面:
-
解释执行开销:蓝图节点图是解释执行的,每帧都要遍历节点、计算输入输出。相比之下,手写C++代码是编译执行的,没有解释开销。
-
虚拟函数调用:每个节点都是一个对象,通过虚函数调用执行。虚函数调用本身开销不大,但大量节点累积起来就可观了。
-
内存访问模式:节点图的内存布局不一定是连续的,缓存命中率可能低于线性的代码执行。
-
冗余计算:可视化编程容易产生冗余——同一个动画可能被采样多次,同一个计算可能重复执行。程序员手写代码时会自然地优化这些,但可视化工具很难自动优化。
-
过度设计:可视化的便捷性容易导致过度设计——为了"可能会用到"的灵活性添加大量节点和分支,而其中大部分在运行时很少被触发。
据经验,一个复杂的动画蓝图比等效的手写C++实现慢2-5倍是正常的。
性能优化策略
策略一:编译蓝图(Blueprint Compilation / Nativization)
将蓝图自动编译为C++代码或字节码,在运行时以编译后的代码执行。Unreal Engine就提供了蓝图Nativization功能——在打包时将蓝图转换为C++代码,然后一起编译。
优点:保留可视化编辑的开发效率,同时获得接近原生代码的性能。 缺点:编译时间增加;调试原生代码比调试蓝图困难;某些蓝图特性难以编译。
策略二:关键路径C++化
将性能最关键的部分用C++实现,封装成节点供蓝图调用。例如:
- 复杂的IK求解用C++实现
- 大量骨骼的计算用C++ + SIMD优化
- 自定义的混合逻辑用C++实现
蓝图负责"编排",C++负责"计算"。这是最实用也最常见的混合策略。
策略三:运行时优化
在运行时对动画图进行优化:
- 图简化:移除不活动的分支、常量折叠、死代码消除
- 缓存:缓存计算结果,相同输入直接返回缓存
- 批量计算:将多个节点的计算合并,减少中间数据的传递
策略四:LOD驱动的精度降级
根据角色的距离和重要性动态调整动画计算的精度:
- 近距离:完整动画蓝图、所有节点都执行
- 中距离:简化状态机、跳过IK、减少混合
- 远距离:直接播放简单动画、跳过复杂逻辑
可视化编辑的"度"
架构师需要把握可视化编辑的"适用范围"。不是所有东西都适合可视化。
适合可视化的:
- 状态机和过渡逻辑(结构清晰,频繁调整)
- 混合空间和参数混合(直观,需要预览效果)
- 简单的逻辑运算和参数传递
- 动画通知和事件绑定
不适合可视化的:
- 复杂的数学计算(IK、物理模拟)
- 大量数据的循环处理
- 性能关键路径
- 复杂的算法逻辑
架构师的设计原则
- 可视化优先,性能兜底:先用可视化工具快速迭代,等稳定后再把瓶颈部分用C++重写。
- 分层设计:上层用可视化(便于设计师调整),下层用代码(保证性能)。
- 提供性能分析工具:让使用者知道每个节点的开销,避免"不知不觉变慢"。
- 编译作为可选方案:开发期用解释执行(快速迭代),发布版用编译(最佳性能)。
- 设立规范:制定蓝图复杂度规范,如单个蓝图节点数上限、嵌套深度限制等。
动画蓝图代表了游戏开发中的一个重要趋势——将技术能力下放到创作者手中。架构师的任务不是抵制这种趋势,而是设计出既灵活又高效的架构,让创作者的创造力在性能预算内自由发挥。可视化与性能不是对立的,通过合理的架构设计,两者可以兼得。
第18章 动画混合与IK系统
18.1 动画混合技术:线性混合、叠加混合、差量混合
动画混合是动画系统的核心技术之一。如果只有单个动画片段的播放,角色的动作会很生硬——状态切换是跳变的、动作是离散的。混合技术让多个动画可以"融合"在一起,产生平滑、连续、丰富的动作效果。
为什么混合如此重要?
想象一个角色从行走过渡到奔跑。如果没有混合,角色会在某一帧突然从行走姿势变成奔跑姿势,视觉上是"跳"的。有了混合,在0.2秒内,行走动画的权重从1降到0,奔跑动画的权重从0升到1,中间是两者的平滑过渡——视觉上完全自然。
混合不仅用于过渡,还用于组合:让角色一边走路一边挥手、一边奔跑一边瞄准、一边跳跃一边做表情……这些都需要混合技术将多个动画组合起来。
线性混合(Linear Blending)
线性混合是最基本、最常用的混合方式。两个动画按权重加权平均:
pose_result = w_A × pose_A + w_B × pose_B
其中 w_A + w_B = 1,w_A 和 w_B 分别是两个动画的权重。
对于骨骼的位置和缩放,线性混合就是简单的线性插值(Lerp)。对于旋转(四元数),通常用归一化线性插值(Nlerp)或球面线性插值(Slerp)。
线性混合可以扩展到任意数量的动画:
pose_result = Σ(w_i × pose_i),其中 Σw_i = 1
这就是混合空间的数学基础——多个样本按权重混合。
线性混合的优点:
- 简单、直观、计算高效
- 理论基础扎实,行为可预测
- 易于扩展到任意数量的输入
线性混合的局限:
- 混合的是姿态,不是运动。两个不同速度的动画混合后,速度是两者的平均,这可能不是期望的结果。
- "平均姿态"不一定是"合理姿态"。例如,左臂抬起的动画和右臂抬起的动画混合,得到的是两臂都半抬——这在数学上正确,但在运动学上可能很奇怪。
- 旋转混合的问题:线性混合两个旋转,得到的不一定是最短路径(虽然Nlerp/Slerp可以缓解)。
叠加混合(Additive Blending)
叠加混合(也叫加法混合)是另一种重要的混合方式。它不是在两个完整Pose之间混合,而是将一个动画的差值叠加到另一个动画上。
数学表达:
pose_result = pose_base + (pose_additive - pose_reference)
其中:
- pose_base 是基础动画(如行走)
- pose_additive 是叠加动画(如挥手)
- pose_reference 是叠加动画的参考Pose(通常是叠加动画的第一帧或T-Pose)
- (pose_additive - pose_reference) 是叠加动画相对于参考Pose的"偏移量"
叠加混合的核心思想是:只加"变化",不加"基础"。这样,叠加动画可以应用到任何基础动画上,而不会覆盖基础动画的整体姿态。
例如:
- 基础动画:行走(全身都在运动)
- 叠加动画:挥手(只有手臂在动,身体其他部分和参考Pose一样)
- 结果:走路的同时挥手
叠加混合的类型(按参考Pose区分):
-
相对于T-Pose(Mesh Space Additive):叠加动画的参考Pose是绑定姿态。差值是相对于模型空间的。适合"替换"类的叠加(如换一个表情、换一个手势)。
-
相对于动画第一帧(Animation Space Additive):叠加动画的参考Pose是叠加动画自己的第一帧。差值是相对于动画初始姿态的。适合"补充"类的叠加(如呼吸、发抖等次级运动)。
-
相对于基础动画的某一帧:更复杂的情况,参考Pose随基础动画变化。
叠加混合的优点:
- 可以在不干扰基础动画的前提下添加额外动作
- 一个叠加动画可以复用于多种基础动画(走、跑、跳都可以叠加挥手)
- 可以多层叠加(挥手 + 呼吸 + 表情……)
叠加混合的缺点:
- 叠加过多会导致姿态失真(每加一层就多一层偏差)
- 参考Pose的选择很关键,选择不当会导致奇怪的结果
- 旋转的叠加比位置复杂(需要用差值旋转,而非简单相加)
差量混合(Difference Blending)
差量混合是叠加混合的逆操作。它计算两个动画之间的差值,可以用于后续的叠加。
差量动画 = 动画A - 动画B
差量混合的一个重要应用是动作重定向。例如,有一个"普通行走"动画和一个"受伤行走"动画,两者的差值就是"受伤效果"。这个差值可以叠加到"奔跑"动画上,得到"受伤奔跑"的效果——不需要单独制作受伤奔跑动画。
其他混合技术
-
时间同步混合(Time-synchronized Blending):混合的两个动画在时间上对齐。例如行走和奔跑混合时,脚步相位对齐,避免出现"滑步"。需要将两个动画的播放时间映射到同一个归一化时间轴上。
-
按骨骼混合(Per-bone Blending / Masked Blending):身体不同部分用不同的动画。如下半身用行走动画,上半身用瞄准动画。通过骨骼掩码(Bone Mask)控制每块骨骼的混合权重。
这是非常常用的技术——角色的上下半身往往需要做不同的动作。实现方式是:
- 定义一个骨骼掩码,指定哪些骨骼受影响、哪些不受影响
- 混合时,对每块骨骼分别计算混合权重
- 掩码的边缘骨骼可以用渐隐权重,避免明显的分界线
- 运动混合(Motion Warping / Space-time Blending):不仅混合姿态,还混合运动轨迹(根运动)。可以让混合后的动画在位置和旋转上也保持连续。
18.2 IK(反向运动学)原理:FABRIK、CCD、Jacobian
正向运动学(FK)是从根骨骼向末端计算——给定每块骨骼的旋转,求末端(手、脚)的位置。
反向运动学(IK)正好相反——给定末端的目标位置,反求每块骨骼应该是什么旋转。
IK在游戏中有大量应用:
- 脚贴合地面(Foot IK):角色站在不平的地面上,脚应该贴合地面
- 手抓物体(Hand IK):角色伸手去抓某个物体,手要准确到达物体位置
- 瞄准(Aim IK):角色的头和手朝向瞄准目标
- 注视(Look At):角色的眼睛/头部看向目标
- 全身IK(Full Body IK):全身姿态根据多个末端目标调整
IK的数学困难
IK之所以难,是因为它的解不是唯一的,甚至可能没有解。对于一个有n个关节的骨骼链:
- 如果自由度(DOF)少于3(在3D空间中定位末端需要3个自由度),可能没有解
- 如果自由度等于3,可能有唯一解或有限个解
- 如果自由度大于3(冗余链),有无穷多解
对于冗余链(比如人的手臂有7个DOF:肩关节3个、肘关节1个、腕关节3个),IK需要从无穷多解中选一个"最自然"的解。这就是IK的艺术所在。
算法一:CCD(Cyclic Coordinate Descent,循环坐标下降)
CCD是最简单、最直观的IK算法。它的思想是:从末端骨骼开始,逐节向根骨骼调整,每一步只调整一块骨骼的旋转,让末端尽量靠近目标。
算法步骤:
- 从最末端的骨骼开始(如手)
- 计算从当前骨骼到末端的向量,和从当前骨骼到目标的向量
- 旋转当前骨骼,使这两个向量对齐
- 移动到上一块骨骼(如前臂),重复步骤2-3
- 继续向上,直到根骨骼
- 如果末端还不够接近目标,再来一轮(从末端到根)
- 重复迭代,直到收敛或达到最大迭代次数
CCD的优点:
- 实现极其简单,几十行代码就能搞定
- 计算速度快,每次迭代是O(n)
- 容易加入关节限制(如肘关节不能向后弯)
- 概念直观,容易调试
CCD的缺点:
- 收敛速度不稳定,有时需要很多次迭代
- 解的质量不高——倾向于让末端关节先动,根关节后动,姿态可能不自然
- 对初始姿态敏感
- 长骨骼链收敛慢
CCD适合简单的、对自然度要求不高的IK问题,如简单的触手、尾巴、机械臂。
算法二:FABRIK(Forward and Backward Reaching IK)
FABRIK是近年来非常流行的IK算法,由Aristidou和Lasenby于2011年提出。它的思想独特且高效——直接操作关节的位置,而不是旋转。
FABRIK分两个阶段迭代:
向前阶段(Forward Reaching):
- 将末端关节直接移动到目标位置
- 然后处理前一个关节:将它放在"从末端位置向当前关节方向,距离为骨骼长度"的位置
- 继续向根方向处理,每块骨骼都保持长度不变
- 最后,根关节的位置可能偏离了原来的位置
向后阶段(Backward Reaching):
- 将根关节移回原来的位置
- 然后处理下一个关节:将它放在"从根位置向前一个关节方向,距离为骨骼长度"的位置
- 继续向末端方向处理
- 最后,末端关节可能偏离了目标
这样一前一后算一次迭代。重复若干次,末端会越来越接近目标。
FABRIK示意(2D,3节骨骼):
初始姿态: ○──○──○──● (末端)
目标位置: ★
向前阶段: ○──○───● (末端移到目标)
╲
●
╲
★
向后阶段: ● (根回到原位)
╲
●──●──★
FABRIK的优点:
- 收敛速度极快——通常3-5次迭代就能得到很好的效果
- 计算简单——主要是向量运算,没有三角函数
- 解的质量好——姿态自然,运动分布均匀
- 容易扩展到树状结构(有分支的骨骼链)
FABRIK的缺点:
- 直接操作位置,旋转需要从位置反推
- 加入关节角度限制比较麻烦(虽然有扩展方法)
- 不直接支持骨骼的旋转自由度限制(如铰链关节)
FABRIK是目前游戏中应用最广泛的IK算法之一。Unreal Engine的FBRIK、Unity的Animation Rigging包中的FABRIK都是它的实现。
算法三:Jacobian(雅可比法)
雅可比法是基于微分运动学的方法。它的思想是:末端位置的微小变化与关节角度的微小变化之间存在线性关系,这个关系由雅可比矩阵描述。
Δx = J · Δθ
其中 Δx 是末端位置的变化量,Δθ 是关节角度的变化量,J 是雅可比矩阵。
IK的目标是找到 Δθ 使得 Δx = target - current_end_effector。如果 J 是方阵且可逆,可以直接求:
Δθ = J⁻¹ · Δx
但通常 J 不是方阵(冗余链的情况,关节数 > 自由度),这时需要求伪逆(Moore-Penrose Pseudoinverse):
Δθ = J⁺ · Δx
雅可比法的优点:
- 数学上精确,一次迭代就能向目标迈进一大步
- 可以自然地处理冗余链——伪逆给出的是"最小能量"解(关节变化量最小)
- 可以加入各种约束和优化目标
- 适合全身IK等复杂问题
雅可比法的缺点:
- 计算量大——求伪逆需要SVD分解或其他矩阵运算,复杂度是O(n³)
- 大角度情况下不精确(雅可比是局部线性近似),需要多次迭代
- 实现复杂,数值稳定性需要精心处理
- 奇异位置附近(如手臂完全伸直)会出现问题
雅可比法主要用于对精度要求高的场景,如全身IK、面部动画、机器人运动规划。
算法对比与选型
| 算法 | 实现难度 | 速度 | 解的质量 | 关节限制 | 适用场景 |
|---|---|---|---|---|---|
| CCD | 很低 | 快 | 一般 | 容易 | 简单链、低精度要求 |
| FABRIK | 低 | 很快 | 好 | 中等 | 大多数游戏IK需求 |
| Jacobian | 高 | 慢 | 很好 | 灵活 | 全身IK、高精度需求 |
对于大多数游戏,FABRIK是最佳选择——它在简单性、速度和质量之间取得了最好的平衡。
18.3 全身IK与部分身体IK
IK不只是单条骨骼链的问题。在实际应用中,我们经常需要同时控制多个末端,或者让整个身体协调运动。
部分身体IK(Partial Body IK)
最常见的IK应用都是针对身体的某一部分的:
1. 脚部IK(Foot IK)
角色站在不平的地面上时,脚应该贴合地面,而不是悬空或穿模。
实现要点:
- 每帧向下射线检测(Raycast),找到脚下地面的高度和法线
- 用IK调整脚部骨骼,让脚掌贴合地面
- 骨盆需要相应地上下移动,避免腿被拉长
- 两只脚分别处理,但骨盆的移动需要协调(取两只脚的平均值或最大值)
脚部IK是3A游戏的标配——它让角色与环境的互动更真实。没有Foot IK的角色站在楼梯和斜坡上会显得非常"飘"。
2. 手部IK(Hand IK / Reach IK)
角色伸手去抓物体、推开门、扶墙壁时,手需要到达准确的位置。
实现要点:
- 通常用FABRIK或两骨IK(Two Bone IK,针对小臂+上臂的解析解)
- 需要考虑肩膀的运动——手伸得远时,肩膀应该向前伸
- 抓取物体时还需要考虑手部的旋转(手掌朝向物体)
3. 瞄准IK(Aim IK)
角色瞄准目标时,头、肩膀、手臂都要朝向目标方向。
实现要点:
- 不只是手臂的旋转,整个上半身都要参与
- 通常用脊柱的旋转 + 手臂的调整
- 可以叠加混合的方式实现——在基础动画上叠加一个瞄准偏移
- 瞄准范围有限制(不能180度转身瞄准)
4. 注视IK(Look At IK)
角色的眼睛/头部看向目标。比瞄准IK更轻柔,主要是头部和颈部的运动。
全身IK(Full Body IK, FBIK)
全身IK同时控制多个末端(双手、双脚、头),让整个身体协调运动。这比单链IK复杂得多。
全身IK的典型应用:
- 角色攀爬:手和脚都要抓在特定的位置
- 角色被推:根据受力点和方向,生成全身的受击反应
- 角色抱物:双手抱在物体两侧,身体相应前倾
- 布娃娃到动画的过渡:从物理驱动的布娃娃姿态,平滑过渡到动画姿态
全身IK的算法挑战:
- 多个目标之间可能有冲突(手要到A,脚要到B,但身体不够长)
- 解空间巨大,需要高效的求解方法
- 需要保持姿态的自然性——不能出现"反关节"等奇怪的姿势
全身IK的主流方法
-
基于FABRIK的扩展:FABRIK本身就支持树状结构(有分叉的骨骼链)。对于全身IK,可以在FABRIK的基础上扩展,处理多个末端。迭代时,每个末端分别向前/向后传播。
-
基于雅可比的全身IK:将整个身体的所有关节放入一个大的雅可比矩阵中,同时求解。精度最高,但计算量也最大。Unreal Engine的Full Body IK插件和Unity的Animation Rigging中的FBIK都基于这种方法。
-
基于优先级的方法:给不同的末端目标分配优先级。高优先级的目标先满足,低优先级的在剩余的自由度中尽量满足。例如脚的优先级最高(必须站在地上),手的优先级次之,头的优先级最低。
IK与动画的融合
纯IK生成的姿态往往看起来"死"——因为它只满足了末端位置,没有运动的细节和韵律。实际应用中,IK通常是在动画的基础上做修正。
典型的融合策略:
-
IK偏移(IK Offset):基础动画给出大致姿态,IK只做小范围的修正。例如脚离地面只差几厘米,用IK把脚压下去。这样大部分动画的细节得以保留。
-
权重混合:用一个权重参数控制IK的影响程度。权重为0时完全是动画,权重为1时完全是IK。可以根据距离、角度等参数动态调整权重——目标越接近当前姿态,IK权重越低。
-
分层IK:不同部分用不同程度的IK。例如脚完全用IK(必须贴合地面),腿部分混合(一部分动画,一部分IK),骨盆轻微调整,上半身不受影响。
18.4 根运动(Root Motion)设计
根运动(Root Motion)是动画系统中的一个重要概念。它指的是将角色的整体移动(位移和旋转)从动画中提取出来,由动画驱动角色的位置变化。
为什么需要根运动?
如果没有根运动,角色的移动完全由逻辑层控制(设置速度、直接移动位置),动画只是"播放"走路的动作。这会导致一个问题:动画的脚步速度和实际移动速度不匹配——角色看起来在走,但实际移动得快了或慢了,就像在冰面上滑一样。
根运动的解决思路是:让移动来自动画本身。动画师在制作走路动画时,就让角色的根骨骼向前移动。播放动画时,提取根骨骼的位移增量,应用到角色的Transform上。这样,脚步和移动速度天然匹配——因为它们本来就是同一个动画。
根运动的实现
根运动的提取过程:
每帧更新:
1. 采样当前帧动画,得到根骨骼的位置/旋转
2. 计算与上一帧的差值(delta position, delta rotation)
3. 将这个差值应用到角色的Transform上
4. 将动画中的根骨骼位置"归零"(保持在原点附近),
否则角色会在动画中越走越远
注意第4步——我们不希望动画中的根骨骼移动累积起来(那样角色会离原点越来越远),而是希望根骨骼相对角色本体不动,移动由角色的Transform承担。所以提取根运动后,需要把根骨骼的位移"消耗"掉。
根运动 vs 纯代码驱动
| 特性 | 根运动 | 代码驱动移动 |
|---|---|---|
| 脚步匹配 | 完美匹配,不滑步 | 需要手动调整速度 |
| 动作自然度 | 高,动画师完全控制 | 中,依赖参数调整 |
| 与物理的协调 | 需要特殊处理 | 简单直接 |
| 网络同步 | 复杂(移动来自动画) | 简单(移动是确定的) |
| 程序控制力 | 弱(动画决定一切) | 强(代码完全控制) |
| 动画制作量 | 大(每个速度/方向都要做) | 小(几个基础动画+混合) |
根运动和代码驱动不是非此即彼的。大多数游戏采用混合方案:
- 移动(走、跑、跳)用混合空间 + 代码驱动,方便控制速度和方向
- 特殊动作(攀爬、翻越、受击、处决)用根运动,保证动作的真实感
- 两种模式之间可以切换
根运动的架构挑战
- 碰撞处理:根运动驱动角色移动时,如何处理碰撞?是移动前先检测,还是移动后再修正?
通常的做法是:先计算根运动的位移量,然后把这个位移量交给角色控制器(Character Controller)去执行移动——角色控制器会处理碰撞和滑动。这样既保留了根运动的速度曲线,又保证了碰撞正确。
-
动画混合中的根运动:当多个动画混合时,根运动如何混合?简单的线性混合可能导致奇怪的移动轨迹。这需要混合空间的样本在根运动上也是连续的。
-
根运动的旋转:除了位移,根骨骼的旋转也可以驱动角色的朝向。这让角色的转身动画更自然——动画师可以制作"向左转90度"的动画,播放时角色真的转了90度。
-
叠加动画的根运动:叠加动画是否应该有根运动?通常不应该——叠加动画是在基础动画之上添加的,不应该影响角色的整体移动。
Motion Warping(运动扭曲)
Motion Warping是根运动的高级应用。它允许在运行时修改动画的根运动轨迹,使动画适应不同的距离和角度。
例如:角色有一个"向前走三步"的动画。如果目标位置正好是三步远,直接播放就行。但如果目标位置是2.5步或3.5步远呢?Motion Warping可以在播放过程中动态调整根运动的速度,让角色正好停在目标位置。
更高级的Motion Warping还可以调整方向——让角色在移动过程中转一个弯,正好到达目标朝向。
Motion Warping大大减少了需要制作的动画数量——不再需要为每种距离和角度都做一个动画,只用一个基础动画就能适应各种情况。这是程序化动画的一个重要方向。
18.5 架构权衡:动画质量与计算成本的平衡
动画系统的质量提升往往伴随着计算成本的增加。每增加一个IK节点、每增加一层混合、每增加一组动画,都会消耗更多的CPU时间。架构师需要在质量和成本之间找到最优平衡点。
成本构成分析
动画系统的计算成本主要来自以下几个方面:
-
动画采样:从动画片段中采样当前帧的Pose。成本与骨骼数、关键帧数成正比。通常占总时间的20-30%。
-
混合计算:多个Pose的加权混合。成本与骨骼数和混合的动画数量成正比。通常占15-25%。
-
IK求解:IK的计算量因算法而异。FABRIK比较轻量,Jacobian类的全身IK很重。简单的Foot IK可能只占5%,复杂的全身IK可能占30%以上。
-
蒙皮计算:将骨骼变换应用到网格顶点。成本与顶点数和骨骼数成正比。通常在GPU上执行,不算CPU时间。
-
状态机/蓝图逻辑:状态判断、过渡计算、参数更新。通常占10-20%。
-
物理动画:布料、头发、Ragdoll等物理模拟。成本差异很大,从10%到50%都有可能。
优化策略全景
策略一:算法层面的优化
- 选择合适的IK算法:能用FABRIK就不用Jacobian;能用两骨解析解就不用FABRIK。
- 减少迭代次数:IK和物理的迭代次数对性能影响很大。从10次减到5次,可能质量损失不大,但性能翻倍。
- 提前终止:如果误差已经足够小,就停止迭代。不需要每次都跑满迭代次数。
- 降频更新:IK不需要每帧都更新。每2帧或每3帧更新一次,中间用插值过渡,玩家不会察觉。
策略二:数据层面的优化
- 减少骨骼数:这是最有效的优化。对次要角色,移除手指、面部等精细骨骼。
- 动画压缩:减少动画数据的内存占用,提高缓存命中率。
- 共享动画数据:相同角色共享动画资源,减少内存占用和加载时间。
策略三:架构层面的优化
- 动画LOD:根据距离和重要性动态调整动画质量。这是ROI最高的优化——大部分角色在大部分时间里都不在玩家的近距离视野内。
- 多线程并行:不同角色的动画计算可以并行执行。利用任务系统将动画计算分发到多个核心。
- GPU动画:将蒙皮和部分动画计算转移到GPU。
- 睡眠机制:不可见的角色跳过动画计算。
- 重要性排序:当角色太多时,优先保证重要角色(玩家、附近的NPC)的动画质量,远处的角色可以降低质量或跳过。
策略四:艺术层面的优化
- 简化动画蓝图:移除不必要的节点和分支。很多时候,80%的效果来自20%的节点。
- 用更少的动画达到同样的效果:通过混合、叠加、IK等技术,用少量基础动画组合出丰富的效果。
- 动画复用:一套动画尽量复用于多个角色和场景。
质量预算的分配
就像渲染有"帧时间预算"一样,动画系统也应该有"动画质量预算"。
一个典型的质量预算分配可能是这样的:
主角(1个):
- 骨骼数:80-100
- 动画蓝图:完整功能,包括全身IK、物理动画
- 动画帧率:60fps
- 预算:~2ms
重要NPC(5-10个):
- 骨骼数:50-60
- 动画蓝图:简化版,Foot IK + 基础状态机
- 动画帧率:30fps
- 预算:~0.5ms/个
普通NPC(20-50个):
- 骨骼数:30-40
- 动画蓝图:简单状态机,无IK
- 动画帧率:20fps
- 预算:~0.1ms/个
远景角色(50-100个):
- 骨骼数:10-20 或 顶点动画
- 动画:简单循环动画
- 动画帧率:10-15fps
- 预算:~0.02ms/个
总预算可以控制在几毫秒内,同时保证视觉上的丰富度。
架构师的决策框架
在设计动画系统的性能/质量权衡时,建议按以下步骤思考:
-
确定主角的质量目标:主角是玩家看得最多的角色,应该投入最多的预算。先确定主角需要达到什么质量(是否需要全身IK?物理动画?面部动画?)。
-
统计同屏角色数量:最多同屏多少个角色?各是什么类型?这决定了总预算的分配。
-
设定总预算:动画系统在一帧中占用多少CPU时间?通常2-5ms是合理的。
-
分级设计:设计2-3个动画质量等级,不同等级的角色使用不同等级的动画资源和计算逻辑。
-
建立性能监控:在开发过程中持续监控动画系统的性能,发现问题及时优化。
-
预留优化空间:架构上预留优化手段(LOD、降频、简化开关),在项目后期性能紧张时有牌可打。
动画系统最迷人的地方在于,它是技术与艺术的交汇点。最优秀的动画架构师,既懂代码又懂艺术——他们知道什么样的技术能实现什么样的视觉效果,也知道在哪里可以"以假乱真",用最低的成本达到最好的效果。动画质量的提升没有止境,但架构师的智慧就在于,在有限的预算内,创造出无限的生命力。