Three.js ボーンを使ってアニメーション

メタセコイア 4 の有償版のライセンスを購入したので、期間を気にせずモデルデータを.glb で出せるようになりました。
それで、ボーンを設定したモデルのアニメーションに挑戦しました。
(パーツ分割でアニメーションするのって苦しかったなぁと、5 年ぶりにチャレンジしたら苦しかったのを思い出したのもあるけれども。)

今回作ったのは、こんなもの。

ボーンを設定したら、枝の構造があるモデルも簡単に扱えるようになりました。(世の中的には当たり前のことをおそらく言っている)

実行環境

以前のThree.js で 3D モデル(glTF)を表示するで用意した vite で環境を用意します。

前提

public/models/bone ディレクトリ以下に読み込むモデルが保存されている状態です。

  • public/models/bone/bone.glb 長い棒にボーンを設定したもの
  • public/models/bone/bone2.glb 関節のようにボーンを設定したもの
  • public/models/bone/bone3.glb 枝のようにボーンを設定したもの

実装

app/src/part.ts

モデルデータの読み込み処理。

app/src/part.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import * as THREE from "three/build/three.module.js";
// GLTFLoader を使う
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";

// 非同期なモデル読み込み
class Part {
_object: any;
_fileName: string;

constructor(modelDataUrl: string) {
this._object = null;
this._fileName = modelDataUrl;
}

asyncload(isTest = false): Promise<GLTF> {
return new Promise((resolve) => {
new GLTFLoader().load(this._fileName, (data: THREE.Group) => {
for (let i = 0; i < data.scene.children.length; i++) {
if (isTest && data.scene.children[i].isMesh) {
data.scene.children[i].material.wireframe = true;
}
}
resolve(data);
});
});
}

async load(isTest = false) {
let data = await this.asyncload(isTest);
this._object = data.scene;
return this;
}

getObject() {
return this._object;
}
}

export default Part;

app/src/body.ts

関節角度の決定などの処理担当部分。

app/src/body.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
import * as THREE from "three/build/three.module.js";
import Part from "./part";

interface PartSet {
file: string;
position: {
x: number;
y: number;
z: number;
};
}

interface Bone {
name: string;
radians: { x: number; y: number; z: number };
}

interface Pose {
name: string;
timeline: [
{
time: number;
bones: Bone[];
}
];
}

interface BodySetup {
file: string;
poses: Array<Pose>;
}

