一刀之后,角色系统发生了什么:深入拆解《消逝的光芒 2》断肢管线

砍掉一只手,看起来只需要把手部模型隐藏,再生成一个掉在地上的模型。但只要让角色动起来,这个方案就会接连出问题:手臂弯曲时断口裂开,掉落的手突然跳回默认姿态,袖子仍然连着两端,角色还在用已经消失的手发动攻击。

《消逝的光芒 2》把这件事拆成了几层:身体模型负责描述“砍的是哪里”,伤害规则决定“能不能砍断”,表现定义决定“隐藏什么、补上什么”,脱落体负责自己的骨架与物理,动画轨道则继续控制角色的挣扎、倒地和碰撞反馈。

下面沿着一次感染者左前臂被切断的过程,讲清楚命中选择、断口生成、骨架交接和动画适配,再反推美术应该交付哪些资产。伪代码保留脚本和反汇编中的关键条件;自行实现的部分标作“实现示例”。

角色资产、部件装配与运行时分离

本文三维配图为 AI 生成的丧尸技术示意图;橙色表示断点或补片,青色表示骨架与空间辅助信息。


1. 身体模型:一次命中怎样变成“切掉左前臂”

1.1 身体区域、骨骼和损伤操作是三层数据

角色身上有很多骨骼,但战斗逻辑不需要对每根手指骨单独设计一套受伤规则。游戏先将身体分成 Head、Torso、ArmLeft、ArmRight、LegLeft、LegRight 等区域,再在区域里定义具体骨骼支持的操作。

damageable_body_models.scr 的左臂定义,简化后可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 身体模型脚本展开:区域内的骨骼 → 对应损伤表现
ArmLeft = {
l_upperarm: {
parent: l_clavicle,
Cut: left_upperarm_cut,
Deformation: left_upperarm_deformation
},
l_forearm: {
parent: l_upperarm,
Cut: left_forearm_cut,
Deformation: left_forearm_deformation
},
l_hand: {
parent: l_forearm,
Cut: left_hand_cut
},
Break: left_arm_break
};

这里有两个容易忽略的细节。

首先,切断与骨折没有共用一个表现入口。前臂切断走 left_forearm_cut,整条左臂的骨折走 left_arm_break。前者要产生脱落体,后者可以让手臂仍然挂在身体上,只是失去主动支撑。

其次,l_forearm 只是语义定位点,不等于最终断面必须贴着这根骨骼的原点。具体补口位置还会引用网格中的辅助元素,左前臂使用 patch_elem_arm_left_b。这样,骨骼负责动作,辅助元素负责伤口摆放,两者不必强行挤在同一个坐标上。

1.2 破洞也不一定需要增加动画骨骼

左臂还定义了 hole_forearm_left,类型是 VirtualBone,父级为 l_forearm,关联辅助元素 helper_forearm_left,最后执行 Hole 类操作。

1
2
3
4
5
6
7
// 虚拟损伤位置:借用已有骨骼的空间关系
hole_forearm_left = VirtualDamageLocation {
parent: l_forearm,
helper: helper_forearm_left,
helperExtentsScale: [1.0, 1.5, 2.0],
operation: { input: Cut, visual: hole_forearm_left, category: Hole }
};

这类位置用于表达“前臂表面的一处破损”,不要求动画师为每个伤口制作一根参与动作的骨骼。它把损伤定位从动画层级里抽离出来,避免角色骨架随着伤口数量不断膨胀。

1.3 损伤关系图与动画骨架必须分开

Parent 容易被误读成建模软件里的骨骼父级。身体模型却给 neck 同时写了 l_clavicle 和 r_clavicle 两个父节点:

1
2
3
4
5
6
// 游戏逻辑:身体模型中的关系
damageParents[neck] = { l_clavicle, r_clavicle };
damageParents[neck1] = { neck };
damageParents[head] = { neck1 };
damageParents[l_forearm] = { l_upperarm };
damageParents[l_hand] = { l_forearm };

动画骨架是一棵树,一个骨骼通过唯一父级计算变换;这里组织的则是损伤节点之间的关系图。同一套骨骼名称可以用于两套系统,但不能把这份表直接拿去重建 Skeleton。否则脖子应该跟随哪一个锁骨,就已经说不通了。

这个区别也解释了断肢系统为什么需要额外的身体状态:动画描述骨骼怎样运动,损伤图描述部位之间的关联。手不再可见以后,动画轨道仍然可以保留手骨的变换数据。

1.4 腰斩还会筛选攻击平面的方向

大切口也不只是“命中了躯干”。pelvis 对应的 horizontal_cut 使用间接损伤条件,指定 helper_waist_0,将辅助区域沿三个局部轴放大为 [3.0, 2.5, 2.5],允许的攻击平面偏差为 30°。左右纵向切断使用胸部辅助区域,允许偏差为 45°。

把这几项输入展开:

1
2
3
4
5
6
7
8
9
10
11
// 游戏逻辑:腰斩候选的条件与操作
horizontalCutCandidate = {
location: pelvis,
requirement: Indirect,
helper: helper_waist_0,
helperExtentsScale: [3.0, 2.5, 2.5],
maxPlaneAngleDeviationDegrees: 30.0,
indirectPriority: 2,
operation: { input: Cut, visual: horizontal_cut, category: FullBodyCut },
damageExclusive: true
};

这些参数让美术预制的切口与攻击方向发生联系。辅助区域决定在哪里接受候选,平面偏差限制什么方向的挥砍适合这个断面,优先级用于候选选择。它并不要求刀刃走到哪里,就实时沿着那条轨迹重建一个任意形状的切口。

1.5 先匹配身体语义,再交给规则层

把关系串起来,数据流是:

%%{init: {"theme":"base","themeVariables":{"primaryColor":"#25201d","primaryTextColor":"#f1ece4","primaryBorderColor":"#ef8950","lineColor":"#79cbd5"}}}%%
flowchart LR
    A[武器命中] --> B[左臂区域]
    B --> C[前臂骨骼]
    C --> D[切断候选]
    D --> E[伤害条件与概率]
    E --> F[左前臂切断表现]
    F --> G[身体隐藏与补口]
    F --> H[脱落体与物理]

