【VRフェス 審査員特別賞】『ペンと鍵の部屋VR』補助資料

## はじめに

本記事は2025年3月1日（土）に開催した、[**VRプロフェッショナルアカデミー**](https://vracademy.jp/)主催の [**『第16回VRフェス ～VRが創るミライ★ARが救うセカイ』virtual reality festival vol.16**](https://vrfes16.peatix.com/) にて、**VRエキスパートコース16期生** として出展させていただいた作品 **『ペンと鍵の部屋VR』** のブースで現地でのみ掲示した補助資料になります。

大変ありがたいことに「この資料も載せた方がいい」「ゆっくり見たい」とのお言葉をいただきましたので記事にさせていただきます。


:::note info
こちらの作品で **VRアカデミー賞・審査員特別賞** をいただきました！
:::

## **ペンと鍵の部屋VR** とは？
まずプレイヤーは閉じ込められた部屋で絵を描きます。
その描いた絵をAIで形状認識させ、正しく認識されれば3Dアイテムとして生成され、そのアイテムで脱出を目指すゲームです。

- 2D動画
<iframe width="560" height="315" src="https://www.youtube.com/embed/2egvXLUD4BY?si=JcBb2HFH3n9qlOVh" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

- 360°動画
<iframe width="560" height="315" src="https://www.youtube.com/embed/yykYbxPiDXY?si=ahIzBDKZ6JQQq45E" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

## 開発環境
### クライアント
- Windows 11
- MetaQuest 3
- Unity（6000.0.35f1）
    - Meta XR Core SDK（71.0.0）
    - Meta XR Interaction SDK（71.0.0）
    - Meta XR Interaction SDK Essentials（71.0.0）
    - Meta MR Utility Kit（71.0.0）
    - [UniTask（2.5.10）](https://github.com/Cysharp/UniTask?path=src/UniTask/Assets/Plugins/UniTask)
    - [YetAnothoerHttpHandler（1.9.0）](https://github.com/Cysharp/YetAnotherHttpHandler?path=src/YetAnotherHttpHandler#1.9.0)
    - [NetGetForUnity（4.2.0）](https://github.com/GlitchEnzo/NuGetForUnity?path=/src/NuGetForUnity)
### サーバー
- AWS
    - EC2：t3.medium
    - OS：Amazon Linux 2023 AMI
- Python（3.9.20）
    - grpcio（1.54.3）
    - grpcio-tools（1.54.3）
    - protobuf（4.25.5）
    - openai（1.60.0）
    - google-generativeai（0.5.4）
    - anthropic（0.20.0）
    - mistralai（0.1.8）
- Docker（25.0.5）
- Docker Compose（2.33.1）
- MySQL（8.0.41）
- Nginx（1.27.3）

## システム構成図

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3862511/18993940-183b-4dfa-89a2-5857c1f16c32.png)

### クライアント
- **MetaQuest3**とPCのブラウザを使用
- PCは描いた絵を **Three.js** でブラウザ上で閲覧できるようにしていますが、VRフェスの時点ではデバッグ用途となっています

### サーバー
- AWSのEC2上にDockerコンテナでデプロイしています
- EC2のインスタンスタイプは「t3.medium」で動いていますので、クライアントPCでも十分動く構成と思います
- 描画した絵をブラウザで閲覧できるように簡易的にWebサーバーも構築しています

### データベース
- MySQLを採用
- 描画データやAI判定ログ、マスタデータなどを格納しています

### 通信プロトコル
- **MetaQuest3**とサーバー間の通信は**gRPC**を使用

### 生成AI
-  LLM（Large Language Model、大規模言語モデル）を使用し、テキストtoテキストの処理で形状判定がどこまでできるかを検証してます

## データベース（MySQL）
### 役割
描画データやAI判定結果を保存し、クライアントが過去の描画データを参照できるようにする。（現在は主にデバッグ用途）

### 選定理由
- データの一貫性 が重要（AIの判定結果と描画データを紐づける）
- スケーラビリティ（将来の拡張を考慮し、クラウド環境で運用可能）

### テーブル設計
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3862511/2930a749-27e2-42f8-a8d9-988938cf0acb.png)

## 通信プロトコル（gRPC）
### 役割
MetaQuest3とサーバー間で描画データと生成AIの判定結果をやり取りするための通信プロトコル。

### 選定理由
- 遅延によって体験を損なわないようにしたい → 低遅延で軽量＆リアルタイム性かつTCPの信頼性
- UnityとAWS間の通信をスムーズにできる
- 将来の拡張性：双方向ストリーミング通信を活かしたマルチプレイ対応の可能性を検証
　※今回はマルチプレイの実装をしていないため、リクエスト/レスポンス型で実施

### 詳細
こちらは別の記事に記載しております。※どちらも同じ内容です。
[Qiita（Unity×gRPCでVR描画データを送信する）](https://qiita.com/tky116/items/a198e512fadc132d55c3)
[Zenn（Unity×gRPCでVR描画データを送信する）](https://zenn.dev/tky116/articles/fc0b87addaacd7)


## 処理フロー

各メイン部分となる処理についてフローをまとめています。

### VR描画

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3862511/be343728-2f45-4ac6-883b-a06a34ec141c.png)

#### ■ TrailRenderer でリアルタイム描画
```c#
public void StartDrawing(Vector3 position)
{
    GameObject trailObj = Instantiate(trailRendererPrefab, inkPoolSynced);
    activeTrail = trailObj.GetComponent<TrailRenderer>();
    activeTrail.material.color = currentColor;
    activeTrail.startWidth = inkWidth;
    activeTrail.endWidth = inkWidth;
    
    lastRecordedPoint = inkPoolSynced.InverseTransformPoint(position);
    activeTrail.transform.localPosition = lastRecordedPoint;
}
```
- ポイント
    - TrailRenderer を生成し、ペンの動きに応じたリアルタイム描画を開始
    - 色や太さを設定し、スムーズな線を描画

#### ■ TrailRenderer の線を LineRenderer に確定
```c#
public void FinishDrawing(Vector3 position)
{
    if (currentLinePoints.Count < 2) { Destroy(activeTrail.gameObject); return; }

    GameObject lineObj = Instantiate(lineRendererPrefab, inkPoolSynced);
    LineRenderer lineRenderer = lineObj.GetComponent<LineRenderer>();
    lineRenderer.positionCount = currentLinePoints.Count;
    lineRenderer.SetPositions(currentLinePoints.ToArray());
    
    Destroy(activeTrail.gameObject);
    completedLines.Add(lineRenderer);
}
```
ポイント：
- `TrailRenderer` の線を `LineRenderer` に変換し、確定
- `LineRenderer` に `currentLinePoints`（描画済みの点リスト）をセット
- 確定後に `TrailRenderer` を削除し、管理対象へ

#### ■ Undo / Redo の実装
```c#
public void UndoLastDrawing()
{
    if (!CanUndo) return;

    LineRenderer lastLine = completedLines.Last();
    completedLines.Remove(lastLine);
    redoStack.Push(lastLine);
    Destroy(lastLine.gameObject);
}

public void RedoLastDrawing()
{
    if (!CanRedo) return;

    LineRenderer restoredLine = redoStack.Pop();
    completedLines.Add(restoredLine);
}
```
ポイント：
- `Undo` → 最後に描いた線 (`LineRenderer`) を削除し、RedoStack に保存
- `Redo` → `RedoStack` から復元し、シーンに再描画

#### ■ MeshCollider を適用
```c#
private void SetupMeshCollider(LineRenderer lineRenderer)
{
    Mesh mesh = new Mesh();
    lineRenderer.BakeMesh(mesh, true);
    MeshCollider meshCollider = lineRenderer.gameObject.AddComponent<MeshCollider>();
    meshCollider.sharedMesh = mesh;
}
```
ポイント：
- `BakeMesh` を活用し、描画した線 (`LineRenderer`) に `MeshCollider` を適用
- 線と物理的に干渉可能（今回は消しゴムとの当たり判定のためです）

### gRPCクライアント
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3862511/d49814b6-4e5a-4032-97f1-78d206f16f02.png)

#### ■ 送信リクエスト作成
```c#
var drawingData = new DrawingData
{
    DrawingId = Guid.NewGuid().ToString(),
    SceneId = sceneId,
    DrawTimestamp = DateTimeOffset.Now.ToUnixTimeMilliseconds(),
    Center = ConvertToVector3Proto(center),
    UseAi = useAi,
    ClientId = SystemInfo.deviceUniqueIdentifier,
    ClientInfo = CreateClientInfo(),
    DrawLines = { worldLines }
};

// メタデータの追加
drawingData.Metadata.Add("created_at_utc", DateTime.UtcNow.ToString("o"));
```
ポイント：
- データを gRPC 用にフォーマット

#### ■ gRPCでデータ送信
```c#
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
var uploadResponse = await client.UploadDrawingAsync(drawingData, cancellationToken: cts.Token)
                                 .ResponseAsync
                                 .AsUniTask();

if (!uploadResponse.Success) {
    throw new Exception("送信に失敗しました");
}
```
ポイント：
- `UniTask` を活用し非同期通信を実施
- 送信が完了するまで待機しつつ、エラーハンドリングも入れる

#### ■ AIの応答処理とアイテム生成
```c#
var aiResponse = await client.ProcessDrawingAsync(drawingData)
                             .ResponseAsync
                             .AsUniTask();

if (aiResponse.PrefabName == "Unknown") {
    // プレイヤーに失敗のフィードバック
} else {
    // プレハブのスポーンを開始
}
```
ポイント：
- 成功時と失敗時の分岐処理を実装


### gRPCサーバー
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3862511/a0e0940d-ba51-4909-90a8-276c1fa1d97b.png)

#### ■ クライアントからリクエスト受信
```python
async def UploadDrawing(self, request, context):
    """ 描画データをアップロード

    Args:
        request (DrawingRequest): gRPCリクエスト
    Returns:
        UploadResponse: アップロード結果
    """
    try:
        drawing_data = self._convert_request_to_dict(request)
        saved_id = await self.drawings_repository.insert_drawings(drawing_data)
        return UploadResponse(success=True, message="", upload_id=saved_id)
    except Exception as e:
        logger.error(f"Error uploading drawing: {e}")
        return UploadResponse(success=False, message=f"Error: {str(e)}", upload_id="")
```
ポイント：
- リクエストを受け取ってDB保存の準備をする
- エラーハンドリングを行い、失敗時にはエラーメッセージを返す

#### ■ データ前処理
```python
def _convert_request_to_dict(self, request):
    return {
        "drawing_id": request.drawing_id,
        "scene_id": request.scene_id,
        "draw_timestamp": request.draw_timestamp,
        "draw_lines": [
            {
                "positions": [{"x": pos.x, "y": pos.y, "z": pos.z} for pos in line.positions],
                "width": line.width,
                "color": {"r": line.color.r, "g": line.color.g, "b": line.color.b, "a": line.color.a} if line.color else None,
            }
            for line in request.draw_lines
        ],
        "use_ai": request.use_ai,
        "client_id": request.client_id,
    }
```
ポイント：
- gRPCリクエストをPythonの辞書データに変換
- DB保存時のフォーマットに合わせる

#### ■ AI処理の並列実行
```python
# 特徴量保存とAI処理を並行実行
feature_id = str(uuid.uuid4())
save_task = self.features_repository.insert_features({
    "feature_id": feature_id,
    "drawing_id": request.drawing_id,
    "total_strokes": features["global_features"]["total_strokes"],
    "total_points": features["global_features"]["total_points"],
    "features": features  # 全特徴量をJSONとして保存
})

shapes = await self.shape_repository.get_available_shapes(request.scene_id)
ai_task: ShapeRecognitionServer = self.ai_service_manager.process_drawing(
    request,
    shapes,
    features,
)

# 両方の処理を待機
ai_result, _ = await asyncio.gather(ai_task, save_task)
```
ポイント：
- asyncio.gather() を使い、特徴量のDB保存とAI処理を同時実行
- DB保存が終わるのを待たずにAI判定を進めることで高速化

#### ■ 判定結果の確認
```python
if ai_result.success and ai_result.score >= shape_info["threshold"]:
    prefab_name = shape_info["prefab_name"]
else:
    prefab_name = "Unknown"
```
ポイント：
- AIが返してきたスコアをチェックし、閾値を超えていれば成功と判定
- スコアが低い場合は "Unknown" を返す

#### ■ クライアントへレスポンス送信
```python
return ShapeRecognitionClient(
    success=True,
    drawing_id=request.drawing_id,
    prefab_name=prefab_name
)
```
ポイント：
- gRPCレスポンスをクライアントへ返送
- Prefab名を送信し、クライアント側でオブジェクトを生成

### AI実行処理
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3862511/e55ca4cd-d8b5-4ecf-8021-2ea41e710c72.png)

