PlayableGraph 重建失败时,旧姿态为什么必须继续可见

角色从探索状态切入战斗,新的动画图已经创建,Animator 输出也完成绑定,画面却在切换瞬间冻结;另一种情况更隐蔽:较早发起的图重建稍后完成,把刚启用的新战斗图覆盖回旧配置。节点和连线都合法,错误发生在“图已经构造”与“图可以接管可见姿态”被当成了同一件事。

活动动画图与隔离候选图之间的提交门

这个问题不能只靠构建顺序处理。PlayableGraph 是一组共同生效的对象:可播放节点、输出绑定、首帧求值结果,以及发起本次重建的玩法请求。只要其中一项仍属于半成品,先销毁活动图再逐项接线,就会让 Animator 暂时没有可信输入;若异步加载允许并发完成,构建完整也不代表结果仍然新鲜。

可见姿态只由活动快照拥有

探针把边界压缩为两个对象。GraphCandidate 在隔离区持有候选名称、请求代次、输出集合与预期首帧姿态;PlayableGraphOwner 唯一持有当前公开的 Active 以及最新请求号。候选可以失败并被释放,但在提交点之前不能修改 Active

核心实现位于 $WORK_DIR/PlayableGraphSwap.csPlayableGraphOwner.TryCommit()。这里要求 AnimatorGameplayEvents 两项输出同时存在。前者驱动可见骨骼,后者代表动画标记到玩法事件桥;只绑定 Animator 虽然可能产生姿态,事件合同却是不完整的,仍不能发布。

public SwapStatus TryCommit(GraphCandidate candidate)
{
    if (candidate.RequestId != _latestRequestId)
    {
        candidate.Dispose();
        return SwapStatus.StaleRequest;
    }

    if (RequiredOutputs.Any(output => !candidate.BoundOutputs.Contains(output)))
    {
        candidate.Dispose();
        return SwapStatus.MissingOutput;
    }

    if (!candidate.EvaluationSucceeds)
    {
        candidate.Dispose();
        return SwapStatus.EvaluationFailed;
    }

    Active = candidate.Freeze();
    candidate.Dispose();
    return SwapStatus.Committed;
}

顺序有实际含义。代次先挡住已经失去资格的旧工作,避免继续为它执行绑定与求值;完整性检查保证输出集合是一个整体;首帧求值则把“结构可连接”推进到“结果可消费”。只有三道检查都通过,冻结后的候选才成为新的活动快照。

这也明确了资源回收的方向。失败候选从未获得公开所有权,可以立即统一释放;活动图仍被 Animator 和事件桥读取,不能因为新请求开始就提前销毁。成功提交时应先把完整候选提升为活动对象,再让旧图退出读取集合,最后释放旧资源。若引擎接入层需要把这些动作拆到多个 API 调用中,宿主仍要把中间状态包在不可被 Update 读取的临界区内,否则所谓“整体替换”只存在于业务类型上,并没有落实到引擎生命周期。

候选图通过代次、输出和求值门禁后整体替换活动图

首帧求值是提交条件,不是提交后的补救

如果先把候选设为活动图,再调用一次 Evaluate(0) 试运行,失败时就必须反向恢复所有输出、时间和事件游标。恢复逻辑稍有遗漏,渲染看到旧姿态,事件桥却可能已经消费新图标记。隔离求值把问题改成单向选择:旧图始终可见,候选只有成功或丢弃两种结果。

固定样本从 LocomotionV7[email protected] 开始。第一个 CombatV8 只绑定 Animator,返回 MissingOutput;第二个绑定齐全但模拟首帧求值失败,返回 EvaluationFailed。随后请求 3 的完整候选迟到,此时最新请求已是 4,因此返回 StaleRequest。三个失败分支都断言活动图仍为 LocomotionV7。请求 4 的 CombatV9 绑定两项输出并通过求值后,才把姿态切换为 [email protected]

回归入口是同目录 Program.cs。决定性检查没有只比较状态码,还同时确认失败候选被释放、活动名称和姿态未变、成功快照保留了完整输出集合:

var staleStatus = owner.TryCommit(staleCandidate);
Check("stale-request-rejected", staleStatus == SwapStatus.StaleRequest);
Check("stale-request-keeps-active", owner.Active.Name == "LocomotionV7");

var committedStatus = owner.TryCommit(currentCandidate);
Check("current-request-committed", committedStatus == SwapStatus.Committed);
Check("active-graph-swapped",
    owner.Active.Name == "CombatV9" && owner.Active.RequestId == currentRequest);

当天以 Release 配置重新构建和运行,命令中的工作目录用 $WORK_DIR 表示:

dotnet build $WORK_DIR/PlayableGraphSwapProbe.csproj -c Release --nologo
dotnet run --project $WORK_DIR/PlayableGraphSwapProbe.csproj -c Release --no-build
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:05.63
missing-output: candidate=CombatV8 status=MissingOutput active=LocomotionV7
evaluation-failure: candidate=CombatV8 status=EvaluationFailed active=LocomotionV7
stale-completion: request=3 latest=4 status=StaleRequest active=LocomotionV7
commit: request=4 active=CombatV9 [email protected] outputs=Animator,GameplayEvents
tests: pass=10 fail=0 skip=0 duration_ms=1

进程总耗时为 1.333 秒,这只记录本次探针执行,不构成 Unity 性能结论。这里能够证明的是状态转换:三类失败没有改变公开快照,完整且最新的候选发生一次替换。

接入 Unity 时,提交点仍应留在宿主

真实 Unity 代码可以把 GraphCandidate 换成包含 PlayableGraphAnimationPlayableOutput 和事件输出桥的资源对象,并在主线程执行最终绑定与销毁。这个探针没有覆盖多 Animator、Timeline 混合、音频输出,也没有测量大图构建成本;这些能力会扩展候选内容,却不改变提交合同。

如果业务允许同一角色连续换装、切战斗姿态或加载 DLC 动画包,请求代次必须由拥有当前角色动画配置的宿主分配,不能藏进某个节点。节点只知道自己是否构建完成,不知道玩家是否已发起更新的配置。旧图的销毁也应发生在新快照取得所有权之后,而不是在开始重建时。

代次相等也不能替代取消。取消可以减少已经无用的加载和构建工作,代次校验负责处理无法及时取消、已经进入完成队列或与取消并发的结果。两者解决的是成本与正确性两个问题:即使所有加载器都支持取消,提交点仍需比较代次,因为完成回调可能已经排队;即使代次能拒绝旧结果,也不应放任昂贵候选继续占用资源。

开篇的冻结与回退覆盖因此有同一个修复方向:让半成品始终处于不可见候选区。当前方案解决的是图级切换的一致性,不保证两个完整姿态之间自动平滑;跨图混合若是需求,应作为候选中可验证的过渡图进入同一提交门,而不是绕过它直接改写活动输出。