因此,命中检测不应该直接负责生成碎块。它提供受击位置和上下文,由身体模型解释这个位置,再让规则层决定最终效果。


2. 伤害决策:砍中、砍断和死亡为什么必须分开

2.1 75 表示生命比例条件,不是断肢概率

damageable_body_presets.scr 的 Biter 预设,将左臂的 Cut 和 Break 都配置为最大身体生命百分比 75.0,同时标记为 NonLethal。

用伪代码表达这组规则:

1
2
3
4
5
6
7
8
9
10
11
12
// Biter 左臂规则展开;healthPercent 是规则求值时使用的身体生命比例
EvaluateArmRule(operation, healthPercent) {
if (operation != Cut && operation != Break)
return NoRule;

if (healthPercent > 75.0)
return Rejected;

return Allowed {
operationItselfIsLethal: false
};
}

NonLethal 的含义是这次身体损伤操作本身不强制杀死角色,而不是免除武器本身的伤害。一次攻击仍可能先把生命值打空,然后同时产生断肢。

同一个 Biter 预设里,头部切断的生命比例条件是 50.0,另外还配置 ProbabilityMul("Cut", 0.5)。这两个数控制不同的事情:前者限制是否进入候选,后者调整概率权重。不能把它们相加,也不能因为身体已经低血量,就默认每次命中都必然断头。

2.2 受击状态会继续修改断肢概率

damage_rules.scr 中有一个很具体的规则:ragdoll_during_bleeding_increases_limb_cut_prob。它要求这次伤害带有 Ragdoll 反应标记,并且目标正在流血,然后调用 AddLimbCutProb(0.1)。

1
2
3
4
5
6
7
// 伤害规则脚本展开
ApplyBleedingRagdollModifier(damage, victim) {
if (damage.reactionFlags.contains(Ragdoll)
&& victim.bodyStates.contains(Bleeding)) {
damage.limbCutProbability += 0.1;
}
}

这个判断检查的是伤害携带的反应标记,不是笼统地判断“敌人已经躺在地上”。例如其他规则可以给一次命中加上布娃娃反应,从而影响后续伤害处理。

这一条规则给当前概率加上 0.1;头部的 ProbabilityMul 则属于部位倍率。实现自己的规则系统时,要明确两者在哪个阶段求值,否则“先加再乘”和“先乘再加”会产生不同的手感。

2.3 处决与环境撞击走不同的伤害预设

sync_action_damage_presets.scr 中的 finisher_limb_cut 组合了以下设置:

1
2
3
4
5
6
7
8
9
10
// 处决伤害预设展开
finisher_limb_cut = {
damageDefinition: Player_KillVictim,
limbCutProbability: 1.0,
flags: {
BodyDamageEnforced,
BodyDamageUnconstrained,
Kill
}
};

处决不是普通挥刀简单增加一点伤害,而是把“致死”和“身体损伤要求”一起写进预设。表现层仍要找到目标可用的损伤操作和资源;概率设成 1.0 不会替美术生成一套不存在的断面。

环境碰撞也能进入同一套身体损伤系统。ai_damage_presets.scr 中的 wall_hit_ragdoll 使用 AI_WallHitRagdoll,配置 BodyDamageEnforced、切断概率 1.0 和破坏概率 0.6,同时引用墙面碰撞的反应预设。

两者的共同点,是把不同来源的攻击统一转成“伤害定义、身体损伤参数、反应参数”。这样刀刃、处决和环境碰撞可以复用后面的断肢资产,不必各做一套生成碎块的逻辑。


3. 外观分离:原身体怎么消失,断面怎么出现

3.1 一个切断操作包含两个端口

left_forearm_cut 并不是一个单独的模型文件。它是一份表现配方,组织了原身体和脱落体两侧的资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// left_forearm_cut 的表现配置展开,省略各体型的椭球数值
LeftForearmCut = {
helper: patch_elem_arm_left_b,
hideVolume: HideEllipsoidBySkeletonGroup,

bodyPatch: {
type: Simple,
prefab: human_patch,
defaultMesh: chr_dmg_cut_patch_default_a,
slimMesh: chr_dmg_cut_patch_default_a_slim
},
bodyEffect: left_forearm_off_wound_bleeding,

gib: {
prefab: human_gib,
patch: SameDefaultAndSlimPatchChoice,
effect: left_forearm_off_bleeding,
physicsSimset: sim_cut_limbs,
additionalPhysicsBone: l_upperarm,
collideWithMainBody: true,
bloodPuddle: gib_blood_puddle
}
};

这里的 BodyPatch 填住身体剩下的那一端,Gib.Patch 填住掉落前臂的另一端。两端必须各自封口,否则无论从身体还是地面碎块看过去,都可能看到空壳内部。

补片选择还检查 DamageSize。尺寸为 slim 时使用细版,普通尺寸使用默认版。把这个局部选择展开就是:

1
2
3
4
5
SelectForearmPatch(characterProperties) {
if (characterProperties.DamageSize == "slim")
return chr_dmg_cut_patch_default_a_slim;
return chr_dmg_cut_patch_default_a;
}

这比“所有感染者都用一块伤口模型,再统一缩放”更稳妥。瘦手臂、厚外套和特殊感染者的断面轮廓不同,单纯缩放常常只能匹配直径,无法同时匹配轮廓和厚度。

3.2 隐藏外壳为什么能比切三角形更省事

HideEllipsoid 描述了一种体积隐藏需求:在角色表面限定一个区域,让这一带的外层不再显示。后面的补片负责填充可见内部。

这类算法不必首先把每个相交三角形切成两半。用一个椭球判断表面是否处于隐藏范围,就可以在外观上形成缺口。下面用最小着色实现说明这个计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 实现示例:所有输入先转换到同一个参考空间
ShouldHideSurface(position, ellipsoid) {
local = transpose(ellipsoid.rotation)
* (position - ellipsoid.center);
q = local / ellipsoid.halfAxes;
return dot(q, q) < 1.0;
}

