骨骼动画 · Skeleton Bone · ▶ 在线运行案例

你将学到什么

  • Bone → Skeleton → SkinnedMesh 底层结构(非 glTF 导入)
  • skinIndex / skinWeight 顶点绑骨
  • SkeletonHelper 显示骨骼线框

效果说明

Three.js 官方骨骼示例简化版:手工创建骨骼层级与蒙皮网格,GUI 可开关 animateBones 旋转关节。

核心概念

Bone (parent-child 链)
  ↓
Skeleton(bones, boneInverses)
  ↓
SkinnedMesh(geometry, material) + bind(skeleton)
  ↓
geometry.attributes.skinIndex / skinWeight

理解此结构后,glTF 骨骼动画(modelAnimation)的 AnimationMixer 只是在驱动这些 Bone 的 matrix。

代码要点

import {
    Bone,
    Color,
    CylinderGeometry,
    BoxGeometry,
    DirectionalLight,
    DoubleSide,
    Float32BufferAttribute,
    MeshPhongMaterial,
    PerspectiveCamera,
    Scene,
    SkinnedMesh,
    Skeleton,
    SkeletonHelper,
    Vector3,
    Uint16BufferAttribute,
    WebGLRenderer,
} from "three";

import { GUI } from "three/addons/libs/lil-gui.module.min.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";

let gui, scene, camera, renderer, orbit, lights, mesh, bones, skeletonHelper;

const state = {
    animateBones: false,
};

function initScene() {
    gui = new GUI();

    scene = new Scene();
    scene.background = new Color(0x444444);

    camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 200);
    camera.position.z = 30;
    camera.position.y = 30;

    renderer = new WebGLRenderer({ antialias: true });
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);

    orbit = new OrbitControls(camera, renderer.domElement);
    orbit.enableZoom = false;

    lights = [];
    lights[0] = new DirectionalLight(0xffffff, 3);
    lights[1] = new DirectionalLight(0xffffff, 3);
    lights[2] = new DirectionalLight(0xffffff, 3);

    lights[0].position.set(0, 200, 0);
    lights[1].position.set(100, 200, 100);
    lights[2].position.set(-100, -200, -100);

    scene.add(lights[0]);
    scene.add(lights[1]);
    scene.add(lights[2]);

    window.addEventListener(
        "resize",
        function () {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();

            renderer.setSize(window.innerWidth, window.innerHeight);
        },
        false
    );

    initBones();
    setupDatGui();
}

function createGeometry(sizing) {
    // const geometry = new CylinderGeometry(
    // 	5, // radiusTop
    // 	5, // radiusBottom
    // 	sizing.height, // height
    // 	8, // radiusSegments
    // 	sizing.segmentCount * 3, // heightSegments
    // 	true // openEnded
    // );

    const geometry = new BoxGeometry(5,sizing.height,5,1,sizing.segmentCount*10)

    const position = geometry.attributes.position;

    const vertex = new Vector3();

    const skinIndices = [];
    const skinWeights = [];

    for (let i = 0; i < position.count; i++) {
        vertex.fromBufferAttribute(position, i);

        const y = vertex.y + sizing.halfHeight;

        const skinIndex = Math.floor(y / sizing.segmentHeight);
        const skinWeight = (y % sizing.segmentHeight) / sizing.segmentHeight;

        skinIndices.push(skinIndex, skinIndex + 1, 0, 0);
        skinWeights.push(1 - skinWeight, skinWeight, 0, 0);
    }

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

    return geometry;
}

function createBones(sizing) {
    bones = [];

    let prevBone = new Bone();
    bones.push(prevBone);
    prevBone.position.y = -sizing.halfHeight;

    for (let i = 0; i < sizing.segmentCount; i++) {
        const bone = new Bone();
        bone.position.y = sizing.segmentHeight;
        bones.push(bone);
        prevBone.add(bone);
        prevBone = bone;
    }

    return bones;
}

function createMesh(geometry, bones) {
    const material = new MeshPhongMaterial({
        color: 0x156289,
        emissive: 0x072534,
        side: DoubleSide,
        flatShading: true,
    });

    const mesh = new SkinnedMesh(geometry, material);
    const skeleton = new Skeleton(bones);

    mesh.add(bones[0]);

    mesh.bind(skeleton);

    skeletonHelper = new SkeletonHelper(mesh);
    skeletonHelper.material.linewidth = 2;
    scene.add(skeletonHelper);

    return mesh;
}

function setupDatGui() {
    let folder = gui.addFolder("General Options");

    folder.add(state, "animateBones");
    folder.controllers[0].name("Animate Bones");

    folder.add(mesh, "pose");
    folder.controllers[1].name(".pose()");

    const bones = mesh.skeleton.bones;

    for ( let i = 0; i < bones.length; i ++ ) {

        const bone = bones[ i ];

        folder = gui.addFolder( 'Bone ' + i );

        folder.add( bone.position, 'x', - 10 + bone.position.x, 10 + bone.position.x );
        folder.add( bone.position, 'y', - 10 + bone.position.y, 10 + bone.position.y );
        folder.add( bone.position, 'z', - 10 + bone.position.z, 10 + bone.position.z );

        folder.add( bone.rotation, 'x', - Math.PI * 0.5, Math.PI * 0.5 );
        folder.add( bone.rotation, 'y', - Math.PI * 0.5, Math.PI * 0.5 );
        folder.add( bone.rotation, 'z', - Math.PI * 0.5, Math.PI * 0.5 );

        folder.add( bone.scale, 'x', 0, 2 );
        folder.add( bone.scale, 'y', 0, 2 );
        folder.add( bone.scale, 'z', 0, 2 );

        folder.controllers[ 0 ].name( 'position.x' );
        folder.controllers[ 1 ].name( 'position.y' );
        folder.controllers[ 2 ].name( 'position.z' );

        folder.controllers[ 3 ].name( 'rotation.x' );
        folder.controllers[ 4 ].name( 'rotation.y' );
        folder.controllers[ 5 ].name( 'rotation.z' );

        folder.controllers[ 6 ].name( 'scale.x' );
        folder.controllers[ 7 ].name( 'scale.y' );
        folder.controllers[ 8 ].name( 'scale.z' );

    }
}

function initBones() {
    const segmentHeight = 8;
    const segmentCount = 4;
    const height = segmentHeight * segmentCount;
    const halfHeight = height * 0.5;

    const sizing = {
        segmentHeight,
        segmentCount,
        height,
        halfHeight,
    };

    const geometry = createGeometry(sizing);
    const bones = createBones(sizing);
    mesh = createMesh(geometry, bones);

    mesh.scale.multiplyScalar(1);
    scene.add(mesh);
}

function render() {
    requestAnimationFrame(render);

    const time = Date.now() * 0.001;

    //Wiggle the bones
    if (state.animateBones) {
        for (let i = 0; i < mesh.skeleton.bones.length; i++) {
            mesh.skeleton.bones[i].rotation.z = (Math.sin(time) * 2) / mesh.skeleton.bones.length;
        }
    }

    renderer.render(scene, camera);
}

initScene();
render();

完整源码:GitHub

小结