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");
失败语义
重复键并非“后写覆盖前写”,因为那会让调用顺序成为隐藏协议;非有限数也不能在表现层钳制,因为钳制会掩盖玩法计算错误。探针分别提交重复 Speed 与 NaN 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;不能以允许半更新换取表面上的少一次复制。