ShadeSurface(position) {
if (ShouldHideSurface(position, woundVolume))
discard;
return ShadeSkinOrClothes();
}

椭球本质上是一个经过旋转和非均匀缩放的球。把位置逆变换回单位球空间后,只需检查距离平方是否小于 1。

椭球限定隐藏范围,再由独立补片填充

外壳负责角色的连续表面,补片负责损伤后露出的内部;图中的灰色和橙色是辅助材质。

这样的“外层隐藏 + 内部补片”有公开的工业先例。Valve 在 Rendering Wounds in Left 4 Dead 2 中使用姿态空间椭球、投影纹理与独立伤口模型,并强调阴影和深度 Pass 也要应用裁剪。技术展开:GDC 2010 演讲

实际落地时,坐标空间比距离公式更重要。伤口如果固定在世界空间,角色一转身,它就会在皮肤上滑动;补片如果跟骨骼走、隐藏区域却不跟,二者又会错开。隐藏区域和补片必须遵守同一套附着与形变约定。

3.3 大切口需要能够变形的补片

普通前臂切口使用 Simple 类型。躯干纵向切断则使用 Skinned 补片,身体端和脱落端分别引用胸部补片模型;脱落体还配置 ShowEllipsoid,物理改用 sim_ragdoll.lite。

这组差异对应了两个制作问题:

  • 小型端口的局部形状比较稳定,可以围绕一个附着位置组织。
  • 胸部切口跨越更大的形变范围,角色弯腰或旋转时,内部模型也要随骨架变形。

因此,美术交付不能只有一张“断面贴图”。小补片、蒙皮补片、身体端与脱落端各有职责;哪种资产用于哪种断点,要由表现配方明确指定。

3.4 骨架类型与断面尺寸是两次独立选择

前臂配方里有一组按 man、woman、banshee 等名字索引的隐藏体积,同时又有一组按 DamageSize 选择的补片。两组选择解决不同问题。

damageable_body_skeleton_groups.scr 把 man_zmb_skeleton.msh、man_zmb_light_skeleton.msh、man_srv_skinybiter_skeleton.msh 等骨架都映射到 man。因此,某个瘦感染者可以使用 man 的隐藏参数,同时使用 slim 的补片模型:

1
2
3
4
5
6
7
// 游戏数据关系展开:两个索引不能合并成一个体型枚举
group = SkeletonGroups.Find(character.skeletonName);
hideVolume = left_forearm_cut.HideEllipsoid[group];

patchMesh = character.properties.DamageSize == "slim"
? "chr_dmg_cut_patch_default_a_slim.msh"
: "chr_dmg_cut_patch_default_a.msh";

对制作管线而言,这意味着调整骨架比例时要复查隐藏区域,调整局部粗细时要复查补片轮廓。只把模型整体缩放一遍,并不能自动完成这两层适配。


4. 美术管线:怎样制作一套真正能用的断肢资产

4.1 从断点表开始制作

以左前臂为一个完整制作单元,交付物应围绕运行时实际需要的输入组织:

1
2
3
4
5
6
7
左前臂断点
├── 定位:patch_elem_arm_left_b
├── 外层:皮肤、袖子、手套和附件的处理范围
├── 身体端:补片模型、材质、附着与权重
├── 脱落端:前臂外观、补片、骨架与碰撞代理
├── 变体:普通尺寸、细尺寸及特殊体型
└── 反馈:身体端效果、脱落端效果、落地效果

先把这些关系定好,再去雕刻伤口。否则高模做得再精细,进入引擎后也可能发现补片没有可靠的附着点,或者袖口压根不在隐藏范围里。

4.2 完整角色和损伤内部可以分开生产

基础角色仍然按照常规流程完成高模、低模、UV、烘焙、材质和蒙皮。额外的损伤资产主要集中在允许露出的区域:局部断面、破洞内部、衣物破边,以及必要的体型变体。

不必为了三个前臂断点就给整个人制作完整的可解剖内部。能被镜头看到的部分才需要真实几何;永远被外壳挡住的区域没有必要支付建模、贴图和运行时成本。

制作补片时,先让低模轮廓与外壳开口吻合,再增加内部层次。伤口外沿需要适度覆盖原表面,用来隐藏微小误差;覆盖过宽会形成凸起,表面过近则会闪烁。贴图要留足边缘扩张,避免缩小采样后出现一圈亮边。

4.3 袖子必须和皮肤响应同一个断点

“皮肤断了,袖子还连着”并不是材质问题,而是部件归属没有定义完整。袖子、皮肤可以是不同网格,却必须知道同一个断点会移走哪些部分。

手套一般跟随手,护腕可能跟随前臂,挂在腰间的道具则留在身体。长外套、披挂与布料还要检查连接关系:不能让一块属于脱落体的布料继续被身体端的约束拉住。

游戏的 human_gib 组件里有 CoMeshPartClothSet,说明碎块对象本身预留了处理衣物部件的职责。它不会替代美术对衣服边界和归属的制作。

4.4 蒙皮不是沿着断点一刀切成硬权重

切口边界、蒙皮权重与双侧补口

左侧展示边界定位,中间展示骨骼影响的连续分布,右侧展示独立封口资产。预分段路线需要匹配边界顶点;体积隐藏路线则重点检查补片与遮罩的重合。

正常手肘需要多骨骼混合才能自然弯曲。不能为了方便断肢,就把断点两侧所有顶点分别硬绑定到一根骨骼。

线性混合蒙皮的核心关系是:

pw=W∑iwiGiBi−1pb\mathbf p_w=\mathbf W\sum_i w_i\mathbf G_i\mathbf B_i^{-1}\mathbf p_b

顶点最终位置取决于所有影响骨骼的变换。如果一圈前臂顶点仍受上臂影响,生成脱落体时就必须为这部分影响安排姿态来源,或者使用提前处理好权重的脱落版本。直接删除上臂骨骼,再归一化剩余权重,很容易让断口瞬间收缩或偏移。

