☰
第 29 章 骨骼与 Morph Target
2026/10/15 1:11:09 网站建设 项目流程

本章实测环境: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 本章要做成什么样

跑起来之后你应该看到两个演示区域:

  1. “骨骼动画”:一个简单的人形模型(用基本几何体拼成),包含骨架结构。点击按钮可以播放预设动画:Idle(待机)、Walk(行走)、Wave(挥手)。HUD 显示当前激活的骨骼名称和旋转角度。
  2. “Morph Target 表情”:一个球体模型,有多个 Morph 目标对应不同表情:微笑、皱眉、惊讶。拖动滑块可以混合多个表情,观察面部变形效果。
  3. “Retargeting 演示”:展示如何把一个动画从一个骨架重定向到另一个不同比例的骨架。
  4. “glTF 动画加载”:从 Khronos glTF 示例模型加载带骨骼和动画的角色,播放其内置动画。

左上角实时显示当前播放的动画名称和帧号。

【图 29-1:本章终态截图(人工验收时补,须来自真实浏览器)】

如果你的画面和上面描述有任何一条不符,先别往下读,直接跳到 29.8 的自查清单。


29.2 原理(一):Skeleton 与 Bone

29.2.1 为什么需要骨骼

在第 26-28 章我们动画的是整个网格的属性(位置、旋转、缩放)。但角色动画需要更细粒度的控制:手臂要独立于身体摆动,手指要独立于手臂弯曲。

如果为每个身体部位创建独立的网格,然后用父子关系连接,会有几个问题:

  1. 接缝处会裂开:手臂和肩膀是两个独立网格,旋转时中间会出现缝隙
  2. 顶点无法平滑过渡:肩膀的皮肤应该同时受手臂和躯干影响,但独立网格无法实现
  3. 性能差:几十个身体部位意味着几十个 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)是一种顶点级别的变形技术。它的原理是:

  1. 定义一个基础网格(比如中性表情的脸)
  2. 定义多个变形目标(比如微笑的脸、皱眉的脸)
  3. 通过混合系数在基础网格和变形目标之间插值

和骨骼动画的区别:

特性骨骼动画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=mtm

CreateFromMesh()的参数:

  1. mesh:变形目标的网格(必须和基础网格有相同的顶点数和拓扑)
  2. name:目标名称
  3. 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 的主要性能瓶颈是顶点数量。每个目标都要存储一套完整的顶点位置数据。优化方法:

  1. 只变形必要的区域:比如面部表情只变形脸部顶点,不变形身体
  2. 降低目标分辨率:用低模做变形,高模做渲染
  3. 限制同时激活的目标数: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 那样),但可以通过以下方式实现:

  1. 手动映射骨骼:遍历源骨架的骨骼,找到目标骨架中名称匹配的骨骼,复制动画数据
  2. 用 glTF 的 retargeting 扩展:某些 glTF 文件包含重定向信息
  3. 第三方工具:用 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())

逐行解释:

  1. new Skeleton():创建骨架容器
  2. new Bone():创建骨骼,参数是名称、所属骨架、父骨骼、初始变换矩阵
  3. 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.5

29.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 章节挑战

任务:做一个"简易角色控制器"。

要求:

  1. 加载一个带骨骼的 glTF 角色(可以用 CesiumMan)
  2. 用 WASD 控制角色移动
  3. 按 W 时播放行走动画,松开时播放待机动画
  4. 按空格时播放跳跃动画(如果没有跳跃动画,用位移动画模拟)
  5. 角色面向移动方向(用lookAt()或旋转)

提示:

  • 用scene.animationGroups获取加载的动画
  • 切换动画前先停掉旧的:animGroup.stop()
  • 移动时用deltaTime保证帧率无关

参考答案见附录 F。


29.10 本章小结

本章你学会了:

  1. Skeleton 与 Bone:层级结构的骨架驱动网格顶点变形
  2. 蒙皮(Skinning):顶点受多个骨骼影响,权重混合
  3. Morph Target:顶点级别的变形,适合面部表情
  4. Retargeting:把动画从一个骨架重定向到另一个
  5. glTF 动画加载:从 glTF 文件加载骨骼动画和 Morph Target

下一章我们会讲物理(上):Havok 插件 v2,包括刚体、碰撞形状、重力与冲量。


术语卡

中文English一句话解释
骨架Skeleton骨骼的容器对象
骨骼Bone单个骨骼,有变换矩阵
蒙皮Skinning把网格顶点绑定到骨骼的过程
权重Weight顶点对某骨骼的受影响程度
变形目标Morph Target顶点位置的变形状态
影响系数InfluenceMorph Target 的混合程度
重定向Retargeting把动画从一个骨架应用到另一个
拓扑Topology网格的顶点和面的连接关系

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询