约 18 分钟技术学习游戏引擎

Games104学习笔记(3)

GAMES104:如何构建游戏世界

课程:GAMES104《现代游戏引擎:从入门到实践》
讲次:Lecture 03 / How to Build a Game World
讲者:王希(Xi Wang,Booming Tech)
视频时长:约 59 分 17 秒
笔记性质:根据本地视频中的幻灯片与讲解主题整理,不是逐字稿;时间点为复习定位用的近似值。

一句话理解

游戏世界可以看成由大量 Game Object(游戏对象,GO) 构成的动态系统。对象自身保存属性并通过组件获得能力;引擎再用 Tick、事件、依赖关系和场景管理,持续推进这些对象的状态变化。

Game Object
    ├─ 属性:位置、旋转、速度、血量、资源引用……
    ├─ 组件:模型、动画、物理、输入、脚本……
    ├─ Tick:随时间更新状态
    └─ 事件:与其他对象或系统通信

本讲学习目标

  • 了解游戏世界中有哪些对象,以及为什么很难做出完美分类。
  • 理解 Game Object 与 Component 的关系。
  • 比较对象式 Tick 和组件式 Tick。
  • 理解事件机制、组件依赖和场景管理的作用。
  • 认识 Unity、Unreal 等商业引擎中的组件化思路。
  • 建立从“对象—组件—系统—场景”理解游戏世界的框架。

1. 游戏世界由什么组成

1.1 Everything is a Game Object

从引擎的角度看,游戏世界中的元素都可以抽象成 Game Object:

  • 动态物体:角色、飞机、无人机、车辆、敌人等。
  • 静态物体:建筑、墙壁、地面、装饰物等。
  • 环境系统:地形、天空、植被和天气等。
  • 检测体:触发器、区域、空气墙和碰撞体等。
  • 可视对象:模型、粒子、特效和 UI 等。
  • 规则或逻辑对象:任务、关卡状态、出生点和游戏管理器等。

一个 GO 通常至少包含两类信息:

  1. 属性(Property):描述对象当前是什么状态,例如位置、旋转、速度、生命值和资源引用。
  2. 行为(Behavior):描述对象能够做什么,例如移动、巡逻、攻击、播放动画和响应事件。

1.2 对象不是简单的“一个类”

如果为每一种对象都设计一个完整的继承类,初期看起来直观,但对象类型一多就会遇到问题:

  • 某些对象同时具有多种身份,难以放入单一分类。
  • 父类层次会越来越深,修改一个基类可能影响大量对象。
  • 不同对象之间会出现重复功能。
  • 设计师需要的组合方式不一定符合程序员预先设计的继承树。

因此,现代引擎通常将“对象是谁”和“对象能做什么”分开处理。


2. 没有完美的游戏对象分类

视频用飞机、直升机、无人机等例子说明:现实世界和游戏世界中的分类边界并不总是清晰。

例如,一个对象可能同时具备:

  • 飞行能力。
  • 载人能力。
  • 武器系统。
  • 物理碰撞。
  • AI 控制。
  • 可被玩家操控。
  • 可播放动画或特效。

如果把这些能力全部写进一个“飞机类”或“角色类”,分类会不断膨胀。更灵活的方式是把能力拆出来,让对象按需求组合:

对象 = 基础身份 + 一组可组合能力

战斗直升机 = Transform
           + Mesh Renderer
           + Physics
           + Motor
           + Weapon
           + AI 或 Player Input

组件化并不是否定对象,而是把对象的复杂行为拆成更容易复用和管理的部分。


3. Component:组件化

3.1 Game Object 与 Component

组件是挂载到 Game Object 上的功能模块。一个对象通常由多个组件共同组成:

Game Object
    ├─ Transform Component
    ├─ Render / Mesh Component
    ├─ Animation Component
    ├─ Physics Component
    ├─ Input Component
    └─ Script / Gameplay Component

组件化带来的好处:

  • 复用:相同能力可以挂到不同对象上。
  • 组合:对象可以按项目需要组合能力。
  • 扩展:增加新能力时不必修改庞大的继承树。
  • 解耦:各组件关注自己的职责,通过接口、事件或数据通信。
  • 编辑器友好:设计师可以在编辑器中添加、删除和配置组件。

