AIを使ってUnreal Engine内のアセットを自然言語で検索したい

本記事は面白法人グループ Advent Calendar 202513日目の記事です。

はじめに

こんにちは。カヤックの伊藤です。
普段はUnreal Engineを用いたバーチャル撮影ツールJEANNE D'ARCの開発や、AIを用いた作業効率化ツールの開発を行っています。

Unreal EngineやUnity等のゲーム開発エンジンを触っていて、
「あのアセット、どこのフォルダにあったっけ……?」
「テキストで検索しようにも変な名前を付けていた気が……分からん」
といった現象に陥ることはないでしょうか。

そこで、今回はUnreal Engineを対象に、言語と画像のマルチモーダルモデルであるCLIPを用いて自然言語で検索する方法を紹介したいと思います。

CLIPって何?

CLIP (Contrastive Language–Image Pre-training) はOpenAIが2021年に公開した、テキストと画像に対応したマルチモーダルモデルです。

openai.com

CLIPは、テキストと画像の特徴を共通の特徴空間へ射影し、それぞれの類似度を学習したモデルです。この空間内では、意味的に関連性の高いペア(例:「猫の画像」と「"猫"というテキスト」)のベクトル同士の距離が近くなるように配置されています。

そのため、画像がごちゃ混ぜになっているところから、特定の画像を探し出したい~!というときに非常に便利なものとなっています。

テキストで画像を検索する取り組みとしてはこちらが参考になります。

構成

全体の構成は以下のようになっています。

全体の構成

データの準備

画像

UEのアセットをどうにかして画像にしてCLIPに入れる必要があります。
テクスチャなど元々画像データとして存在しているアセットはそのまま処理すればよいのですが、Static Mesh等の3Dデータとなると、そのままではCLIPは理解してくれません。

はじめは3Dデータを多角的に撮影した画像群をCLIPでベクトル化し、テキストと各画像のベクトル類似度の平均を取る、といった方法を考えました。しかし、3Dデータを毎回撮影して出力して、という工程では

  • UE上でLevelをまっさらにしてその3Dデータのみが映るようにする必要があり、大変。
  • CLIPが処理する画像がテクスチャでは1枚で済むのに対し、3Dデータでは複数枚になってしまい、CLIPでのベクトル化の処理が多くなる

等の問題が生じてしまいました。そこで、今回はUEで表示されているサムネイル画像を利用することにしました。

テキスト

Editor Utility Widget等を作成して入力したテキストをBlueprintからPythonに送ります。

CLIP

日本語で検索したいのでline-corporation/clip-japanese-baseを使用することにしました。 ベクトルの類似度はコサイン類似度で計算することとします。

環境構築

実装環境

  • Windows 11
  • UE5.3(UE5.6やUE5.7でも動作確認済み)

Unreal Python環境

Unreal EngineにはPythonが組み込まれているため、そのままその環境で作っていっちゃいます。
CLIPを動かす用のライブラリを入れていきましょう。

UEでは

  • プロジェクトのフォルダー内にある「Content/Python」サブフォルダ
  • Unreal Engine のメイン インストール フォルダにある、「Content/Python」サブフォルダ
  • 有効化されている各プラグインのフォルダーにある「Content/Python」サブフォルダ

を自動的にUnreal Engineに組み込まれているPythonのシステムパスに加えてくれます。なんと便利。 dev.epicgames.com

UE全体の環境に影響を与えないように、プロジェクトかプラグインの\Content\Python\にインストールしていきましょう。
今回はUE公式のContent Examplesを使ってプロジェクトを作成、プラグインを作成してインストールする形にします。

いい感じにasset_searchという名前でPluginを作成。Content Examplesは元々Blueprint Projectなので何かC++ファイルを元のプロジェクトで作ってC++ Project化してからPlugin作らないといけないことに注意。

Pythonフォルダをasset_search Content下に作ります。

ここで一旦UEを離れて、Python環境を作っていくためにWindowsのコマンドプロンプトを開きます。
UE5.3のPythonがあるフォルダまで移動して

cd "C:\Program Files\Epic Games\UE_5.3\Engine\Binaries\ThirdParty\Python3\Win64"