制作时可以把检查写成一条清楚的约束:

1
2
3
4
5
6
7
8
// 资产验收逻辑
ValidateDetachedMesh(mesh, detachedSkeleton) {
for (vertex : mesh.vertices) {
assert(NearlyEqual(Sum(vertex.weights), 1.0));
for (influence : vertex.nonzeroInfluences)
assert(detachedSkeleton.hasPoseSource(influence.bone));
}
}

不同 LOD 也要满足同样的约束。低 LOD 可以减少细节,但不能删掉补片依赖的骨骼,却不提供替代映射。

4.5 给美术的交付表应该具体到一个断点

以前臂为例,可以将制作和导出约定落实成下面这张表。前两列对应前面配方实际消费的输入,最后一列是制作时的检查方法。

交付内容 接入位置 制作与验收要点
前臂断口定位元素 patch_elem_arm_left_b 记录局部轴方向;身体端、碎块端用各自的安装变换测试,不能只看模型原点重合
各骨架组的隐藏范围 HideEllipsoid(group, …) 叠加显示隐藏边界与补片;检查伸肘、弯肘、前臂扭转
默认和细尺寸补片 两个 chr_dmg_cut_patch_default_a 变体 外圈盖住开口,内面从身体侧和碎块侧分别可见;检查法线、UV 和材质
衣物部件与附件归属 身体及 human_gib 的网格、布料部件 袖子和皮肤一起处理,护腕随前臂,跨断面的布料约束需要解除
蒙皮与绑定姿态 CoSkinnedMesh、CoSkeleton 保留每个非零权重需要的姿态来源;不要把物理代理骨数量当成蒙皮骨数量
胶囊体和关节 sim_cut_limbs 用碰撞调试视图检查接缝;关闭粒子也应能正常分离和落地
远距离版本 网格、补片及骨骼 LOD 在断口已出现时切换 LOD,检查补片消失、穿插和残余袖口

这些检查适合做成 DCC 导出前的校验,再配一个引擎内预览动作集。检测缺失名称、权重引用和资源引用可以自动完成;补片贴合与动作观感仍要在运动中检查。


5. 骨架交接:身体继续动,掉下来的前臂由谁控制

5.1 动画骨架与物理骨架不是一回事

动画层级里可能有很多手指骨,物理系统却只用一个胶囊体代表整只手。动画需要保留局部动作细节,物理需要少量稳定、便宜的碰撞代理,两者服务的目标不同。

所以,“前臂不再显示”不意味着必须从原角色的 Skeleton 中删除前臂骨。保留骨架索引可以继续使用已有动画;可见性、碰撞和能力状态再分别决定哪些结果仍然有效。

这一原则解决的是兼容性。角色缺了手之后是否仍然播放双手动作,是下一层行为选择的问题,不能指望删几根骨骼就自动解决。

5.2 脱落体拥有自己的骨架和物理组件

human_gib.prefab 把碎块组织成一个完整对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 预制体组件与连接的简化表达
gib.mesh = CoSkinnedMesh();
gib.skeleton = CoSkeleton(rootTransform = true);
gib.collidable = CoSkeletonCollidable();
gib.physics = CoPhysics(type = Ragdoll,
spec = "ragdoll_human.lite.phx");
gib.cloth = CoMeshPartClothSet();
gib.damageForce = CoDamageToPhysicsImpulse(forceMultiplier = 2.0);
gib.puddle = CoBloodPuddleGenerator();
gib.meleeTarget = CoMeleeTargetBody(obstacleType = DeadEnemyGib);

Bind(gib.skeleton, gib.collidable.skeleton);
Bind(gib.skeleton, gib.cloth.skeleton);
Bind(gib.skeleton, gib.puddle.skeleton);
Bind(gib.physics, gib.puddle.physics);
Bind(gib.skeleton, gib.meleeTarget.skeleton);

Connect(gib.TakeDamage, gib.damageForce.Damage);
Connect(gib.damageForce.PhysicsImpulse, gib.physics.PhysicsImpulse);

最后两行很关键:碎块被再次击中时,伤害输入能够继续转成物理冲量。它不是生成后只负责播放一个下落动画的摆设。

CoSkinnedMesh 和 CoSkeleton 则解释了为什么不能一概用静态网格替代脱落体。若需要前臂、手腕继续有相对运动,仍然需要由物理结果驱动骨骼,再由骨骼驱动表面。

前臂的配方还显式请求了 PhysicsAdditionalBone("l_upperarm")。因此,不能按“名字叫前臂”就把它需要的骨骼集合写死成 l_forearm 以下的子树。**可见几何的范围、蒙皮所需的姿态范围、物理需要的骨骼范围,是三个不同集合。**这项额外物理骨配置也不能代替蒙皮权重检查。

5.3 新骨架必须继承切断瞬间的世界姿态

记录姿态、姿态交接与独立物理运动

交接的第一步是保持位置和姿态连续,再让身体与脱落体各自继续运动。

这里展开姿态交接所需的坐标计算。设原角色的模型到世界矩阵为 Ws\mathbf W_s,骨骼当前模型空间矩阵为 Gs\mathbf G_s,脱落体自己的模型到世界矩阵为 Wd\mathbf W_d。希望新骨骼出现在同一个世界位置,就应满足:

WdGd=WsGs⇒Gd=Wd−1WsGs\mathbf W_d\mathbf G_d=\mathbf W_s\mathbf G_s \quad\Rightarrow\quad \mathbf G_d=\mathbf W_d^{-1}\mathbf W_s\mathbf G_s

相应算法可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 姿态转换算法示意:要求双方骨骼映射与绑定姿态空间已经对齐
CopyPoseAtSeparation(source, detached, boneMap) {
snapshot = source.CaptureCurrentPose();
toDetached = Inverse(detached.world) * source.world;

for ((srcBone, dstBone) : boneMap)
detached.globalPose[dstBone] =
toDetached * snapshot.globalPose[srcBone];

for (bone : detached.parentBeforeChildOrder) {
detached.localPose[bone] = bone.isRoot
? detached.globalPose[bone]
: Inverse(detached.globalPose[bone.parent])
* detached.globalPose[bone];
}
}