3.2 商业引擎中的组件化

视频展示了 Unity 和 Unreal 的对象结构作为例子。不同引擎的命名和实现方式不同,但共同思想是:

  • 引擎对象负责生命周期、标识和基本管理。
  • 组件负责具体能力。
  • 编辑器通过组件暴露对象的可配置部分。
  • 运行时由多个组件共同完成对象行为。

可以用下面的抽象理解它们:

引擎对象基类
      ↓
游戏对象 / Actor / GameObject
      ↓
多个功能组件
      ↓
对象在场景中的具体行为

组件化并不意味着组件之间没有关系。动画可能依赖骨骼和模型,物理可能依赖碰撞形状,角色移动可能依赖输入或 AI,因此还需要显式管理组件依赖。


4. Tick:让对象随时间更新

4.1 Tick 的基本含义

游戏世界不是一组静态文件。引擎必须不断推进时间并更新对象状态,这个周期性更新过程通常称为 Tick。

读取时间与输入
       ↓
更新对象和组件
       ↓
处理事件与依赖
       ↓
更新动画、物理和其他系统
       ↓
得到新的世界状态

一个简单的对象式更新可能类似于:

for (GameObject* object : objects) {
    object->tick(delta_time);
}

这种写法直观,容易理解对象的完整行为,也便于早期调试。

4.2 Object-based Tick

对象式 Tick 以对象为中心:每次取出一个 Game Object,再让对象内部的所有功能依次更新。

优点:

  • 结构简单、直观。
  • 对象的行为集中,容易跟踪。
  • 初学和小型项目容易实现。
  • 调试一个对象时上下文比较完整。

局限:

  • 不同对象的同类操作可能被分散执行。
  • 数据访问不够连续,缓存利用率可能较低。
  • 组件之间的并行机会不容易暴露。
  • 对象数量增加后,调度和依赖关系会变复杂。

4.3 Component-based Tick

组件式 Tick 以组件类型或系统为中心:先收集同类组件,再批量处理它们。

for (TransformComponent* transform : all_transforms) {
    update_transform(transform, delta_time);
}

for (PhysicsComponent* physics : all_physics) {
    update_physics(physics, delta_time);
}

优点:

  • 同类数据和操作更集中。
  • 更适合批处理和并行处理。
  • 有利于减少缓存未命中。
  • 可以降低对象之间不必要的访问。
  • 更容易把工作拆成系统任务。

代价:

  • 对象的完整行为被分散到多个系统。
  • 需要明确组件之间的执行顺序和依赖。
  • 调试一条对象行为链时,需要跨多个系统追踪。
  • 组件访问和生命周期管理更复杂。

4.4 两种方式并非绝对对立

对象式 Tick 适合强调直观性和开发便利性的场景;组件式 Tick 适合对象数量大、批处理和并行要求高的场景。实际引擎可能混合使用:

  • 由对象管理生命周期。
  • 由组件或系统执行批量更新。
  • 对特殊对象保留独立 Tick。
  • 对可并行工作使用线程池或任务系统。

选择取决于对象规模、数据布局、系统依赖和性能目标。


5. Event:对象之间的通信

5.1 为什么需要事件

如果每个对象直接调用其他对象的具体函数,会形成紧密依赖:

玩家类 → 直接知道敌人类
敌人类 → 直接知道 UI 类
UI 类 → 直接知道关卡类

对象数量增多后,修改一个系统就可能影响许多其他系统。事件机制可以让发送者只表达“发生了什么”,而不必了解所有接收者。

事件发送者
      ↓ 发布“受到伤害”
事件系统 / 消息分发
      ├─ Health Component
      ├─ UI Component
      ├─ 音效系统
      └─ 特效系统

5.2 事件的优点

  • 降低对象之间的直接耦合。
  • 让多个系统响应同一件事。
  • 便于增加新响应者,而不修改原发送者。
  • 适合碰撞、伤害、任务、输入和关卡触发等场景。

5.3 事件设计中的问题

