中EN
概念页

游戏对象的实时更新

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 文档没有使用"桶"的说法。

  1. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.6 节

相关页面

游戏开发游戏循环与更新方法Game Loop & Update Method游戏循环 (Game Loop) 让游戏时间的推进与玩家输入和处理器速度脱钩:每一轮非阻塞地处理输入、更新状态、渲染,并跟踪真实时间来控制节奏;最常推荐的做法是“固定更新步长、可变渲染”。更新方法 (Update Method) 是它的搭档:世界维护一组对象,每个对象实现一个模拟“一帧”行为的 update(),每帧依次调用。两者和组件模式一起常被视为游戏引擎的核心。游戏开发 · UE5委托与 Tick 机制Delegates & TickUE 有两种驱动游戏逻辑的方式:事件驱动——委托 (Delegate) 在某事发生时回调绑定的函数;轮询驱动——Tick 每帧(或按最小间隔)调用 Actor/组件的更新函数,并按 Tick 组 (Tick Group) 与物理模拟排序。官方建议能用定时器、委托就不用 Tick,必须 Tick 时用 Tick 组和 Tick 依赖保证顺序,避免“差一帧”问题。游戏开发游戏对象并发更新Concurrent Game Object Updates游戏对象模型要和几乎必然是多线程的底层子系统打交道,所以对象代码必须异步思考:耗时操作用非阻塞请求尽早发起、尽晚取结果,必要时容忍一帧延迟。作业之间的依赖决定了并行度 (DOP),每个依赖都引入同步点,"优化并发设计的秘诀是延迟"。对象模型本身也能并行:Naughty Dog 在把引擎从 PS3 移植到 PS4(《最后生还者 重制版》)时,让每个桶内的对象更新成为作业;桶间访问无需加锁,桶内读取用对象快照,桶内修改尽量避免,或加锁、或放进请求队列、或交给下一桶中的同步作业(GEA4 卷 II 17.7)。游戏开发运行时对象模型架构Runtime Object Model Architectures玩法基础系统 (gameplay foundation system) 是位于"引擎与游戏分界线"附近、供构建游戏规则和动态元素的一组运行时组件,其中最复杂的是运行时对象模型:它在运行时实现世界编辑器展示给策划的抽象对象模型。GEA 把架构分为两大类:以对象为中心(每个工具侧对象对应一个或一小组类实例)和以属性为中心(对象只是一个 id,属性分散在按属性类型划分、以 id 为键的表里)。前者从单体类层次走向组合与组件,后者更省内存、更缓存友好、更易数据驱动,但难以表达属性间关系、也更难调试(GEA4 卷 II 17.1–17.2)。游戏开发双缓冲Double Buffer双缓冲 (Double Buffer) 让一系列按顺序执行的修改,在外界看来像是瞬间、同时完成的:类内部保留“当前缓冲”和“下一缓冲”两份状态,读取总是读当前缓冲,写入总是写下一缓冲,改完后用一次原子交换让新状态整体生效。图形里的前后缓冲是最经典的例子;同样的思路也能让一帧内依次更新的游戏对象互相看到的都是上一帧的一致状态。游戏开发游戏时间线与时钟Game Timelines & Clocks游戏引擎要同时处理多种「时间」:真实时间、游戏时间、动画片段的局部时间、CPU 周期等。GEA 用抽象时间线 (abstract timeline) 统一看待它们,并讨论帧时间 \Delta t 的测量方法、高精度计时器、时间单位与时钟变量类型的取舍,以及断点恢复后的异常 \Delta t 处理。(卷 I 第 8 章 引言、8.4–8.5 节)游戏开发物理引擎与游戏的集成Integrating a Physics Engine into a Game碰撞/物理世界里的刚体只是抽象的数学描述,要通过游戏对象这个中枢,间接连到屏幕上的视觉表现。刚体按谁驱动分三类:物理驱动、游戏驱动(Havok 称 key framed)、固定;Havok 用可在运行时切换的"运动类型"统一表示。每帧更新有固定顺序:先更新游戏驱动刚体、幻影、力、冲量和约束,再步进模拟,然后回读物理驱动对象、查询幻影、执行投射查询。物理宜用恒定的 1/30 或 1/60 秒时间步。GEA 还逐一讨论了子弹、手雷、爆炸、可破坏物、角色移动、相机碰撞和布娃娃的实际做法(GEA4 卷 II 14.5–14.6)。游戏开发动画管线与动作状态机Animation Pipeline & Action State Machines低层动画管线把片段和混合规格变成局部姿势、全局姿势和蒙皮矩阵调色板;其上的动作状态机 (ASM) 给游戏代码提供"按状态驱动动画"的接口。每个状态是任意复杂的片段混合,跟踪当前所有片段的方式有扁平加权平均和混合树两种;过渡用数据驱动的过渡矩阵描述,控制参数通过节点搜索、命名变量或控制结构体暴露给代码(GEA4 卷 II 13.9–13.10)。游戏开发数据局部性Data Locality数据局部性 (Data Locality) 是一种优化思路:CPU 读取内存时会把相邻的一整条缓存行一起载入,所以把数据按处理顺序连续存放,就能大幅减少缓存未命中。具体手段包括:组件按类型存进连续数组,不再到处跟随指针;把活跃对象排在前面;把冷热数据拆开。代价是要放弃一部分继承、接口与指针带来的抽象。游戏开发观察者模式Observer Pattern观察者模式 (Observer Pattern) 让一段代码宣布“发生了某件事”,而不必关心谁会收到通知:主体 (Subject) 维护一个观察者列表,在事件发生时逐个同步调用它们。它适合连接彼此几乎无关的子系统(例如物理与成就),但要注意同步阻塞、观察者的注销,以及运行时才能确定的通信关系难以推理。游戏开发游戏世界与游戏对象Game World & Game Objects游戏由玩法 (gameplay) 而不是技术定义;游戏机制 (game mechanics) 是支配游戏中各实体交互的规则,以及目标、成败标准、玩家能力、非玩家实体和整体流程(GEA 引用的论文称实现玩法的软件系统为游戏的 G 因子)。大多数游戏世界由静态元素和动态元素组成,划分为若干世界块,并由高层游戏流程组织玩家目标;动态元素通常实现为游戏对象 (GO),引擎提供的建模设施称为游戏对象模型,且分为工具侧和运行时两套(GEA4 卷 II 16、16.1–16.2)。游戏开发事件与消息传递Events & Message Passing游戏天然是事件驱动的:爆炸、玩家被敌人发现、血包被捡起都是事件,系统要通知感兴趣的对象并让它们以各自的方式处理。用虚函数 OnExplosion() 通知属于静态类型的后期绑定,迫使基类为所有可能的事件声明虚函数;GEA 主张把事件封装成对象(类型 + 参数),实现动态类型的后期绑定,从而获得单一处理函数、持久性和盲转发。GEA 依次讨论事件类型(枚举 vs 字符串/字符串 id)、参数(派生类、Variant 数组、键值对)、处理器、职责链、兴趣注册、排队与否的利弊,最后是数据驱动的事件系统和以数据通路连接端口的图形化系统(如 UE 蓝图)(GEA4 卷 II 17.8)。综合引擎运行时架构总览Engine Runtime Architecture Overview本页把 wiki 中分散在 GEA 第 1、6、8、17 章及 GPP、UE5 相关页面里的内容串成一条线,回答"一个游戏引擎在运行时是怎样组织起来的":静态结构上是自下而上的分层,上层依赖下层;启动关闭按依赖顺序显式进行;每一帧由主循环驱动各子系统批量更新,游戏对象的逻辑被安排在子系统阶段之间,并按依赖分桶或设先后关系;多核上一帧被拆成成百上千个作业;玩法基础系统(对象模型、事件、脚本、世界加载流送、高层流程)位于引擎与游戏的分界线附近。各结论的细节与出处见所链接的概念页。综合游戏中的内存管理Memory Management in Games本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。游戏开发作业系统Job System多核硬件上,游戏循环的工作要分解成可并行执行的子任务。GEA 依次比较「每子系统一个线程」「分散/汇集 (scatter/gather)」和通用的作业系统 (job system),并给出典型作业 API、基于线程池的简单实现及其局限、用协程或纤程让作业可以睡眠、作业计数器、作业同步原语与可视化剖析工具,最后介绍 Naughty Dog 基于纤程的作业系统。(卷 I 8.6 节)游戏开发碰撞响应与约束求解Collision Response & Constraint Solving刚体碰撞后,模拟必须让它们做出逼真的反应,并保证一步结束时不再互相穿透,这就是碰撞响应。主流方法有三种:基于牛顿恢复定律的冲量法、像硬阻尼弹簧一样的惩罚力、以及把碰撞当作约束交给通用约束求解器。此外还要处理摩擦、多边形汤上的伪接触(焊接),以及让物体安静下来(休眠与模拟岛)。约束(点对点、铰链、棱柱、布娃娃、动力约束等)限制刚体的自由度;每帧的物理步骤是"积分 → 碰撞检测 → 碰撞解决 → 约束求解"的迭代误差最小化(GEA4 卷 II 14.4.7–14.4.10)。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原