《Cocos 游戏开发高级编程》6.2 骨骼动画与蒙皮

骨骼动画把动画数据从「节点属性」换成「几十上百个关节的变换矩阵」。本节讲清 Skeleton 资源里的 joints 与 bindposes、SkeletalAnimation 的烘焙开关、蒙皮顶点的加权计算,以及蒙皮模型为什么难以合批、SkinnedMeshBatchRenderer 又如何把共享骨骼的模型合并。

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,tag 3.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 里不动,只是换材质参数」。蒙皮打破了这一点:同一个网格在不同时刻的顶点位置不同,而顶点位置的计算依赖每个实例自己的关节矩阵。

具体障碍有三层:

  1. 关节矩阵是每实例的。两个角色用同一套动画,但播放进度、朝向、缩放不同,关节矩阵就不同,无法像静态网格那样把变换折进顶点。
  2. 蒙皮发生在顶点着色器。实时模式下顶点着色器要读一张关节矩阵纹理,纹理内容是每实例的,合批会破坏「一次 draw 共享一套 uniform」的假设。
  3. 材质与贴图不同。不同角色穿不同衣服,贴图不同自然不能合批。

结论:蒙皮模型默认每个实例一次 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 动画性能与内存 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画