角色挥剑时突然消失:骨骼动画不能沿用绑定姿态 Bounds

角色站在画面边缘挥剑,身体还在视锥外,剑尖却已经进入画面;结果整个角色连同武器一起消失。蒙皮顶点没有坏,Shader 也能正确计算骨骼变换,问题发生在 draw 之前:CPU 仍用绑定姿态的包围盒做视锥剔除,而这个盒子只覆盖身体附近的 -0.50..0.50,没有覆盖动画后伸到 x=2.50 的剑。

视锥边缘的蒙皮角色同时显示过小的绑定姿态包围盒和完整动画包围盒

把 Bounds 手工放大可以隐藏这次消失,但它把所有动画、所有装备和所有姿态都付给最坏情况:过大的盒子会让已经离开镜头的角色继续进入后续渲染阶段。更可靠的输入来自骨骼局部包围盒和当前已接受的骨骼调色板。

绑定姿态描述资产,剔除需要描述当前姿态

探针为躯干骨保存局部 [-0.5, 0.5] 盒,为剑骨保存 X 轴 0.0..0.5 的局部盒。第 7 版调色板保持躯干不动,把剑骨沿 X 轴平移 2.0。每个局部 AABB 的八个角点经过对应矩阵变换,再合并为当前姿态的世界空间 AABB。

$LAB_ROOT/Program.cs 中的 SkinnedBoundsOwner 同时拥有骨骼局部盒、活动修订与活动 Bounds。它不接管动画求值,只接受一份完整候选调色板:

readonly record struct Bounds3(Vector3 Min, Vector3 Max)
{
    public bool Intersects(Bounds3 other) =>
        Min.X <= other.Max.X && Max.X >= other.Min.X &&
        Min.Y <= other.Max.Y && Max.Y >= other.Min.Y &&
        Min.Z <= other.Max.Z && Max.Z >= other.Min.Z;
}

readonly record struct BoneLocalBounds(int Bone, Bounds3 Bounds);

enum CommitResult
{
    Committed,
    StaleRevision,
    MissingBone,
    NonFiniteMatrix
}

sealed class SkinnedBoundsOwner
{
    private readonly BoneLocalBounds[] boneBounds;

    public int ActiveRevision { get; private set; }
    public Bounds3 ActiveBounds { get; private set; }

    public SkinnedBoundsOwner(Bounds3 bindPoseBounds, params BoneLocalBounds[] localBounds)
    {
        if (localBounds.Length == 0)
            throw new ArgumentException("At least one bone bound is required.", nameof(localBounds));
        if (localBounds.Any(item => !IsValid(item.Bounds)))
            throw new ArgumentException("Bone bounds must be finite and ordered.", nameof(localBounds));

        boneBounds = localBounds.ToArray();
        ActiveBounds = bindPoseBounds;
    }

    public CommitResult TryCommit(int revision, IReadOnlyDictionary<int, Matrix4x4> palette)
    {
        if (revision <= ActiveRevision)
            return CommitResult.StaleRevision;

        foreach (var item in boneBounds)
        {
            if (!palette.ContainsKey(item.Bone))
                return CommitResult.MissingBone;
            if (!IsFinite(palette[item.Bone]))
                return CommitResult.NonFiniteMatrix;
        }

        var candidate = Transform(boneBounds[0].Bounds, palette[boneBounds[0].Bone]);
        for (var i = 1; i < boneBounds.Length; i++)
        {
            var item = boneBounds[i];
            candidate = Union(candidate, Transform(item.Bounds, palette[item.Bone]));
        }

        ActiveBounds = candidate;
        ActiveRevision = revision;
        return CommitResult.Committed;
    }

    private static Bounds3 Transform(Bounds3 bounds, Matrix4x4 matrix)
    {
        var min = new Vector3(float.PositiveInfinity);
        var max = new Vector3(float.NegativeInfinity);
        foreach (var corner in Corners(bounds))
        {
            var point = Vector3.Transform(corner, matrix);
            min = Vector3.Min(min, point);
            max = Vector3.Max(max, point);
        }
        return new Bounds3(min, max);
    }

    private static IEnumerable<Vector3> Corners(Bounds3 bounds)
    {
        for (var mask = 0; mask < 8; mask++)
        {
            yield return new Vector3(
                (mask & 1) == 0 ? bounds.Min.X : bounds.Max.X,
                (mask & 2) == 0 ? bounds.Min.Y : bounds.Max.Y,
                (mask & 4) == 0 ? bounds.Min.Z : bounds.Max.Z);
        }
    }

    private static Bounds3 Union(Bounds3 a, Bounds3 b) =>
        new(Vector3.Min(a.Min, b.Min), Vector3.Max(a.Max, b.Max));

    private static bool IsValid(Bounds3 bounds) =>
        IsFinite(bounds.Min) && IsFinite(bounds.Max) &&
        bounds.Min.X <= bounds.Max.X && bounds.Min.Y <= bounds.Max.Y && bounds.Min.Z <= bounds.Max.Z;

    private static bool IsFinite(Vector3 value) =>
        float.IsFinite(value.X) && float.IsFinite(value.Y) && float.IsFinite(value.Z);

