Animator 过渡参数的原子提交:候选快照不能逐项生效

角色移动系统在同一逻辑帧内会同时改变速度、落地状态与转向量。若表现层按调用顺序直接写入 Animator,第三个参数失败时,前两个参数已经生效,过渡条件看到的便不是任何一个完整玩法状态。2026-07-23-1930 探针把这一问题收窄为候选快照与原子提交:玩法侧拥有参数计算,提交器拥有可见快照,Animator 只消费已提交结果。

// .tmp/daily-labs/2026-07-23-1930/TransitionParameters.cs
namespace AnimatorProbe;

public readonly record struct ParameterDelta(string Name, float Value);

public sealed class TransitionParameters
{
    private readonly Dictionary<string, float> committed = new();

    public IReadOnlyDictionary<string, float> Committed => committed;

    public bool TryCommit(IEnumerable<ParameterDelta> candidate, out string error)
    {
        var snapshot = candidate.ToArray();
        if (snapshot.Select(x => x.Name).Distinct().Count() != snapshot.Length)
        {
            error = "duplicate_parameter";
            return false;
        }

        if (snapshot.Any(x => !float.IsFinite(x.Value)))
        {
            error = "non_finite_value";
            return false;
        }

        foreach (var delta in snapshot)
            committed[delta.Name] = delta.Value;

        error = "none";
        return true;
    }
}

动画参数提交主视觉

所有权边界

ParameterDelta 是帧内候选,不是已生效状态。TransitionParameters 在开始写入字典前先物化枚举,因而校验期间调用者不能通过延迟枚举改变输入。两个不变量由此成立:候选键必须唯一,全部值必须为有限数;任一条件失败,Committed 的键、值和数量都保持不变。这个边界也避免 Animator 回调反向修改玩法速度。

调用链刻意不读取 Animator 当前值。表现层可能因混合、层权重或状态机内部处理而持有派生值,那些值不应成为玩法快照的回滚来源。提交器只保留上一份已验证字典,失败时继续暴露它,错误可记录但不改变角色逻辑状态。

正常时序
GameplayState -> Candidate[] : Speed, Grounded, Turn
Candidate[] -> CommitGate    : materialize snapshot
CommitGate -> CommitGate     : validate all fields
CommitGate -> AnimatorView   : publish complete state

失败时序
GameplayState -> Candidate[] : duplicate Speed or NaN Turn
Candidate[] -> CommitGate    : validate all fields
CommitGate -> GameplayState  : reject(error)
CommitGate -> AnimatorView   : no write

提交算法

原子性来自“先验证、后写入”的单一分界,而不是异常补偿。对 n 个参数,当前实现需要一次数组物化、一次唯一性检查和一次有限值检查,空间与时间均为 O(n)。Animator 常用参数数量较小,这比维护逐项回滚日志更直接;若未来参数集合进入数百规模,应先用稳定参数 ID 替代字符串,而不是削弱全量校验。

// .tmp/daily-labs/2026-07-23-1930/Program.cs
var state = new TransitionParameters();
var passed = 0;

void Check(bool condition, string name)
{
    if (!condition) throw new InvalidOperationException(name);
    passed++;
}

Check(state.TryCommit([
    new("Speed", 4.25f),
    new("Grounded", 1f),
    new("Turn", -0.5f)
], out var accepted), "valid_candidate");
Check(accepted == "none" && state.Committed.Count == 3,
    "complete_commit");

var before = JsonSerializer.Serialize(state.Committed);
Check(!state.TryCommit([
    new("Speed", 8f),
    new("Speed", 9f)
], out var duplicate), "reject_duplicate");
Check(duplicate == "duplicate_parameter" &&
      JsonSerializer.Serialize(state.Committed) == before,
    "duplicate_atomicity");

Check(!state.TryCommit([new("Turn", float.NaN)], out var nonFinite),
    "reject_non_finite");
Check(nonFinite == "non_finite_value" &&
      state.Committed["Turn"] == -0.5f,
    "non_finite_atomicity");

失败语义

重复键并非“后写覆盖前写”,因为那会让调用顺序成为隐藏协议;非有限数也不能在表现层钳制,因为钳制会掩盖玩法计算错误。探针分别提交重复 SpeedNaN Turn,两次都返回明确错误,并以序列化前后快照相等、旧 Turn 仍为 -0.5 证明没有半提交。恢复路径是修正下一帧候选后重试,旧快照继续驱动表现。

{
  "slot": "2026-07-23-1930",
  "tests": { "passed": 6, "failed": 0, "skipped": 0 },
  "duration_ms": 18.585,
  "committed": {
    "Speed": 4.25,
    "Grounded": 1,
    "Turn": -0.5
  },
  "failures": ["duplicate_parameter", "non_finite_value"]
}

动画参数原子提交技术图

运行证据

固定输入由三个合法参数、一个重复键候选和一个非有限值候选组成。Release 配置完成六个断言,无失败与跳过;进程总耗时包含构建启动,因此仅作为本次可复现记录,不据此宣称运行时性能。技术图中的三项提交、两类拒绝与状态数量均直接来自该输出。

cd "$WORK_DIR"
/usr/bin/time -p dotnet run \
  --project .tmp/daily-labs/2026-07-23-1930/AnimatorProbe.csproj \
  -c Release

# exit code: 0
# tests: passed=6 failed=0 skipped=0
# process timing:
real 1.71
user 0.87
sys 0.19

取舍边界

该实现选择每次复制候选数组,换取输入冻结和简单错误语义。看似更省分配的逐项 SetFloat 会破坏原子性;发生错误后逐项回滚又需要读取旧值,并扩大 Animator 与玩法状态的耦合。当前边界只保证单线程提交器内的一致性,不处理多线程并发;当多个 PlayableGraph 或动画层需要共享参数时,应演进为带单调版本的不可变快照,并在主线程一次交换引用。

另一个边界是参数缺失:探针只校验给定候选,不声明每一帧必须包含全集。若项目采用全量快照,应在提交器加入必需键集合;若采用稀疏增量,则应把本章原子性限定为“本批候选”,并由上层决定未出现键的继承语义。

容量条件: 参数数 n,校验与提交 O(n),临时候选 O(n)
当前失效信号: duplicate_parameter / non_finite_value
演进触发: 多生产者、跨线程、跨动画层共享同一参数集合
后续接口: TryCommit(IEnumerable<ParameterDelta>, out string)

架构结论

Animator 参数不是一串无关 setter,而是一帧玩法状态在表现边界上的快照。候选先冻结、全量验证、再一次提交,才能让正常过渡与失败恢复共享同一个不变量。若系统需要降低分配,应优化候选容器或参数 ID;不能以允许半更新换取表面上的少一次复制。