角色重建:Actor 是可替换的表示,不是角色身份

自动关联目录:角色重建:Actor 是可替换的表示,不是角色身份

核心判断:角色身份、玩法状态和动画意图必须活在可重建的 UE 表现之上。ACharacter、身体 Actor、USkeletalMeshComponent、骨架、挂件和 UAnimInstance 都可能晚到、替换或销毁;只有以稳定身份和 Generation 驱动幂等装配,角色才能经受复制、Clone、流式进出和换装而不丢状态。

一、业务冲突:角色已经存在,不代表角色已经装好

可配置角色不是一个一次性 Spawn 完成的 Actor。逻辑角色可能先由场景或脚本创建,网络复制的 ACharacter 后到;根节点、身体网格、Skeleton、默认挂点、外观预制体和动画资源又分别异步到达。Clone 会重放一遍属性,流式系统会让表现对象进出,脚本回调还可能在状态 Tick 中直接销毁角色。业务要求却很简单:角色身份不能变,位置与玩法状态不能丢,远端最终要看到正确身体、挂件和当前动画。

工程里出现过的故障都指向同一条错误边界:

  • 场景快照先于复制 Actor 到达,身体的结构挂接因“Character 不存在”提前退出;没有后续重试时,远端长期白模,材质槽也无法落到正确组件。
  • Character 已绑定,但身体兄弟节点的身份稍后才登记;动画虽然能推进,动画线程完成后却无法沿附着链找到所属角色,首个有效姿势一直不能成为 Ready 事实。
  • 身体已经挂到角色上,流式系统仍把它当普通场景网格卸载,逻辑角色和移动仍在,身体 Actor 却被销毁。
  • Clone 或属性快照重复应用相同 Mesh/Skeleton 时,若仍重走 SetSkeletalMesh 和 SetAnimInstanceClass,UE 会重建动画实例,正在播放的 Track、时间和混合状态一起丢失。
  • 状态机 Tick 重入脚本并销毁角色后,先前缓存的逻辑对象裸指针立即悬垂;继续遍历子状态或挂件索引会访问已释放内存。

这些不是五个独立补丁问题,而是同一个架构问题:把“当前有一个 UE 对象”误当成“角色本身仍然存在”。

二、实际实现:从单体 Character 演进为逻辑对象与 UE 宿主装配

后续实现已经形成了比较清晰的分层。逻辑角色保存生命、移动参数、状态机和子对象身份,通过抽象 Host 接口连接 UE;ACharacter 负责 Capsule、CharacterMovement、网络角色和引擎 Tick,不缓存逻辑对象裸指针,而是用本进程对象 ID 做带类型的注册表查询。跨端绑定另用稳定身份,因为每个进程自行分配的对象 ID 不能充当网络身份。

根节点也没有再生成一份独立 Actor,而是作为逻辑对象别名绑定到 Character 的 Capsule。角色位置、旋转、碰撞和触碰由 Capsule 与 Movement 驱动;身体则是单独的网格 Actor,运行时挂到 Character 的占位 Mesh 下,关闭自身碰撞,避免身体网格与 Capsule 双重阻挡。身体可以复用通用网格加载、材质槽、骨骼树和流式链路,代价是必须明确它属于角色装配,不能按普通场景物处理。

flowchart LR
    I[稳定角色身份] --> G[玩法模型与状态机]
    G --> R[Root Transform / Capsule / Movement]
    R --> C[可替换 Character Host]
    C --> B[可替换身体 Actor]
    B --> M[Mesh + Skeleton]
    M --> A[Animation Host]
    M --> T[Attachment Anchor]
    T --> X[Accessory / 装备表现]

    I -.按稳定身份重找.-> C
    I -.按 Generation 重建.-> B
    G -.快照重放.-> A

实际装配顺序被逐步收敛为:先创建逻辑子树,再登记已复制或新生成的 Character Host,绑定逻辑角色,解析根节点和身体身份,完成纯结构挂接;骨骼网格真正就绪后,再建立动画运行时和骨骼挂点。Character 后到、身体 ID 后到、身体 Actor 后到、Mesh 后到都会进入同一个补绑入口。运行时 AttachToComponent 发生在组件注册以后,而 SetupAttachment 只用于构造期,避免动态组件“代码看似挂上、场景层级实际未生效”。

动画侧也补上了 UE 特有的替换语义。Mesh/Skeleton 组合已经完成且组件仍持有同一网格时,重复属性或重复回调直接幂等返回;真正换网格时才推进动画 Generation。因为 SetAnimInstanceClass 会替换当前 UAnimInstance,旧实例会先被强引用保活,再把正在播放的 Track 和运行时状态迁入新实例。资产回调同时校验弱 Actor、捕获的 Generation 和资产身份;过期结果直接丢弃。网格未就绪时,Play 命令和 Sequence 绑定分别暂存,恢复时先创建 Track,再绑定 Sequence。

