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

前回で大分Webアプリっぽくなってきましたね。今回はアニメーションにさらにオブジェクトに当たった時のエフェクトを追加してみましょう。

[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モデル表示アプリハンズオン（投球編）](https://qiita.com/0taUtan/items/b1d7565585b72562ec20)
Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン（Raycaster編）[今回]

# 今回のゴール
以下のように、投げた球がオブジェクトに当たった時に止まるようなエフェクトをつけるのが今回の目標です。

![ストップエフェクト.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/3154feb6-1bf9-8ce0-f7aa-8f864d398277.gif)

# 画面リサイズ機能の追加
まずは復習も兼ねて、画面リサイズ時の画面調整機能を入れちゃいましょう。次のようなハンドラーを作ります。

````ts
    // 画面リサイズ時のハンドラー
    const onResize = () => {
      if (container.value instanceof HTMLElement) {
        // サイズの取得
        const { clientWidth, clientHeight } = container.value;
        // カメラのアスペクト比を更新
        camera.aspect = clientWidth / clientHeight;
        camera.updateProjectionMatrix();
        // Rendererのサイズ調整
        renderer.setSize(clientWidth, clientHeight);
      }
    };
````
画面から取得したサイズでカメラのアスペクト比とRendererのサイズを更新しするだけです。

あとは、`init`関数内で画面リサイズのイベントリスナーを設定すればOKです。

````ts
    const init = () => {
        ...
        // イベントハンドラーの設定
        window.addEventListener("resize", onResize);
        ...
    };
````
これで起動してみると以下のように画面に応じてちゃんとリサイズしてくれるようになります。
![リサイズ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/1bd986af-af43-9dca-cb39-ce4996aed00a.gif)

# Keyframeの編集
まず、最初に準備として、インポートしたオブジェクトのリストをとっておきましょう。次のように`objects`という変数を追加し、ファイル入力のハンドラーを修正します。

````ts
    // Three.js
    ...
    const objects: Object3D[] = [];
    ...
    // ファイル入力時のハンドラー
    export const onFileInput = async ({ target }: Event): Promise<void> => {
      if (target instanceof HTMLInputElement && target.files) {
        // ファイル入力
        const file = target.files[0];
        const group = await loader(file);
        if (group) {
          // Sceneに追加
          scene.add(group);
          // objectsに追加
          objects.push(group);
        }
      }
    };
````
前回の最後で、`moveObject`関数を次のように作ったと思います。

````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, ANIMATION_DURATION],
        [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()));
    };
````
今回はこの関数のKeyframe作成の部分を修正していきます。まず、次のようなKeyframe作成関数を作ります。解説は後ほど。

````ts
    // Keyframeの作成
    const getPositionKeyframe = (
      startPosition: Vector3,
      endPosition: Vector3,
      duration: number
    ): VectorKeyframeTrack => {
      // スケジュール
      const times = [0];
      // 初期位置
      const { x, y, z } = startPosition;
      const values = [x, y, z];
      // 始点と終点から方向ベクトルを算出
      const direction = endPosition
        .clone()
        .add(startPosition.clone().multiplyScalar(-1))
        .normalize();
      // Raycasterの生成
      const raycaster = new Raycaster();
      raycaster.set(startPosition, direction);
      // Raycasterとオブジェクトの交点を探す
      const intersects = raycaster.intersectObjects(objects, true);
      if (intersects.length > 0) {
        // 初期位置からの距離から交点位置を計算
        const intersect = intersects[0];
        const { distance } = intersect;
        const intersectPosition = startPosition
          .clone()
          .add(direction.multiplyScalar(distance));
        // 位置を設定
        const { x, y, z } = intersectPosition;
        values.splice(values.length, 0, x, y, z, x, y, z);
        // スケジュールを追加
        const rate = distance / startPosition.distanceTo(endPosition);
        times.push(duration * rate);
      } else {
        // 交点がなかった場合は直進
        const { x, y, z } = endPosition;
        values.splice(values.length, 0, x, y, z);
      }
      // 終了時刻を設定
      times.push(duration);
      return new VectorKeyframeTrack(".position", times, values);
    };
````
あとは、この関数に合わせて、`moveObject`関数を修正します。

