6.2 骨骼动画与蒙皮
6.1 的求值结果是一组节点属性:位置、旋转、缩放。骨骼动画换了一种数据——求值对象变成几十上百个**关节(joint)**的变换矩阵,再由这些矩阵去「蒙」一张网格。本节回答三个问题:骨骼层级怎么组织、蒙皮怎么算、它和渲染合批为什么天然冲突。
《方块冲刺》本身是 2D,但这套系统值得搞清:微信小游戏里做角色换装、做 3D 展示页、用 Spine 之外的自研骨骼,都会撞上同一批概念。与站内 graphics/ 那批图形学文的分工:那边讲蒙皮矩阵的数学推导;这里讲 Cocos 3.8.8 里这套系统的组件形态与运行时取舍。
6.2.1 骨骼层级:Skeleton 资源里存的是什么
骨骼不是普通的节点树,它是资源 + 节点树的组合。资源侧是 Skeleton(继承自 Asset),三个字段构成全部信息(cc.d.ts 声明):
| 字段 | 含义 |
|---|---|
joints: string[] | 所有关节的路径,长度恒等于 bindposes |
bindposes: Mat4[] | 每个关节的绑定姿势矩阵 |
inverseBindposes: Mat4[] | 绑定姿势的逆矩阵,蒙皮时真正参与乘法 |
「绑定姿势」(bind pose)指模型在建模软件里摆好的那个基准姿态。蒙皮时顶点先被 inverseBindposes 拉回关节局部空间,再乘当前帧的关节世界矩阵,就得到「顶点跟着关节动」的结果。inverseBindposes 是 bindposes 的逆,引擎在 Skeleton 加载时算好并缓存,validate() 用来检查 joints 与 bindposes 是否成对——长度不等时该资源不可用。
源码位置:cocos/3d/assets/skeleton.ts(tag 3.8.8)。关节路径是相对骨骼根节点的字符串,不是对象引用——这也是 .prefab 里骨骼只存路径、运行时才按路径重建节点树的原因。
6.2.2 SkeletalAnimation 与 Animation 的关系
SkeletalAnimation extends Animation(cc.d.ts 原文,源码 cocos/3d/skeletal-animation/skeletal-animation.ts,tag 3.8.8)。它继承了全部播放控制(play / crossFade / stop),额外加了两样东西:
useBakedAnimation: boolean,默认true。开启后动画被预烘焙,性能大幅提升,但「所有动画效果会被彻底固定,不支持任何形式的编辑和混合」(cc.d.ts注释)。运行时改这个值,下一条播放的动画才生效;编辑器(非预览)模式下它不起作用,动画总是非烘焙。- 挂点系统:
sockets: Socket[]、createSocket(path)、querySockets()、rebuildSocketAnimations()。
对应的状态类是 SkeletalAnimationState extends AnimationState(源码 cocos/3d/skeletal-animation/skeletal-animation-state.ts)。它覆写了 _createState / _doCreateState / doPlayOrCrossFade,把 6.1 那套「采样出节点属性」换成「采样出关节矩阵」。
一个反直觉的点:烘焙模式和 crossFade 互斥。useBakedAnimation = true 时姿态被烘死,weight 混合没有意义。需要「走 → 跑 → 跳」之间平滑过渡的模型,必须关掉烘焙。
6.2.3 蒙皮计算:每个顶点乘一组骨骼矩阵
蒙皮的输入是一张网格、一套骨骼、以及每个顶点的骨骼权重(通常每顶点 4 个关节,权重和为 1)。输出是变形后的顶点位置:
v' = Σ(i=0..3) w_i * (currentJointMatrix_i * inverseBindPose_i) * v
currentJointMatrix_i * inverseBindPose_i 这个乘积叫蒙皮矩阵,可以预乘成 4×4 矩阵存起来,每帧只需对每个关节算一次,然后所有共享该关节的顶点复用。这就是骨骼动画比「逐顶点脚本变换」快的原因:矩阵算 N 次(N = 关节数),顶点只做加权求和。
引擎有两种模型后端(cc.d.ts 里 SkinnedMeshRenderer.model 的返回类型直接佐证):
SkinningModel(源码cocos/3d/models/skinning-model.ts,tag3.8.8)——实时计算,每帧把关节矩阵上传到 GPU,顶点着色器里做蒙皮。BakedSkinningModel(源码cocos/3d/models/baked-skinning-model.ts)——预烘焙,把整段动画的关节矩阵烘成一张纹理,播放时按时间采样纹理。
烘焙数据由 cocos/3d/skeletal-animation/skeletal-animation-data-hub.ts 统一管理(cc.d.ts 里烘焙符号的返回类型 BakeData 指向该模块)。同一个 Skeleton 的多个模型共享同一份烘焙纹理,这是下一节「共享骨骼能合批」的前提。
网格侧还需要两组顶点属性配合:JOINTS_0(每顶点最多 4 个关节下标)与 WEIGHTS_0(对应权重)。它们由建模软件导出、经编辑器导入时写入 .mesh,运行时只读。顶点权重的分配质量直接决定关节处会不会「穿模」——这不是引擎能替你修的问题,权重必须在 DCC 工具里调好。
6.2.4 为什么蒙皮模型难以合批
普通 MeshRenderer 合批的前提是「顶点数据在 GPU 里不动,只是换材质参数」。蒙皮打破了这一点:同一个网格在不同时刻的顶点位置不同,而顶点位置的计算依赖每个实例自己的关节矩阵。
具体障碍有三层:
- 关节矩阵是每实例的。两个角色用同一套动画,但播放进度、朝向、缩放不同,关节矩阵就不同,无法像静态网格那样把变换折进顶点。
- 蒙皮发生在顶点着色器。实时模式下顶点着色器要读一张关节矩阵纹理,纹理内容是每实例的,合批会破坏「一次 draw 共享一套 uniform」的假设。
- 材质与贴图不同。不同角色穿不同衣服,贴图不同自然不能合批。
结论:蒙皮模型默认每个实例一次 draw call。场景里 30 个敌人,就是 30 次。这条和卷一 3.3 讲的 2D 合批是两个独立的机制,别指望 2D 那套 BATCHER2D_MEM_INCREMENT 的自动合批能覆盖 3D 蒙皮。
6.2.5 SkinnedMeshBatchRenderer:共享骨骼的合批
有一类场景能合批:同一套骨骼、同一套动画、只换贴图。典型是「同一个角色模型换不同颜色的皮肤」,或者一队完全同步的士兵。引擎为此提供了 SkinnedMeshBatchRenderer extends SkinnedMeshRenderer(源码 cocos/3d/skinned-mesh-renderer/skinned-mesh-batch-renderer.ts)。
它的工作方式是把多份贴图拼进一张图集,让所有实例共用一张纹理,从而合成一次 draw:
| 成员 | 作用 |
|---|---|
units: SkinnedMeshUnit[] | 合批前的子蒙皮模型数组,数据来源 |
atlasSize: number | 生成的图集边长,需要按子模型数量预估 |
batchableTextureNames: string[] | 材质里真正参与合图的贴图属性名 |
cook() | 执行合图与数据合并 |
SkinnedMeshUnit 里存 mesh / skeleton / material,以及它在图集里的 offset / size(UV 偏移与尺寸)。合批后顶点带着图集 UV,不同实例落在图集不同区域,一次 draw 画完。
代价很明确:atlasSize 要足够大,否则贴图被压缩;所有实例必须共享骨骼与动画进度,一旦某个实例要播不同动作,就得拆回独立渲染。所以这个组件解决的是「大量完全同步的同类单位」,不是通用优化手段。
6.2.6 挂点 Socket(L2)
「把武器挂在角色手上」这类需求,靠挂点实现。先注册挂点,再取回目标节点:
import { _decorator, Component, Node, SkeletalAnimation } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('WeaponSocket')
export class WeaponSocket extends Component {
@property(SkeletalAnimation) skel: SkeletalAnimation | null = null;
@property(Node) weapon: Node | null = null;
start(): void {
if (!this.skel || !this.weapon) return;
// 路径是相对骨骼根节点的关节路径
const hand = this.skel.createSocket('Root/Hips/Spine/RightHand');
if (hand && this.weapon.parent !== hand) {
this.weapon.setParent(hand);
}
// 骨骼刚加载完时用这个把挂点矩阵同步一次
this.skel.rebuildSocketAnimations();
}
}
三个约束:
- 必须先注册挂点,再挂自定义节点。
cc.d.ts对sockets的注释是「要挂载自定义节点到受动画驱动的骨骼上,必须先在此注册挂点」。createSocket会自动注册,直接setParent到一个未注册的关节节点会错位。 - 路径要相对骨骼根节点。
querySockets()可以回读当前所有挂点路径,调试时先打出来核对。 - 烘焙模式下挂点仍然有效,因为挂点同步的是关节世界矩阵,与姿态怎么算出来无关。但烘焙后关节矩阵按纹理采样,挂点更新在
rebuildSocketAnimations之后才能取到当帧值。
6.2.7 在编辑器里看骨骼结构(需编辑器,不可命令行复现)
本节涉及 .prefab / .scene 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 在编辑器里导入一个带动画的 FBX 后,层级管理器会展开成一棵骨骼节点树,SkinnedMeshRenderer 挂在根节点上:
{
"__type__": "cc.Node",
"_name": "Character",
"_children": [
{ "__type__": "cc.Node", "_name": "Hips" },
{ "__type__": "cc.Node", "_name": "Spine" },
{ "__type__": "cc.Node", "_name": "RightHand" }
],
"_components": [
{ "__type__": "cc.SkeletalAnimation" },
{ "__type__": "cc.SkinnedMeshRenderer" }
]
}
(字段做了简化,实际 .prefab 里 _children 是嵌套的完整树,组件上还有 __uuid__ 资源引用。该片段同样需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。)打开后应看到:
- 层级管理器里根节点
Character下有Hips→Spine→RightHand的嵌套结构。 - 属性检查器里
SkinnedMeshRenderer分组有Skeleton(拖拽槽,指向.skeleton资源)和Skinning Root(指向动画组件所在节点)。 SkeletalAnimation分组有Use Baked Animation(勾选框,默认勾选)和Sockets(数组)。
skeleton 资源对应 cocos/3d/assets/skeleton.ts 里的 Skeleton,skinningRoot 必须指向控制该模型的动画组件所在节点(cc.d.ts 原文),指错节点会导致蒙皮跟着错误的变换走。
6.2.8 用纯 TS 验证蒙皮权重(L1)
上面那串公式可以抽出一个与引擎无关的最小实现:给定顶点在绑定姿势下的坐标、一组关节的当前平移、以及该顶点的权重表,算出变形后的坐标。这段逻辑完全在 CPU 上、可单测,用来验证「权重是否归一化」「零权重怎么退化」。
// mini2d 侧:骨骼蒙皮的最小模型,纯 TS,可单测
export interface Bone {
// 关节当前世界变换的平移分量;旋转、缩放同理,这里只演示平移
x: number;
y: number;
}
export interface SkinWeight {
boneIndex: number;
weight: number;
}
export function normalizeWeights(ws: SkinWeight[]): SkinWeight[] {
let sum = 0;
for (const w of ws) sum += w.weight;
if (sum <= 0) return ws.map((w) => ({ boneIndex: w.boneIndex, weight: 0 }));
return ws.map((w) => ({ boneIndex: w.boneIndex, weight: w.weight / sum }));
}
export function skinPoint(
rest: { x: number; y: number },
bones: Bone[],
ws: SkinWeight[],
): { x: number; y: number } {
const normalized = normalizeWeights(ws);
let x = 0;
let y = 0;
for (const w of normalized) {
const bone = bones[w.boneIndex];
x += (rest.x + bone.x) * w.weight;
y += (rest.y + bone.y) * w.weight;
}
return { x, y };
}
import { describe, expect, it } from 'vitest';
import { normalizeWeights, skinPoint } from './skinning';
describe('skinning', () => {
it('权重归一化后总和为 1', () => {
const n = normalizeWeights([
{ boneIndex: 0, weight: 2 },
{ boneIndex: 1, weight: 2 },
]);
expect(n[0].weight + n[1].weight).toBeCloseTo(1);
expect(n[0].weight).toBeCloseTo(0.5);
});
it('单骨骼时顶点跟随骨骼平移', () => {
const p = skinPoint({ x: 0, y: 0 }, [{ x: 10, y: 5 }], [{ boneIndex: 0, weight: 1 }]);
expect(p.x).toBeCloseTo(10);
expect(p.y).toBeCloseTo(5);
});
it('双骨骼按权重取中点', () => {
const p = skinPoint(
{ x: 0, y: 0 },
[{ x: 0, y: 0 }, { x: 10, y: 0 }],
[{ boneIndex: 0, weight: 1 }, { boneIndex: 1, weight: 1 }],
);
expect(p.x).toBeCloseTo(5);
});
it('权重全为 0 时顶点不动', () => {
const p = skinPoint({ x: 3, y: 4 }, [{ x: 100, y: 100 }], [{ boneIndex: 0, weight: 0 }]);
expect(p.x).toBe(3);
expect(p.y).toBe(4);
});
});
这里刻意保留了一个与引擎的差异:引擎的关节矩阵是 4×4、包含旋转与缩放,而上面的 Bone 只有平移。把平移换成矩阵乘法就是完整的蒙皮——v' = Σ w_i · M_i · v,normalizeWeights 那段仍然通用。权重不归一化是 DCC 导出最常见的坑,表现是模型整体缩放异常或关节处鼓包;normalizeWeights 那三条测试就是防止代码里偷偷引入未归一化的权重。
6.2.9 工程决策与常见错误
- 角色数量少、动作要混合 → 用
SkeletalAnimation+ 关烘焙。混合优先于性能。 - 角色数量多、动作统一(同屏小怪、人群)→ 用
SkinnedMeshBatchRenderer,但先确认它们真的共享骨骼与进度。 - 只是 2D 换帧动画 → 别引入骨骼系统。
AnimationClip.createWithSpriteFrames(6.1.1)就够,骨骼的蒙皮开销对 2D 精灵是纯浪费。 - 关节数要克制。蒙皮矩阵是逐关节算的,手机上超过 ~60 个关节就要留意;超出部分用 LOD 或烘焙降到更低帧率。
- 挂点路径写死在代码里很脆。把关节路径做成可配置的
@property,美术改骨骼命名时不至于全项目搜索替换。
排查清单,按症状定位:
| 症状 | 大概率原因 |
|---|---|
| 模型不跟随骨骼移动 | skinningRoot 指错节点 |
| 关节处穿模、鼓包 | 顶点权重未归一化或权重分配错误 |
| 换装后贴图错乱 | 合批图集 atlasSize 太小,UV 被压缩 |
| 过渡动画「跳变」 | useBakedAnimation 仍为 true,混合被忽略 |
| 挂点节点错位 | 未先 createSocket 注册就 setParent |
| 切动画不生效 | 运行时改了 useBakedAnimation,需等下一条 clip 才生效 |
下一节把 6.1 和 6.2 的代价合起来算总账:动画到底吃掉多少 CPU 和内存,采样率与烘焙该怎么取舍。
阅读导航:上一节:6.1 动画系统内部与求值 · 下一节:6.3 动画性能与内存 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。