挂点与外观没有简单等同为一个 Socket 指针。持久 Attachment 保存逻辑身份和局部变换,骨骼物化后按目标 MeshPart 做局部校准;晚到 Attachment 在复制批次结束时只重试所属身体,不能让任意场景网格完成一次加载就扫描全场。默认外观按稳定条目身份复用、创建或删除系统生成实例,同时保留手工添加的外观;目标骨骼不存在时保持明确 Pending,或按既定规则退回身体挂点,而不是悄悄挂到错误骨骼。

DS 表示则保留逻辑身份、属性、复制、根变换、碰撞、物理与玩法事件,并按能力裁掉显示组件。普通对象可以使用无显示载体;角色身体、骨骼求值或未审计的自定义能力走受控回退。这里的关键不是 IsDedicatedServer() 后到处 return,而是让逻辑语义不依赖 USkeletalMeshComponent 存在。

三、值得保留:把“可重复恢复”设计进绑定,而不是依赖幸运顺序

第一,稳定身份与瞬时指针分离。Actor 只保存逻辑对象 ID,需要使用时做带类型查询;任何可能重入脚本的调用之后都重新解析。它既防止对象销毁后的 UAF,也防止 ID 复用或错误类型命中后用 static_cast 读取错误内存布局。

第二,结构挂接与资源就绪分离。身体 Actor 可以在 Mesh 为空时先挂到 Character,等 SkeletalMesh 到达再建立动画运行时。这样“父子关系已成立”和“画面可用”是两个可观察状态,也允许流式保护在资产未完成时识别角色子树。

第三,重复应用是正常输入。复制 OnRep、Clone 快照、反序列化回填和异步完成都可能重放同一状态。当前实现对等价 Actor 绑定、相同 Mesh/Skeleton 组合和相同身体身份做幂等判定,只有实际变化才使动画失效,这是正确方向。

第四,运行域按能力裁剪。DS 不需要渲染,但不能因此丢掉碰撞、资源完成语义或脚本可观察属性。构造前选择无显示载体,并在后来获得骨骼能力时原位升级,比“先建完整组件再销毁”更省成本,也比到处判空更容易验证。

第五,挂件有所有权,不只是 Parent。系统生成外观与手工外观分开管理,重复应用只校准自己拥有的实例;晚到挂点按身体反向索引局部恢复。这同时解决了幂等、性能和“不误删创作者内容”三个问题。

四、结构性缺陷:补绑入口统一了,装配状态机仍然是隐式的

现有修复的共同模式是:在 Character 到达、身体身份登记、身体 Actor 创建、Mesh Ready 等入口各补一次 TryBind。它能修复已知到达顺序,但“还缺什么”仍由一串空指针和早退隐式表达。增加新的依赖,例如 Skeleton 元数据、外观描述或动画宿主后,开发者必须记住所有可能完成点;漏掉一个边,就会再次出现永久 Pending。

流式保护也暴露了边界泄漏。身体作为普通网格 Actor 复用了成熟管线,这是好处;但流式系统最终还要沿 live attachment chain 判断它是否属于角色,甚至在 Despawn 最后一道入口兜底。附着关系本来是表现结果,不应成为角色子对象是否可卸载的唯一事实。更稳定的输入应是逻辑所有权和 Presentation Lease,Actor 附着链只能做一致性校验。

身份仍有三层:持久身份、每进程逻辑对象 ID、UE Actor 网络身份。它们各有必要,但绑定代码容易把局部 ID 当跨端 ID,或把当前 Actor 当唯一宿主。应该把三者的作用域写进类型,而不是继续用相同整数或字符串在不同边界传递。

根变换也存在镜像成本。Capsule 是运行时物理真相,逻辑 Root 又要支持保存、编辑、查询和 Clone;晚物化时需要从最终 Character Pose 回填,否则会保留落地修正前的位置。若 Position、Actor Transform 和 Root 数据都允许独立写入,却没有单一方向的提交规则,就会产生循环同步或旧值覆盖。根节点不是“再存一份 Transform”,而应是同一变换真相在逻辑 API 上的投影。

最后,动画运行时状态目前主要寄存在身体 Actor 和 UAnimInstance。同一个 Actor 内换 Mesh 已能迁移 Track,但整个身体 Actor 因流式或重建被替换时,旧 Actor 上的 Pending 命令与虚拟时间仍可能一起消失。稳定的动画意图应属于角色 Presentation Session,AnimInstance 只是当前求值器。

五、目标设计:把角色装配做成带代际的提交事务

目标结构不要求取消 Actor 或 Component,而是明确谁能被替换:

  • CharacterModel:持久身份、玩法属性、状态机与系统/手工子对象所有权。
  • RootState:权威 Transform、速度、移动模式和碰撞配置;客户端由 Movement/网络修正提交,编辑与加载由命令提交。
  • AssemblySpec:Body Mesh、Skeleton、动画契约、挂点和外观条目,只描述想要什么。
  • PresentationSession:当前 World、Generation、候选表示、Pending 依赖、动画意图快照和提交状态。
  • HostHandle:TWeakObjectPtr 或可失效句柄,只缓存当前 Actor/Component,不承担身份。

