3Dモデルをブラウザに放り込んで、スマホやVision Proでサクッと見る話

## はじめに
ここ最近、ChatGPTなどが作る絵から3Dモデルを自動生成するワークフローについて研究してます。

- [ChatGPTの絵から3Dモデルの部品を大量生成する。](https://qiita.com/yanosen_jp/items/f98e8b03917b465a92b3)
- [ChatGPT, ImageFXで箱庭を作って3D背景にする](https://qiita.com/yanosen_jp/items/9824d1d68e6e4a0dc050)

こうやって作ったモデル、PCやVRで手軽に出来栄えを確認したいですよね。
　
Mozilla Hubs(およびDOORなどの派生サービス）では、PCのブラウザで開いているシーンにドラッグ&ドロップで3Dモデルを放り込み、そのシーンのURLをQuestなどのVRヘッドセットで開いて見たり操作したりする、ってことが簡単に出来て便利でしｔ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58503/9630dffe-c33c-41fb-b6b5-d7e0c015bc92.png)

しかし、残念ながらMozilla HubsもDOORもサービス終了、、、。似たようなことはSpatialでできるものの、VR側ではオブジェクトの操作ができない、という問題点があってイマイチ。

そこで、同じような機能を自分で作って見ることにしました。ややこしいサーバーとか準備しなくてもできる方法です。

## どんな感じ？

### まずは、[DropViewer](https://dropviewer.vercel.app/)のページに入ってください。

URLの後ろにルーム番号（/?room=000001など）が割り当てられます。

ページが開いて台？のようなものが見えたら、PCのブラウザで、下の動画のようにフォルダからドラッグ&ドロップして3Dシーンに放り込みます。

![Screen Recording 2025-04-08 at 0.41.46.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58503/148bed59-b3b2-4555-8785-8ddb1fee70b3.gif)

その後、シーンのURLをスマホやVRHMDと共有します。Mac-iPhoneなら、URLをデバイス間で直接コピペできますね。

![ScreenRecording_04-08-2025 01-04-00_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58503/d1856710-d8ec-48bb-89fb-eb0b80b2ceb1.gif)

Vision ProのブラウザでもURLをペーストするだけです。

![ScreenRecording_04-08-2025 2 3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58503/b8e55937-57b1-4f78-8298-d72ddcbf97fa.gif)

もちろん、VR(イマーシブ)モードにも入れます。

![ScreenRecording_04-08-2025 2 4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58503/21464b7f-8712-4b61-8651-aa844bec674a.gif)

Questなどでも使えます。しかも、どのデバイスで入ってもオブジェクトは操作できて、位置などはシンクロします。クラウドにアップロードしたモデルは1日で消えますが、もしすぐに消したければDelete All Modelsボタンを使ってください。

(追記)
シーン内のオブジェクト（最初からある背景オブジェクトは除く）を一つのglbにまとめてダウンロードする機能を追加しました。右上の"Download Combined Model"ボタンです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58503/eec9175e-6fb0-4f97-974d-8f3b130c155e.png)



## どんなふうに作っている

ページのバナーとサイトのURLからわかるとおり、Needle EngineをVercel上にホストしています。Needle Engineについては[こちらの記事(Needle EngineでマルチプレイヤーWebXRアプリをVercelにセルフホストする)](https://qiita.com/yanosen_jp/items/8362f1ca36f9012ac923)をご覧いただければと思いますが、簡単に言えばWebXRベースの3Dエンジンで、UnityやBlenderをエディタとして用い、言語はTypescript、レンダリングにはthree.jsが使われています。UnityのWebGLアプリより軽量で、簡単にマルチプレイヤーとVR機能を導入できるのが大きな利点です。

今回の[DropViewer](https://dropviewer.vercel.app/)のベースにしているSandboxシーンにも、VR機能とネットワーク機能が標準でついています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58503/e9b456c2-133f-45b0-b4c2-fa3ea0d69319.png)

ただ、以下のことはNeedle Engineに標準ではついてこないので、カスタムコンポーネントを作る必要があります。

- Webページ上にDropされたglbモデルファイルをインポートする
- インポートしたファイルをクラウド上に保存する
- glbファイルをクライアント間で共有する

Drag and Dropイベントについては、ブラウザのAPIで取得できるので簡単です。ただ、glbファイルをそのまま他のクライアントと共有は出来ないので、いったんクラウドにアップロード（下記コードの```CloudStorageService```）して、そのURLを他のクライアントと共有します（下記コードの```RoomModelManager```）。

```GLBDropHandler.ts
export class GLBDropHandler extends Behaviour {
    @serializable(CloudStorageService)
    storageService?: CloudStorageService;
    
    @serializable(RoomModelManager)
    modelManager?: RoomModelManager;
    
    private onDrop = async (event: DragEvent) => {
        event.preventDefault();            
        
        if (event.dataTransfer?.files) {
            const files = event.dataTransfer.files;
            let filesUploaded = 0;            
            this.isUploading = true;
                        
            try {
                for (let i = 0; i < files.length; i++) {
                    const file = files[i];
                    
                    if (file.name.toLowerCase().endsWith('.glb')) {
                        try {
                            const url = await this.storageService.uploadFile(file, file.name);
                            if (url) {
                                this.modelManager.setModelUrl(url);
                                filesUploaded++;
                            } else {
                                console.error("Failed to upload file");
                            }
                        } catch (error) {
                            console.error("Error processing file:", error);
                        }
                    }
                }
```


クラウドストレージにはGoogle Cloud Storage(GCS)を使ってますが、認証情報をクライアント側に持たせるのはセキュリティ的にアレなので、GCSに[署名付きURL](https://cloud.google.com/storage/docs/access-control/signed-urls)を発行してもらい、それを使ってクライアント側からアップロードしてます（[参考資料](https://zenn.dev/tbsten/articles/a05e2ff7f2a460))。

署名付きURLの発行は、[vercel functions](https://vercel.com/docs/functions)というserverless functionで行います（[参考資料](https://zenn.dev/you_5805/articles/vanilla-vercel-functions)）。プロジェクトのルートに```api```フォルダを作って、handlerをexport defaultするファイルを置くと、vercel functionsとして動作します。

```generate-upload-url.js
export default async function handler(req, res, env) {...
```

クライアント側は、これから証明つきURLをfetchして、アップロードに使います。
```CloudStorageService.ts
    async uploadFile(file: File, filename: string): Promise<string | null> {
        try {
            const response = await fetch('/api/generate-upload-url', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    filename: filename,
                    contentType: file.type || 'model/gltf-binary'
                })
            });
            
            if (!response.ok) {
                throw new Error(`Failed to get signed URL: ${response.status}`);
            }
            
            const { signedUrl, publicUrl } = await response.json();
            
            const uploadResponse = await fetch(signedUrl, {
                method: 'PUT',
                headers: {
                    'Content-Type': file.type || 'model/gltf-binary'
                },
                body: file
            });
            
            if (!uploadResponse.ok) {
                throw new Error(`Failed to upload to GCS: ${uploadResponse.status}`);
            }
            
            return publicUrl;
        } catch (error) {
            alert(`ファイルのアップロード中にエラーが発生しました: ${error.message}`); // Optionally show an alert to the user
            return null;
        }
    }
```

ドロップされたglbファイルのアップロード先URLはNeedle Engineの```syncField```によってクライアント間で共有されます（[参考資料](https://engine.needle.tools/docs/api/@needle-tools/engine/4.2.2/functions/Engine_Core.syncField.html)）

```RoomModelManager.ts

export class RoomModelManager extends Behaviour {
    @syncField()
    currentModelUrl: string = "";
```

新しいURLが与えられると、それをGLTFLoaderに渡してモデルをロードしています。

## おわりに

今回のアプリ制作には[devin.ai](https://devin.ai/)にかなり頑張ってもらいました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58503/6e3fac4d-09d5-498f-a1bc-38dbf9fb03a0.png)

アプリ全体の構成の計画からコーディングまで、一通りやってくれるのはすごいですね。お値段が高いので細かい修正はCursorも併用しましたが、お金が問題でないなら、ずっとdevinで開発したかったです。