事件并不是越多越好,需要考虑:

  • 事件由谁发送、谁接收?
  • 接收者是否会被销毁或失效?
  • 事件是立即处理还是延迟到下一帧?
  • 事件顺序是否重要?
  • 多线程下如何保证安全?
  • 事件链是否会形成循环?
  • 调试时能否追踪事件来源和去向?

5.4 立即事件与延迟事件

视频展示了商业引擎中常见的事件 / 消息调用方式。可以粗略区分为:

  • 立即事件:发送后立即调用接收者,逻辑直接,但可能产生深层调用链。
  • 延迟事件:先放入队列,在指定阶段统一处理,便于排序、跨线程和批处理,但会引入延迟。

选择哪种方式,取决于响应速度、确定性、线程模型和系统依赖。


6. 组件依赖与对象绑定

6.1 组件之间存在依赖

组件并不是完全独立的。例如:

  • Motor 需要 Transform 才能改变位置。
  • Animation 需要 Skeleton 或 Mesh 才能播放。
  • Physics 需要碰撞形状和 Transform。
  • Render 需要 Mesh、Material 和 Transform。
  • Gameplay 可能需要 Input、Camera 和 Physics。

可以把依赖关系表示为:

Input ───────┐
             ↓
          Motor ─→ Transform ─→ Render
             ↓          ↑
          Physics ───────┘
             ↑
         Collision

6.2 依赖管理的目标

  • 在对象创建时检查所需组件是否存在。
  • 明确哪个组件拥有数据,哪个组件只读取数据。
  • 控制组件的初始化和销毁顺序。
  • 保证 Tick 顺序与数据依赖相符。
  • 避免组件之间形成难以维护的双向依赖。
  • 让编辑器能够提示缺少的组件或配置。

6.3 绑定关系

Game Object 的绑定关系可以包含:

  • 对象与组件的绑定。
  • 父对象与子对象的层级绑定。
  • 组件与资源的引用绑定。
  • 系统与事件的订阅绑定。
  • 功能之间的依赖绑定。

绑定关系越多,越需要稳定的对象标识、清晰的生命周期和可追踪的依赖图。


7. Scene Management:场景管理

7.1 为什么需要场景管理

当场景中的对象很少时,遍历全部对象还可以接受;当场景包含大量对象时,每次查询都扫描整个世界会产生很高的成本。

常见查询包括:

  • 哪些对象位于摄像机视野内?
  • 某个范围内有哪些敌人?
  • 射线可能击中哪些碰撞体?
  • 哪些对象需要加载或卸载?
  • 哪些对象可以参与当前物理计算?

场景管理的目标是把“在一大堆对象中找一个对象”变成更快的空间查询。

7.2 不分区与分区

最简单的方式是把所有对象放在一个列表中:

查询区域
   ↓
遍历整个场景中的所有对象
   ↓
逐一判断是否命中

这种方式容易实现,但对象数量增加后效率较低。

分区方法会把空间划分成多个区域:

┌────┬────┬────┬────┐
│    │    │    │    │
├────┼────┼────┼────┤
│    │查询│    │    │
├────┼────┼────┼────┤
│    │    │    │    │
└────┴────┴────┴────┘

查询时只检查相关区域,从而减少无关对象的判断。

7.3 常见的空间组织方式

  • 网格(Grid):实现简单,适合规则空间和快速邻域查询。
  • 四叉树(Quadtree):递归划分二维空间。
  • 八叉树(Octree):递归划分三维空间。
  • BVH(Bounding Volume Hierarchy):用包围体组织对象层级。
  • 场景图(Scene Graph):按层级关系组织对象和变换。
  • 区域流式加载:按玩家或摄像机位置加载、卸载场景块。

7.4 分区的取舍

空间分区不是免费优化:

  • 对象移动时需要更新所属区域。
  • 动态对象可能跨越多个区域。
  • 分区结构本身需要内存和维护成本。
  • 不同查询类型适合不同数据结构。
  • 过度细分会增加管理开销。

合适的方案取决于场景大小、对象运动方式、查询类型和目标平台。


8. 从对象到整个游戏世界

可以把本讲内容串成一条层次关系:

