Three.js Bone 类深入解析:骨骼动画体系中最基础的骨架单元
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
本文围绕 Three.js 官方 API 文档 Bone.html.md 展开,系统讲解THREE.Bone类的定位、构造方式与全部公开属性,并结合仓库源码 src/objects/Bone.js 以及它直接服务的 Skeleton、SkinnedMesh 实现,帮助读者掌握从创建骨骼层级、参与蒙皮计算到进入 GPU 蒙皮渲染的完整技术链路。
一、Bone 是什么:继承关系与角色定位
官方文档对Bone的定义非常明确:
A bone which is part of a Skeleton. The skeleton in turn is used by the SkinnedMesh.
(骨骼是 Skeleton 的一部分,而 Skeleton 又被 SkinnedMesh 使用。)
其继承链为:
EventDispatcher → Object3D → Bone这意味着Bone完整复用了 Object3D 的全部能力:位置(position)、旋转(quaternion/rotation)、缩放(scale)、父子层级(add/parent)、世界矩阵(matrixWorld)、名称与 UUID 等。Bone本身没有为渲染提供任何几何或材质,它的存在意义是承载一次骨骼变换,供蒙皮计算使用。在 Three.js 的骨骼动画体系中,三者的分工是:
| 类 | 文件 | 职责 |
|---|---|---|
Bone | src/objects/Bone.js | 单个骨骼节点,存储局部变换 |
Skeleton | src/objects/Skeleton.js | 持有骨骼数组、逆绑定矩阵,负责把骨骼数据打包给着色器 |
SkinnedMesh | src/objects/SkinnedMesh.js | 携带skinIndex/skinWeight属性的网格,顶点按骨骼权重变形 |
需要强调的是:Bone是"纯数据 + 变换"节点,不参与常规绘制。官方文档与 SkinnedMesh 的源码注释都指出,带骨骼的网格"通常不是手工创建的,而是由GLTFLoader、FBXLoader等加载器导入的",手写Bone更多用于理解原理或搭建简单骨架。
二、构造与最小示例
构造函数
文档给出的构造器签名只有一个:
new Bone()无任何参数。对应源码实现极为精简(全文仅 39 行):
// 摘自 src/objects/Bone.js class Bone extends Object3D { constructor() { super(); /** * This flag can be used for type testing. * @type {boolean} * @readonly * @default true */ this.isBone = true; this.type = 'Bone'; } }构造函数(src/objects/Bone.js)只做三件事:
- 调用
super()完成Object3D的初始化(分配 UUID、单位矩阵等); - 设置只读类型标记
this.isBone = true(Bone.js); - 设置类型字符串
this.type = 'Bone',供序列化器与类型判断使用。
文档示例:构建骨骼层级
官方文档给出的核心示例,展示了利用继承自Object3D的add()方法建立骨骼父子链:
const root = new THREE.Bone(); const child = new THREE.Bone(); root.add( child ); child.position.y = 5;这里的关键点在于:子骨骼的变换是相对于父骨骼的局部变换。child.position.y = 5表示该骨骼在其父坐标系中沿 Y 轴偏移 5 个单位;而该骨骼最终的世界矩阵matrixWorld由Object3D的层级更新机制沿父链自动累乘得到。
在此基础上,Skeleton 的源码注释提供了一个更贴近实战的"手臂骨架"示例,值得完整收录:
const bones = []; const shoulder = new THREE.Bone(); const elbow = new THREE.Bone(); const hand = new THREE.Bone(); shoulder.add( elbow ); elbow.add( hand ); bones.push( shoulder, elbow, hand); shoulder.position.y = -5; elbow.position.y = 0; hand.position.y = 5; const armSkeleton = new THREE.Skeleton( bones );注意Skeleton的bones参数是一个扁平数组:层级关系由add()建立,数组只负责登记"这个骨架包含哪些骨骼"。后续蒙皮计算、序列化都依赖这个数组的顺序与骨骼索引一一对应。
三、属性详解:.isBone
文档列出的唯一公开属性是类型标记:
.isBone : boolean (readonly)
This flag can be used for type testing. Default is
true.
isBone在 Three.js 中是贯穿整个引擎的"鸭子类型"约定(类似isMesh、isSkinnedMesh)。它不是摆设,仓库源码中有多处真实依赖:
Skeleton.pose()判断父链是否为骨骼(src/objects/Skeleton.js):恢复基础姿势时,只有当bone.parent.isBone为真才按"骨骼对骨骼"的方式反推局部矩阵,否则直接取世界矩阵。这保证了骨架根节点即使挂在普通Object3D下也能正确工作。SkeletonHelper绘制辅助线(src/helpers/SkeletonHelper.js):遍历网格子节点时用bone.parent.isBone区分骨段端点与骨架根节点,决定画线段的起点。ObjectLoader反序列化(src/loaders/ObjectLoader.js):解析 JSON 场景时通过child.isBone把骨骼注册进bones字典,供Skeleton.fromJSON()按 UUID 找回。
因此在自定义代码中对场景树做遍历或过滤时,推荐以isBone作为类型判定依据,而不是instanceof(跨 bundle 时instanceof可能失效)。
四、Bone 在 Skeleton 中的数据流
理解Bone不能脱离它被消费的方式。以下均出自 src/objects/Skeleton.js 源码:
逆绑定矩阵:boneInverses
构造函数(Skeleton.js)接收bones与可选的boneInverses。若未提供逆矩阵,init()会自动调用calculateInverses():
// src/objects/Skeleton.js —— calculateInverses() for ( let i = 0, il = this.bones.length; i < il; i ++ ) { const inverse = new Matrix4(); if ( this.bones[ i ] ) { inverse.copy( this.bones[ i ].matrixWorld ).invert(); } this.boneInverses.push( inverse ); }每个骨骼的"绑定姿态"世界矩阵在此刻被取逆并固化。这个逆矩阵是蒙皮计算的基准:着色器需要用当前姿态相对绑定姿态的偏移量来变形顶点,而非世界矩阵本身,否则模型会整体跳变到骨骼世界位置。
每帧更新:update()
// src/objects/Skeleton.js —— update()(简化) const matrix = bones[ i ] ? bones[ i ].matrixWorld : _identityMatrix; _offsetMatrix.multiplyMatrices( matrix, boneInverses[ i ] ); _offsetMatrix.toArray( boneMatrices, i * 16 );update()把每根骨骼的matrixWorld × boneInverse压平为Float32Array(每 16 个浮点一个矩阵),写入boneMatrices。若骨骼数组存在空洞(bones[i]为undefined),则以单位矩阵占位,保证纹理布局连续。
这一步由渲染管线自动触发,无需手动调用。src/renderers/webgl/WebGLObjects.js 在更新几何体时按帧号去重:
if ( object.isSkinnedMesh ) { const skeleton = object.skeleton; if ( updateMap.get( skeleton ) !== frame ) { skeleton.update(); updateMap.set( skeleton, frame ); } }同一帧内多个SkinnedMesh共享同一Skeleton时,update()只执行一次——这也是 SkinnedMesh 文档中DetachedBindMode(多网格共享骨架)能够成立的前提。
骨骼纹理:computeBoneTexture()
GPU 端不直接消费 CPU 数组,而是消费纹理。computeBoneTexture()(Skeleton.js)把矩阵数据排布成DataTexture,其布局规则在源码注释中有明确表格:
// 1 个矩阵 = 4 个像素(RGBA RGBA RGBA RGBA 对应矩阵四列) // 8x8 像素纹理最多容纳 16 根骨骼 // 16x16 像素纹理最多容纳 64 根骨骼 // 32x32 像素纹理最多容纳 256 根骨骼 // 64x64 像素纹理最多容纳 1024 根骨骼 let size = Math.sqrt( this.bones.length * 4 ); size = Math.ceil( size / 4 ) * 4; size = Math.max( size, 4 );尺寸取"4 的倍数且不小于 4",是为了保证texelFetch取列时不会跨行取错。顶点着色器端对应实现见 src/renderers/shaders/ShaderChunk/skinning_pars_vertex.glsl.js:
uniform highp sampler2D boneTexture; mat4 getBoneMatrix( const in float i ) { int size = textureSize( boneTexture, 0 ).x; int j = int( i ) * 4; int x = j % size; int y = j / size; vec4 v1 = texelFetch( boneTexture, ivec2( x, y ), 0 ); vec4 v2 = texelFetch( boneTexture, ivec2( x + 1, y ), 0 ); vec4 v3 = texelFetch( boneTexture, ivec2( x + 2, y ), 0 ); vec4 v4 = texelFetch( boneTexture, ivec2( x + 3, y ), 0 ); return mat4( v1, v2, v3, v4 ); }USE_SKINNING宏开启时(由材质/几何体是否具备蒙皮属性在WebGLProgram中注入),每个顶点按skinIndex取最多 4 根骨骼的矩阵、按skinWeight加权合成,再乘bindMatrixInverse回到网格空间。也就是说,Bone上任何position/rotation改动,最终都经过"世界矩阵 → 偏移矩阵 → 骨骼纹理 → 顶点加权变形"这条链路抵达画面。
其他实用方法
getBoneByName( name )(Skeleton.js):按名字线性查找骨骼。从加载器导入的模型骨骼普遍带名字(如mixamorig:Spine),这是动画重定向、手动驱动某根骨骼的常用入口。因此手动创建Bone时给其命名是良好习惯。pose()(Skeleton.js):利用boneInverses恢复绑定姿态的世界矩阵,再逐骨骼分解回position/quaternion/scale。注意其中再次出现bone.parent.isBone判断(L177),与上文第三节的分析呼应。toJSON()/fromJSON()(Skeleton.js):序列化时bones字段存的是每根骨骼的 UUID,boneInverses存矩阵展开数组;反序列化时若 UUID 找不到对应骨骼会warn并补一根空Bone占位,从而保持索引对齐。
五、使用建议与注意事项
结合文档与源码结构,总结若干实践要点:
- 不要拿
Bone当普通容器用。它没有渲染职责,且会参与Skeleton的索引体系。需要纯分组时请使用Group或Mesh;Bone只应在蒙皮骨架内部出现。 - 绑定时机很关键。
calculateInverses()以"调用那一刻"的matrixWorld为准。若骨架尚未加入场景(matrixWorld未更新),逆矩阵会失真——正确姿势是先scene.updateMatrixWorld()或调用object.updateWorldMatrix(true, true),再创建/init()骨架。 - 手动动画骨骼只需改局部变换。例如每帧
elbow.rotation.x = Math.sin( t ) * 0.5,剩下的世界矩阵累乘、skeleton.update()、纹理上传都由引擎完成,不需要手动调用skeleton.update()(渲染循环会代劳,见 WebGLObjects.js)。 - 骨架数量要匹配。
Skeleton构造时若boneInverses长度与bones不一致,源码会warn并重置为一组单位矩阵(Skeleton.js),蒙皮结果将是错误的——此时应检查骨骼数组是否漏填。 - 复杂角色模型交给加载器。如 SkinnedMesh 的类注释所述,典型流程是
GLTFLoader/FBXLoader导入模型,Bone、Skeleton、skinIndex/skinWeight由解析器装配;AnimationMixer再对命名骨骼播放动画。手写骨骼适合原型验证与教学,生产环境建议以加载器产物为准。
六、小结
THREE.Bone虽然源码只有不到四十行,却处于 Three.js 骨骼动画体系的枢纽位置:它作为Object3D的子类承载局部变换,靠isBone标记被Skeleton、SkeletonHelper、ObjectLoader识别;其世界矩阵经Skeleton.update()与逆绑定矩阵相乘后,打包进骨骼纹理,最终在顶点着色器中驱动SkinnedMesh的顶点变形。理解了这条"局部变换 → 偏移矩阵 → 骨骼纹理 → 蒙皮着色"的数据链路,也就掌握了 Three.js 中骨骼动画的运行原理。
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考