2. 虚拟形象骨骼绑定

任务说明

本案例为3D虚拟形象(以标准T-pose人体网格为例)添加骨骼系统并完成蒙皮,使模型能够通过骨骼旋转驱动顶点变形,实现基本的肢体动画(如手臂摆动)。采用Three.js的SkinnedMesh与Bone实现,核心目标:给定一个顶点网格和骨骼层级,正确建立骨骼-顶点权重绑定,并验证骨骼旋转能带动对应区域顶点平滑移动。

实现方案

使用Three.js内置的骨骼动画系统。构建一个简化的上肢骨骼链(肩→肘→腕),创建对应的圆柱体网格作为蒙皮对象,通过SkinnedMesh将骨骼与网格关联。蒙皮权重按顶点到骨骼线的距离自动计算(最近骨骼权重为1),实现基本的刚性蒙皮效果。最终通过旋转肩部骨骼,观察前臂与手部顶点跟随运动。

代码实现

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

// --- 1. 场景基础 ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(5, 3, 8);
camera.lookAt(0, 1, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
new OrbitControls(camera, renderer.domElement);

// 光照
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(2, 5, 3);
scene.add(dirLight);

// --- 2. 创建骨骼层级(肩→肘→腕)---
const shoulder = new THREE.Bone();
const elbow = new THREE.Bone();
const wrist = new THREE.Bone();
shoulder.add(elbow);
elbow.add(wrist);
// 骨骼位置(局部坐标)
shoulder.position.set(0, 2.2, 0);   // 肩部
elbow.position.set(0, -1.2, 0);    // 肘部(相对肩)
wrist.position.set(0, -1.0, 0);    // 腕部(相对肘)

// 骨骼辅助显示
const skeletonHelper = new THREE.SkeletonHelper(new THREE.Object3D());
// 稍后绑定到SkinnedMesh

// --- 3. 创建蒙皮网格(手臂圆柱体)---
// 分段数:径向8,高度20,保证顶点密度足够
const geometry = new THREE.CylinderGeometry(0.25, 0.25, 2.4, 8, 20);
geometry.translate(0, 1.2, 0); // 使圆柱底部位于原点,顶部在y=2.4

// 计算蒙皮权重:每个顶点归属最近的骨骼
const boneList = [shoulder, elbow, wrist];
const skinIndices = [];
const skinWeights = [];

const positionAttr = geometry.attributes.position;
for (let i = 0; i < positionAttr.count; i++) {
    const v = new THREE.Vector3().fromBufferAttribute(positionAttr, i);
    // 将顶点变换到骨骼局部空间比较距离(简化:用世界坐标近似)
    // 骨骼世界坐标(此时尚未加入场景,手动计算)
    const shoulderPos = new THREE.Vector3(0, 2.2, 0);
    const elbowPos = new THREE.Vector3(0, 1.0, 0);   // 2.2 - 1.2
    const wristPos = new THREE.Vector3(0, 0.0, 0);   // 1.0 - 1.0

    const dists = [
        v.distanceTo(shoulderPos),
        v.distanceTo(elbowPos),
        v.distanceTo(wristPos)
    ];
    const minDist = Math.min(...dists);
    const boneIndex = dists.indexOf(minDist);

    // 刚性权重:最近骨骼权重1,其余0
    skinIndices.push(boneIndex, 0, 0, 0);
    skinWeights.push(1, 0, 0, 0);
}

geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(skinIndices, 4));
geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(skinWeights, 4));

// --- 4. 构建SkinnedMesh ---
const material = new THREE.MeshStandardMaterial({
    color: 0x4a90d9,
    skinning: true,  // 启用蒙皮
    side: THREE.DoubleSide
});
const mesh = new THREE.SkinnedMesh(geometry, material);
mesh.add(shoulder);  // 骨骼加入mesh
mesh.bind(new THREE.Skeleton([shoulder, elbow, wrist]));

// 调整mesh位置使肩部在原点附近
mesh.position.y = -1.0;
scene.add(mesh);

// 添加骨骼可视化
const skeleton = mesh.skeleton;
const helper = new THREE.SkeletonHelper(mesh);
helper.material.linewidth = 2;
scene.add(helper);

// --- 5. 动画:旋转肩部骨骼实现手臂摆动 ---
let angle = 0;
function animate() {
    requestAnimationFrame(animate);
    // 肩部绕Z轴摆动(手臂前后摆动)
    angle = Math.sin(Date.now() * 0.002) * 0.6;
    shoulder.rotation.z = angle;
    // 肘部轻微弯曲
    elbow.rotation.z = Math.sin(Date.now() * 0.003) * 0.3;
    renderer.render(scene, camera);
}
animate();

// 窗口自适应
window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
});

落地与运行

依赖:Node.js环境,安装three(^0.160.0)。使用Vite或直接通过importmap方式在浏览器运行。

npm init -y
npm install three
# 将上述代码保存为 main.js,配合index.html引入

index.html:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>骨骼绑定演示</title>
    <style>body{margin:0;overflow:hidden}</style>
</head>
<body>
    <script type="importmap">
    {
        "imports": {
            "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
            "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
        }
    }
    </script>
    <script type="module" src="./main.js"></script>
</body>
</html>

启动本地服务器(如npx vite),浏览器打开页面。

验证方法

判定标准:

  1. 页面显示一个蓝色圆柱体,肩部(顶部)、肘部(中部)、腕部(底部)有黄色骨骼线框标记。
  2. 圆柱体自动往复摆动:肩部骨骼绕Z轴旋转,圆柱体上半部分(上臂)随之整体摆动;肘部骨骼弯曲时,圆柱体下半部分(前臂)相对上臂产生弯曲。
  3. 蒙皮无撕裂:圆柱体表面在关节处平滑过渡,没有顶点飞散或网格断裂。
  4. 通过OrbitControls旋转视角,从不同方向观察骨骼与网格的贴合度,骨骼旋转时对应顶点跟随运动。

预期输出:圆柱体呈现周期性的摆臂动作,摆动幅度约±34°(肩部),肘部弯曲约±17°,网格保持连续完整。

骨骼绑定与蒙皮验证结构 骨骼层级 肩 (Bone) 肘 (Bone) 腕 (Bone) 蒙皮网格(圆柱体) 上臂段 前臂段 手部段 肩关节 肘关节 腕关节 蒙皮权重分配 上臂顶点 → 肩骨 (w=1) 前臂顶点 → 肘骨 (w=1) 手部顶点 → 腕骨 (w=1) 刚性蒙皮:最近骨骼权重=1 skinIndex 记录骨骼序号 验证流程 1. 旋转肩部骨骼 2. 观察上臂跟随 3. 肘部弯曲 4. 前臂相对弯曲 ✓ 判定通过: • 网格无撕裂,关节处平滑过渡 • 骨骼旋转角度与网格弯曲角度一致 • 骨骼线框始终贴合在网格内部

关键说明

本案例采用刚性蒙皮(每顶点仅受最近骨骼影响),适用于演示骨骼绑定核心机制。实际生产级蒙皮通常使用平滑权重(多骨骼加权混合),可在skinWeights中分配多组非零权重实现。当前实现中骨骼位置与网格顶点坐标在同一坐标系下计算权重,确保绑定正确。通过SkeletonHelper可视化骨骼层级,便于直观验证骨骼与网格的联动关系。