Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン（投球編）

前回は3Dモデルを単に表示するだけでしたが、今回は右クリックでモデルに球体を投げつけるアニメーションを追加してみましょう。なお、今回はちょっと重めです。また、幾何学的なセンスが試されるかもしれません。
[Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン（導入編）](https://qiita.com/0taUtan/items/c4ded63cd2c912b4ce74)
[Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン（ファイルインポート編）](https://qiita.com/0taUtan/items/e71436beaf612e7bb93f)
Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン（投球編）[今回]
[Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン（Raycaster編）](https://qiita.com/0taUtan/items/7959c00da37068dd210e)

# 今回のゴール
以下のように、アップロードしたオブジェクトに球を投げられるようにするのが今回のゴールです。
![投球.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/f4e26a8f-0b73-cc65-2a81-16220680675b.gif)


# ライトの追加とクリックイベントの検知
### ライトの追加
前回のものではライトが当たってないところは真っ暗でした。まず、前回の復習も兼ねて、背景のライトを追加してみましょう。前回までに書いた`init`関数に次を足しましょう。

````ts
    ...
    const 
    ...
    const init = () => {
        ...
        // ライトの設定
        light.color.setHex(0xffffff);
        light.position.set(10, 10, 0);
        scene.add(light);
        ...
    };
````
すると、こんな感じで下から見ても色がはっきりわかるようになります。
![背景.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/0d854c7f-c425-2e99-9215-0af1229518ee.gif)

### 右クリックイベントの検知
まずは右クリック時の画面平面内の相対位置座標を取得するハンドラーを作りましょう。

````ts
    // 右クリック時のハンドラー
    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になるように正規化してあります。
![X (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/e5beda9b-a1e7-a000-9a2f-f25a2b98b32a.png)


あとは、ハンドラーをリスナーに設定すればOKです。なお、Orbit Controlsの右クリック操作も無効化しておきました。

````ts
    // 初期化
    const init = () => {
        ...
        // OrbitControlsの右クリックイベントリスナーを無効化
        controls.enablePan=false;
        // イベントハンドラーの設定
        container.value.addEventListener("contextmenu", onContextMenu);
        ...
    };
````
なお、このままだとファイル入力のボックスをクリックした時にもクリックを認識してしまうので、`container`の位置を変えておくといいです。

````@/components/Three.vue
<template>
  <div class="fixed w-full h-full top-0 left-0">
    <div ref="container" class="w-full h-full"></div>
    ...
  </div>
</template>
````

# 球の配置
クリックの相対位置座標が取得できたので、いよいよ投げつける球を配置してみましょう。まずは、右クリック時のハンドラーに球を生成するコードを足しましょう。`createSphere`関数は初回で作成した球オブジェクトを生成する関数です。

````ts
    // 右クリック時のハンドラー
    const onContextMenu = ({ clientX, clientY }: MouseEvent) => {
        ...
        // 投げつけるオブジェクトを生成
        const ball = createSphere();
        scene.add(ball);
        ...
    };
````
次に球の初期位置を設定する関数を作りましょう。ここで幾何学的なセンスと知識が試されます。解説は次項で。

````ts
    // 投げつけるオブジェクトの初期値を設定
    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);
    };
````
あとは、ハンドラー内でこの関数を呼び出すだけです。

````ts
    const onContextMenu = ({ clientX, clientY }: MouseEvent) => {
        ...
        // 投げつけるオブジェクトを生成
        const ball = createSphere();
        scene.add(ball);
        // オブジェクトを初期位置に配置
        setObjectInitialPosition(ball.position, { relativeX, relativeY });
        ...
    };
````
ここまでやると、次のように右クリックで球が生成できるようになります。
![球追加.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/7de292f0-c584-5329-883a-737b606ed9c4.gif)

### 解説
ここで、`setObjectInitialPosition`関数の処理を解説しておきます。処理は次の3ステップで進めます。

1. カメラの位置と向きから方向ベクトルを取得
1. 取得した方向ベクトルに長さを掛けてカメラを原点とする相対位置ベクトルを算出
1. カメラ位置と相対位置ベクトルを足し合わせて絶対位置に変換

#### 方向ベクトルの取得
````ts
      // カメラが向く方向ベクトルを取得
      const forward = new Vector3();
      camera.getWorldDirection(forward);
      forward.normalize();
      // カメラからの相対方向ベクトルを取得
      const { up } = camera; // カメラから見て上方向
      const left = up.clone().cross(forward); // カメラから見て左方向
````
まず、カメラを中心とした相対位置ベクトルを取得するコードです。`camera`から`getWorldDirection`で正面方向ベクトル`forward`と上方向ベクトル`up`を取り出し、その二つの外積を取ることで、垂直なベクトル`left`を作成しています。
![名称未設定のデザイン (15).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/7bd32870-113c-8185-2e08-6f48c4ebf95d.png)
#### 相対位置ベクトルの算出
````ts
      // 目的の位置へのカメラからの相対位置を計算
      left.multiplyScalar(relativeX * camera.getFilmWidth()); // 画面左方向
      const top = up.clone().multiplyScalar(relativeY * camera.getFilmHeight()); // 画面上方向
      forward // 画面正面方向
        .multiplyScalar(camera.near); // カメラの視錐台まで前進させる
````
次に、得た方向ベクトルに入力に合わせた長さを設定しています。`top, left`方向のカメラからの相対位置ベクトルは、右クリック位置から取得した画面内の相対位置ベクトルにPerspective Cameraの幅と高さを掛けて相対位置を計算しています。`forward`方向については、カメラと描画領域([視錐台](https://en.wikipedia.org/wiki/Viewing_frustum))の距離分だけの長さを設定しています。

#### 絶対位置に変換し、位置を設定
最後の一文では、カメラ位置に相対位置を足すことでオブジェクトの初期位置を絶対位置として設定しています。

````ts
      // 位置を設定
      position.copy(camera.position).add(left).add(top).add(forward);
````
最終的な球の位置は下図のようになります。
![up (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/449e5d97-ca68-036e-da88-241f06035057.png)

# アニメーション
最後に、球が飛んでいくアニメーションを追加しましょう。これには、[Keyframe Animation](https://threejs.org/docs/?q=animation#manual/en/introduction/Animation-system)を使います。次の4ステップでアニメーションを作成します。

1. Keyframeを定義
1. Keyframeを組み合わせてClipを作成
1. Mixerを定義し、Clipを実際に動くActionに変換
1. Actionを再生し、毎フレームでMixerをアップデートする

実際に作ってみたのが次の関数です。解説は次項。

````ts
    // アニメーションの作成と起動
    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`関数を次のように変更しました。

````ts
    // 描画
    const animate = (callback?: () => void) => {
      const frame = () => {
        // カメラの視点変更
        controls.update();
        // コールバック関数を実行
        if (callback) callback();
        // 描画
        renderer.render(scene, camera);
        // 画面を更新
        requestAnimationFrame(frame);
      };
      frame();
    };
````
あとは、右クリック時のハンドラー内で`moveObject`関数を起動すればOK。

````ts
    // 右クリック時のハンドラー
    const onContextMenu = ({ clientX, clientY }: MouseEvent) => {
        ...
        // アニメーションを作成して起動
        moveObject(ball);
        ...
    };
````
ここまでできれば、次のように球が投げられるようになっているはず。
![投球.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/f4e26a8f-0b73-cc65-2a81-16220680675b.gif)

### 解説
#### Keyframeの作成
````ts
      // アニメーションの始点と終点を計算
      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](https://threejs.org/docs/?q=animation#api/en/animation/tracks/VectorKeyframeTrack)クラスを使って作成します。最初の引数は値を変更する箇所を表す文字列で、2つ目はタイミングのリスト、3つ目が各タイミングにおける具体的な値です。`x, y, z`の順で数値を並べます。
#### Clipの作成
````ts
      // Clipを作成
      const moveObjectClip = new AnimationClip(`move-object-${object.id}`, -1, [
        positionKF,
      ]);
````
次に、Clipを作成します。最初の引数はClipの名前、2つ目はClipの時間間隔（-1は自動で算出）、3つ目はKeyframeのリストです。
#### MixerとActionの作成
````ts
      // 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の実行と各フレームでのアップデート
````ts
      // Actionの実行
      action.play();
      // アニメーションを更新
      const clock = new Clock();
      animate(() => mixer.update(clock.getDelta()));
````
先ほど作ったActionを再生します。アニメーションの更新は[Clock](https://threejs.org/docs/?q=clock#api/en/core/Clock)クラスを使って、`animate`関数の各フレームで更新をかけます。

# まとめ
お疲れ様でした。今回は長かったですが、なんとか完成させれらましたよね？？大分３D幾何の比重が大きくなってきたような気がします。。ここまで来られれば、Three.js初級者は名乗っても良いのではないかと思います！最終的なソースは[リポジトリ](https://github.com/mininjin/display-3d-models/tree/throw-balls)にあげました。ハマっている方はぜひ参照してくださいね。それではこれからも最高のエンジニアライフを！