游戏对象的实时更新
Updating Game Objects in Real Time
游戏对象更新就是根据上一时刻状态 S_i(t − Δt) 求出当前状态 S_i(t)。"每帧对每个对象调一次虚函数 Update(),并在里面顺带更新它的动画、物理、渲染组件"的简单做法在商业引擎里行不通:底层子系统需要批量更新才能高效,而对象之间、子系统之间又有依赖顺序。GEA 介绍的对策依次是:子系统由游戏循环直接批量更新、对象只操纵子系统中的"代理状态";在帧内多个阶段挂回调;按依赖森林把对象分桶,逐桶运行完整的分阶段更新(Naughty Dog 的做法);并用状态缓存和时间戳对付更新过程中的状态不一致和一帧延迟(GEA4 卷 II 17.6)。
状态与时间
对象的状态就是它所有属性(C++ 里是数据成员)的值,例如 Pong 中球的屏幕位置 (x, y) 和速度。游戏是离散采样的动态模拟,但 GEA 建议把对象状态想成连续变化、由引擎离散采样,这有助于避免一些常见陷阱。GEA 用 S_i(t) 表示对象 i 在时刻 t 的状态,并提醒它像一个由各种数据类型组成的异构 n 维向量;这里的"状态"不是有限状态机的状态,对象里各个 FSM 的当前状态只是 S(t) 的一部分(卷 II 17.6)。
几乎所有引擎都把游戏对象模型当作又一个需要周期性服务的子系统,在主游戏循环中更新。引擎维护一个或多个时钟(见 游戏时间线与时钟):一个精确跟踪真实时间,其他可以偏离真实时间;驱动对象更新的时钟通常允许偏离,从而能暂停、减速、加速甚至倒放,这对设计和调试都很有价值。游戏对象更新属于动态、实时、基于代理的计算机模拟,也有离散事件模拟的成分,GEA 建议游戏程序员向这两个研究领域学习。
简单方法(行不通)
遍历对象集合,每帧对每个对象调用 virtual void Update(float dt);,传入上一帧的时间差。对组件模型可以对每个组件调 Update(),或只调"枢纽"对象、让它自行更新组件;以属性为中心的设计可以对每个属性实例调更新函数(卷 II 17.6.1)。
- 活动对象集合通常由单例管理器(如
GameWorld、GameObjectManager)维护;因为对象会动态生成销毁,指针、智能指针或句柄的链表是简单有效的做法;禁止动态生成的引擎可以用固定大小的数组。多数引擎实际用更复杂的结构(卷 II 17.6.1.1)。 - Update 的职责:根据 S_i(t − Δt) 求 S_i(t),可能包括刚体动力学模拟、采样预制动画、响应本时间步发生的事件等(卷 II 17.6.1.2)。
诱人的写法是在对象的 Update() 里直接替它更新各个子系统:
virtual void Tank::Update(float dt) {
MoveTank(dt); DeflectTurret(dt); FireIfNecessary(); // 更新坦克自身
// 替这辆坦克更新底层子系统(不是好主意!)
m_pAnimationComponent->Update(dt);
m_pCollisionComponent->Update(dt);
m_pPhysicsComponent->Update(dt);
m_pAudioComponent->Update(dt);
m_pRenderingComponent->draw();
}
这样游戏循环几乎完全由对象更新驱动(轮询手柄、算 dt、逐个 Update()、交换缓冲)。GEA 说这在商业级引擎中通常不可行。
性能约束与批量更新
多数底层系统要处理大量数据、每帧做大量计算,所以受益于批量更新:一次批量更新大量动画,远比把每个对象的动画更新与碰撞、物理、渲染等无关操作交错进行高效(卷 II 17.6.2)。
商业引擎里各子系统由主循环直接或间接更新。对象需要某个子系统的服务时,请它代为分配子系统专用的状态,例如请渲染器分配一个网格实例(卷 II 11.3.8.2:记录世界空间位置朝向缩放、是否可见、每实例材质数据等)。渲染器内部按自己的方式管理网格实例以最大化性能;对象通过修改网格实例属性控制自己如何被渲染,但不直接控制渲染;所有对象更新后,渲染器一次批量绘制所有可见的网格实例。
virtual void Tank::Update(float dt) {
MoveTank(dt); DeflectTurret(dt); FireIfNecessary();
// 控制子系统组件的属性,但不在这里更新它们
if (justExploded) m_pAnimationComponent->PlayAnimation("explode");
if (isVisible) { m_pCollisionComponent->Activate(); m_pRenderingComponent->Show(); }
else { m_pCollisionComponent->Deactivate(); m_pRenderingComponent->Hide(); }
}
while (true) {
PollJoypad();
float dt = g_gameClock.CalculateDeltaTime();
for (each gameObject) gameObject.Update(dt);
g_animationEngine.Update(dt);
g_physicsEngine.Simulate(dt);
g_collisionEngine.DetectAndResolveCollisions(dt);
g_audioEngine.Update(dt);
g_renderingEngine.RenderFrameAndSwapBuffers();
}
批量更新的好处:
- 最大缓存一致性:子系统的每对象数据保存在内部,可以放在一段连续内存里(见 数据局部性);
- 最少重复计算:全局计算做一次,供许多对象复用;
- 减少资源重新分配:交错更新时子系统要为每个对象释放再分配资源,批量时每帧分配一次、整批复用;
- 高效流水线:对每个对象做几乎相同的计算时,可以用分散/汇集把大工作量分给多个核(见 作业系统),逐个处理对象做不到。
此外,有的子系统逐对象更新根本不能工作:多个动态刚体的碰撞一般不能逐个孤立求解,必须整体解决穿透,或迭代,或解线性系统(见 碰撞响应与约束求解)。
对象与子系统之间的依赖
即使不关心性能,对象相互依赖时逐对象更新也会失效:一个人抱着猫,要算猫骨骼的世界空间姿势,得先算出人的。子系统之间也有依赖:布娃娃物理必须与动画协同更新。动画系统先产生中间的局部空间姿势,关节变换转到世界空间后应用到物理系统中近似骨骼的一组相连刚体;物理系统向前模拟,再把关节最终位置应用回骨骼;最后动画系统计算世界空间姿势和蒙皮矩阵调色板(卷 II 17.6.3,另见 物理引擎与游戏的集成)。
分阶段更新
在主循环里显式按正确顺序编写子系统更新(卷 II 17.6.3.1):
while (true) {
// ...
g_animationEngine.CalculateIntermediatePoses(dt);
g_ragdollSystem.ApplySkeletonsToRagDolls();
g_physicsEngine.Simulate(dt); // 也运行布娃娃
g_collisionEngine.DetectAndResolveCollisions(dt);
g_ragdollSystem.ApplyRagDollsToSkeletons();
g_animationEngine.FinalizePoseAndMatrixPalette();
// ...
}
对象也要在合适时刻更新,而且可能依赖子系统的中间结果:例如在动画系统更新前请求播放动画,又想在中间姿势交给布娃娃和最终姿势计算之前程序化地调整它,于是需要更新两次。很多引擎允许对象在帧内多个点运行逻辑。Naughty Dog 引擎(神秘海域、最后生还者系列)每帧更新对象三次:动画混合前、动画混合后但最终姿势生成前、最终姿势生成后,可以用三个虚函数"钩子" PreAnimUpdate()、PostAnimUpdate()、FinalUpdate() 实现。
GEA 随即指出这个例子不完全现实:直接遍历所有对象调用三个钩子很低效,因为每个钩子里真正有事做的对象只占小部分;只支持游戏对象(想在动画后阶段更新粒子系统就不行);还让底层系统与对象系统产生不必要的耦合。更好的设计是通用回调机制:动画系统让任何客户代码(对象或其他引擎系统)为三个阶段各注册回调,动画系统遍历调用、对游戏对象一无所知。只有真正需要的客户才注册和被调用,性能和灵活性都最大、耦合最小(回调与通知见 观察者模式)。
分桶更新
对象间依赖会使更新顺序规则相互冲突:B 被 A 拿着,B 只能在 A 完全更新(含最终世界空间姿势和矩阵调色板)之后更新,这与"所有对象的动画批量一起更新"相冲突(卷 II 17.6.3.2)。
对象间依赖可以看成依赖树组成的森林(GEA 图 17.14):不依赖任何对象的是根;直接依赖根的是第一层子节点,依此类推。解法是把对象分成独立的组,GEA 称为桶 (bucket):第一桶是所有根,第二桶是所有第一层子节点……对每个桶运行一次包含所有阶段的完整对象和引擎系统更新,再处理下一个桶。
flowchart TB
subgraph "桶 1 根"
V["载具/移动平台"]
end
subgraph "桶 2"
C["角色"]
end
subgraph "桶 3"
W["角色手中的武器"]
end
V --> C --> W上图是 GEA 的例子:角色拿着武器,角色可能骑在移动平台或载具上,只需三层、三个桶。理论上依赖树深度无界,实际上通常很浅;很多引擎显式限制依赖森林深度以使用固定数量的桶。
enum Bucket { kBucketVehiclesPlatforms, kBucketCharacters, kBucketAttachedObjects, kBucketCount };
void UpdateBucket(Bucket bucket) {
for (each gameObject in bucket) gameObject.PreAnimUpdate(dt);
g_animationEngine.CalculateIntermediatePoses(bucket, dt);
for (each gameObject in bucket) gameObject.PostAnimUpdate(dt);
g_ragdollSystem.ApplySkeletonsToRagDolls(bucket);
g_physicsEngine.Simulate(bucket, dt); // 布娃娃等
g_collisionEngine.DetectAndResolveCollisions(bucket, dt);
g_ragdollSystem.ApplyRagDollsToSkeletons(bucket);
g_animationEngine.FinalizePoseAndMatrixPalette(bucket);
for (each gameObject in bucket) gameObject.FinalUpdate(dt);
}
void RunGameLoop() {
while (true) {
UpdateBucket(kBucketVehiclesAndPlatforms); // 书中原文如此,见下方勘误
UpdateBucket(kBucketCharacters);
UpdateBucket(kBucketAttachedObjects);
g_renderingEngine.RenderSceneAndSwapBuffers();
}
}
原书问题:枚举定义的是
kBucketVehiclesPlatforms,RunGameLoop()里调用的却是kBucketVehiclesAndPlatforms,两处名字不一致,照抄不能编译(已核对排版页卷 II p.598–599,不是文本提取造成的)。另外UpdateBucket(Bucket bucket)里用到的dt既不是参数也没有声明,属于伪代码省略。
实际情况会更复杂:物理引擎等子系统可能不支持桶(第三方 SDK,或实际上无法分桶更新)。但 GEA 说这种分桶更新基本就是 Naughty Dog 实现所有神秘海域和最后生还者游戏所用的方法,实践证明可行且相当高效。
状态不一致与一帧延迟
理论上所有对象的状态在一瞬间并行地从 t1 更新到 t2(GEA 图 17.15);单线程循环实际是逐个更新,中途停下来看,一半对象处于 S_i(t2)、另一半仍是 S_i(t1),问两个对象"现在几点"答案可能不一样,甚至对象本身只更新了一部分(例如做完了姿势混合但还没做物理和碰撞),状态介于 t1 和 t2 之间(图 17.16)。GEA 的规则(卷 II 17.6.3.3):
所有游戏对象的状态在更新循环之前和之后是一致的,但在循环之中可能不一致。
这是业内专业人士也常犯的错误来源,最常在对象于更新循环中互相查询状态(即存在依赖)时出现。若 B 读 A 的速度来决定自己 t 时刻的速度,程序员必须清楚要的是 S_A(t1) 还是 S_A(t2);要新状态而 A 还没更新,就是更新顺序问题,会导致一帧延迟 (one-frame-off lag) bug:某个对象的状态比同伴落后一帧,屏幕上表现为对象之间不同步。
状态缓存
分桶对查询施加了有点任意的限制:A 想要 B 的新状态 S_B(t2),B 必须在之前已更新的桶里;想要旧状态 S_B(t1),B 必须在尚未更新的桶里;A 永远不应查询同一桶内对象的状态,因为可能只更新了一部分,至少不能确定拿到的是 t1 还是 t2(卷 II 17.6.3.4)。
改进办法是让每个对象在计算新状态 S_i(t2) 时缓存旧状态 S_i(t1),而不是原地覆盖。GEA 说据他所知没有标准术语,姑且称为状态缓存 (state caching);脚注指出数据库并发系统中密切相关的多版本并发控制 (MVCC) 用于提供事务级一致性。
- 好处:任何对象可以不顾更新顺序安全地查询任何其他对象的旧状态;即使在更新新状态期间,也总有一份完全一致的状态 S_i(t1);还可以在新旧状态间线性插值,近似两时刻之间任意时刻的状态,Havok 物理引擎正是为此保存每个刚体的前后两份状态。
- 坏处:内存是原地更新的两倍;而且只解决一半问题,t1 的旧状态完全一致,t2 的新状态仍可能不一致。
- 它深受纯函数式编程思想影响:所有操作都是有明确输入输出、无副作用的函数,数据不可变,总是产生新数据而不是原地修改。GPP 的同类思路见 双缓冲。
时间戳
一种简单低成本的办法是给对象状态打时间戳,很容易判断某个状态向量对应的是上一时刻还是当前时刻;更新循环中查询其他对象状态的代码可以断言或显式检查时间戳。它不能解决桶内更新期间的不一致,但可以用一个全局或静态变量记录当前正在更新的桶,每个对象知道自己在哪个桶,查询时断言被查对象的桶不等于当前桶,以防止不一致的查询(卷 II 17.6.3.5)。
与 UE5 的对照
UE 的 Tick 组 (TG_PrePhysics、TG_DuringPhysics、TG_PostPhysics、TG_PostUpdateWork) 是一种在帧内固定位置执行对象逻辑的分阶段更新;AddTickPrerequisiteActor/Component 只为真正有依赖的对象设先后关系,同组内其他对象仍可并行。官方例子里激光粒子"PostPhysics + 依赖准星"的写法,相当于 GEA 依赖森林中"依赖者晚于被依赖者"的约束,但粒度是逐对象而不是逐桶。详见 委托与 Tick 机制。这是对比归纳,UE 文档没有使用"桶"的说法。
出处
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.6 节