Three.js ボーンを使わないで「関節」を表現する

Three.jsを思い出すための実装として「関節」を作ります。
読み込むモデル側でボーンを定義するということも選択肢にはあるのですが、あまりモデリングに明るくないこともあるので、オブジェクトの関連付けで表現します。

こんなものを作ります。

実行環境

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

雑な実装

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
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
import * as THREE from "three/build/three.module";
// GLTFLoader を使う
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
// ?url を使ってアセットになるファイルのURLを取得する
import modelDataUrl from "./public/model/model.glb?url";

let camera, scene, renderer;
let object, object2, object3, axis, light;
let direction = 1;
let count = 0;
let rad = 750;

init();

function init() {
camera = new THREE.PerspectiveCamera(
70,
window.innerWidth / window.innerHeight,
0.1,
100
);
camera.position.set(1, 1.5, 1);
camera.lookAt(new THREE.Vector3(0, 0.5, 0));

scene = new THREE.Scene();

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

// GLTFLoaderを使って ファイルを読み込む
const gltfLoader = new GLTFLoader();

// オブジェクトを入れ子にしてsceneに登録する
gltfLoader.load(modelDataUrl, function (data) {
const gltf = data;
console.log(gltf);
object = gltf.scene;

gltfLoader.load(modelDataUrl, function (data) {
const gltf = data;
console.log(gltf);
object2 = gltf.scene;

object2.position.y = 0.38;
gltfLoader.load(modelDataUrl, function (data) {
const gltf = data;
console.log(gltf);
object3 = gltf.scene;

object3.position.y = 0.38;
object2.add(object3);
object.add(object2);

// object3 object2 が関連づいた object を scene に登録する
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.setAnimationLoop(animation);
document.body.appendChild(renderer.domElement);
}

function animation(time) {
// オブジェクトが非同期で読み込まれるために
// null ではないことを確認する

if (count % 300 == 0) {
direction *= -1;
}
count++;
rad += direction * 5;

if (object) {
object.rotation.x = rad / 2000;
}
if (object2) {
object2.rotation.x = rad / 2000;
}
if (object3) {
object3.rotation.x = rad / 2000;
}

renderer.render(scene, camera);
}

これを読み込み、実行すると冒頭のようになります。

オブジェクトに対して .add することで、そのオブジェクトのローカル座標系に新たにオブジェクトが登録できます。
このことで、個別のモデルの角度と距離を定義しておくだけで、関節のような動きが可能です。
ワールド座標系で、末端の角度を考えなくて済みます。

整理してちゃんと実装

複雑な物体、例えば人体のような複雑な関節を持つ物体を作るには苦しいところがあります。
整理して以下のように実装しました。

定義に基づいてパラメータを作れば、複雑なオブジェクトを定義できます。

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
// 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(): Promise<GLTF> {
return new Promise((resolve) => {
new GLTFLoader().load(this._fileName, resolve);
});
}

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

getObject() {
return this._object;
}
}

export default Part;
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
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
import { Group } from "three/src/Three";
import Part from "./part";

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

interface Pose {
time: number;
radians: Array<{ x: number; y: number; z: number }>;
}

interface BodySetup {
body: Array<PartSet>;
connects: Array<number>;
poses: Array<Pose>;
}

// 関節のあるモデルの定義
class Body {
_setup: BodySetup;
_objects: Array<Group>;
_count: number;
_elapsedCount: number;
_poseNumber: number;
_targetPose: Pose | undefined;
_changePose: Pose | undefined;

constructor(setup: BodySetup) {
this._setup = setup;
this._objects = [];
this._count = 0;
this._elapsedCount = 0;
this._poseNumber = 0;
}
async load() {
let body = this._setup.body;

console.log(this._objects);
console.log(body);

let parts: Array<Group> = await Promise.all(
body.map(async (partSet: PartSet) => {
console.log(partSet);
let part = new Part(partSet.file);
let object = (await part.load()).getObject();
object.position.x = partSet.position.x;
object.position.y = partSet.position.y;
object.position.z = partSet.position.z;
console.log(this._objects);
return object;
})
);

this._objects.push(...parts);

this._setup.connects.forEach((connect, index) => {
if (connect == -1) {
return;
}
this._objects[connect].add(this._objects[index]);
});

this.setTargetPose();

return this._objects[0];
}
setTargetPose() {
this._targetPose = this._setup.poses[this._poseNumber];
let radians: Array<{ x: number; y: number; z: number }> = [];
this._objects.forEach((object, index) => {
if (!this._targetPose) return;
let x = this._targetPose.radians[index].x - object.rotation.x;
let y = this._targetPose.radians[index].y - object.rotation.y;
let z = this._targetPose.radians[index].z - object.rotation.z;
radians.push({ x, y, z });
});

this._changePose = {
time: this._targetPose.time,
radians: radians,
};
}
nextTargetPose() {
this._poseNumber++;
if (this._setup.poses.length - 1 < this._poseNumber) {
this._poseNumber = 0;
}
this.setTargetPose();
}
update(time: number) {
let difference = time - this._count;
this._count = time;
this._elapsedCount += difference;

this._setup.poses[this._poseNumber];
this._objects.forEach((object, index) => {
if (!this._changePose) return;

object.rotation.x +=
(difference * this._changePose.radians[index].x) /
this._changePose.time;
object.rotation.y +=
(difference * this._changePose.radians[index].y) /
this._changePose.time;
object.rotation.z +=
(difference * this._changePose.radians[index].z) /
this._changePose.time;
});
if (!this._changePose) return;

if (this._elapsedCount >= this._changePose.time) {
this.nextTargetPose();
this._elapsedCount = 0;
}
}
}

export default Body;
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
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
import * as THREE from "three/build/three.module";
// ?url を使ってアセットになるファイルのURLを取得する
import modelDataUrl from "./public/model/model.glb?url";

import Part from "./src/part";
import Body from "./src/body";

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

// モデル・関節・ポーズの定義
let set = {
body: [
{ file: modelDataUrl, position: { x: 0, y: 0, z: 0 } },
{ file: modelDataUrl, position: { x: 0, y: 0.38, z: 0 } },
{ file: modelDataUrl, position: { x: 0, y: 0.38, z: 0 } },
],
connects: [-1, 0, 1],
poses: [
{
time: 1000,
radians: [
{ x: 0, y: 0, z: 0 },
{ x: -0.5, y: 0, z: 0 },
{ x: -0.5, y: 0, z: 0 },
],
},
{
time: 1000,
radians: [
{ x: 0, y: 0, z: 0 },
{ x: -0.5, y: 0, z: 0 },
{ x: 0.5, y: 0, z: 0 },
],
},
{
time: 1000,
radians: [
{ x: 0, y: 0, z: 0 },
{ x: 0, y: 0, z: 0 },
{ x: 0, y: 0, z: 0 },
],
},
{
time: 1000,
radians: [
{ x: 0, y: 0, z: 0 },
{ x: 0.5, y: 0, z: 0 },
{ x: 0.5, y: 0, z: 0 },
],
},
{
time: 1000,
radians: [
{ x: 0, y: 0, z: 0 },
{ x: 0.5, y: 0, z: 0 },
{ x: -0.5, y: 0, z: 0 },
],
},
{
time: 1000,
radians: [
{ x: 0, y: 0, z: 0 },
{ x: 0, y: 0, z: 0 },
{ x: 0, y: 0, z: 0 },
],
},
],
};

init();

async function init() {
camera = new THREE.PerspectiveCamera(
70,
window.innerWidth / window.innerHeight,
0.1,
100
);
camera.position.set(1, 1.5, 1);
camera.lookAt(new THREE.Vector3(0, 0.5, 0));

scene = new THREE.Scene();

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

//関節のあるオブジェクトの読み込み
body = new Body(set);
object = await body.load();
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.setAnimationLoop(animation);
document.body.appendChild(renderer.domElement);
}

function animation(time) {
body.update(time);

renderer.render(scene, camera);
}

準備したら表示してみます。
冒頭のアニメーションですね。

複雑な動きをパラメーターの定義だけで実現できるようになりました。


Three.jsの記事は 2 本目でした。
昔にThree.jsを触っていたころにも同じような関節を持つような動木のために同じようなものを作っていました。
が、当時の作ったものも見つからないので 2021 版として新たに作り直しました。

ではでは。