🦒

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

2025/03/05に公開

はじめに

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

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

ペンと鍵の部屋VR とは?

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

  • 2D動画
  • 360°動画

開発環境

クライアント

サーバー

  • 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

クライアント

  • 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

通信プロトコル(gRPC)

役割

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

選定理由

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

詳細

こちらは別の記事に記載しております。※どちらも同じ内容です。
Qiita(Unity×gRPCでVR描画データを送信する)
Zenn(Unity×gRPCでVR描画データを送信する)

処理フロー

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

VR描画

image.png

■ TrailRenderer でリアルタイム描画

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 に確定

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 に変換し、確定
  • LineRenderercurrentLinePoints(描画済みの点リスト)をセット
  • 確定後に TrailRenderer を削除し、管理対象へ

■ Undo / Redo の実装

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 に保存
  • RedoRedoStack から復元し、シーンに再描画

■ MeshCollider を適用

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

■ 送信リクエスト作成

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でデータ送信

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の応答処理とアイテム生成

var aiResponse = await client.ProcessDrawingAsync(drawingData)
                             .ResponseAsync
                             .AsUniTask();

if (aiResponse.PrefabName == "Unknown") {
    // プレイヤーに失敗のフィードバック
} else {
    // プレハブのスポーンを開始
}

ポイント:

  • 成功時と失敗時の分岐処理を実装

gRPCサーバー

image.png

■ クライアントからリクエスト受信

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保存の準備をする
  • エラーハンドリングを行い、失敗時にはエラーメッセージを返す

■ データ前処理

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処理の並列実行

# 特徴量保存と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判定を進めることで高速化

■ 判定結果の確認

if ai_result.success and ai_result.score >= shape_info["threshold"]:
    prefab_name = shape_info["prefab_name"]
else:
    prefab_name = "Unknown"

ポイント:

  • AIが返してきたスコアをチェックし、閾値を超えていれば成功と判定
  • スコアが低い場合は "Unknown" を返す

■ クライアントへレスポンス送信

return ShapeRecognitionClient(
    success=True,
    drawing_id=request.drawing_id,
    prefab_name=prefab_name
)

ポイント:

  • gRPCレスポンスをクライアントへ返送
  • Prefab名を送信し、クライアント側でオブジェクトを生成

AI実行処理

image.png

■ 特徴量抽出

@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モデルの選択

@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へデータ送信

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の応答解析

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開発の試行と検証)
Zenn(生成AIを活用したVR開発の試行と検証)

まとめ

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

さいごに

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

Discussion