这不是把动画文件再播放一遍。它复制的是当前这一刻已经求出的姿态,所以无论角色正弯肘、挥手还是跌倒,脱落体都能从正确形状开始。

还要保证根变换和骨骼姿态来自同一个时刻。拿上一帧的手臂姿态搭配这一帧的角色位置,会导致高速运动中的肢体突然“弹”出一截。

碰撞体还多一层偏移。骨骼原点通常放在关节,胶囊体原点却可能位于前臂中段。设 Lbody\mathbf L_{body} 是碰撞体相对骨骼的安装矩阵,那么初始刚体世界矩阵应当是:

Tbody=WsGsLbody\mathbf T_{body}=\mathbf W_s\mathbf G_s\mathbf L_{body}

物理求解完成后,再通过 Gd=Wd−1TbodyLbody−1\mathbf G_d=\mathbf W_d^{-1}\mathbf T_{body}\mathbf L_{body}^{-1} 写回新骨架。少乘或少除这层安装矩阵,就会出现“网格出生位置正确,一开启物理就错位”的问题。

5.4 骨折为什么不会直接掉下来

left_arm_break 引用的 partial_ragdoll_left_arm,把 spine3 设置为运动学刚体,左上臂、前臂和手设置为动态刚体。

1
2
3
4
5
6
7
8
9
// 局部布娃娃配置展开
spine3.mode = Kinematic;
l_upperarm.mode = Dynamic;
l_forearm.mode = Dynamic;
l_hand.mode = Dynamic;

ConnectJoint(spine3, l_upperarm);
ConnectJoint(l_upperarm, l_forearm);
ConnectJoint(l_forearm, l_hand);

运动学胸部跟随角色动作,动态手臂接受物理求解;连接它们的关节约束仍然存在,所以手臂会垂落和摆动,却不会成为独立碎块。

肩、肘、腕使用 Dof6Spring2 约束。三个平移轴的上下限都设为零,表示关节连接点不能自由拉开;旋转方向则给不同范围。肘部示例还设置 frame_b_rotation_xyz([0, 0, -90]),用来调整约束坐标系。

1
2
3
4
5
6
// 肘部约束参数展开:角度定义在约束局部坐标系中
elbow.linearLower = [0, 0, 0];
elbow.linearUpper = [0, 0, 0];
elbow.angularLowerDegrees = [10, -80, 90];
elbow.angularUpperDegrees = [125, 10, 90];
elbow.frameBRotationDegrees = [0, 0, -90];

不能把这些角度直接当成世界坐标里的抬手角度。约束坐标系放错了,即使角度数值相同,手肘也可能朝相反方向弯曲。

5.5 脱落体使用另一套约束参数

sim_cut_limbs 里,左上臂、前臂、手分别对应槽位 3、5、14,三者都是动态胶囊体;肘连接 3 → 5,腕连接 5 → 14。这套模板覆盖全身可分离部件,并不表示每次切下前臂都创建整个人的物理体。

两套配置的左肘限制就有所不同:

左肘参数 挂在身体上的断臂 脱落体模板
角度下限 [10, -80, 90] [10, -40, 90]
角度上限 [125, 10, 90] [120, 30, 90]
平移上下限 均为 [0, 0, 0] 均为 [0, 0, 0]
B 端坐标系旋转 [0, 0, -90] [0, 0, -90]

所以复用骨骼不等于复用全部物理参数。悬挂手臂要跟随角色运动,落地碎块要避免夸张扭转,两者可以共享骨名和胶囊体结构,再分别调整关节活动范围。


6. 动画适配:断肢之后的挣扎,是一条带事件的时间轴

6.1 专用反应动作不只是换一个动画名字

感染者动画资源中存在断臂、断腿和断头的专用片段。比如 biter.scr 的 m_bit_death_arm_cutoff_sl,除了动作片段,还配置了一系列事件。

将其中最重要的部分按轨道时间展开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// m_bit_death_arm_cutoff_sl 的事件脚本展开
// 数字保留脚本的 Event 时间坐标,不当作屏幕渲染帧号
OnTrackEvent(0, EnableDamageOnCollision);
OnTrackEvent(0, PlayPainScream);
OnTrackEvent(6, PlayRightFootStep);
OnTrackEvent(8, RightFootLand);
OnTrackEvent(61, PlayRightFootStep);
OnTrackEvent(81, DisableDamageOnCollision);
OnTrackEvent(156, PlayDeathScream);
OnTrackEvent(159, [] {
PlayAudioEvent("biter_body_falling");
SurfaceInteraction("collision_body_surface_layer", "pelvis", 4.0);
});
OnTrackEvent(161, AllowBlendOut);

这一条时间轴承担了三件事。

**第一,控制碰撞伤害窗口。**动作开始时开启,到了指定事件关闭。角色正在踉跄、倒下还是已经稳定落地,适用的碰撞反馈并不相同。

**第二,让声音和落地位置对齐。**身体砸地时,事件既播放 biter_body_falling,也以 pelvis 为位置关联表面交互。音效和地面反馈不是随便延迟一个固定秒数后触发,而是放在动作的合适位置。

第三,控制什么时候允许离开动作。USER_CAN_BLEND_OUT 表示开放混出窗口,不等于立即删除角色或强制切成全身布娃娃。具体接下一个状态什么动作,由上层控制器决定。

6.2 不同动作的交接窗口并不相同

断腿轨道 m_bit_death_leg_cutoff_sr 的对应事件是:在 0 开启碰撞伤害,63 关闭,83 允许混出,87 触发身体落地音效和骨盆表面交互。

这意味着不能写一个所有断肢通用的“受击后等待两秒再倒地”。每条动作的重心变化、迈步和落地节奏不同,事件也要跟着动作制作。