// 関節のあるモデルの定義
class Body {
_setup: BodySetup;
_objects: THREE.Group;
_poseNumber: number;
_timelineNumber: number;
_targetPose: Pose | undefined;
_diffBones: Bone[] | undefined;
_homeBones: Bone[] | undefined;
_count: number;
_accumulationCount: number;

constructor(setup: BodySetup) {
this._setup = setup;
this._objects = [];
this._poseNumber = 0;
this._timelineNumber = 0;
this._count = 0;
this._accumulationCount = 0;
}

// モデルデータ読み込み、初期化処理
async load(isTest = false) {
let file = this._setup.file;

let part = new Part(file);
this._objects = (await part.load(isTest)).getObject();

this._defaultPose();
this._setTargetPose();

return this._objects;
}
// モデルが読み込まれた時点で設定されているボーンの角度をデフォルトとして補完しておく
_defaultPose() {
const pose = this._setup.poses.find((pose) => pose.name == "default");

if (!pose) return;

const targetTimelineBones = pose.timeline[this._timelineNumber].bones;
const boneNames = targetTimelineBones.map((bone) => bone.name);

this._homeBones = boneNames.map((boneName) => {
const currentBone = this._objects.getObjectByName(boneName, true);
if (!currentBone) return this._noneBone();

return {
name: boneName,
radians: {
x: currentBone.rotation.x,
y: currentBone.rotation.y,
z: currentBone.rotation.z,
},
};
});
}

// 次に取るポーズを設定
_setTargetPose(poseName = "default") {
const pose = this._setup.poses.find((pose) => pose.name == poseName);
this._targetPose = pose;

this._setDiffPose();
} // 提示されたポーズに対して、現在のポーズとの差分を計算

_setDiffPose() {
const pose = this._targetPose;

if (!pose) return;

if (!this._homeBones) return;

const targetTimelineBones = pose.timeline[this._timelineNumber].bones;
const boneNames = targetTimelineBones.map((bone) => bone.name);

this._diffBones = boneNames.map((boneName) => {
const currentBone = this._objects.getObjectByName(boneName, true);
const targetBone = targetTimelineBones.find(
(bone) => bone.name == boneName
);
if (!this._homeBones) return this._noneBone();

const homeBone = this._homeBones.find((bone) => bone.name == boneName);

if (!homeBone) return this._noneBone();
if (!currentBone) return this._noneBone();
if (!targetBone) return this._noneBone();

return {
name: boneName,
radians: this._boneRadiansDiff(homeBone, targetBone, currentBone),
};
});
} // 関節角度の変更差分計算処理

_boneRadiansDiff(home: Bone, target: Bone, current: THREE.object3d) {
return {
x: home.radians.x + target.radians.x - current.rotation.x,
y: home.radians.y + target.radians.y - current.rotation.y,
z: home.radians.z + target.radians.z - current.rotation.z,
};
}

// 読み込めないboneがあった際のデフォルト値 => クラス外に出してしまってもよかったかも
_noneBone(): Bone {
return {
name: "none",
radians: {
x: 0,
y: 0,
z: 0,
},
};
} // 次のポーズを設定

_nextTagetPose() {
this._timelineNumber++;

const pose = this._targetPose;

if (!pose) return;

let targetPoseName = pose.name;

if (this._timelineNumber >= pose.timeline.length) {
this._timelineNumber = 0;
targetPoseName = "default";
}
this._setTargetPose(targetPoseName);
}

// 設定に基づく角度変更
_updatePose(difference: number) {
const diffBones = this._diffBones;

if (!diffBones) return;

diffBones.forEach((diffBone) => {
// getObjectByName によりオブジェクト名でオブジェクトを呼び出せる true を付与すると再帰的に調べてくれるため、
// 深いところにあるオブジェクトも簡単に取得できる。
const currentBone = this._objects.getObjectByName(diffBone.name, true);

if (!currentBone) return;
if (!this._targetPose) return;
const time = this._targetPose.timeline[this._timelineNumber].time;
const targetBone = this._targetPose.timeline[
this._timelineNumber
].bones.find((bone) => bone.name == diffBone.name);

currentBone.rotation.x += (difference * diffBone.radians.x) / time;
currentBone.rotation.y += (difference * diffBone.radians.y) / time;
currentBone.rotation.z += (difference * diffBone.radians.z) / time;
});
}

// 関節角度の変更を外部から呼び出す
update(time: number, poseName = "default") {
const difference = time - this._count;
this._count = time;
this._accumulationCount += difference;

this._setup.poses[this._poseNumber];

if (!this._diffBones) return;

if (!this._targetPose) return;

if (
this._accumulationCount >=
this._targetPose.timeline[this._timelineNumber].time
) {
this._nextTagetPose();
this._accumulationCount = 0;
}

if (poseName != "default") {
this._accumulationCount = 0;
this._timelineNumber = 0;
this._setTargetPose(poseName);
}

this._updatePose(difference);
}
}

export default Body;

app/src/bone.ts

関節角度や、モデルのデータをまとめてエクスポートします。

app/src/bone.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
import bone from "../public/models/bone/bone3.glb?url";