#### ■ 特徴量抽出
```python
@staticmethod
def prepare_features(features: Dict[str, Any]) -> Dict[str, Any]:
    """特徴量データの前処理

    Args:
        features: 特徴量データ
    Returns:
        Dict[str, Any]: 前処理済みの特徴量データ
    """
    enhanced_features = features.copy()
    # point_densityを計算
    enhanced_features["point_density"] = AIServiceManager._calculate_point_density(features)
    return enhanced_features
```
ポイント：
- 描画データから特徴量を算出
- point_density（点の密度） などを計算

#### ■ AIモデルの選択
```python
@staticmethod
def classify_drawing(features: Dict[str, Any]) -> DrawingGroup:
    """
    特徴量に基づいてグループを分類
    - GROUP_A: gpt-3.5-turbo-0125（OKの判定に強い）
    - GROUP_B: gemini-1.5-pro（NGの判定に強い）
    - GROUP_BOTH: mistral-large-latest（両方の判定が安定）

    Args:
        features: 特徴量データ
    Returns:
        DrawingGroup: グループ
    """
    global_features = features.get("global_features", {})
    strokes = features.get("strokes", [])

    total_points = global_features.get("total_points", 0)
    total_length = sum(stroke.get("total_length", 0) for stroke in strokes)
    point_density = total_points / total_length if total_length > 0 else 0
    total_strokes = global_features.get("total_strokes", 0)

    # mistral-large-latestで処理（安定した判定が必要な場合）
    if (6 < point_density < 82) and (4 <= total_strokes <= 16):
        return DrawingGroup.GROUP_BOTH
    # gemini-1.5-proで処理（NGの判定が重要な場合）
    elif point_density > 50 or total_strokes <= 2 or total_length < 0.2:
        return DrawingGroup.GROUP_B
    # それ以外はgpt-3.5-turbo-0125で処理（OKの判定が重要な場合）
    else:
        return DrawingGroup.GROUP_A
```
ポイント：
- 特徴量に基づいて最適なAIを選択

