断肢伤口网格被拉成尖刺:共享骨架下的蒙皮拉扯
断肢伤口网格被拉成尖刺:共享骨架下的蒙皮拉扯
玩家砍断僵尸肢体时,断口处那块"断骨"伤口网格会被拉扯成一根又细又长的尖刺,从蜷缩的躯干里穿出来,伴着被拉长的红色血肉,严重穿模。
这块伤口网格挂在一个 UPoseableMeshComponent 上,和宿主僵尸共用同一套骨架,骨骼名一一对应。问题就出在"共用骨架但只摆正了一根骨头"。
先说清楚:蒙皮和骨骼的关系
要看懂这个 bug,得先分清骨骼和网格这两件事。
骨骼(Skeleton / Bone) 是一套层级化的坐标系——父子变换树。骨头本身不可见,只是空间中的一组变换(位置、旋转、缩放)。动画每帧改变这些骨头的变换。
Pelvis
└─ Spine_01
└─ Spine_02
└─ Neck
└─ Head
网格(Mesh) 才是眼睛看到的皮和肉,由成千上万个顶点组成。蒙皮(Skinning) 就是把每个顶点绑定到一根或多根骨头上、并记录权重:
顶点 A:受 UpperArm 影响 60%,受 LowerArm 影响 40%
运行时,顶点的最终位置 = 各绑定骨头变换 × 权重的加权混合。骨头是操纵杆,顶点是被按权重拉动的布料——骨头动,顶点跟着动。
正常情况下相邻骨头协调运动,中间的顶点被平滑地带着走。但如果一个顶点绑定的两根骨头,一根被摆到了正确位置、另一根还停在 bind pose(建模时的初始姿势),这个顶点就会被往两个方向撕扯,加权混合的结果就是被拉长、扭曲。这就是蒙皮拉扯,也就是这里看到的尖刺。
根因:只摆了一根骨头,其余全停在 bind pose
伤口网格 spawn 时,代码会调一个 HideUnwantedBones 之类的接口隐藏无关骨头。踩坑就在于它的真实行为和名字给人的直觉不一样。
它的做法是:在"排除列表"里找到索引最小的那根骨头(也就是最靠上的父骨),然后隐藏所有既不是这根父骨的子孙、又不在排除列表里的骨头。
净效果——它保留可见的是「父骨 + 父骨的整条子骨链」:切断骨、脊柱链、脖子、头、手臂等一整串都还在,而不是只留你以为的那一两根。
而旧代码只摆正了父骨一根:
// 旧代码:只摆父骨一根
BoneMeshComp->SetBoneTransformByName(
BodyPart->GetParentBoneName(),
OwnerCharacter->GetMesh()->GetSocketTransform(BodyPart->GetParentBoneName()),
EBoneSpaces::Type::WorldSpace);
于是:
父骨 → 被摆正(对齐宿主)
子骨链其余骨头 → 全部停在 bind pose
跨在两者之间的顶点 → 一端被拉到正确位置,
另一端被钉在初始位置
→ 被拉成一根长刺
可见骨链很长,只摆一根骨头远远不够。没摆的那些骨头,成了把顶点往回拽的"锚点"。
修复:把每一根可见骨头都对齐到宿主
既然伤口网格和宿主共用同一套骨架、骨骼名一一对应,那就在 spawn 时把伤口网格的每一根可见骨头,都对齐到宿主对应骨头的世界变换。所有操纵杆都到位,就没有任何一根骨头把顶点往回拽,也就没有撕扯。
// One-time FULL pose copy: HideUnwantedBones keeps the parent bone AND its entire child
// subtree visible, so posing only the parent + cut bone left the rest of that chain in
// bind pose and stretched the wound mesh into a long spike. Since the wound mesh shares
// the owner's skeleton 1:1, snap every wound bone to the owner's matching bone world
// transform. Iterate in bone-index order so each parent is set before its children and
// the world->local conversion uses an already-corrected parent. Still a one-time snapshot
// at spawn: no per-frame tick.
const int32 NumWoundBones = BoneMeshComp->GetNumBones();
for (int32 WoundBoneIdx = 0; WoundBoneIdx < NumWoundBones; ++WoundBoneIdx)
{
const FName WoundBoneName = BoneMeshComp->GetBoneName(WoundBoneIdx);
const int32 OwnerBoneIdx = OwnerCharacter->GetMesh()->GetBoneIndex(WoundBoneName);
if (OwnerBoneIdx != INDEX_NONE)
{
BoneMeshComp->SetBoneTransformByName(
WoundBoneName,
OwnerCharacter->GetMesh()->GetBoneTransform(OwnerBoneIdx),
EBoneSpaces::Type::WorldSpace);
}
}
有两个实现细节决定它能不能对齐正确:
按骨骼索引顺序遍历。 UE 骨架保证父骨索引一定小于子骨。SetBoneTransformByName(..., WorldSpace) 内部会把世界变换换算成相对父骨当前 component-space 的局部变换,所以必须先摆父骨、再摆子骨,让子骨的换算基于已经修正好的父骨。顺序遍历天然满足这一点——如果乱序摆,子骨会基于还没修正的父骨去算局部变换,又错了。
GetBoneTransform 返回的是世界变换。 它由始终有效的 component-space 缓冲支撑,配合 WorldSpace 参数就能一一对齐,不需要额外做空间换算。
代价:接受刚性偏移,消灭拉扯
这是 spawn 时的一次性快照,不加任何 per-frame tick,性能开销可以忽略。
代价是宿主之后继续运动(比如倒地 ragdoll)时,伤口网格不会跟随,可能出现整块的刚性偏移——整体平移或旋转。但刚性偏移和拉扯是两码事:刚性偏移形状不变、可以接受;拉扯才是撕裂穿模。这次修复把问题从"不可接受的拉扯"降级成了"可接受的刚性偏移"。
之前也试过"每帧跟随宿主姿势"的方案,复杂度高,还引入了漂浮等新问题。最后选了"回到原始 attachment 无 tick 设计 + 一次性全量摆正"这个最小、可靠的方案。追求让伤口网格完美跟随一具正在 ragdoll 的尸体,投入产出比不划算。
排查这类问题的经验
| 现象 | 优先怀疑 |
|---|---|
| 网格被拉成尖刺/长条,形状撕裂 | 蒙皮拉扯:部分绑定骨头没摆正,还停在 bind pose |
| 网格整体位置不对,但形状正常 | 变换 / attach 问题,不是蒙皮 |
用了 HideUnwantedBones 类接口后仍拉扯 | 先确认它到底保留了哪些骨头可见,别假设只留了你指定的那几根 |
一句话教训:处理共享骨架的局部网格时,"可见的骨头"必须全部摆正。漏摆任何一根,跨在它上面的顶点都会被拉扯。