-t でフォルダ指定した状態で、(hugging faceのサンプルを参考に)\asset_search\Content\Pythonへ必要なライブラリをインストール

python -m pip install pillow requests sentencepiece transformers torch timm -t "path\to\asset_search\Content\Python"

これでひとまずclip-japanese-baseが動く環境が整いました。

Unreal Engine x Pythonの解説記事はこちらの記事が分かりやすいです。

実装するぞ!

画像の処理

こちらを参考に、Plugins内でC++で実装します。

SaveThumbnail.h
#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "SaveThumbnail.generated.h"

UCLASS()
class ASSET_SEARCH_API USaveThumbnail : public UBlueprintFunctionLibrary
{
    GENERATED_BODY()
public:
    UFUNCTION(BlueprintCallable, Category = "SaveThumbnail")
    static bool SaveThumbnailToFile(UObject* Object, FString OutputPath);
};

後々Editor Utility Widgetからサムネイル保存を呼び出したいため、UFUNCTION(BlueprintCallable)を付けています。

SaveThumbnail.cpp
#include "SaveThumbnail.h"
#include "ObjectTools.h"
#include "IImageWrapper.h"
#include "IImageWrapperModule.h"
#include "AssetRegistry/AssetRegistryModule.h"

bool USaveThumbnail::SaveThumbnailToFile(UObject* Object, FString OutputPath)
{
    if (Object)
    {
        FObjectThumbnail* ObjectThumbnail = ThumbnailTools::GenerateThumbnailForObjectToSaveToDisk(Object);
        if (ObjectThumbnail)
        {
            IImageWrapperModule& ImageWrapperModule = FModuleManager::Get().LoadModuleChecked<IImageWrapperModule>(TEXT("ImageWrapper"));
            TSharedPtr<IImageWrapper> ImageWrapper = ImageWrapperModule.CreateImageWrapper(EImageFormat::PNG);
            ImageWrapper->SetRaw(ObjectThumbnail->GetUncompressedImageData().GetData(), ObjectThumbnail->GetUncompressedImageData().Num(), ObjectThumbnail->GetImageWidth(), ObjectThumbnail->GetImageHeight(), ERGBFormat::BGRA, 8);
            if (ImageWrapper)
            {
                const TArray64<uint8>& CompressedByteArray = ImageWrapper->GetCompressed();
                FFileHelper::SaveArrayToFile(CompressedByteArray, *OutputPath);
                return true;
            }
        }
        return false;
    }
    return false;
}

asset_search.Build.csに必要なmoduleを追加しましょう。

asset_search.Build.cs
PrivateDependencyModuleNames.AddRange(
    new string[]
    {
        "CoreUObject",
        "Engine",
        "Slate",
        "SlateCore",
        "ImageWrapper", // 追加
        "UnrealEd", //追加
        "AssetRegistry" // 追加
        // ... add private dependencies that you statically link with here ... 
    }
    );

PythonでのCLIPの処理

import os
import sys
from pathlib import Path
from PIL import Image
import torch
from transformers import AutoImageProcessor, AutoModel, AutoTokenizer
import unreal

HF_MODEL_PATH = 'line-corporation/clip-japanese-base'
IMAGES_DIR = os.path.abspath(unreal.Paths.project_dir()) + "\\Saved\\Images\\"

device = "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH, trust_remote_code=True)
processor = AutoImageProcessor.from_pretrained(HF_MODEL_PATH, trust_remote_code=True)
model = AutoModel.from_pretrained(HF_MODEL_PATH, trust_remote_code=True).to(device)

def get_image_features(image_path):
    image = Image.open(image_path).convert("RGB")
    inputs = processor(image, return_tensors="pt").to(device)
    with torch.no_grad():
        features = model.get_image_features(**inputs)
    return features

def get_text_features(text):
    inputs = tokenizer([text]).to(device)
    with torch.no_grad():
        features = model.get_text_features(**inputs)
    return features

def cosine_similarity(a, b):
    a_norm = a / a.norm(dim=-1, keepdim=True)
    b_norm = b / b.norm(dim=-1, keepdim=True)
    return (a_norm @ b_norm.T).item()

