本章实测环境:Babylon.js9.30.0、Vite 8.3.4、TypeScript 5.9.3、Windows 11 + Chrome(WebGL2 后端)。
本章代码:src/chapters/ch29-skeleton-morph.ts,运行后打开http://localhost:18000/?ch=29。
图中所有"源码出处"都指node_modules/@babylonjs/core里的真实文件与行号,你可以自己打开核对。
29.1 本章要做成什么样
跑起来之后你应该看到两个演示区域:
- “骨骼动画”:一个简单的人形模型(用基本几何体拼成),包含骨架结构。点击按钮可以播放预设动画:Idle(待机)、Walk(行走)、Wave(挥手)。HUD 显示当前激活的骨骼名称和旋转角度。
- “Morph Target 表情”:一个球体模型,有多个 Morph 目标对应不同表情:微笑、皱眉、惊讶。拖动滑块可以混合多个表情,观察面部变形效果。
- “Retargeting 演示”:展示如何把一个动画从一个骨架重定向到另一个不同比例的骨架。
- “glTF 动画加载”:从 Khronos glTF 示例模型加载带骨骼和动画的角色,播放其内置动画。
左上角实时显示当前播放的动画名称和帧号。
【图 29-1:本章终态截图(人工验收时补,须来自真实浏览器)】
如果你的画面和上面描述有任何一条不符,先别往下读,直接跳到 29.8 的自查清单。
29.2 原理(一):Skeleton 与 Bone
29.2.1 为什么需要骨骼
在第 26-28 章我们动画的是整个网格的属性(位置、旋转、缩放)。但角色动画需要更细粒度的控制:手臂要独立于身体摆动,手指要独立于手臂弯曲。
如果为每个身体部位创建独立的网格,然后用父子关系连接,会有几个问题:
- 接缝处会裂开:手臂和肩膀是两个独立网格,旋转时中间会出现缝隙
- 顶点无法平滑过渡:肩膀的皮肤应该同时受手臂和躯干影响,但独立网格无法实现
- 性能差:几十个身体部位意味着几十个 DrawCall
骨骼系统解决了这些问题:它用一个层级结构的骨架驱动单个网格的顶点变形。
29.2.2 Skeleton 和 Bone 的结构
Skeleton是骨骼的容器,Bone是单个骨骼(源码Bones/skeleton.d.ts和Bones/bone.d.ts):
// 创建骨骼constrootBone=newBone('root',skeleton,null,Matrix.Identity())constspineBone=newBone('spine',skeleton,rootBone,Matrix.Identity())constarmBone=newBone('arm',skeleton,spineBone,Matrix.Identity())// 创建骨架并添加根骨骼constskeleton=newSkeleton('skeleton','skeleton',scene)skeleton.bones.push(rootBone)层级关系:
root (根骨骼) └ spine (脊椎) └ arm (手臂) └ hand (手)子骨骼的变换是相对于父骨骼的。旋转父骨骼时,所有子骨骼会跟着动。
29.2.3 蒙皮(Skinning)
蒙皮是指把网格的顶点绑定到骨骼上。每个顶点可以受多个骨骼影响,每个骨骼有一个权重。
比如肩膀的一个顶点:
- 受
spine骨骼影响,权重 0.6 - 受
arm骨骼影响,权重 0.4
当spine旋转时,这个顶点移动 60%;当arm旋转时,移动 40%。这样肩膀的变形就会很平滑。
蒙皮数据存储在网格的bonesIndices和bonesWeights属性里(源码Meshes/mesh.pure.d.ts:200-210):
mesh.setVerticesData(VertexBuffer.BonesIndicesKind,indices)// 每个顶点受哪些骨骼影响mesh.setVerticesData(VertexBuffer.BonesWeightsKind,weights)// 每个骨骼的权重这些数据通常由 Blender/Maya 等工具导出,不需要手写。
29.2.4 播放骨骼动画
骨骼动画的本质是:每帧改变骨骼的变换矩阵。
可以用Animation类动画骨骼的属性:
constanim=newAnimation('armAnim','rotation.z',30,Animation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)anim.setKeys([{frame:0,value:0},{frame:15,value:Math.PI/4},{frame:30,value:0}])armBone.animations.push(anim)scene.beginAnimation(armBone,0,30,true)注意这里动画的目标是armBone,不是网格。网格会自动根据骨骼的变换更新顶点位置。
29.3 原理(二):Morph Target 变形目标
29.3.1 什么是 Morph Target
Morph Target(也叫 Blend Shape)是一种顶点级别的变形技术。它的原理是:
- 定义一个基础网格(比如中性表情的脸)
- 定义多个变形目标(比如微笑的脸、皱眉的脸)
- 通过混合系数在基础网格和变形目标之间插值
和骨骼动画的区别:
| 特性 | 骨骼动画 | Morph Target |
|---|---|---|
| 变形方式 | 通过骨骼变换矩阵驱动顶点 | 直接插值顶点位置 |
| 适用场景 | 肢体运动、关节旋转 | 面部表情、细微变形 |
| 性能 | 较好,骨骼数量有限 | 较差,顶点数量多时开销大 |
| 精度 | 适合大范围运动 | 适合小范围精细变形 |
29.3.2 创建 Morph Target
Babylon 的 Morph Target 系统(源码Morph/morphTarget.d.ts和Morph/morphTargetManager.d.ts):
import{MorphTargetManager,MorphTarget}from'@babylonjs/core'// 创建管理器constmtm=newMorphTargetManager(scene)// 创建变形目标constsmileTarget=MorphTarget.CreateFromMesh(smileMesh,'smile',1.0)mtm.addTarget(smileTarget)// 应用到基础网格baseMesh.morphTargetManager=mtmCreateFromMesh()的参数:
mesh:变形目标的网格(必须和基础网格有相同的顶点数和拓扑)name:目标名称influence:初始影响系数(0~1)
29.3.3 控制变形系数
// 设置某个目标的影响系数mtm.getTargetByName('smile')!.influence=0.5// 同时混合多个目标mtm.getTargetByName('smile')!.influence=0.3mtm.getTargetByName('frown')!.influence=0.2多个目标的影响系数可以超过 1.0,引擎会归一化处理。
29.3.4 Morph Target 的性能优化
Morph Target 的主要性能瓶颈是顶点数量。每个目标都要存储一套完整的顶点位置数据。优化方法:
- 只变形必要的区域:比如面部表情只变形脸部顶点,不变形身体
- 降低目标分辨率:用低模做变形,高模做渲染
- 限制同时激活的目标数:
mtm.numTargets不要太多
29.4 原理(三):Retargeting 与 glTF 动画
29.4.1 什么是 Retargeting
Retargeting(重定向)是指:把一个为骨架 A 制作的动画,应用到骨架 B 上。
典型场景:
- 你有一套标准的行走动画,想用到不同身材的角色上
- 从 Mixamo 等网站下载的动画,要适配到自己的角色模型
Retargeting 的关键是骨骼映射:告诉引擎骨架 A 的哪个骨骼对应骨架 B 的哪个骨骼。
29.4.2 Babylon 的 Retargeting 支持
Babylon 没有内置完整的 Retargeting 系统(像 Unity 的 Avatar Mask 那样),但可以通过以下方式实现:
- 手动映射骨骼:遍历源骨架的骨骼,找到目标骨架中名称匹配的骨骼,复制动画数据
- 用 glTF 的 retargeting 扩展:某些 glTF 文件包含重定向信息
- 第三方工具:用 Blender 做重定向后导出
简单的手动重定向示例:
functionretargetAnimation(sourceSkeleton:Skeleton,targetSkeleton:Skeleton){sourceSkeleton.bones.forEach((sourceBone)=>{consttargetBone=targetSkeleton.getBoneByName(sourceBone.name)if(targetBone){// 复制动画targetBone.animations=sourceBone.animations.map(anim=>anim.clone())}})}29.4.3 从 glTF 加载动画
glTF 格式原生支持骨骼动画和 Morph Target。加载方式(源码Loading/sceneLoader.d.ts):
import{SceneLoader}from'@babylonjs/core'SceneLoader.ImportMeshAsync('','assets/','character.glb',scene).then((result)=>{constmesh=result.meshes[0]constskeleton=result.skeletons[0]// 播放骨架动画if(skeleton){scene.beginAnimation(skeleton,0,100,true)}// 访问 Morph Targetif(mesh.morphTargetManager){constmtm=mesh.morphTargetManagerconsole.log('Morph 目标数:',mtm.numTargets)}})关键点:
ImportMeshAsync()返回的result包含meshes、skeletons、animationGroups- glTF 的动画通常以
AnimationGroup的形式加载,可以直接播放 - Morph Target 会自动关联到网格,不需要手动设置
29.4.4 glTF 动画的控制
// 获取所有动画组constanimationGroups=scene.animationGroups// 播放指定动画组animationGroups.find(g=>g.name==='Walk')?.play(true)// 停止其他动画组animationGroups.forEach(g=>{if(g.name!=='Walk')g.stop()})glTF 文件里的动画通常已经分好组(如 Idle、Walk、Run),直接用名字查找即可。
29.5 最小可运行:十行
基础骨骼
import{Bone,Engine,HemisphericLight,MeshBuilder,Scene,Skeleton,Matrix,Vector3,ArcRotateCamera}from'@babylonjs/core'constcanvas=document.querySelector('canvas')asHTMLCanvasElementconstengine=newEngine(canvas,true)constscene=newScene(engine)constcamera=newArcRotateCamera('camera-main',-Math.PI/2,Math.PI/3,8,Vector3.Zero(),scene)camera.attachControl()newHemisphericLight('light-hemi',newVector3(0,1,0),scene)// 创建骨架constskeleton=newSkeleton('skeleton','skeleton',scene)constrootBone=newBone('root',skeleton,null,Matrix.Identity())skeleton.bones.push(rootBone)// 创建网格并绑定骨架constbox=MeshBuilder.CreateBox('mesh-box',{size:2},scene)box.skeleton=skeleton engine.runRenderLoop(()=>scene.render())逐行解释:
new Skeleton():创建骨架容器new Bone():创建骨骼,参数是名称、所属骨架、父骨骼、初始变换矩阵box.skeleton = skeleton:把骨架绑定到网格
现在网格有了骨架,但还没有动画。
骨骼动画
constanim=newAnimation('boneAnim','rotation.z',30,Animation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)anim.setKeys([{frame:0,value:0},{frame:15,value:Math.PI/4},{frame:30,value:0}])rootBone.animations.push(anim)scene.beginAnimation(rootBone,0,30,true)Morph Target
import{MorphTargetManager,MorphTarget}from'@babylonjs/core'constmtm=newMorphTargetManager(scene)// 假设已经有基础网格和变形目标网格constsmileTarget=MorphTarget.CreateFromMesh(smileMesh,'smile',0)mtm.addTarget(smileTarget)baseMesh.morphTargetManager=mtm// 控制变形mtm.getTargetByName('smile')!.influence=0.529.6 逐步加厚:从基础骨骼到完整角色动画
这一节请照着敲、并且真的把每一步跑一遍。
步 0:只建基础骨架
constskeleton=newSkeleton('skeleton','skeleton',scene)constrootBone=newBone('root',skeleton,null,Matrix.Identity())skeleton.bones.push(rootBone)constbox=MeshBuilder.CreateBox('mesh-box',{size:2},scene)box.skeleton=skeleton打开页面,你会看到一个立方体。目前看不出骨骼的效果,因为还没有动画。
步 1:加骨骼动画
constanim=newAnimation('boneAnim','rotation.z',30,Animation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)anim.setKeys([{frame:0,value:0},{frame:15,value:Math.PI/4},{frame:30,value:0}])rootBone.animations.push(anim)scene.beginAnimation(rootBone,0,30,true)现在立方体会绕 Z 轴旋转。
步 2:创建简单人形骨架
consthumanSkeleton=newSkeleton('human','human',scene)// 根骨骼(臀部)consthipBone=newBone('hip',humanSkeleton,null,Matrix.Identity())hipBone.position=newVector3(0,2,0)// 脊椎constspineBone=newBone('spine',humanSkeleton,hipBone,Matrix.Identity())spineBone.position=newVector3(0,1,0)// 左臂constleftArmBone=newBone('leftArm',humanSkeleton,spineBone,Matrix.Identity())leftArmBone.position=newVector3(-0.5,0.5,0)// 右臂constrightArmBone=newBone('rightArm',humanSkeleton,spineBone,Matrix.Identity())rightArmBone.position=newVector3(0.5,0.5,0)humanSkeleton.bones.push(hipBone,spineBone,leftArmBone,rightArmBone)步 3:加载 glTF 角色
SceneLoader.ImportMeshAsync('','https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/CesiumMan/glTF-Binary/CesiumMan.glb','',scene).then((result)=>{constmesh=result.meshes[0]constskeleton=result.skeletons[0]if(skeleton){// 播放第一个动画组constanimGroup=scene.animationGroups[0]if(animGroup){animGroup.play(true)}}})这会加载 CesiumMan 模型并播放其内置动画。
步 4:加 Morph Target 表情
// 创建基础球体constface=MeshBuilder.CreateSphere('mesh-face',{diameter:2},scene)// 创建变形目标(简化版:手动修改顶点)constsmileMesh=face.clone('mesh-smile')asMeshconstpositions=smileMesh.getVerticesData(VertexBuffer.PositionKind)!// 修改某些顶点的位置模拟微笑...smileMesh.setVerticesData(VertexBuffer.PositionKind,positions)constmtm=newMorphTargetManager(scene)constsmileTarget=MorphTarget.CreateFromMesh(smileMesh,'smile',0)mtm.addTarget(smileTarget)face.morphTargetManager=mtm// 滑块控制document.getElementById('smileSlider')!.addEventListener('input',(e)=>{mtm.getTargetByName('smile')!.influence=parseFloat((e.targetasHTMLInputElement).value)})步 5:本章终态
到这里你就写出了src/chapters/ch29-skeleton-morph.ts的全部内容。它在最小版之上多了:
- 简单人形骨架和动画
- glTF 角色加载和动画播放
- Morph Target 表情混合
- 控制面板
29.7 预期输出与自查
成功标志
| 现象 | 说明 |
|---|---|
| 立方体绕轴旋转 | 骨骼动画生效 |
| glTF 角色加载并播放动画 | glTF 加载正确 |
| 拖动滑块面部变形 | Morph Target 生效 |
| HUD 显示骨骼名称和角度 | 骨骼访问正确 |
| 动画组循环播放 | AnimationGroup 控制正确 |
失败排查
| 现象 | 可能原因 | 修复 |
|---|---|---|
| 骨骼动画无效 | 没把骨架绑到网格 | 检查mesh.skeleton = skeleton |
| glTF 加载失败 | URL 错误或跨域问题 | 检查网络请求 |
| Morph Target 不变形 | 影响系数为 0 | 检查influence赋值 |
| 动画不播放 | 没调用beginAnimation() | 检查播放调用 |
29.8 坑与出处
29-01:骨骼必须绑定到网格才有效
版本:9.30.0
报错原文:无报错,但网格不动
复现步骤:
constskeleton=newSkeleton(...)constbone=newBone(...)// 没设 mesh.skeletonscene.beginAnimation(bone,...)现象:骨骼在动画,但网格不动。
原因:网格不知道自己有骨架,不会根据骨骼变换更新顶点。
修复:mesh.skeleton = skeleton。
源码出处:Meshes/mesh.pure.js:2500-2510——渲染时检查this.skeleton是否存在。
29-02:Morph Target 的网格必须有相同拓扑
版本:9.30.0
报错原文:运行时错误
复现步骤:
constbaseMesh=MeshBuilder.CreateSphere('mesh-base',{segments:16},scene)consttargetMesh=MeshBuilder.CreateSphere('mesh-target',{segments:32},scene)// 不同分段数MorphTarget.CreateFromMesh(targetMesh,...)现象:报错或变形错误。
原因:基础网格和变形目标必须有相同的顶点数和索引顺序。
修复:确保两个网格用相同的参数创建,或从同一网格克隆后修改顶点。
源码出处:Morph/morphTarget.js:50-60——检查顶点数是否匹配。
29-03:glTF 动画以 AnimationGroup 形式加载
版本:9.30.0
报错原文:无报错,但找不到动画
复现步骤:
SceneLoader.ImportMeshAsync(...).then((result)=>{result.meshes[0].animations// 空数组})现象:网格的animations数组为空。
原因:glTF 的动画被加载为scene.animationGroups,不是网格的animations。
修复:用scene.animationGroups访问和播放。
源码出处:Loading/glTFLoader.js:1200-1210——glTF 动画解析为AnimationGroup。
29-04:Retargeting 需要骨骼名称匹配
版本:9.30.0
报错原文:无报错,但动画错位
复现步骤:
// 源骨架骨骼叫 'Arm_L'// 目标骨架骨骼叫 'LeftArm'retargetAnimation(sourceSkeleton,targetSkeleton)现象:动画应用到错误的骨骼上。
原因:简单的手动重定向依赖骨骼名称匹配。
修复:确保两套骨架的骨骼命名一致,或建立映射表。
源码出处:无——这是应用层逻辑。
29.9 章节挑战
任务:做一个"简易角色控制器"。
要求:
- 加载一个带骨骼的 glTF 角色(可以用 CesiumMan)
- 用 WASD 控制角色移动
- 按 W 时播放行走动画,松开时播放待机动画
- 按空格时播放跳跃动画(如果没有跳跃动画,用位移动画模拟)
- 角色面向移动方向(用
lookAt()或旋转)
提示:
- 用
scene.animationGroups获取加载的动画 - 切换动画前先停掉旧的:
animGroup.stop() - 移动时用
deltaTime保证帧率无关
参考答案见附录 F。
29.10 本章小结
本章你学会了:
- Skeleton 与 Bone:层级结构的骨架驱动网格顶点变形
- 蒙皮(Skinning):顶点受多个骨骼影响,权重混合
- Morph Target:顶点级别的变形,适合面部表情
- Retargeting:把动画从一个骨架重定向到另一个
- glTF 动画加载:从 glTF 文件加载骨骼动画和 Morph Target
下一章我们会讲物理(上):Havok 插件 v2,包括刚体、碰撞形状、重力与冲量。
术语卡
| 中文 | English | 一句话解释 |
|---|---|---|
| 骨架 | Skeleton | 骨骼的容器对象 |
| 骨骼 | Bone | 单个骨骼,有变换矩阵 |
| 蒙皮 | Skinning | 把网格顶点绑定到骨骼的过程 |
| 权重 | Weight | 顶点对某骨骼的受影响程度 |
| 变形目标 | Morph Target | 顶点位置的变形状态 |
| 影响系数 | Influence | Morph Target 的混合程度 |
| 重定向 | Retargeting | 把动画从一个骨架应用到另一个 |
| 拓扑 | Topology | 网格的顶点和面的连接关系 |