角色挥剑时突然消失:骨骼动画不能沿用绑定姿态 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);
}
只变换局部盒的 Min 与 Max 不成立。旋转或负缩放后,这两个点未必仍是世界空间的最小角和最大角;八角点枚举让旋转 90 度与 X 轴负缩放都进入同一算法,不需要按矩阵形状增加分支。
若一个角色纳入剔除计算的骨骼数为 B,这个合并过程固定变换 8B 个角点,时间复杂度为 O(B),额外状态只是一对累计最小值与最大值。这里没有把它写成性能结论:真实成本还取决于动画更新频率、角色数量、矩阵布局和任务调度。这个上界的用途是明确优化位置——减少参与 Bounds 的骨骼或降低更新频率,而不是省略角点后接受错误范围。
Bounds 必须与骨骼调色板属于同一修订
如果骨骼调色板缺少剑骨,已经计算出的躯干盒不能先写入活动 Bounds。那会把完整旧范围替换成更小的半份新范围,正好重新制造误剔除。TryCommit 因此先检查所有被引用骨骼和全部矩阵元素,候选完整后才计算并一次提交 ActiveBounds 与 ActiveRevision。

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 仍可作为初始化与失败回退;当前可见性则应由完整、有限、未过期的姿态候选生成,并与同版骨骼调色板一起进入帧边界。这样,剔除看到的范围和顶点着色阶段将要生成的姿态才描述同一个角色。