Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン（ファイルインポート編）

[前回](https://qiita.com/0taUtan/items/c4ded63cd2c912b4ce74)は球体を表示するだけの簡単なアプリでしたが、今回はファイルから3Dイメージをインポートできるようにしましょう。

[Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン（導入編）](https://qiita.com/0taUtan/items/c4ded63cd2c912b4ce74#%E3%81%BE%E3%81%A8%E3%82%81)
Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン（ファイルインポート編）[今回]
[Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン（投球編）](https://qiita.com/0taUtan/items/b1d7565585b72562ec20)
[Three.js with Vue3 Typescript で作る3Dモデル表示アプリハンズオン（Raycaster編）](https://qiita.com/0taUtan/items/7959c00da37068dd210e)

# ゴール
以下のようにファイル入力したものを表示できるようになるのが今回の目標です。([GitHub](https://github.com/mininjin/display-3d-models/tree/file-input))
![fileinput.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/1a7442d4-72be-2b67-a902-b44ae6135c64.gif)

# Orbit Controls の追加
### 背景とグリッドの変更
まずは前回の復習も兼ねて、3D空間の背景とグリッドを変更してみましょう。こんな感じのディレクトリ構成でした。

````
app/src
├── App.vue
├── assets
│   ├── logo.png
│   └── tailwind.css
├── components
│   └── Three.vue
├── main.ts
└── shims-vue.d.ts
````
`Three.vue`の`init`関数の背景のグリッドの部分を次のように書き換えます。

````ts
    // 初期化
    const init = () => {
      if (container.value instanceof HTMLElement) {
        ...
        // 背景のグリッドの追加
        scene.add(new GridHelper(50));
        scene.background = new Color(0xcccccc);
        ...
    };
````
これで実行すると、次のように表示されるはずです。
![名称未設定.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/6a00f907-fd5b-077c-964e-a18ad2d22d7d.gif)

### Orbit Controls の導入
[公式](https://threejs.org/docs/index.html?q=orbit#examples/en/controls/OrbitControls)に従って導入していきます。まず、次のようにインポートします。

````ts
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
````

さらに、次のようにインスタンスを生成します。第1引数はCameraオブジェクト、第２引数はRendererのDOMです。

````ts
    // Three.js
    ...
    const controls = new OrbitControls(camera, renderer.domElement);
````

あとは、アニメーションフレーム内でコントロールを効かせてやるだけです。以下のように、`animate`関数の`frame`を変更します。

````ts
    // 描画
    const animate = () => {
      const frame = () => {
        // カメラの視点変更
        controls.update();
        // 描画
        renderer.render(scene, camera);
        // 画面を更新
        requestAnimationFrame(frame);
      };
      frame();
    };
````
ここまでできれば、次のように視点が変更できるようになっているはずです。
![controls.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/8805d22f-55f1-ba1c-b3a8-a63c94274c2c.gif)

ここまでで、以下のようなコードになっているかと思います。

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

<script lang="ts">
import { defineComponent, onMounted, ref } from "vue";
import {
  Color,
  GridHelper,
  Mesh,
  MeshLambertMaterial,
  PerspectiveCamera,
  PointLight,
  Scene,
  SphereGeometry,
  WebGLRenderer,
} from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

export default defineComponent({
  setup() {
    // 描画するDOMの指定
    const container = ref();
    // Three.js
    const scene = new Scene();
    const camera = new PerspectiveCamera();
    const renderer = new WebGLRenderer();
    const light = new PointLight();
    const controls = new OrbitControls(camera, renderer.domElement);
    // 初期化
    const init = () => {
      if (container.value instanceof HTMLElement) {
        // DOMのサイズを取得
        const { clientWidth, clientHeight } = container.value;
        // 背景のグリッドの追加
        scene.add(new GridHelper(50));
        scene.background = new Color(0xcccccc);
        // ライトの設定
        light.color.setHex(0xffffff);
        light.position.set(10, 10, 0);
        scene.add(light);
        // 球体の追加
        const sphere = createSphere();
        scene.add(sphere);
        // カメラの設定
        camera.aspect = clientWidth / clientHeight;
        camera.updateProjectionMatrix();
        camera.position.set(10, 10, 0);
        camera.lookAt(0, 0, 0);
        // rendererの設定
        renderer.setSize(clientWidth, clientHeight);
        renderer.setPixelRatio(clientWidth / clientHeight);
        container.value.appendChild(renderer.domElement);
        // 描画
        animate();
      }
    };
    // 描画
    const animate = () => {
      const frame = () => {
        // カメラの視点変更
        controls.update();
        // 描画
        renderer.render(scene, camera);
        // 画面を更新
        requestAnimationFrame(frame);
      };
      frame();
    };

    // マウント時に初期化して描画
    onMounted(() => {
      init();
    });

    // Sphereの作成
    const createSphere = (): Mesh => {
      const geometry = new SphereGeometry(3, 100, 100);
      const material = new MeshLambertMaterial();
      return new Mesh(geometry, material);
    };

    return {
      container,
    };
  },
});
</script>
````

# インポート機能の作成
さて、次はいよいよモデルのファイルをインポートして表示していきます。

### fbx形式ファイルのインポート
まず、ファイル入力用のHTMLを書きましょう。`template`内を以下のように変更します。

````vue
<template>
  <div ref="container" class="fixed w-full h-full top-0 left-0">
    <div
      class="
        absolute
        top-0
        right-0
        bg-blue-900 bg-opacity-80
        text-white
        p-5
        py-6
      "
    >
      <label
        for="file"
        class="p-3 cursor-pointer border-2 rounded-lg border-white bg-blue-400"
        >ファイルを選択してください</label
      >
      <input type="file" id="file" class="hidden" @input="onFileInput" />
    </div>
  </div>
</template>
````
次にscript部分を書いていきましょう。次のようにローダーをインポートします。

````ts
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader";
````

さらに、次のようなハンドラーを作ります。内容としては単純にファイル入力をData URLに変換して、ローダーに投げてやるだけです。

````ts
    // ファイル入力時のハンドラー
    const onFileInput = async ({ target }: Event) => {
      if (target instanceof HTMLInputElement && target.files) {
        // ファイル入力
        const file = target.files[0];
        // DataURL形式に変換
        const dataURL = URL.createObjectURL(file);
        // ローダーで読み込み
        const loader = new FBXLoader();
        const group = await loader.loadAsync(dataURL);
        // Sceneに追加
        scene.add(group);
      }
    };
````
あとはvueの記法に従って`setup`関数の返り値にハンドラーを追加するだけです。そこまでできたら、以下のようにファイル入力ができるようになります。
![fileinput.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2103236/1a7442d4-72be-2b67-a902-b44ae6135c64.gif)

### そのほかのファイル形式への対応
基本的にはそのほかのファイル形式でも導入方法は同じで、

1. ローダーをインポート
1. ファイル入力をData URLに変えてローダーに渡す
1. ローダーから返されるオブジェクトをSceneに追加する

のようにすればOKです。[GitHub](https://github.com/mininjin/display-3d-models/tree/file-input)の方には、他のファイル形式のものでもロードできるように修正したものをあげておきました。`fbx, dae, glb, obj`形式には対応しています。

# まとめ
最終的なソースコードは以下のようになりました。

````@/components/Three.vue
<template>
  <div ref="container" class="fixed w-full h-full top-0 left-0">
    <div
      class="
        absolute
        top-0
        right-0
        bg-blue-900 bg-opacity-80
        text-white
        p-5
        py-6
      "
    >
      <label
        for="file"
        class="p-3 cursor-pointer border-2 rounded-lg border-white bg-blue-400"
        >ファイルを選択してください</label
      >
      <input type="file" id="file" class="hidden" @input="onFileInput" />
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, onMounted, ref } from "vue";
import {
  Color,
  GridHelper,
  PerspectiveCamera,
  PointLight,
  Scene,
  WebGLRenderer,
} from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader";

export default defineComponent({
  setup() {
    // 描画するDOMの指定
    const container = ref();
    // Three.js
    const scene = new Scene();
    const camera = new PerspectiveCamera();
    const renderer = new WebGLRenderer();
    const light = new PointLight();
    const controls = new OrbitControls(camera, renderer.domElement);
    // 初期化
    const init = () => {
      if (container.value instanceof HTMLElement) {
        // DOMのサイズを取得
        const { clientWidth, clientHeight } = container.value;
        // 背景のグリッドの追加
        scene.add(new GridHelper(50));
        scene.background = new Color(0xcccccc);
        // ライトの設定
        light.color.setHex(0xffffff);
        light.position.set(10, 10, 0);
        scene.add(light);
        // カメラの設定
        camera.aspect = clientWidth / clientHeight;
        camera.updateProjectionMatrix();
        camera.position.set(10, 10, 0);
        camera.lookAt(0, 0, 0);
        // rendererの設定
        renderer.setSize(clientWidth, clientHeight);
        renderer.setPixelRatio(clientWidth / clientHeight);
        container.value.appendChild(renderer.domElement);
        // 描画
        animate();
      }
    };
    // 描画
    const animate = () => {
      const frame = () => {
        // カメラの視点変更
        controls.update();
        // 描画
        renderer.render(scene, camera);
        // 画面を更新
        requestAnimationFrame(frame);
      };
      frame();
    };

    // マウント時に初期化して描画
    onMounted(() => {
      init();
    });

    // ファイル入力時のハンドラー
    const onFileInput = async ({ target }: Event) => {
      if (target instanceof HTMLInputElement && target.files) {
        // ファイル入力
        const file = target.files[0];
        // DataURL形式に変換
        const dataURL = URL.createObjectURL(file);
        // ローダーで読み込み
        const loader = new FBXLoader();
        const group = await loader.loadAsync(dataURL);
        // Sceneに追加
        scene.add(group);
      }
    };

    return {
      container,
      onFileInput,
    };
  },
});
</script>
````
今回はカメラ操作とファイルからモデルを取り込んで表示する機能を追加しました。ここまでできればあとは[公式ドキュメント](https://threejs.org/)読んでリッチにしていくだけですね！！