````ts
    const moveObject = (object: Mesh): void => {
      // アニメーションの始点と終点を計算
      const startPosition = object.position; // 始点
      const endPosition = new Vector3(); // 終点
      camera.getWorldDirection(endPosition);
      // カメラの向く方向にカメラの描画距離だけ増加
      endPosition.multiplyScalar(camera.far).add(startPosition);
      // Keyframeを作成
      const positionKF = getPositionKeyframe(startPosition, endPosition, 10);
      ...
    };
````
ここまでやれば、次のようにオブジェクトへの接触を検知して止まるようになります。
![ストップエフェクト.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/3154feb6-1bf9-8ce0-f7aa-8f864d398277.gif)

### 解説
`getPositionKeyFrame`関数を見ていきましょう。まずはKeyframeの元となる部分です。

````ts
      // スケジュール
      const times = [0];
      // 初期位置
      const { x, y, z } = startPosition;
      const values = [x, y, z];

      ...具体的な処理...

      // 終了時刻を設定
      times.push(duration);
      return new VectorKeyframeTrack(".position", times, values);
````

次に、中身の処理を見ていきましょう。処理の概要は以下の通りです。

1. `startPosition`と`endPosition`を結ぶベクトル（光線）からRaycasterインスタンスを作成
1. Raycasterで光線とオブジェクトの交点を探索
1. 交点位置を計算してKeyframeを作成

以下の図を参考に計算していきます。
![startPosition.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/15f92c82-a5f4-07e1-f7d1-c50853a7372e.png)


#### Raycasterの生成
[Raycater](https://threejs.org/docs/?q=raycas#api/en/core/Raycaster)を作成していきます。Raycasterはカメラから取得する方法もありますが、今回は始点と方向ベクトルから作成する方法をとります。

````ts
      // 始点と終点から方向ベクトルを算出
      const direction = endPosition
        .clone()
        .add(startPosition.clone().multiplyScalar(-1))
        .normalize();
````
まずは方向ベクトルを計算します。単純なベクトル演算です。なお、Raycasterの使用上正規化が必要なことに注意してください。方向ベクトルを計算したあとは、Raycasterインスタンスを生成します。

````ts
      // Raycasterの生成
      const raycaster = new Raycaster();
      raycaster.set(startPosition, direction);
````

#### Raycasterで光線とオブジェクトの交点を探索
````ts
      // Raycasterとオブジェクトの交点を探す
      const intersects = raycaster.intersectObjects(objects, true);
      if (intersects.length > 0) {
      ...
      } else {
        // 交点がなかった場合は直進
        const { x, y, z } = endPosition;
        values.splice(values.length, 0, x, y, z);
      }
````
`Raycaster.intersectObjects`でオブジェクトと光線の交差を求めます。２つ目の引数はオブジェクト内から再起的に交点を探索するかどうかの設定です。今回はファイルからインポートしたオブジェクトなのでこれをONにしないとオブジェクトとの交点を取得できません。

なお、交点がなかった場合は前回と同様にして直進させます。

#### 交点位置の計算とKeyframeへの設定
Raycasterの使用上、交点の位置は光線と距離から算出しないといけないので、まずはそれを計算します。

````ts
        // 初期位置からの距離から交点位置を計算
        const intersect = intersects[0];
        const { distance } = intersect;
        const intersectPosition = startPosition
          .clone()
          .add(direction.multiplyScalar(distance));
````
先ほど計算した`duration`ベクトルを使って始点を交点との距離分平行移動させます。

````ts
        // 位置を設定
        const { x, y, z } = intersectPosition;
        values.splice(values.length, 0, x, y, z, x, y, z);
        // スケジュールを追加
        const rate = distance / startPosition.distanceTo(endPosition);
        times.push(duration * rate);
````
Keyframeに位置を追加します。今回は静止させるので、2つ同じ点を設定します。最後にスケジュールを設定します。ここでは、始点と交点との距離と、始点と直進時の終点との距離の比から静止開始時刻を計算しています。

# まとめ
お疲れ様です。最終的なソースは[こんな感じ](https://github.com/mininjin/display-3d-models/tree/raycaster)です。今回も少し数学的な感覚が必要だったでしょうか？Three.jsのようなライブラリで3D表示しようとするとどうしても少し頭の柔らかさが必要になりますね。ここまで読んでくださった方は、Three.jsのことを大分マスターできたのではないかと思います。ぜひこれからも自己研鑽に励んでくださいね！応援してます！