1
2
3
4
5
6
7
8
9
10
11
// 动画事件消费逻辑示意:跳帧时处理跨过的事件,而非只判断相等
AdvanceReaction(previousTime, currentTime) {
for (event : EventsCrossed(previousTime, currentTime)) {
switch (event.type) {
case EnableDamageOnCollision: collisionDamageEnabled = true; break;
case DisableDamageOnCollision: collisionDamageEnabled = false; break;
case AllowBlendOut: canBlendOut = true; break;
default: DispatchAudioAndContactEvent(event);
}
}
}

这里的消费函数展示实现要点:如果一帧跨过了某个时间点,事件仍然需要执行。单纯写 currentFrame == 81,低帧率时可能漏掉关闭事件,导致碰撞伤害状态一直残留。

6.3 活着时还要适配攻击、装备和 IK

专用死亡反应解决的是一部分情况。前面 Biter 的左臂损伤允许非致命,因此活体角色还需要处理能力变化。

这层可以按依赖关系来组织:缺少手部时,依赖该手的握持目标和攻击判定失效;武器明确留在身体、随肢体脱落还是单独掉落;使用双手的动作需要退出或切换。下面是用于自建控制器的适配示意:

1
2
3
4
5
6
7
RefreshCapabilities(bodyState) {
canAttackWithLeftHand = bodyState.HasUsableLeftArmAndHand();
leftHandIK.enabled = canAttackWithLeftHand && actionNeedsLeftHand;

if (!canAttackWithLeftHand)
CancelAttacksRequiringLeftHand();
}

不要用“骨骼还存在”作为手仍然可用的判断条件。骨架可以为了动画兼容而保留,能力状态必须依据身体损伤结果计算。


7. 物理反馈与特效:让切断具有速度、重量和持续性

7.1 脱落体需要继承运动

掉下来的前臂原本就在运动。若创建时速度清零,它会像突然被刹住一样垂直掉落。对于一个刚体参考,其空间点速度可以写为:

vd=vc+ω×(xd−xc)\mathbf v_d=\mathbf v_c+\boldsymbol\omega\times(\mathbf x_d-\mathbf x_c)

其中后半项就是旋转带来的速度。对于动画驱动的手臂,还需要从连续世界姿态估计局部运动,不能只复制角色根节点的奔跑速度。

游戏的 DefaultGibPhysicsImpulse 进一步为碎块提供表现性调节:

1
2
3
4
5
6
7
8
9
// 默认碎块运动参数展开;这是参数,不是完整求解公式
gibImpulse = {
target: AllPhysicsBodies,
impactDirectionMaxDeviation: 1.0,
impactVelocityMultiplierRange: [2.0, 3.0],
additionalVerticalVelocityRange: [1.0, 3.0],
torqueMin: [1.0, 2.0, 1.0],
torqueMax: [10.0, 10.0, 10.0]
};

设计师可以放大攻击方向上的运动,再叠加向上速度和旋转。整段躯干切断则还使用沿切面法线的附加速度,使分离方向更清楚。

这些范围展示了物理反馈的调参入口,不应把它们当成真实人体质量或严格守恒的实验结果。游戏表现追求的是动作读起来清楚,且运动能与碰撞稳定衔接。

7.2 隐藏网格不会自动移除碰撞

身体上已经消失的部分不能继续作为原角色的有效受击表面;脱落体也不能继续被跨越断面的旧约束拉回去。这要求视觉和物理对同一个身体状态作出响应。

同时,碎块与身体相互碰撞是允许的:普通切断和全身切断的相关开关都设置为 true。因此生成时的碰撞代理和初始姿态必须足够准确,否则两者严重穿插,求解器可能把碎块突然推出很远。

检查问题时,应先画出胶囊体和约束。看到网格位置正确,不代表物理代理也正确;视觉网格和碰撞骨骼的原点偏差,往往比冲量参数更值得先排查。

7.3 两端出血与表面血迹分别更新

左前臂配方分别指定身体端和碎块端效果。身体端效果跟着残余角色运动,碎块端效果跟着新骨架运动,落地血泊则由碎块自己的组件管理。

此外,blood_mask_default 使用 Incremental 模式和 12 个等级,值域配置为 0 到 51。这是另一层持续外观状态,不等于每次切断都新建一组粒子。

1
2
3
4
5
6
7
8
9
10
11
// 表现配置的职责划分
body.AttachEffect(left_forearm_off_wound_bleeding);
gib.AttachEffect(left_forearm_off_bleeding);
gib.SetPuddlePreset(gib_blood_puddle);

bloodMask = {
mode: Incremental,
levels: 12,
minValue: 0,
maxValue: 51
};

瞬时飞溅负责命中的那一下,端口效果负责位置连续性,表面血迹负责保留变化。把它们分开,才可以分别调整时长和成本,而不是通过无限增加粒子来掩盖错误的几何。


8. 状态与回收:断过的身体必须记得自己缺了什么

8.1 一张状态表不够

游戏程序的身体损伤状态字段里,同时存在 bones_states、body_elements_states、body_masks_values、命中计数和最近命中信息。保留字段名,用不限定底层类型的伪代码表达如下:

1
2
3
4
5
6
7
8
9
10
11
DamageableBodyReplicatedState {
bones_states;
body_elements_states;
body_masks_values;
body_deformation_value;
body_elements_hit_counts;
body_hit_count;
last_hit_impact_position;
last_hit_impact_direction;
time_since_last_hit;
}

它们解决的问题不同:骨骼级状态对应具体损伤位置,区域级状态对应整条手臂等更粗的语义,遮罩状态保存血迹等持续外观,命中计数服务于累计条件,最近命中信息则为反应提供位置和方向。

把这些都塞进一个 isDismembered 布尔值,会马上失去表达能力:一个角色可能手已经没了,上臂还在;左臂失能,右臂仍能攻击;全身有血迹,但没有任何部位脱落。

联机时,应先让各端得到一致的部位状态,再更新外观。碎块飞到哪里可以采用较便宜的表现同步,但“这只手是否还在、还能否攻击”必须保持一致。

