对象身份:ObjectId 不是跨 VM 的全局身份

自动关联目录:对象身份:ObjectId 不是跨 VM 的全局身份

核心判断:整数 ObjectId 适合在一棵已装载对象树中快速寻址,但不能单独证明对象属于哪个项目、关卡、VM、World 和生命周期。跨这些边界的引用必须携带稳定身份与作用域。

一、业务问题:同一个数字可能指向不同对象

编辑器、游戏客户端和 DS 都需要引用同一个内容对象。与此同时,撤销删除会重新反序列化对象,试玩会创建新 VM,切关会替换对象树,一个进程还可能短暂持有多个 World。此时裸整数只有在“调用方与被调方已经共享上下文”时才安全。

早期实践把类型、创建端和场景序号压进一个 32 位整数。优点很直接:传输紧凑、比较便宜、无需查 GUID;但位宽同时固化了数量上限和业务分类,后来增加创建来源、跨端同步或多世界并存时,身份协议就会被迫理解历史位布局。

二、实际演进:工程已经在补作用域

后续代码中可以看到三类补救:

  • 对象引用跨 VM 时优先使用稳定字符串身份,再解析为目标 VM 的本地 ObjectId;
  • 异步动画绑定除了对象号,还捕获 World 和域身份,避免迟到回调命中另一个 World 中的同号 Actor;
  • 结构撤销反序列化后维护“记录 ID -> 当前存活 ID”的 lineage,因为重建对象不保证沿用旧整数。

这些处理说明:ObjectId 实际是局部句柄,而不是全局主键。问题在于大量接口仍只暴露 int32,作用域靠调用约定隐含传递,漏掉一次校验就可能跨域写错对象。

三、推荐模型:把身份拆成四层

struct ObjectRef {
    Guid contentId;       // 可保存、可迁移的逻辑身份
    Guid levelId;         // 内容归属
    Guid runtimeDomain;   // VM / World / PlaySession
    uint32 incarnation;   // 本域内重建代次
    int32 localId;        // 仅用于快速查表
};

Resolve(ref, context):
    require(ref.runtimeDomain == context.domain)
    object = context.byContentId[ref.contentId]
    require(object.incarnation == ref.incarnation)
    return object

保存数据通常只需要 contentId + levelId;运行时查找再派生 localId。网络协议、异步回调和跨 World 服务必须带 runtimeDomain + incarnation。实例化渲染、物理和 Actor 句柄还应各自有代次,不能反过来充当内容身份。

四、取舍与验证

GUID 占用更大,也需要索引;因此本地热路径仍可使用整数,但入口必须先由强引用解析,缓存也要绑定域和代次。测试应覆盖两个 World 出现同号对象、删除后撤销重建、切关后旧回调到达、客户端与 DS 本地编号不同,以及存档迁移后引用仍能解析。

这次复盘最重要的升级是:不要再问“这个 ID 是否唯一”,而要问“它在什么作用域、什么时间范围内唯一,以及越过边界时由谁重新证明身份”。