def main():
    if len(sys.argv) < 2:
        print("Usage: python main.py <search_text>")
        sys.exit(1)

    search_text = sys.argv[1]
    print(f"Search text: {search_text}")

    images_path = Path(IMAGES_DIR)
    png_files = list(images_path.glob("*.png"))

    if not png_files:
        print(f"No PNG images found in {IMAGES_DIR}")
        sys.exit(1)

    text_features = get_text_features(search_text)

    results = []
    for image_path in png_files:
        image_features = get_image_features(image_path)
        similarity = cosine_similarity(image_features, text_features)
        results.append((image_path.name, similarity))

    results.sort(key=lambda x: x[1], reverse=True)

    print(f"\nResults (sorted by similarity):")
    for filename, similarity in results[:5]:
        print(f"{similarity:.4f}: {filename}")

if __name__ == "__main__":
    main()

UEから検索テキストをsysで受け取れるようにしてみました。

Blueprint

Editor Utility Widgetで検索用のWidgetを作成します。

Editor Utility Widget

うーん最低限こんな感じでしょうか?

指定したPathとClassのアセットのサムネイルを出力するBlueprint

今回は簡単のためContent/ExampleContent内のStatic Meshに絞ってサムネイル画像を出力するBPにしています。

Pythonを実行するBlueprint。Content/Pythonにあるpythonファイルは名前だけで実行してくれる。

SearchTextを引数とするために「asset_search.py SearchText(string)」とPython Commandを実行しています。

検索してみる

人、自然、青色で検索してみました。

LogPython: Search text: 人
LogPython: Results (sorted by similarity):
LogPython: 0.2241: head.png
LogPython: 0.2152: SM_LightSculpture.png
LogPython: 0.2049: SM_Traffic_Light.png
LogPython: 0.2033: Creature.png
LogPython: 0.2026: SM_Pipe_Straight.png

head

自然

 LogPython: Search text: 自然
 LogPython: Results (sorted by similarity):
 LogPython: 0.2531: GrassClump.png
 LogPython: 0.2371: S_Nordic_Coastal_Groundcover_vlqkdeaja_Var24_lod0.png
 LogPython: 0.2329: S_Vine.png
 LogPython: 0.2213: S_Nordic_Coastal_Groundcover_vlqkdeaja_Var6_lod0.png
 LogPython: 0.2198: tree.png

GrassClump

青色

 LogPython: Search text: 青色
 LogPython: Results (sorted by similarity):
 LogPython: 0.3164: S_Pipe_Spline.png
 LogPython: 0.3028: SM_Interactive_Crank_Wheel.png
 LogPython: 0.3013: SM_Button2.png
 LogPython: 0.2999: SM_Door.png
 LogPython: 0.2975: SM_FBX_Custom_Normal_A.png

S_Pipe_Spline

まあまあいい感じに検索できていそうです。

出てきた問題点

作ってみて、以下の問題点が浮かびました。

UEから毎回Pythonを走らせていると、毎回CLIPモデルを読み込んでしまい、UEで処理待ちが生じる

検索ボタンを押すたびに毎回python scriptを上から下まで実行してしまうので、当たり前ですが……。

対処法として、subprocess.Popen(creationflags=subprocess.CREATE_NEW_CONSOLE))によってUEのPython環境をUEの外で立ち上げることで回避しました(それでいいのか?)。 また、画像やテキストのベクトル化、類似度計算によってUEで処理待ちが生じてしまっていたため、FastAPIを用いてローカルサーバーを立ち上げ、HttpRequest, HttpResponseを介して情報をやり取りすることに変更しました。これでいいのか……?

画像とテキストの類似度だけでは、毎回上位にランクインするアセットや、あまりランクインしないアセットがある

こちらはベクトル類似度を画像のみでなく、アセット名と検索テキストのベクトル類似度も用いることで(体感の)精度が向上しました。 画像との類似度 x 0.65 + アセット名との類似度 x 0.35 ぐらいの比率がいい感じです(体感)。

色々改良してみた

最終的にこんな形になりました。Content Browserへジャンプできると楽ですね~~~~~~~。

終わりに

少し長めになりましたが、AIを使ってUnreal Engine内のアセットを自然言語で検索するツールの開発を紹介しました。

明日は id:acidlemon さんによる記事です!お楽しみに~