8.2 候选检查:先排除重复状态,再选择一个槽

DamageableBodyRuntime 把状态掩码和四个活动槽分开维护。下面将候选检查与执行入口的选槽部分合并成一个函数,保留实际判断顺序。两个状态掩码沿用中性名称 stateMaskA、stateMaskB;函数名为了阅读重新命名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 游戏逻辑:掩码为 64 位,返回执行入口选择的槽位
FindCandidateSlot(runtime, candidate) {
mask = candidate.mask;
if ((runtime.stateMaskB & mask) == mask)
return InvalidSlot;
if ((runtime.stateMaskA & mask) == mask)
return InvalidSlot;

if (runtime.occupiedSlotsMask == 0)
return 0;

for (i = 0; i < 4; ++i) {
existing = runtime.slots[i];
if (existing == null)
return i;
if ((existing.mask & candidate.relationMask) != 0)
return i;
}
return InvalidSlot;
}

开头检查的是候选要求的位是否已经全部被覆盖。例如当前状态是 0011,候选为 0010,相与后仍是 0010,所以拒绝;候选为 0110 时只重叠了一部分,这一条不会拒绝它。

槽位扫描则按顺序返回第一个符合条件的项。空槽可以用,已有项与候选的关系掩码相交也可以用。它没有先扫描所有槽寻找关联项、再回头找空槽;先遇到哪个就返回哪个。

occupiedSlotsMask 是占用位图。例如 0101 表示槽 0 和槽 2 被占用,数值是 5,但活动项只有两个。不能拿这个数直接与槽数比较。

8.3 从身体状态到渲染:把矩阵写进蒙皮网格

选好槽并处理完关联节点以后,执行路径会保存候选、设置占用位,然后调用引擎的 CoSkinnedMesh::SetMeshAttributeMtx。传入的属性索引是 3 * slot + 1,参数类型是 mtx34,也就是一个 3×4 矩阵。

1
2
3
4
5
6
7
8
9
10
// 游戏逻辑:执行路径中的槽位提交部分
PublishSlot(runtime, skinnedMesh, candidate, slot, matrix34) {
runtime.slots[slot] = candidate;
runtime.occupiedSlotsMask |= (1u << slot);

attributeIndex = 3 * slot + 1; // 四个入口:1、4、7、10
skinnedMesh.SetMeshAttributeMtx(attributeIndex, matrix34);

runtime.stateMaskA |= candidate.mask;
}

这一步把“角色发生了某个损伤”接到了“蒙皮网格收到一份空间参数”。美术配置的隐藏体积与补片位置,最终必须进入这样的运行时通道,才能影响可见结果。

这里的固定容量属于这条矩阵属性通道,不代表整个游戏只能存在四个伤口或四块碎块。第 3 节的椭球距离判断给出一种着色实现;这条调用已经明确的是矩阵如何送到网格,完整 Shader 内部仍需要另外解析。

移除路径也会主动复位网格属性,而不只是把指针清空。它使用一个固定矩阵:旋转部分为单位矩阵,平移为 [0, -1024, 0]。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 游戏逻辑:对应槽位的网格属性复位
ResetSlotMatrix(skinnedMesh, slot) {
if (slot >= 4)
return;

resetMatrix = Identity3x4();
resetMatrix.translation = [0, -1024, 0];
skinnedMesh.SetMeshAttributeMtx(3 * slot + 1, resetMatrix);
}

// 游戏逻辑:移除路径的前置判断和槽位清理部分
RemoveCandidateSlots(runtime, skinnedMesh, candidate) {
mask = candidate.mask;
if ((runtime.stateMaskB & mask) == mask)
return;
if ((runtime.stateMaskA & candidate.linkMask) != 0)
return;
if ((runtime.stateMaskA & mask) != mask)
return;

runtime.stateMaskA &= ~mask;
if (runtime.occupiedSlotsMask != 0) {
for (i = 0; i < 4; ++i) {
if (runtime.slots[i] == candidate) {
ResetSlotMatrix(skinnedMesh, i);
runtime.occupiedSlotsMask &= ~(1u << i);
runtime.slots[i] = null;
}
}
}
// 后续继续刷新关联节点的状态。
}

linkMask 是移除入口使用的另一份关联掩码,与选槽时的 relationMask 分开。这里最实用的经验是:**逻辑状态、槽位占用和网格属性必须一起更新。**只清掉逻辑状态,旧矩阵仍可能继续影响外观;只清掉矩阵,重复命中检查又可能认为损伤仍然存在。

8.4 重复切断还要处理上下游关系

先切掉手,再切掉前臂,第二次生成的前臂不能长出一只新手。因此,碎块生成应消费当前部位状态,而不是每次都取完整身体模板。

1
2
3
4
5
6
7
8
9
// 基于部件关系的生成算法示意
CollectDetachedParts(cutRoot, bodyState) {
result = [];
for (part : DescendantsIncludingSelf(cutRoot)) {
if (bodyState.IsPresent(part))
result.append(part);
}
return result;
}

对于体积隐藏路线,同样的关系可以直接写成遮罩组合。假设 H 表示手部隐藏体积,F 表示前臂隐藏体积,两者都在角色绑定姿态空间求值。先切手时,身体显示 1-H,手部碎块显示 H;随后切前臂,新碎块显示的是 F × (1-H),身体显示 (1-F) × (1-H)。如果新碎块只使用 F,完整模型里的手就会重新出现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 实现示例:从完整网格复制姿态,用遮罩保留正确的碎块表面
DetachWithExistingDamage(body, newCut) {
oldHiddenVolumes = body.hiddenVolumes; // 提交新损伤之前取快照
pose = body.CaptureCurrentPose();

gib = CreateSkinnedFragment(body.meshes);
gib.ApplyPose(pose);
gib.showVolume = newCut.volume;
gib.hiddenVolumes = oldHiddenVolumes;

for (patch : Copy(body.woundPatches)) {
if (IsInsideDetachedSubtree(patch.helper, newCut.bone)) {
// 手腕伤口现在属于掉下来的前臂,不能留在身体的手骨上。
gib.AddPatch(patch.mesh, patch.helper, patch.localTransform);
body.RemovePatch(patch);
}
}

body.AddPatch(newCut.bodyPatch, newCut.helper);
gib.AddPatch(newCut.gibPatch, newCut.helper);
body.hiddenVolumes.Add(newCut.volume);
gib.StartPhysics();
}

