Files
XCEngine/docs/api/_guides/Components/GameObject-Component-Lifecycle-And-Serialization.md

10 KiB
Raw Blame History

GameObject-Component Lifecycle And Serialization

这篇指南解决什么问题

单看 GameObjectComponentTransformComponent 这些 API 页,你可以知道“接口叫什么”,但很难立刻建立完整心智模型:

  • 为什么 Transform 是内建的,而不是普通组件?
  • 为什么有些对象能被全局查询到,有些不能?
  • 为什么 tag / layer 现在已经可用,但又不等于完整 TagManager
  • 为什么动态加组件后,有时不会自动进入完整生命周期?
  • 为什么 GameObject::Serialize()Scene::SerializeToString() 要分成两层?

这篇指南的目标就是把这些设计取舍讲清楚。

先建立正确心智模型

当前 XCEngine 采用的是 Scene -> GameObject -> Component 这条经典对象树模型,而不是 ECS。

可以把它粗略理解成“更轻量的 Unity 风格对象系统”:

  • Scene 负责对象集合、根入口、更新驱动和整棵树的保存/加载。
  • GameObject 负责对象身份、层级、激活状态、轻量 tag/layer 与组件容器。
  • TransformComponent 是每个对象必有的基础设施组件。
  • 普通组件只负责自身行为,例如相机、灯光、网格、音频、脚本等。

这种设计的主要好处是:

  • 编辑器层级和运行时对象是一套直观模型
  • 脚本侧调用方式更贴近多数开发者已有经验
  • 场景保存/加载天然围绕对象树展开

代价则是:

  • 生命周期入口更依赖 Scene -> GameObject -> Component
  • 全局查询与场景查询有明显边界
  • 运行时动态加组件如果不补生命周期,就会出现“组件挂上了,但初始化没追上”的问题

为什么 TransformComponent 是内建组件

商业引擎通常把 Transform 当成对象树基础设施,而不是完全可选的普通功能块。当前实现也是这个思路:

  • GameObject 构造时立即创建 TransformComponent
  • AddComponent<TransformComponent>() 返回已有实例
  • RemoveComponent() 不能移除它

这样设计的直接收益是:

  • 每个对象都天然可放进层级树
  • 组件和脚本不需要处理“这个对象没有 transform”的分支
  • 场景序列化和编辑器层级不用做额外特判

代价是 TransformComponent 不再与普通组件完全对称。你应该把它理解为对象系统底座,而不是“可以随意增删的业务组件”。

为什么 Tag / Layer 现在是轻量字段

当前 GameObject 已经有真实的:

  • m_tag
  • m_layer

它们不再是旧文档里那种“名字别名”或“预留概念”。但它们也还不是完整的项目级管理系统。

当前已经具备的能力

  • tag 默认值是 "Untagged"
  • SetTag("") 会回退到 "Untagged"
  • CompareTag() 做真实字符串比较
  • FindGameObjectsWithTag()Scene::FindGameObjectWithTag() 都按真实 tag 查询
  • layer 会进入序列化与反序列化
  • 托管 GameObject.tag / layerComponent.tag / layer 直接桥接到同一份 native 字段

当前还没有的能力

  • 项目级 tag 定义表
  • tag 重命名迁移系统
  • layer mask 资产与可视化管理器
  • 专门的查询索引

这正是很多商业引擎早期阶段常见的取舍:先把对象元数据模型做扎实,再决定是否要升级成更重的全局管理系统。

场景托管对象 vs 独立对象

这是最容易误用的一条边界。

独立对象

XCEngine::Components::GameObject probe("Probe");

它会得到一个合法对象,但当前只保证:

  • 有内建 Transform
  • 不属于任何 Scene
  • 不会自动进入全局 registry
  • 不会自动触发 Awake()

场景托管对象

auto* player = scene.CreateGameObject("Player");

这条路径当前会额外完成:

  • 写入所属 Scene
  • 注册到全局 registry
  • 接入根节点或父对象之下
  • 创建后立刻调用 Awake()

很多 API 的真实适用范围,都是围绕“场景托管对象”设计的,而不是裸构造对象。

当前真实生命周期

下面这张表描述的是当前代码已经实现的真实行为

阶段 触发者 当前行为
GameObject 构造 用户或 Scene 创建对象、分配 ID/UUID、创建内建 Transform
Scene::CreateGameObject() Scene 注册场景与全局表、接入层级、调用 Awake()
GameObject::Awake() Scene 或用户 遍历普通组件并调用 Awake()
Scene::Update() Scene 对激活 root 先调用 Start(),再调用 Update()
GameObject::Start() Scene 或用户 只对 active in hierarchy 的对象生效,且每对象只执行一次
GameObject::FixedUpdate() / LateUpdate() Scene 或用户 对激活层级中的已启用普通组件递归分发
SetActive() / SetParent() 用户或内部逻辑 重算层级有效激活态,并向已启用组件发送 OnEnable() / OnDisable()
Destroy() 用户 场景托管对象走 Scene::DestroyGameObject();独立对象只发 OnDestroy(),不自动 delete
Scene::DeserializeFromString() Scene 重建对象、层级与组件,但不经过 CreateGameObject(),因此不补发 Awake() / Start()

