docs: sync api and planning docs
This commit is contained in:
@@ -2,67 +2,98 @@
|
||||
|
||||
## 这篇指南解决什么问题
|
||||
|
||||
只看 `Components` 模块下的单个 API 页,很容易知道“有哪些函数”,但不容易知道“应该怎么组织对象”“生命周期到底是谁驱动的”“为什么有些对象能被查到、有些不能”“为什么某些组件保存后会丢状态”。
|
||||
单看 `GameObject`、`Component`、`TransformComponent` 这些 API 页,你可以知道“接口叫什么”,但很难立刻建立完整心智模型:
|
||||
|
||||
这篇指南专门回答这些问题。它不是第二套 API 参考,而是帮助你建立当前 `XCEngine::Components` 模块的整体心智模型。
|
||||
- 为什么 `Transform` 是内建的,而不是普通组件?
|
||||
- 为什么有些对象能被全局查询到,有些不能?
|
||||
- 为什么 `tag` / `layer` 现在已经可用,但又不等于完整 TagManager?
|
||||
- 为什么动态加组件后,有时不会自动进入完整生命周期?
|
||||
- 为什么 `GameObject::Serialize()` 和 `Scene::SerializeToString()` 要分成两层?
|
||||
|
||||
这篇指南的目标就是把这些设计取舍讲清楚。
|
||||
|
||||
## 先建立正确心智模型
|
||||
|
||||
当前引擎采用的是 **GameObject + Component + Scene 树** 模型,而不是 ECS。
|
||||
当前 `XCEngine` 采用的是 **Scene -> GameObject -> Component** 这条经典对象树模型,而不是 ECS。
|
||||
|
||||
可以把它粗略类比成 Unity 的对象体系:
|
||||
可以把它粗略理解成“更轻量的 Unity 风格对象系统”:
|
||||
|
||||
- `Scene` 像场景容器,拥有对象并驱动更新与序列化。
|
||||
- `GameObject` 像层级树节点,负责身份、轻量 `tag` / `layer`、父子关系、激活状态和组件容器。
|
||||
- `TransformComponent` 像内建 `Transform`,每个对象必有一个。
|
||||
- 其他组件按职责挂接到对象上,例如 `CameraComponent`、`LightComponent`、`MeshRendererComponent`、`AudioSourceComponent`。
|
||||
- `Scene` 负责对象集合、根入口、更新驱动和整棵树的保存/加载。
|
||||
- `GameObject` 负责对象身份、层级、激活状态、轻量 `tag/layer` 与组件容器。
|
||||
- `TransformComponent` 是每个对象必有的基础设施组件。
|
||||
- 普通组件只负责自身行为,例如相机、灯光、网格、音频、脚本等。
|
||||
|
||||
这种设计的优势是:
|
||||
这种设计的主要好处是:
|
||||
|
||||
- 对编辑器层级、脚本调用和运行时调试都比较直观。
|
||||
- 对多数游戏开发者来说学习成本低。
|
||||
- 场景保存/加载可以直接围绕对象树展开。
|
||||
- 编辑器层级和运行时对象是一套直观模型
|
||||
- 脚本侧调用方式更贴近多数开发者已有经验
|
||||
- 场景保存/加载天然围绕对象树展开
|
||||
|
||||
代价是:
|
||||
代价则是:
|
||||
|
||||
- 生命周期不再只是“组件自己的事”,而是依赖 `Scene -> GameObject -> Component` 这条链路。
|
||||
- 某些看起来像全局查询的接口,其实只对场景托管对象成立。
|
||||
- 运行时动态加组件时,如果没有自动补发初始化阶段,就会出现“对象在,但逻辑没进入完整生命周期”的情况。
|
||||
- 生命周期入口更依赖 `Scene -> GameObject -> Component`
|
||||
- 全局查询与场景查询有明显边界
|
||||
- 运行时动态加组件如果不补生命周期,就会出现“组件挂上了,但初始化没追上”的问题
|
||||
|
||||
## 为什么 `TransformComponent` 是内建组件
|
||||
|
||||
商业级游戏引擎里,`Transform` 往往不是普通业务组件,而是对象树的基础设施。当前实现也是这个思路:
|
||||
商业引擎通常把 `Transform` 当成对象树基础设施,而不是完全可选的普通功能块。当前实现也是这个思路:
|
||||
|
||||
- `GameObject` 构造时立即创建 `TransformComponent`
|
||||
- `AddComponent<TransformComponent>()` 返回已有实例
|
||||
- `RemoveComponent()` 不能移除它
|
||||
|
||||
这样设计的好处是:
|
||||
这样设计的直接收益是:
|
||||
|
||||
- 所有对象都天然具有空间位置和层级关系
|
||||
- 相机、灯光、网格、音频这些组件都可以稳定依赖 `transform()`
|
||||
- 编辑器和场景序列化不需要处理“这个对象没有变换”的特殊分支
|
||||
- 每个对象都天然可放进层级树
|
||||
- 组件和脚本不需要处理“这个对象没有 transform”的分支
|
||||
- 场景序列化和编辑器层级不用做额外特判
|
||||
|
||||
代价是 `TransformComponent` 不再是完全对称的组件类型。你在上层逻辑里必须把它当成“基础设施组件”,不是“普通可选功能块”。
|
||||
代价是 `TransformComponent` 不再与普通组件完全对称。你应该把它理解为对象系统底座,而不是“可以随意增删的业务组件”。
|
||||
|
||||
## 为什么 Tag / Layer 现在是轻量字段
|
||||
|
||||
当前 `GameObject` 已经有真实的:
|
||||
|
||||
- `m_tag`
|
||||
- `m_layer`
|
||||
|
||||
它们不再是旧文档里那种“名字别名”或“预留概念”。但它们也还不是完整的项目级管理系统。
|
||||
|
||||
### 当前已经具备的能力
|
||||
|
||||
- `tag` 默认值是 `"Untagged"`
|
||||
- `SetTag("")` 会回退到 `"Untagged"`
|
||||
- `CompareTag()` 做真实字符串比较
|
||||
- `FindGameObjectsWithTag()` 与 `Scene::FindGameObjectWithTag()` 都按真实 tag 查询
|
||||
- `layer` 会进入序列化与反序列化
|
||||
- 托管 `GameObject.tag` / `layer` 与 `Component.tag` / `layer` 直接桥接到同一份 native 字段
|
||||
|
||||
### 当前还没有的能力
|
||||
|
||||
- 项目级 tag 定义表
|
||||
- tag 重命名迁移系统
|
||||
- layer mask 资产与可视化管理器
|
||||
- 专门的查询索引
|
||||
|
||||
这正是很多商业引擎早期阶段常见的取舍:先把对象元数据模型做扎实,再决定是否要升级成更重的全局管理系统。
|
||||
|
||||
## 场景托管对象 vs 独立对象
|
||||
|
||||
这是当前模块最容易被忽略的一条边界。
|
||||
这是最容易误用的一条边界。
|
||||
|
||||
### 独立对象
|
||||
|
||||
```cpp
|
||||
XCEngine::Components::GameObject player("Player");
|
||||
XCEngine::Components::GameObject probe("Probe");
|
||||
```
|
||||
|
||||
这会得到一个可用对象,但它:
|
||||
它会得到一个合法对象,但当前只保证:
|
||||
|
||||
- 只有内建 `Transform`
|
||||
- 默认不在任何 `Scene` 中
|
||||
- 不会自动注册到全局查找表
|
||||
- 不会自动触发 `Awake`
|
||||
|
||||
它适合测试、临时对象或手工控制生命周期的场景。
|
||||
- 有内建 `Transform`
|
||||
- 不属于任何 `Scene`
|
||||
- 不会自动进入全局 registry
|
||||
- 不会自动触发 `Awake()`
|
||||
|
||||
### 场景托管对象
|
||||
|
||||
@@ -70,90 +101,74 @@ XCEngine::Components::GameObject player("Player");
|
||||
auto* player = scene.CreateGameObject("Player");
|
||||
```
|
||||
|
||||
按当前实现,这条路径会额外完成:
|
||||
这条路径当前会额外完成:
|
||||
|
||||
- 设置 `m_scene`
|
||||
- 写入所属 `Scene`
|
||||
- 注册到全局 registry
|
||||
- 接入场景根节点或父节点
|
||||
- 调用 `Awake()`
|
||||
- 接入根节点或父对象之下
|
||||
- 创建后立刻调用 `Awake()`
|
||||
|
||||
这才是更接近“真实运行时对象”的创建方式。
|
||||
|
||||
### 为什么这很重要
|
||||
|
||||
因为下列能力都和“是否已被场景托管”直接相关:
|
||||
|
||||
- `GameObject::Find()`
|
||||
- `GameObject::FindObjectsOfType()`
|
||||
- `GameObject::FindGameObjectsWithTag()`
|
||||
- `Component::GetScene()`
|
||||
- 场景级保存/加载
|
||||
|
||||
如果对象只是裸构造出来的,你不能期待这些能力都成立。
|
||||
很多 API 的真实适用范围,都是围绕“场景托管对象”设计的,而不是裸构造对象。
|
||||
|
||||
## 当前真实生命周期
|
||||
|
||||
下面这张表描述的是 **当前代码已经实现的行为**,不是理想化设计图。
|
||||
下面这张表描述的是**当前代码已经实现的真实行为**:
|
||||
|
||||
| 阶段 | 谁触发 | 当前行为 |
|
||||
| 阶段 | 触发者 | 当前行为 |
|
||||
|------|------|------|
|
||||
| 构造 `GameObject` | 用户或 `Scene` | 创建对象、分配 ID/UUID、创建内建 `Transform` |
|
||||
| `Scene::CreateGameObject()` | `Scene` | 注册全局表、设置场景、挂到父子层级、调用 `Awake()` |
|
||||
| `Scene::DeserializeFromString()` | `Scene` | 重建对象、Transform、普通组件和父子关系,但当前不会自动补发 `Awake()` |
|
||||
| `GameObject` 构造 | 用户或 `Scene` | 创建对象、分配 ID/UUID、创建内建 `Transform` |
|
||||
| `Scene::CreateGameObject()` | `Scene` | 注册场景与全局表、接入层级、调用 `Awake()` |
|
||||
| `GameObject::Awake()` | `Scene` 或用户 | 遍历普通组件并调用 `Awake()` |
|
||||
| `Scene::Update()` | `Scene` | 对激活 root object 调用 `Start()`,之后 `Update()` |
|
||||
| `GameObject::Start()` | `Scene` 或用户 | 只调用一次;仅对激活层级中的已启用普通组件生效 |
|
||||
| `SetEnabled()` / `SetActive()` / `SetParent()` | 用户或内部逻辑 | 根据层级有效激活态触发 `OnEnable()` / `OnDisable()` |
|
||||
| `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 那种“加上就自动进入生命周期”的接口,但当前实现不是。
|
||||
很多人会下意识把它理解成 Unity 那种“加上去就自动进入完整生命周期”的接口,但当前实现更保守。
|
||||
|
||||
```cpp
|
||||
auto* go = scene.CreateGameObject("Emitter");
|
||||
auto* source = go->AddComponent<XCEngine::Components::AudioSourceComponent>();
|
||||
auto* emitter = scene.CreateGameObject("Emitter");
|
||||
auto* source = emitter->AddComponent<XCEngine::Components::AudioSourceComponent>();
|
||||
```
|
||||
|
||||
这里会发生的事情只有:
|
||||
这里当前只保证:
|
||||
|
||||
- 创建组件实例
|
||||
- 回填 `component->m_gameObject = go`
|
||||
- 放进 `m_components`
|
||||
- 回填 `component->m_gameObject`
|
||||
- 放入 `m_components`
|
||||
|
||||
这里 **不会自动发生**:
|
||||
这里**不会自动发生**:
|
||||
|
||||
- `Awake()`
|
||||
- `Start()`
|
||||
- `OnEnable()`
|
||||
|
||||
为什么这很关键?因为很多组件初始化逻辑都习惯写在这些阶段里。如果你在运行中动态加组件,就需要自己明确何时让它进入可用状态。
|
||||
|
||||
还有一个更容易踩坑的点:如果这个 `GameObject` 之前已经进入过 `Start()`,后续再添加的新组件也不会在未来被自动 `Start()`,因为当前实现只用 `m_started` 记录“这个对象是否已经启动过一次”,而不是“每个组件是否已经启动过”。
|
||||
这是一条非常关键的工程边界。如果某个组件把重要初始化逻辑写在这些阶段,运行时动态挂载之后就需要你自己决定何时补足初始化,而不是期待引擎自动帮你追平。
|
||||
|
||||
## 激活状态为什么要区分两层
|
||||
|
||||
和 Unity 一样,这里也区分:
|
||||
当前对象系统和 Unity 一样,明确区分:
|
||||
|
||||
- `activeSelf`
|
||||
- `activeInHierarchy`
|
||||
|
||||
原因很现实:
|
||||
原因很简单:
|
||||
|
||||
- 一个对象自己打开,不代表它真的在运行;只要父对象被禁用,它依然应该整体失活。
|
||||
- 组件的 `OnEnable()` / `OnDisable()` 不能只看自己的 `enabled`,还要看宿主对象是否真的在活跃层级里。
|
||||
- 对象自己打开,并不代表它真的在运行
|
||||
- 只要父对象被禁用,整个子树都应视为失活
|
||||
- 组件的 `OnEnable()` / `OnDisable()` 不能只看自己 `enabled`,还要看宿主对象是否处于有效激活层级
|
||||
|
||||
当前实现里,`Component::SetEnabled()` 就按下面的“有效启用态”处理:
|
||||
这种设计虽然比单布尔值复杂一些,但它能避免大量层级对象状态错乱问题,是商业引擎里成熟且必要的做法。
|
||||
|
||||
`component enabled && gameObject active in hierarchy`
|
||||
## 为什么序列化要分两层
|
||||
|
||||
这能避免父节点关闭时组件仍然误以为自己处于活动状态。
|
||||
初看时可能会觉得:既然 `GameObject` 知道自己的组件和子对象,为什么不让它一个接口把所有东西全写完?
|
||||
|
||||
## 为什么序列化要分层
|
||||
|
||||
初看时可能会觉得:既然 `GameObject` 有组件容器,为什么不让 `GameObject::Serialize()` 一次把所有内容都写完?
|
||||
|
||||
当前实现没有这么做,而是分成两层:
|
||||
当前实现故意没有这么做,而是拆成:
|
||||
|
||||
### `GameObject::Serialize()`
|
||||
|
||||
@@ -169,105 +184,90 @@ auto* source = go->AddComponent<XCEngine::Components::AudioSourceComponent>();
|
||||
|
||||
### `Scene::SerializeToString()`
|
||||
|
||||
负责完整场景结构:
|
||||
负责完整场景树:
|
||||
|
||||
- 所有 root object 递归展开
|
||||
- 场景名和场景 active 位
|
||||
- 根对象入口
|
||||
- `parent` 关系
|
||||
- 每个普通组件的类型名和 payload
|
||||
- 反序列化时通过 `ComponentFactoryRegistry` 重建组件
|
||||
- 递归子对象
|
||||
|
||||
这种分层的好处是职责清晰:
|
||||
这样拆的好处是职责清晰:
|
||||
|
||||
- `GameObject` 负责“我是谁,我的基础状态是什么”
|
||||
- `Scene` 负责“我如何把整棵对象树持久化”
|
||||
- 对象级接口关注“这个对象自己是什么状态”
|
||||
- 场景级接口关注“整棵树如何保存与恢复”
|
||||
|
||||
这和商业引擎里把对象局部状态和场景容器职责拆开的思路是一致的。
|
||||
这和商业引擎里把对象局部状态与场景容器边界拆开的思路是一致的。
|
||||
|
||||
但当前加载路径还有一个必须知道的现实差异:`Scene::DeserializeFromString()` 在恢复对象和组件后,没有自动重放 `Awake()`。所以“新建场景对象”和“从场景文本加载出来的对象”在初始化阶段并不完全对称。
|
||||
## 托管脚本为什么能立刻影响原生查询
|
||||
|
||||
## `ComponentFactoryRegistry` 在这里的作用
|
||||
当前 Mono 运行时已经把这些接口桥接到了同一份 native 数据:
|
||||
|
||||
反序列化场景时,文本里只有组件类型名,例如:
|
||||
- `GameObject.tag`
|
||||
- `GameObject.layer`
|
||||
- `Component.tag`
|
||||
- `Component.layer`
|
||||
- `CompareTag()`
|
||||
|
||||
```text
|
||||
component=Camera;projection=0;fov=60;...
|
||||
```
|
||||
这意味着:
|
||||
|
||||
引擎需要把 `"Camera"` 重新变成具体组件实例。当前实现就是通过 `ComponentFactoryRegistry` 完成这件事。
|
||||
- 脚本读到的不是副本
|
||||
- 脚本写回后,原生对象字段会立即变化
|
||||
- 场景查询和全局查询都会立刻看到新值
|
||||
|
||||
它当前预注册了:
|
||||
这也是为什么 `tests/Scripting/test_mono_script_runtime.cpp` 中,脚本把对象 tag 改成 `"Player"` 之后,原生 `Scene::FindGameObjectWithTag("Player")` 会马上返回同一个对象。
|
||||
|
||||
- `Camera`
|
||||
- `Light`
|
||||
- `AudioSource`
|
||||
- `AudioListener`
|
||||
- `MeshFilter`
|
||||
- `MeshRenderer`
|
||||
- `ScriptComponent`
|
||||
## 当前你必须知道的限制
|
||||
|
||||
注意 `Transform` 不在里面,因为 `TransformComponent` 不是靠工厂反序列化创建的,它是 `GameObject` 的内建成员。
|
||||
### 1. tag / layer 已经可用,但仍是轻量模型
|
||||
|
||||
## 你现在必须知道的限制
|
||||
不要再把它理解成“只是名字别名”,但也不要把它理解成已经具备完整项目配置、索引和重命名迁移能力的 TagManager。
|
||||
|
||||
### 1. `FindGameObjectsWithTag()` 现在是真实 tag 查询,但仍是轻量实现
|
||||
### 2. 静态全局查询只对已注册对象成立
|
||||
|
||||
当前实现已经有独立 `m_tag` 字段:
|
||||
裸构造对象默认不会出现在:
|
||||
|
||||
- 默认是 `"Untagged"`
|
||||
- `SetTag("")` 会回退到 `"Untagged"`
|
||||
- `FindGameObjectsWithTag()` / `Scene::FindGameObjectWithTag()` 都通过 `CompareTag()` 查询
|
||||
- `GameObject::Find()`
|
||||
- `GameObject::FindObjectsOfType()`
|
||||
- `GameObject::FindGameObjectsWithTag()`
|
||||
|
||||
但它仍不是完整 TagManager:
|
||||
### 3. 独立对象的 `Destroy()` 不会释放对象自身
|
||||
|
||||
- 没有项目级 tag 定义表
|
||||
- 没有索引或缓存
|
||||
- 查询仍是线性扫描
|
||||
它只发送 `OnDestroy()`。真正的内存释放仍由对象所有者负责。
|
||||
|
||||
### 2. `TransformComponent::Find()` 不是局部子树查找
|
||||
### 4. 反序列化路径不补发生命周期
|
||||
|
||||
它会从整个场景的 root objects 开始搜索第一个名字匹配项。
|
||||
`Scene::DeserializeFromString()` 目前是“忠实恢复内存状态”,不是“模拟一次完整运行时创建流程”。
|
||||
|
||||
### 3. `TransformComponent` 的 sibling API 还不完整
|
||||
### 5. `TransformComponent` 不参与普通组件遍历
|
||||
|
||||
`SetSiblingIndex()`、`SetAsFirstSibling()`、`SetAsLastSibling()` 目前只修改索引字段,没有真正重排父节点子列表。
|
||||
|
||||
### 4. 音频组件当前不参与完整序列化
|
||||
|
||||
`AudioSourceComponent` 和 `AudioListenerComponent` 没有自定义 `Serialize()` / `Deserialize()`,所以场景保存/加载不会持久化它们的运行时参数。
|
||||
|
||||
### 5. 独立对象的 `Destroy()` 不会删除对象本身
|
||||
|
||||
它只调用 `OnDestroy()`。如果对象不是 `Scene` 持有的,内存释放仍然要由真正的所有者负责。
|
||||
|
||||
### 6. 反序列化路径当前不会自动重放 `Awake()`
|
||||
|
||||
如果某些组件把关键初始化逻辑写在 `Awake()`,那么从场景文本恢复后的状态可能和新建对象不同。
|
||||
无论是生命周期还是普通组件列表逻辑,都不要把它当成 `m_components` 里的普通成员来理解。
|
||||
|
||||
## 推荐工作流
|
||||
|
||||
### 场景内对象
|
||||
### 运行时对象
|
||||
|
||||
1. 用 `Scene::CreateGameObject()` 创建对象。
|
||||
2. 用 `GameObject::SetParent()` 组织层级。
|
||||
3. 用 `AddComponent<T>()` 挂功能组件。
|
||||
4. 用 `Scene::Update()` / `FixedUpdate()` / `LateUpdate()` 驱动生命周期。
|
||||
5. 用 `Scene::SerializeToString()` / `DeserializeFromString()` 保存和恢复。
|
||||
2. 用 `SetParent()` 组织层级。
|
||||
3. 用 `AddComponent<T>()` 挂普通组件。
|
||||
4. 用 `SetTag()` / `CompareTag()` / `SetLayer()` 管理轻量元数据。
|
||||
5. 用 `Scene::SerializeToString()` / `DeserializeFromString()` 做整棵树持久化。
|
||||
|
||||
### 非场景测试对象
|
||||
### 测试或临时对象
|
||||
|
||||
1. 直接构造 `GameObject`。
|
||||
2. 只依赖本地组件访问和 `Transform` 操作。
|
||||
3. 不要假定静态查找、场景序列化或自动生命周期已经可用。
|
||||
1. 可以直接构造 `GameObject`。
|
||||
2. 只依赖本地 `Transform` 与组件操作。
|
||||
3. 不要假设全局查询、场景序列化和自动生命周期已经可用。
|
||||
|
||||
## 和 Unity 的关系应该怎么理解
|
||||
|
||||
最合理的理解方式是:
|
||||
比较准确的表述是:
|
||||
|
||||
- 它在对象模型上明显借鉴了 Unity。
|
||||
- 它提供了足够熟悉的 `GameObject + Transform + Component` 心智模型。
|
||||
- 但当前实现仍然比 Unity 轻得多,特别是在 tag/layer 管理、运行时加组件生命周期、完整组件序列化和层级顺序控制上。
|
||||
- 它已经提供了足够熟悉的 `GameObject + Transform + Component + tag + layer` 心智模型。
|
||||
- 但当前实现仍然比 Unity 更轻,特别是在 tag/layer 管理、运行时加组件生命周期、完整组件序列化和项目级对象分类工具方面。
|
||||
|
||||
把它理解成“Unity 风格、但当前版本能力更收敛的对象系统”是比较准确的。
|
||||
把它理解成“Unity 风格、但当前阶段更收敛的商业引擎对象系统”是比较准确的。
|
||||
|
||||
## 从这里继续读什么
|
||||
|
||||
@@ -275,4 +275,4 @@ component=Camera;projection=0;fov=60;...
|
||||
- [GameObject](../../XCEngine/Components/GameObject/GameObject.md)
|
||||
- [Component](../../XCEngine/Components/Component/Component.md)
|
||||
- [TransformComponent](../../XCEngine/Components/TransformComponent/TransformComponent.md)
|
||||
- [ComponentFactoryRegistry](../../XCEngine/Components/ComponentFactoryRegistry/ComponentFactoryRegistry.md)
|
||||
- [Scene](../../XCEngine/Scene/Scene/Scene.md)
|
||||
|
||||
Reference in New Issue
Block a user