#### ■ AIへデータ送信
```python
async def process_drawing(
    self, drawing_data, shapes: List[dict], features: Dict[str, Any]
) -> Optional[ShapeRecognitionServer]:
    """描画データの処理実行

    Args:
        drawing_data: 描画データ
        shapes: 利用可能な形状
        features: 特徴量データ
    Returns:
        ShapeRecognitionServer: サーバーのレスポンス
    """
    try:
        # グループを判定
        group = self.classify_drawing(features)

        # 対応するサービスを取得
        service = self.services.get(group)
        if not service:
            logger.error(f"No service found for group {group}")
            return ShapeRecognitionServer(
                success=False, error_message=f"No AI service configured for group {group.value}"
            )

        # 単一サービスでの処理
        result = await service.recognize_shape(drawing_data, shapes, features)
        return result
    except Exception as e:
        logger.error(f"Error in process_drawing: {e}")
        return ShapeRecognitionServer(
            success=False,
            error_message=f"Error processing with {group.value} service: {str(e)}",
    )
```
ポイント：
- 選択したAIにデータを送信
- `recognize_shape()` を呼び出し、AIで解析

#### ■ AIの応答解析
```python
async def parse_response(self, response: str, shape_infos: List[Dict[str, str]], result_id: str) -> ShapeRecognitionServer:
    """AIのレスポンスを解析"""
    try:
        result = json.loads(response)
        shape_info = next((s for s in shape_infos if s["shape_id"] == result["shape_id"]), None)
        if not shape_info:
            raise ValueError("Invalid shape ID")

        return ShapeRecognitionServer(
            success=True,
            result_id=result_id,
            shape_id=result["shape_id"],
            score=int(result["score"]),
            reasoning=result["reason"],
            model_name=self.model_name,
            api_response=json.dumps(result),
        )
    except Exception as e:
        logger.error(f"Error parsing response: {e}")
        return ShapeRecognitionServer(success=False, error_message=str(e))
```
ポイント：
- AIのレスポンスを解析し、スコアと判定結果を取得
- スコアが低ければ `Unknown`を返す