游戏世界
  └─ 场景 Scene
      └─ Game Object
          ├─ 属性与标识
          ├─ Component 组件
          │   ├─ Transform
          │   ├─ Render
          │   ├─ Animation
          │   ├─ Physics
          │   └─ Gameplay
          ├─ Tick 更新
          ├─ Event 通信
          └─ 与其他对象的绑定和依赖

引擎需要同时解决四个问题:

  1. 对象如何表示?
  2. 能力如何复用和组合?
  3. 对象如何随时间更新?
  4. 大量对象如何被高效查询和管理?

Game Object、Component、Tick、Event 和 Scene Management 分别回答了这四个问题。


9. 本讲最重要的结论

9.1 对象是世界中的实体,组件是能力的组织方式

Game Object 负责成为场景中的对象,Component 负责提供可复用的能力。二者结合后,既能表达世界中的实体,又能避免继承树无限膨胀。

9.2 组件化带来灵活性,也带来依赖管理问题

拆分组件可以提高复用和扩展能力,但组件之间仍然存在数据依赖、初始化顺序和 Tick 顺序。组件化不是简单地把代码拆成多个类。

9.3 Tick 决定世界如何随时间演进

对象式 Tick 易懂,组件式 Tick 更适合批处理和并行。大型引擎通常会在可读性、缓存效率、并行能力和调试成本之间取舍。

9.4 事件机制用于降低耦合

事件让系统围绕“发生了什么”通信,而不是互相了解内部实现。事件的时机、顺序、生命周期和线程安全必须被明确设计。

9.5 场景管理决定大世界查询效率

场景管理的核心思想是:不要每次都在全部对象中搜索,只检查和查询目标相关的空间区域或层级。

9.6 游戏世界的组织方式会影响性能

对象结构、组件布局、Tick 调度、事件队列和空间分区,既是架构问题,也是性能问题。引擎设计需要同时考虑表达能力和运行效率。


10. 复习用速记

Game Object = 属性 + 行为

组件化:
对象负责身份和生命周期
组件负责具体能力
系统负责批量处理组件

Object-based Tick:
直观、易懂、易调试,但不利于批处理和并行

Component-based Tick:
更适合数据局部性、批处理和并行,但依赖和调试更复杂

Event:
发送“发生了什么”,降低对象之间的直接耦合

Scene Management:
用网格、树、包围体或场景图减少空间查询范围

11. 自测题

  1. 为什么说游戏世界中的很多东西都可以抽象成 Game Object?
  2. 为什么现实世界中的分类不能直接变成游戏对象的继承树?
  3. Game Object 和 Component 各自应该负责什么?
  4. 对象式 Tick 与组件式 Tick 的主要差别是什么?
  5. 组件式 Tick 为什么更容易进行批处理和并行?
  6. 组件化之后,哪些依赖关系必须被显式管理?
  7. 事件机制如何降低对象之间的耦合?
  8. 立即事件和延迟事件分别有什么优缺点?
  9. 场景中对象数量很多时,为什么不能每次都遍历全部对象?
  10. 网格、四叉树、八叉树和 BVH 分别适合解决什么类型的空间组织问题?
  11. 如果一个角色移动组件需要 Transform 和 Physics,你会如何表示它们的依赖?
  12. 组件化、Tick 和场景管理之间为什么会互相影响?

12. 术语表

术语 含义
Game Object(GO) 场景中的游戏对象,包含属性、行为和组件
Component 挂载到对象上的可组合功能模块
Property 描述对象状态的属性,如位置、速度和血量
Behavior 描述对象如何行动的逻辑
Tick 引擎推进一小段时间并更新状态的过程
Object-based Tick 以对象为中心,逐个更新对象及其功能
Component-based Tick 以组件或系统为中心,批量更新同类组件
Event 用于对象或系统之间通信的事件 / 消息
Scene 一组对象及其空间、资源和状态的集合
Scene Management 对场景对象进行组织、查询、加载和卸载的系统
Spatial Partitioning 将空间划分为区域,以减少查询范围
Scene Graph 按层级组织对象和空间变换的数据结构
GUID Global Unique Identifier,全局唯一对象或资源标识
DCC Digital Content Creation,数字内容创建工具

13. 参考资料