前回は3Dモデルを単に表示するだけでしたが、今回は右クリックでモデルに球体を投げつけるアニメーションを追加してみましょう。なお、今回はちょっと重めです。また、幾何学的なセンスが試されるかもしれません。
Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン(導入編)
Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン(ファイルインポート編)
Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン(投球編)[今回]
Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン(Raycaster編)
今回のゴール
以下のように、アップロードしたオブジェクトに球を投げられるようにするのが今回のゴールです。
ライトの追加とクリックイベントの検知
ライトの追加
前回のものではライトが当たってないところは真っ暗でした。まず、前回の復習も兼ねて、背景のライトを追加してみましょう。前回までに書いたinit
関数に次を足しましょう。
...
const
...
const init = () => {
...
// ライトの設定
light.color.setHex(0xffffff);
light.position.set(10, 10, 0);
scene.add(light);
...
};
すると、こんな感じで下から見ても色がはっきりわかるようになります。
右クリックイベントの検知
まずは右クリック時の画面平面内の相対位置座標を取得するハンドラーを作りましょう。
// 右クリック時のハンドラー
const onContextMenu = ({ clientX, clientY }: MouseEvent) => {
if (container.value instanceof HTMLElement) {
// 画面平面内の位置座標を取得
const { clientWidth, clientHeight } = container.value;
const relativeX = (clientWidth / 2 - clientX) / clientWidth;
const relativeY = (clientHeight / 2 - clientY) / clientHeight;
}
};
ここで、座標系は以下の画像のように設定されています。画面サイズとマウス位置から、原点を中心とした相対座標値をrelativeX, relativeY
として計算しました。なお、それぞれ最大値が1になるように正規化してあります。
あとは、ハンドラーをリスナーに設定すればOKです。なお、Orbit Controlsの右クリック操作も無効化しておきました。
// 初期化
const init = () => {
...
// OrbitControlsの右クリックイベントリスナーを無効化
controls.enablePan=false;
// イベントハンドラーの設定
container.value.addEventListener("contextmenu", onContextMenu);
...
};
なお、このままだとファイル入力のボックスをクリックした時にもクリックを認識してしまうので、container
の位置を変えておくといいです。
<template>
<div class="fixed w-full h-full top-0 left-0">
<div ref="container" class="w-full h-full"></div>
...
</div>
</template>
球の配置
クリックの相対位置座標が取得できたので、いよいよ投げつける球を配置してみましょう。まずは、右クリック時のハンドラーに球を生成するコードを足しましょう。createSphere
関数は初回で作成した球オブジェクトを生成する関数です。
// 右クリック時のハンドラー
const onContextMenu = ({ clientX, clientY }: MouseEvent) => {
...
// 投げつけるオブジェクトを生成
const ball = createSphere();
scene.add(ball);
...
};
次に球の初期位置を設定する関数を作りましょう。ここで幾何学的なセンスと知識が試されます。解説は次項で。
// 投げつけるオブジェクトの初期値を設定
const setObjectInitialPosition = (
position: Vector3,
{ relativeX, relativeY }: { relativeX: number; relativeY: number }
) => {
// カメラが向く方向ベクトルを取得
const forward = new Vector3();
camera.getWorldDirection(forward);
forward.normalize();
// カメラからの相対方向ベクトルを取得
const { up } = camera; // カメラから見て上方向
const left = up.clone().cross(forward); // カメラから見て左方向
// 目的の位置へのカメラからの相対位置を計算
left.multiplyScalar(relativeX * camera.getFilmWidth()); // 画面左方向
const top = up.clone().multiplyScalar(relativeY * camera.getFilmHeight()); // 画面上方向
forward // 画面正面方向
.multiplyScalar(camera.near); // カメラの視錐台まで前進させる
// 位置を設定
position.copy(camera.position).add(left).add(top).add(forward);
};
あとは、ハンドラー内でこの関数を呼び出すだけです。
const onContextMenu = ({ clientX, clientY }: MouseEvent) => {
...
// 投げつけるオブジェクトを生成
const ball = createSphere();
scene.add(ball);
// オブジェクトを初期位置に配置
setObjectInitialPosition(ball.position, { relativeX, relativeY });
...
};
ここまでやると、次のように右クリックで球が生成できるようになります。
解説
ここで、setObjectInitialPosition
関数の処理を解説しておきます。処理は次の3ステップで進めます。
- カメラの位置と向きから方向ベクトルを取得
- 取得した方向ベクトルに長さを掛けてカメラを原点とする相対位置ベクトルを算出
- カメラ位置と相対位置ベクトルを足し合わせて絶対位置に変換
方向ベクトルの取得
// カメラが向く方向ベクトルを取得
const forward = new Vector3();
camera.getWorldDirection(forward);
forward.normalize();
// カメラからの相対方向ベクトルを取得
const { up } = camera; // カメラから見て上方向
const left = up.clone().cross(forward); // カメラから見て左方向
まず、カメラを中心とした相対位置ベクトルを取得するコードです。camera
からgetWorldDirection
で正面方向ベクトルforward
と上方向ベクトルup
を取り出し、その二つの外積を取ることで、垂直なベクトルleft
を作成しています。
相対位置ベクトルの算出
// 目的の位置へのカメラからの相対位置を計算
left.multiplyScalar(relativeX * camera.getFilmWidth()); // 画面左方向
const top = up.clone().multiplyScalar(relativeY * camera.getFilmHeight()); // 画面上方向
forward // 画面正面方向
.multiplyScalar(camera.near); // カメラの視錐台まで前進させる
次に、得た方向ベクトルに入力に合わせた長さを設定しています。top, left
方向のカメラからの相対位置ベクトルは、右クリック位置から取得した画面内の相対位置ベクトルにPerspective Cameraの幅と高さを掛けて相対位置を計算しています。forward
方向については、カメラと描画領域(視錐台)の距離分だけの長さを設定しています。
絶対位置に変換し、位置を設定
最後の一文では、カメラ位置に相対位置を足すことでオブジェクトの初期位置を絶対位置として設定しています。
// 位置を設定
position.copy(camera.position).add(left).add(top).add(forward);
アニメーション
最後に、球が飛んでいくアニメーションを追加しましょう。これには、Keyframe Animationを使います。次の4ステップでアニメーションを作成します。
- Keyframeを定義
- Keyframeを組み合わせてClipを作成
- Mixerを定義し、Clipを実際に動くActionに変換
- Actionを再生し、毎フレームでMixerをアップデートする
実際に作ってみたのが次の関数です。解説は次項。
// アニメーションの作成と起動
const moveObject = (object: Mesh) => {
// アニメーションの始点と終点を計算
const startPosition = object.position; // 始点
const { x, y, z } = startPosition;
const endPosition = new Vector3(); // 終点
camera.getWorldDirection(endPosition);
endPosition.multiplyScalar(camera.far).add(startPosition); // カメラの向く方向に距離100だけ増加
// Keyframeを作成
const positionKF = new VectorKeyframeTrack(
".position",
[0, 10],
[x, y, z, endPosition.x, endPosition.y, endPosition.z]
);
// Clipを作成
const moveObjectClip = new AnimationClip(`move-object-${object.id}`, -1, [
positionKF,
]);
// Mixerを定義
const mixer = new AnimationMixer(object);
mixer.addEventListener("finished", () => { // 終了時にオブジェクトを削除
scene.remove(object);
object.geometry.dispose();
});
// Actionを定義
const action = mixer.clipAction(moveObjectClip);
action.setLoop(LoopOnce, 0);
// Actionの実行
action.play();
// アニメーションを更新
const clock = new Clock();
animate(() => mixer.update(clock.getDelta()));
};
なお、毎フレームでMixerを更新するためにanimate
関数を次のように変更しました。
// 描画
const animate = (callback?: () => void) => {
const frame = () => {
// カメラの視点変更
controls.update();
// コールバック関数を実行
if (callback) callback();
// 描画
renderer.render(scene, camera);
// 画面を更新
requestAnimationFrame(frame);
};
frame();
};
あとは、右クリック時のハンドラー内でmoveObject
関数を起動すればOK。
// 右クリック時のハンドラー
const onContextMenu = ({ clientX, clientY }: MouseEvent) => {
...
// アニメーションを作成して起動
moveObject(ball);
...
};
ここまでできれば、次のように球が投げられるようになっているはず。
解説
Keyframeの作成
// アニメーションの始点と終点を計算
const startPosition = object.position; // 始点
const { x, y, z } = startPosition;
const endPosition = new Vector3(); // 終点
camera.getWorldDirection(endPosition);
endPosition.multiplyScalar(camera.far).add(startPosition); // カメラの向く方向に距離100だけ増加
// Keyframeを作成
const positionKF = new VectorKeyframeTrack(
".position",
[0, 10],
[x, y, z, endPosition.x, endPosition.y, endPosition.z]
);
まず、Keyframeの作成です。今回は位置の調整なので、VectorKeyFrameTrackクラスを使って作成します。最初の引数は値を変更する箇所を表す文字列で、2つ目はタイミングのリスト、3つ目が各タイミングにおける具体的な値です。x, y, z
の順で数値を並べます。
Clipの作成
// Clipを作成
const moveObjectClip = new AnimationClip(`move-object-${object.id}`, -1, [
positionKF,
]);
次に、Clipを作成します。最初の引数はClipの名前、2つ目はClipの時間間隔(-1は自動で算出)、3つ目はKeyframeのリストです。
MixerとActionの作成
// Mixerを定義
const mixer = new AnimationMixer(object);
mixer.addEventListener("finished", () => { // 終了時にオブジェクトを削除
scene.remove(object);
object.geometry.dispose();
});
// Actionを定義
const action = mixer.clipAction(moveObjectClip);
action.setLoop(LoopOnce, 0);
オブジェクトを指定してMixerを定義します。アニメーションの終了時にオブジェクトを削除する設定を入れました。その後、Actionを作成します。なお、今回はループは無しです。
Actionの実行と各フレームでのアップデート
// Actionの実行
action.play();
// アニメーションを更新
const clock = new Clock();
animate(() => mixer.update(clock.getDelta()));
先ほど作ったActionを再生します。アニメーションの更新はClockクラスを使って、animate
関数の各フレームで更新をかけます。
まとめ
お疲れ様でした。今回は長かったですが、なんとか完成させれらましたよね??大分3D幾何の比重が大きくなってきたような気がします。。ここまで来られれば、Three.js初級者は名乗っても良いのではないかと思います!最終的なソースはリポジトリにあげました。ハマっている方はぜひ参照してくださいね。それではこれからも最高のエンジニアライフを!