运行时 AddComponent<T>() 的现实语义

很多人会下意识把它理解成 Unity 那种“加上去就自动进入完整生命周期”的接口,但当前实现更保守。

auto* emitter = scene.CreateGameObject("Emitter");
auto* source = emitter->AddComponent<XCEngine::Components::AudioSourceComponent>();

这里当前只保证:

  • 创建组件实例
  • 回填 component->m_gameObject
  • 放入 m_components

这里不会自动发生

  • Awake()
  • Start()
  • OnEnable()

这是一条非常关键的工程边界。如果某个组件把重要初始化逻辑写在这些阶段,运行时动态挂载之后就需要你自己决定何时补足初始化,而不是期待引擎自动帮你追平。

激活状态为什么要区分两层

当前对象系统和 Unity 一样,明确区分:

  • activeSelf
  • activeInHierarchy

原因很简单:

  • 对象自己打开,并不代表它真的在运行
  • 只要父对象被禁用,整个子树都应视为失活
  • 组件的 OnEnable() / OnDisable() 不能只看自己 enabled,还要看宿主对象是否处于有效激活层级

这种设计虽然比单布尔值复杂一些,但它能避免大量层级对象状态错乱问题,是商业引擎里成熟且必要的做法。

为什么序列化要分两层

初看时可能会觉得:既然 GameObject 知道自己的组件和子对象,为什么不让它一个接口把所有东西全写完?

当前实现故意没有这么做,而是拆成:

GameObject::Serialize()

只写对象基础状态:

  • name
  • tag
  • active
  • layer
  • id
  • uuid
  • transform

Scene::SerializeToString()

负责完整场景树:

  • 场景名和场景 active 位
  • 根对象入口
  • parent 关系
  • 每个普通组件的类型名和 payload
  • 递归子对象

这样拆的好处是职责清晰:

  • 对象级接口关注“这个对象自己是什么状态”
  • 场景级接口关注“整棵树如何保存与恢复”

这和商业引擎里把对象局部状态与场景容器边界拆开的思路是一致的。

托管脚本为什么能立刻影响原生查询

当前 Mono 运行时已经把这些接口桥接到了同一份 native 数据:

  • GameObject.tag
  • GameObject.layer
  • Component.tag
  • Component.layer
  • CompareTag()

这意味着:

  • 脚本读到的不是副本
  • 脚本写回后,原生对象字段会立即变化
  • 场景查询和全局查询都会立刻看到新值

这也是为什么 tests/Scripting/test_mono_script_runtime.cpp 中,脚本把对象 tag 改成 "Player" 之后,原生 Scene::FindGameObjectWithTag("Player") 会马上返回同一个对象。

当前你必须知道的限制

1. tag / layer 已经可用,但仍是轻量模型

不要再把它理解成“只是名字别名”,但也不要把它理解成已经具备完整项目配置、索引和重命名迁移能力的 TagManager。

2. 静态全局查询只对已注册对象成立

裸构造对象默认不会出现在:

  • GameObject::Find()
  • GameObject::FindObjectsOfType()
  • GameObject::FindGameObjectsWithTag()

3. 独立对象的 Destroy() 不会释放对象自身

它只发送 OnDestroy()。真正的内存释放仍由对象所有者负责。

4. 反序列化路径不补发生命周期

Scene::DeserializeFromString() 目前是“忠实恢复内存状态”,不是“模拟一次完整运行时创建流程”。

5. TransformComponent 不参与普通组件遍历

无论是生命周期还是普通组件列表逻辑,都不要把它当成 m_components 里的普通成员来理解。

推荐工作流

运行时对象

  1. Scene::CreateGameObject() 创建对象。
  2. SetParent() 组织层级。
  3. AddComponent<T>() 挂普通组件。
  4. SetTag() / CompareTag() / SetLayer() 管理轻量元数据。
  5. Scene::SerializeToString() / DeserializeFromString() 做整棵树持久化。

测试或临时对象

  1. 可以直接构造 GameObject
  2. 只依赖本地 Transform 与组件操作。
  3. 不要假设全局查询、场景序列化和自动生命周期已经可用。

和 Unity 的关系应该怎么理解

比较准确的表述是:

  • 它在对象模型上明显借鉴了 Unity。
  • 它已经提供了足够熟悉的 GameObject + Transform + Component + tag + layer 心智模型。
  • 但当前实现仍然比 Unity 更轻,特别是在 tag/layer 管理、运行时加组件生命周期、完整组件序列化和项目级对象分类工具方面。

把它理解成“Unity 风格、但当前阶段更收敛的商业引擎对象系统”是比较准确的。

从这里继续读什么