    private static bool IsFinite(Matrix4x4 value) =>
        float.IsFinite(value.M11) && float.IsFinite(value.M12) && float.IsFinite(value.M13) && float.IsFinite(value.M14) &&
        float.IsFinite(value.M21) && float.IsFinite(value.M22) && float.IsFinite(value.M23) && float.IsFinite(value.M24) &&
        float.IsFinite(value.M31) && float.IsFinite(value.M32) && float.IsFinite(value.M33) && float.IsFinite(value.M34) &&
        float.IsFinite(value.M41) && float.IsFinite(value.M42) && float.IsFinite(value.M43) && float.IsFinite(value.M44);
}

只变换局部盒的 MinMax 不成立。旋转或负缩放后,这两个点未必仍是世界空间的最小角和最大角;八角点枚举让旋转 90 度与 X 轴负缩放都进入同一算法,不需要按矩阵形状增加分支。

若一个角色纳入剔除计算的骨骼数为 B,这个合并过程固定变换 8B 个角点,时间复杂度为 O(B),额外状态只是一对累计最小值与最大值。这里没有把它写成性能结论:真实成本还取决于动画更新频率、角色数量、矩阵布局和任务调度。这个上界的用途是明确优化位置——减少参与 Bounds 的骨骼或降低更新频率,而不是省略角点后接受错误范围。

Bounds 必须与骨骼调色板属于同一修订

如果骨骼调色板缺少剑骨,已经计算出的躯干盒不能先写入活动 Bounds。那会把完整旧范围替换成更小的半份新范围,正好重新制造误剔除。TryCommit 因此先检查所有被引用骨骼和全部矩阵元素,候选完整后才计算并一次提交 ActiveBoundsActiveRevision

绑定姿态和第七版调色板在固定 X 轴样本中的剔除结果

MissingBonePreserves 先提交第 4 版完整调色板,再给第 5 版只提供躯干矩阵。结果为 MissingBone,活动 Bounds 和修订号 4 均保持不变。NonFinitePreserves 把剑骨平移量设为 NaN,同样不会把非有限坐标带进剔除结构。相同或更旧的修订返回 StaleRevision,防止较慢的动画任务覆盖更新姿态。

private static void MissingBonePreserves()
{
    var owner = CreateOwner();
    owner.TryCommit(4, ValidPalette());
    var before = owner.ActiveBounds;
    var incomplete = new Dictionary<int, Matrix4x4> { [0] = Matrix4x4.Identity };

    Check.Equal(CommitResult.MissingBone, owner.TryCommit(5, incomplete), "missing bone");
    Check.Equal(before, owner.ActiveBounds, "missing active bounds");
    Check.Equal(4, owner.ActiveRevision, "missing active revision");
}

固定样本把消失条件变成可执行断言

Release 构建退出码为 0,0 个警告、0 个错误,耗时 2.48 秒。测试进程退出码为 0,8 个通过、0 个失败、0 个跳过,内部计时 41 毫秒:

dotnet build "$LAB_ROOT/SkinnedBoundsLab.csproj" -c Release --no-restore --nologo
dotnet run --project "$LAB_ROOT/SkinnedBoundsLab.csproj" -c Release --no-build
RESULT bind_visible=False commit=Committed revision=7 min_x=-0.50 max_x=2.50 animated_visible=True
TESTS passed=8 failed=0 skipped=0 elapsed_ms=41

视锥切片的 X 范围是 1.50..3.00。绑定姿态盒到 0.50 为止,因此相交结果为假;第 7 版姿态盒扩到 2.50,相交结果变为真。这个固定结果证明 Bounds 输入错误足以在 Shader 执行前剔除本应可见的剑,不需要用截图猜测消失发生在哪一层。

该探针验证的是 CPU 侧 AABB 的计算和提交合同,没有测量逐角色逐帧更新的成本,也没有声称与 GPU 蒙皮自动同步。真实引擎需要让 Bounds 使用与渲染调色板相同的姿态修订,并在剔除读取之前发布。角色数量增大后,可以离线烘焙动画片段的保守 Bounds、按关键帧间隔更新,或只合并对轮廓贡献明显的骨骼;这些策略改变计算频率和保守程度,不能改变修订一致性。

装备切换也会改变局部盒集合。若武器独立渲染,它拥有自己的 Bounds;若武器进入同一蒙皮网格,它的骨骼局部盒必须随装备版本一起替换。观察到剔除成本超出预算时,应调整局部盒粒度和更新频率,而不是退回一个覆盖所有动作的巨大静态盒。后者虽然减少计算,却把本可剔除的角色长期留在渲染候选中。

还有一个不能从本探针外推的边界:线性混合蒙皮下,顶点可能同时受多根骨骼影响,单骨骼局部盒的质量取决于离线归属策略和保守余量。过紧会漏掉混合后的顶点,过松会降低剔除收益。工程上应拿离线采样动画或运行时顶点回读做覆盖率验证,再决定是否需要为高变形角色改用片段级烘焙范围。

挥剑时的角色消失,最终不是把一个数值调大就能稳定解决的问题。绑定姿态 Bounds 仍可作为初始化与失败回退;当前可见性则应由完整、有限、未过期的姿态候选生成,并与同版骨骼调色板一起进入帧边界。这样,剔除看到的范围和顶点着色阶段将要生成的姿态才描述同一个角色。