补片迁移与遮罩继承必须一起做。只复制遮罩,新前臂的手腕会留下一个空洞;只复制补片,补片外面又可能长出完整的手。这里迁移的是部件与附着关系,不是把旧补片当成一个世界坐标固定的物体搬过去。

随后,身体端移除的是这批仍然存在的部分,脱落端生成的也是同一批。重复事件不能再生成第二份;角色已经被回收时,迟到的资源回调也不能把旧碎块重新放回场景。

8.5 创建距离与保留距离要分开

parts_manager_params.scr 将镜头前方的创建距离设置为 25、保留距离设置为 30;后方分别是 15 和 20。按这组距离策略,可以写出一个容易理解的管理算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 距离策略示意;前后判定细节由相机系统提供
ChooseDistanceLimits(isInFrontOfCamera) {
return isInFrontOfCamera
? { create: 25, keep: 30 }
: { create: 15, keep: 20 };
}

CanCreatePart(distance, limits) {
return distance <= limits.create;
}

ShouldRemoveExistingPart(distance, limits) {
return distance > limits.keep;
}

假设镜头前方有一个已经生成的碎块,玩家退到距离 27 时,它仍然保留;但在同样位置发生的新断肢,不必继续生成同等级碎块。这中间的距离差形成了滞回区,可以避免镜头在阈值附近移动时反复创建和删除。

管理器还将更新间隔、移除检查间隔和最大寿命分开配置。它们属于对象生命周期策略,不能当成刚体求解频率。数量多起来后,真正需要预算的是活动刚体、约束、蒙皮与特效,不只是碎块的三角形数。


9. 把一条断肢链真正做通

回到最开始的左前臂。假设身体规则求值时,Biter 的生命比例是 60%,目标位置是 l_forearm,本次切断概率判定也已通过。那么后面的输入和输出应当具体到这些内容:

阶段 本次例子的结果
身体模型 ArmLeft → l_forearm → left_forearm_cut
规则 满足 ≤ 75% 的条件;切断操作本身非强制致死
定位 patch_elem_arm_left_b
外观 按骨架组选择隐藏体积,按 DamageSize 选择双侧补片
运行时槽 检查状态掩码,选择槽,将矩阵写入蒙皮网格属性
脱落体 human_gib,自己的骨架、蒙皮网格、布料与物理组件
物理 sim_cut_limbs,额外请求 l_upperarm,应用碎块运动参数
反馈 身体端出血、碎块端出血、碎块血泊;角色按生死状态选择后续反应

把这些接口组装成自己的系统时,最容易犯的错误,是先把身体状态标成“前臂不存在”,再去读取要生成的前臂。第二步读到的已经是修改后的状态,可能漏掉部件,或退回完整模板把之前切掉的手重新带回来。

因此,集成顺序应当先取快照、准备对象,再提交状态。下面是一个实现示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 实现示例:快照中同时保存当前部件、姿态和运动
HandleBodyDamage(hit, victim) {
location = ResolveBodyLocation(hit, victim.bodyModel);
decision = EvaluateDamageRules(hit, location, victim.bodyState);
if (!decision.hasBodyOperation)
return;

visual = ResolveVisualization(decision.operation, victim.properties);
if (!ResourcesReady(visual)) {
RequestResources(visual);
return; // 此时还不改变身体
}

before = CaptureBodySnapshot(victim);
parts = CollectDetachedParts(decision.cutRoot, before.bodyState);
if (parts.empty())
return; // 重复事件不再生成第二份

staged = PrepareHiddenObjects(visual, parts);
if (!staged.valid)
return;

CopyPoseAtSeparation(before, staged.gib, staged.boneMap);
InitializePhysicsTransforms(staged.gib, before, visual.physics);
InitializePhysicsVelocities(staged.gib, before, hit);

if (victim.bodyRevision != before.revision) {
Discard(staged); // 准备期间状态变了,重新求值
return;
}

// 在同一次提交中更新两端,随后才开放碎块模拟。
ApplyBodyStateChange(victim, decision);
UpdateBodyVisibilityAndCollisions(victim, visual);
AttachPreparedPatches(victim, staged, visual.helper);
RevealAndActivateGib(staged.gib);
ConnectGibEffectsAndLifecycle(staged.gib, visual);

RefreshActionCapabilities(victim);
StartAppropriateHitOrDeathReaction(victim, decision);
}

这个例子专门处理产生碎块的切断路径,破洞、骨折分别进入自己的表现分支。实际接入时,可以在角色生成阶段就预载常用断口资源,避免第一次挥刀才开始加载;如果必须延后创建,则需要保存命中请求,资源就绪后重新检查目标和身体状态。

姿态交接和状态提交应当发生在明确的更新阶段:动画先得到这一帧的姿态,身体与碎块使用同一份快照,物理从初始化好的碰撞体开始接管。这样才能同时避免缺口先出现、碎块晚出现,以及碰撞体出生在旧位置的问题。

做原型时,先用一个调试按钮触发左前臂切断,把这条链跑通,再接真实武器命中。依次检查四组情况:

检查 要回答的问题
静止、弯肘与挥臂时切断 断口是否贴合,碎块有没有姿态跳变?
先断手再断前臂 会不会重复生成或重新长出部件?
不同衣服和远近 LOD 袖口、补片与骨骼映射是否仍然正确?
倒地、贴墙与连续受击 约束、碰撞窗口和动画事件是否协同?

把一条手臂上的这些问题解决,再扩展到全身,才会得到可维护的断肢系统。角色美术交付的是一组能够分离、补口和继续变形的资产;运行时则负责让这些资产在正确时刻,由正确的动画或物理系统接管。


延伸阅读