export default {
file: bone,
poses: [
{
name: "default",
timeline: [
{
time: 2000,
bones: [
{ name: "bone1", radians: { x: 1, y: 0, z: 0 } },
{ name: "bone2", radians: { x: 1, y: 0, z: 0 } },
{ name: "bone3", radians: { x: 1, y: 0, z: 0 } },
{ name: "bone5", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone6", radians: { x: 0, y: 0, z: 0 } },
],
},
{
time: 2000,
bones: [
{ name: "bone1", radians: { x: -1, y: 0, z: 0 } },
{ name: "bone2", radians: { x: -1, y: 0, z: 0 } },
{ name: "bone3", radians: { x: -1, y: 0, z: 0 } },
{ name: "bone5", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone6", radians: { x: 0, y: 0, z: 0 } },
],
},
],
},
{
name: "action1",
timeline: [
{
time: 300,
bones: [
{ name: "bone1", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone2", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone3", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone5", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone6", radians: { x: 0, y: 0, z: 0 } },
],
},
{
time: 300,
bones: [
{ name: "bone1", radians: { x: 0, y: 2, z: 0 } },
{ name: "bone2", radians: { x: 0, y: -2, z: 0 } },
{ name: "bone3", radians: { x: 0, y: 2, z: 0 } },
{ name: "bone5", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone6", radians: { x: 0, y: 5, z: 0 } },
],
},
{
time: 300,
bones: [
{ name: "bone1", radians: { x: 0, y: 2, z: 0 } },
{ name: "bone2", radians: { x: 0, y: -2, z: 0 } },
{ name: "bone3", radians: { x: 0, y: 2, z: 0 } },
{ name: "bone5", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone6", radians: { x: 0, y: 5, z: 0 } },
],
},
{
time: 300,
bones: [
{ name: "bone1", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone2", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone3", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone5", radians: { x: 0, y: 0, z: 0 } },
{ name: "bone6", radians: { x: 0, y: 0, z: 0 } },
],
},
],
},
],
};

app/main.ts

エントリーポイントと、メインの処理を呼び出す app/main.ts。

app/main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import * as THREE from "./node_modules/three/build/three.module.js";
import boneParam from "./src/bone";
import Body from "./src/body";

let camera, scene, renderer;
let object, axis, light;
let body;

init();

async function init() {
camera = new THREE.PerspectiveCamera(
70,
window.innerWidth / window.innerHeight,
0.01,
100
);
camera.position.set(0.03, 0.01, 0.03);
camera.lookAt(new THREE.Vector3(0, 0.0, 0));

scene = new THREE.Scene();

light = new THREE.AmbientLight(0xffffff, 1.0);
scene.add(light);

//関節のあるオブジェクトの読み込み
body = new Body(boneParam);
object = await body.load(true);
scene.add(object);

// 座標情報をはっきりさせるためにx=0 y=0 z=0 に軸表示のヘルパーを置く
axis = THREE.AxisHelper(2000);
axis.position.set(0, 0, 0);
scene.add(axis);

renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.name = "AA";
renderer.setAnimationLoop(animation);
document.getElementById("app").appendChild(renderer.domElement);
}

let t = 0;

function animation(time) {
t++;
body.update(time);
if (t % 300 == 0) {
body.update(time, "action1");
}
renderer.render(scene, camera);
}

アニメーションさせるボーンを名前で設定できるので、順番で設定するよりもだいぶわかり易くなったと感じます。

動作確認

3 種類のモデルを読み込ませ、同じモーションで動かしてみます。

public/models/bone/bone.glb 長い棒にボーンを設定したもの

public/models/bone/bone2.glb 関節のようにボーンを設定したもの

public/models/bone/bone3.glb 枝のようにボーンを設定したもの


今回は、ボーンでのアニメーションをやってみました。
普段は、ロボロボしいものしか動かそうとしないものの、スキン周りのことモデリングを考えるとほぼ必須という印象でした。

Three.js を使うとき、おそらく考え方が悪いのですが 1 点困っています。
ブラウザの別タブで、動かしたときなど動きが一瞬ぐちゃぐちゃになるので、どうにかしたいところ。

ではでは。