引擎世界与对象模型
全库目录221
本文目录10
游戏引擎的各个模块要围绕同一套数据契约工作:平台、资源、运行时功能和制作工具都受它约束。分层的价值在于让依赖方向、对象所有权和更新时机可以被解释,目录整齐只是顺带结果。
Entity、Component 与世界
Entity 通常只提供稳定身份。Component 保存某一方面的数据,System 按组件组合执行逻辑。一个对象由多个组件组合出来,比深继承树更容易替换功能。
运行时引用更适合使用带代数或版本号的 Handle,而不是长期保存裸指针。对象销毁后,Handle 校验可以拒绝访问已经复用的槽位。世界管理器负责对象注册、场景归属、空间索引和销毁队列;组件不能假设依赖对象永远存在。
场景不是单纯的对象数组。大型世界还需要 Cell、Layer 或 Chunk 等空间单元,用于流式加载、可见性、物理激活和编辑协作。跨区引用要么通过软引用和 ID 延迟解析,要么由更高层的持久对象持有。
Transform 层级和 Scene Graph
Transform 层级表达父子坐标关系。子节点的世界矩阵由父节点世界矩阵和自身局部矩阵组成:
例如父节点沿X平移10,子节点局部沿X平移2,子世界位置就是12。父节点改成20以后,即使子局部没有变化,子世界位置也应变成22。因此只给父节点标脏却继续使用子节点缓存,会把渲染与碰撞留在旧位置。根节点没有父节点时,世界矩阵等于局部矩阵。更新必须按父节点在前的顺序进行:
以下C++机制片段采用列向量、父矩阵左乘约定,输入局部矩阵和包围盒,输出世界矩阵及包围盒。GetNode负责句柄解析,包围盒变换函数需把局部包围范围保守变换到世界空间;完整类型与失效处理由世界系统提供。
void UpdateWorldTransform(Node& node, const Matrix4x4& parentWorld)
{
node.world = parentWorld * node.local;
node.worldBounds = TransformBounds(node.localBounds, node.world);
for (NodeHandle child : node.children)
UpdateWorldTransform(GetNode(child), node.world);
}实际引擎通常使用 Dirty Flag。局部变换变化时,当前节点及其后代的世界变换失效;读取或更新阶段再按拓扑顺序重算。层级很深时递归可能造成栈和缓存问题,可以保存扁平拓扑数组并批量更新。
Reparent 需要明确是否保持世界变换。保持世界变换时,新局部矩阵为:
仍用上述子世界位置12,若新父节点只平移5,保持世界位置要求新的局部平移为7;若直接沿用原局部2,世界位置会跳到7。换父节点以前先确认新父矩阵可逆,并禁止形成环。若组件只保存 TRS,非均匀缩放叠加旋转可能产生无法精确表示的剪切,需要规定分解误差或使用矩阵表示。父节点销毁也要明确解除挂接还是递归销毁。Scene Graph 表达语义与 Transform 层级,空间查询应使用 BVH、Octree、Grid 等独立索引。一个父节点的子对象可以分布在世界不同区域,层级本身不适合直接承担可见性剔除。
身份复用与对象失效
对象槽位可以复用,身份不能仅等于数组下标。令句柄为{index, generation},槽位保存当前代数。查询时同时比较下标、存活标记和代数,只有三者吻合才返回对象。例如旧句柄为{3,7},销毁后槽位3的代数成为8,新对象取得{3,8}。旧句柄的下标仍在范围内,却因7不等于8而被拒绝;只做范围检查会把新对象误认为旧对象。销毁后递增代数,再把下标放入空闲队列,旧句柄就无法指向新对象。
resolve(handle):
if handle.index >= slots.count: return invalid
slot = slots[handle.index]
if not slot.alive or slot.generation != handle.generation: return invalid
return slot.object这是单线程或世界写入阶段的伪代码。多线程查询还需要防止“校验通过后恰好销毁”,例如读取快照、读者保护或延迟回收。代数只区分身份,不提供内存生命周期保证;有限位宽回绕时也要保证仍存活的旧句柄不可能撞上当前身份。
层级引用、空间索引和任务包都应保存同一套身份约定。对象销毁时先从可查询集合注销,再让正在消费的快照退役,最后回收存储。场景切换因此不仅是清空对象数组,还要结束这些外部引用的可见期。
反射与代码生成
在常见C++17/20引擎工程中,序列化、编辑器和脚本绑定所需的运行时反射通常由项目建立,常使用 Clang AST 或自定义 Header Tool 扫描标记类型,生成 Schema、注册表、Accessor 和模板代码。生成链需要固定编译参数、宏环境和模块顺序,否则工具看到的类型与编译器实际类型不一致。
例如Transform组件登记字段ID 17、类型float32、名称speed,序列化器根据登记表写入{fieldId:17,type:float32,value:2.5},编辑器根据同一登记表生成数值框,修改时通过Accessor完成范围校验和脏标记。两条路径因此共享字段含义。若源字段改成float64而工具仍按float32解析,后续字段的读取位置就可能错位;生成器必须验证类型版本,加载器按版本迁移或明确拒绝。字段重命名若保留稳定ID,可以保持旧资产对应;直接把显示名称当唯一键则需要处理重命名迁移。
Schema保存字段名、类型、版本和属性;Accessor统一属性读写、校验与通知;模板生成序列化、编辑器面板或脚本桥接代码。生成文件应是可重复构建产物,源类型变化要精确触发增量更新,并检测重名 ID、循环依赖和陈旧注册信息。
Tick与发布阶段
对象拥有稳定身份以后,还要让各系统看到一致的更新。以角色跳跃为例:输入先产生Jump命令,Gameplay消费许可并给出起跳意图,物理更新速度与位置,动画读取移动状态,最终渲染快照记录骨骼与世界矩阵。若渲染快照在物理之前发布,画面可以合法地消费上一版本,却不能声称它代表本步物理结果。
延迟销毁同样放在明确边界。伤害处理把目标标记为待销毁后,当前遍历先结束,再从世界查询集合注销;此前发布的渲染快照继续按其所有权使用。直接在回调里删除数组元素,可能让正在迭代的物理或AI跳过下一项。事件立即派发与排队派发因此分别决定状态在哪个阶段可见。
固定步累计器可在一次显示帧内执行零次或多次逻辑步骤,渲染读取最近已发布快照并插值。追赶次数需要预算,不能让一次长帧生成无限补步。输入按逻辑步消费,显示时间不直接参与权威积分,具体循环见 刚体物理求解。
线程与快照
逻辑线程拥有世界状态的写权限,并在同步点生成只读渲染快照,渲染线程消费已经发布的版本。双缓冲或环形缓冲为生产与消费保留独立存储,代价是额外内存。是否增加跨帧延迟取决于快照发布和消费时序:消费上一逻辑帧时会滞后一帧,同帧发布后立即消费则需要安排相应同步。缓冲数量本身不能决定延迟。
并行化前先确认数据所有权。只把 Tick 函数丢进线程池,通常会把隐式依赖变成数据竞争。系统级阶段、任务依赖和只读数据边界比“多开线程”更重要。
模块边界
平台层封装窗口、文件、线程与图形API,世界通过稳定服务获取这些能力;资源层提供已加载句柄,渲染、物理与动画消费世界快照或各自受控数据。下层不直接反查某个玩法单例,退出时才有明确的停止与释放方向。工具编辑的是同一份世界契约,但编辑动作按事务提交,不能依赖游戏正在运行才补齐状态。
RHI把资源、命令和同步映射到具体图形后端。抽象仍需表达队列与完成条件,否则世界认为渲染工作已提交就可以销毁资源,后端只能用额外等待补洞。跨系统契约应保留真实使用期间,不能仅靠模块目录分层表达所有权。
验证
可以画一张对象生命周期图,标出创建、注册、激活、更新、停用和销毁。再检查每条跨系统引用由谁拥有、何时失效。场景切换、热重载和退出流程是最容易暴露所有权错误的测试。
相关主题
参考资料
- Unity 6000.0 Transform.SetParent,
worldPositionStays的语义;2026-10-22核读。本文矩阵关系为列向量约定下的独立推导。 - 对象身份与回收的通用机制见对象生命周期。本文不是某个引擎内部结构的逐行复刻,完整世界系统编译与运行状态尚未验证。