骨骼任务都完成了,为什么角色仍会撕裂一帧
角色蒙皮偶尔撕裂一帧,最容易被归因于权重、逆绑定矩阵或 GPU 同步。可若下一帧自动恢复,资源和公式通常没有永久损坏。更值得检查的是 CPU 侧骨骼任务怎样把结果交给渲染:四个任务分别算完骨骼 0、1、2、3,并不代表渲染线程曾经看到一份属于同一姿势的四矩阵调色板。

逐骨骼写入会制造不存在的姿势
假设活动调色板仍是姿势修订 0,玩法线程已经请求修订 2。若每个 Job 完成时直接覆盖活动数组,渲染线程可能读到骨骼 0、1 来自修订 2,骨骼 2、3 仍来自修订 0。每个矩阵单独看都合法,组合起来却不是动画系统计算过的任何姿势。角色撕裂不是浮点误差,而是读到了跨修订的混合状态。
探针将活动数组收进 Program.cs::BonePaletteOwner。并行任务只产生以骨骼索引为键的候选结果;TryCommit 先验证数量与连续索引,再拒绝包含 NaN 或 Infinity 的矩阵,最后比较姿势修订。三道门禁全部通过前,活动快照保持只读。
sealed record PaletteSnapshot(int Revision, Matrix4x4[] Matrices);
sealed class BonePaletteOwner
{
private readonly int _boneCount;
private int _requestedRevision;
private PaletteSnapshot _active;
public BonePaletteOwner(Matrix4x4[] initial)
{
_boneCount = initial.Length;
_active = new PaletteSnapshot(0, initial.ToArray());
}
public int RequestPose() => ++_requestedRevision;
public CommitResult TryCommit(
int revision,
IReadOnlyDictionary<int, Matrix4x4> completed)
{
if (completed.Count != _boneCount ||
Enumerable.Range(0, _boneCount).Any(i => !completed.ContainsKey(i)))
return CommitResult.Incomplete;
var candidate = new Matrix4x4[_boneCount];
for (var bone = 0; bone < _boneCount; bone++)
{
var matrix = completed[bone];
if (!IsFinite(matrix))
return CommitResult.InvalidMatrix;
candidate[bone] = matrix;
}
if (revision != _requestedRevision)
return CommitResult.StaleRevision;
_active = new PaletteSnapshot(revision, candidate);
return CommitResult.Committed;
}
public PaletteSnapshot Snapshot() =>
new(_active.Revision, _active.Matrices.ToArray());
private static bool IsFinite(Matrix4x4 matrix) =>
new[]
{
matrix.M11, matrix.M12, matrix.M13, matrix.M14,
matrix.M21, matrix.M22, matrix.M23, matrix.M24,
matrix.M31, matrix.M32, matrix.M33, matrix.M34,
matrix.M41, matrix.M42, matrix.M43, matrix.M44
}.All(float.IsFinite);
}
这里特意在候选数组完成后才比较修订号。构建候选可能耗时,但它不触碰活动状态;即使请求在此期间从 1 推进到 2,完成者只会得到 StaleRevision。反过来,若先写活动数组再检查修订,所谓拒绝只是在承认错误之后返回一个错误码,无法撤销渲染线程已经观察到的半份姿势。
快照还切断了候选容器与活动容器的共享。提交后修改原字典中的骨骼 0,不会把活动矩阵的 X 位移从 11 改成 999。实际引擎可以用双缓冲、NativeArray 或持久映射上传区减少复制,但“候选槽位不可被当前 Draw 使用”这一所有权边界不能随优化消失。
修订号也不应由 Job 调度器临时生成。它必须绑定动画系统已经决定的那次姿势请求,例如逻辑 Tick 与动画图版本的组合;否则同一姿势拆成两批任务时会得到两个身份,或动画状态已经推进后仍被误认为当前批次。调度器负责完成工作,姿势所有者负责判断工作是否仍有发布资格,这两个职责不能由一个“任务完成”布尔值替代。
三种拒绝都应保持旧角色完整
固定样本使用四根骨骼。第一次只完成三个索引;第二次让骨骼 2 的矩阵含 NaN;第三次在请求已经推进到 2 后,提交修订 1 的完整结果。三者都保持活动修订 0。只有修订 2 的 [11,22,33,44] 四个 X 位移同时有效时,活动引用才整体替换。
校验不能只比较 completed.Count == boneCount。字典若重复覆盖骨骼 1,同时缺少骨骼 3,数量仍可能看似正确;因此实现还逐个检查 0..boneCount-1 是否存在。NaN 校验则放在发布之前,因为一个非法矩阵进入顶点变换后会扩散到包围盒与裁剪判断,症状可能从局部撕裂升级为整批角色消失。两种错误都应当使候选整体死亡,而不是用单位矩阵补洞后继续提交。

cd "$WORK_DIR"
dotnet build "$LAB_ROOT/BonePaletteLab.csproj" -c Release
dotnet run --project "$LAB_ROOT/BonePaletteLab.csproj" \
-c Release --no-build
incomplete: completed=3 result=INCOMPLETE active_revision=0 palette_x=0.0,0.0,0.0,0.0
invalid: bone=2 value=NaN result=INVALID_MATRIX active_revision=0
stale: completed_revision=1 requested_revision=2 result=STALE_REVISION active_revision=0
commit: revision=2 result=COMMITTED palette_x=11.0,22.0,33.0,44.0 vertex_bone3_x=44.0
tests: pass=14 fail=0 skip=0 duration_ms=51
Release 构建退出码为 0,0 警告、0 错误,MSBuild 报告耗时 8.14 秒;运行退出码同样为 0。耗时只记录本次证据环境,不用于推导吞吐或性能收益。决定性结果是骨骼 3 绑定顶点最终读取 44.0,且所有拒绝分支都没有改变活动修订。
上传优化必须保留快照语义
小骨架可以在主线程拼装数组,大骨架常会把局部层级更新、蒙皮矩阵计算和 GPU 上传拆开。拆分本身没有问题,问题在于哪个阶段取得发布权。只要渲染提交仍以一份 PaletteSnapshot 为单位,任务可以乱序完成,上传也可以写入备用缓冲;交换活动槽位必须等候同一修订的全部骨骼与上传栅栏完成。
在多线程实现中,“整体替换”还包含内存可见性:生产者完成候选数组写入后,以一次受同步保护的引用或槽位交换发布;消费者先取得活动快照,再在本次绘制期间保持该引用。若消费者在循环中反复读取全局活动指针,即使每次交换都是原子的,同一次 Draw 的不同骨骼也可能跨越两个快照。发布单位与读取单位必须一致,原子指针本身并不会自动提供这一点。
骨骼数量动态变化是当前方案的边界。换装若改变骨架拓扑,不能把新骨骼数量塞进旧所有者;拓扑版本、逆绑定矩阵和姿势调色板需要组成更大的候选包一起切换。另一个边界是 GPU 读取周期:探针证明 CPU 发布顺序,不证明覆盖持久映射缓冲的时机安全,生产实现仍需用帧槽位或 Fence 防止写入在途数据。
因此,排查“一帧撕裂”时,单看每个 Job 是否完成远远不够。应当审查活动调色板是否具有唯一修订、失败候选是否从未进入活动槽位,以及 GPU 是否只读取完整发布的那一份。满足这些条件后,权重与矩阵公式的错误会稳定复现;只闪一帧的混合姿势则在提交边界上被消除。