重建必须先构造候选表示,验证完成后原子切换,而不是边加载边破坏当前可用表示:

RebuildResult RebuildCharacter(CharacterId id, AssemblySpec desired)
{
    CharacterModel& model = ResolveModel(id);
    Session& session = model.presentation;
    const Generation gen = session.Begin(desired); // 旧表现继续服务
    const RuntimeSnapshot snapshot = CaptureRuntimeIntent(model);

    RequestDependencies(desired, [id, gen, desired, snapshot](Assets assets) {
        CharacterModel* model = ResolveModel(id);
        if (!model || !model->presentation.Accepts(gen, desired) || !assets.Match(desired))
            return; // 已销毁、换 World、被新装配取代或返回错误制品

        Candidate next = BuildHiddenHost(model->root, assets.placeholder);
        BindRoot(next, model->root);                 // Capsule/Movement,不复制真相
        BindBodyAndSkeleton(next, assets);           // 验证 Mesh-Skeleton 组合
        CreateAnimationEvaluator(next, snapshot);    // 先 Track,后 Sequence,恢复虚拟时间
        ReconcileAttachments(next, model->OwnedBindings());

        if (!Validate(next) || !model->presentation.Accepts(gen, desired))
            return Destroy(next);

        model->presentation.Commit(gen, next);       // 一帧内切换可见表示
        DestroyPreviousAfterDetach();
    });

    return RebuildResult::Pending(gen);
}

同一 AssemblySpec 重复提交必须返回当前 Session,不推进 Generation;只有影响表示兼容性的字段变化才新建代际。挂件绑定保存 {角色身份, 条目身份, 来源所有权, 目标语义挂点, 局部偏移},每次骨架或身体重建后重新解析到当前 Bone/Attachment,不持久化 Socket 或组件指针。销毁先关闭 Session、取消需求并使 Generation 失效,再拆附着和销毁 Actor,保证晚到回调只能成为 Stale。

DS 使用同一 CharacterModel、RootState 和 AssemblySpec,但 Presentation Factory 返回 headless host:保留 Capsule、Movement、碰撞代理、必要骨骼拓扑或玩法查询数据,不创建材质、DMI、SceneProxy 和纯视觉动画求值。某项玩法真的依赖骨骼或动画事件时,应把它声明为 DS 能力需求并走显式回退,不能假装纯表现已经被裁掉。

六、决策规则:只要状态要跨重建存在,就不能只放在 Actor 上

角色系统可以用下面六条规则做设计审查:

  1. Actor 重建后仍要存在的状态,必须属于逻辑模型或可序列化运行快照。
  2. 跨异步边界的回调至少校验稳定身份、World、Generation 和目标资产身份。
  3. SetSkeletalMesh、SetAnimInstanceClass、组件重注册和 Actor 替换都视为运行时失效点,必须定义迁移或回退。
  4. Root Transform 在每个阶段只有一个写入权威;其它表示只能投影或提交命令。
  5. Attachment/Accessory 通过稳定关系重解,不能把 Bone Index、Socket 指针或组件地址当持久身份。
  6. DS 裁剪以可观察语义为边界;缺少显示组件不等于缺少角色,也不能把“未创建”伪装成“加载成功”。

验证不应只看“屏幕上出现了角色”,而要覆盖到达顺序和重建边界:

场景必须保持的断言主要观测量
逻辑角色先到、Actor 后到Host 到达后只补绑一次,状态与 Transform 不重置Pending 时长、重复提交数
Actor 先到、Body/Mesh 后到占位可用;结构挂接、Mesh、动画按序完成各阶段 Ready、空洞帧
相同 Mesh/Skeleton 重放不推进 Generation,不替换 AnimInstance,不丢 Track幂等命中、实例替换数
真正换装或换 Skeleton旧表示保持到候选可提交;旧回调被拒绝Stale 回调、切换空洞
Clone / Stream-out / Stream-in新 Actor 恢复身份、挂件所有权和动画相位状态丢失、误删手工外观
脚本回调中销毁角色回调后重新解析失败并停止后续访问UAF 防御、销毁后事件
DS 创建同一角色玩法、碰撞和复制一致,纯显示对象数为零或明确回退载体组成、回退原因

我的反思是:早期把 Character、Mesh 和 AnimInstance 组织到一起,能快速得到一个会动的角色;真正进入异步资产、多人复制、Clone、流式和 DS 后,问题已经从“如何 Spawn”变成“如何反复重建而不改变它是谁”。后续补上的稳定身份、类型化弱查询、补绑入口、动画 Generation、挂件局部校准和 DS 能力裁剪都是有效进步;还需要再向前一步,把分散的补绑条件收敛成显式装配状态机和提交事务。角色架构的成熟度,不由初次创建是否成功衡量,而由任意到达顺序和重建后能否恢复同一角色衡量。