## 生成AI
### 役割
- プレイヤーの描いた絵を解析し、適切な形状かどうかを判定
- AIの結果に基づいて、VR空間にアイテムを生成

### 本アプリで実装しているサービス
- OpenAI：GPT 3.5 Turbo
- Google AI：Gemini 1.5 Pro
- Mistral AI：Mistral Large

### 詳細
こちらは別の記事に記載しております。※どちらも同じ内容です。
[Qiita（生成AIを活用したVR開発の試行と検証）](https://qiita.com/tky116/items/46979280d7c56d68c659)
[Zenn（生成AIを活用したVR開発の試行と検証）](https://zenn.dev/tky116/articles/9bc26c476b2ae3)

## まとめ
- 「ペンと鍵の部屋VR」は、VR描画とAI形状認識を組み合わせた新しい脱出ゲーム体験 を提供する作品
- MetaQuest3を使用したVR描画をgRPC経由でサーバーに送信し、AIが形状を判定することで、プレイヤーが描いたアイテムを3Dオブジェクトとして生成する仕組みを実現
- 特徴：リアルタイム描画（TrailRenderer + LineRenderer）、描画データの保存・再利用（MySQL + Webサーバー）、高速通信（gRPC）、AI形状判定（LLM活用）
- プレイヤーは直感的な操作で自由に絵を描き、それがゲーム内アイテムとして機能するインタラクティブな体験が可能に

## さいごに
今回の作品は好きなものの一つである謎解き・脱出ゲームを題材とし、『VR + 生成AI』で新しいプレイヤー体験を目指しました。
2ヶ月ほどの開発期間でしたが大変やりがいがありました。
今回生成AIに関しては研究の意味合いがとても強いのですが、**生成AIをどのように扱っていくか** はポイントになっていくかなと思います。
今回の制作で少しでも皆さんの創造力を刺激するきっかけに何か新しいオモロいものが世の中に生まれれば幸いです。