🧞

【UE5×AI】Antigravityに「Mermaidで描いた図をそのままマテリアルにしたい」と無茶振りしたらUE5のプラグインができた話

に公開

はじめに

この記事はUnreal Engine (UE) Advent Calendar 2025 シリーズ 4の15日目の記事となります。

今回はコーディングエージェントとのバイブコーディングでUE5のプラグインを作れるのかとコードだけでマテリアルを作ることはできるのかの2つの実験をした際に得た知見について共有できたらいいなと思い、技術記事を書かせていただきました。

今回はUE5とコーディングエージェントであるAntigravity、フローチャートを表現できるMermaidを使った内容となっております。

UE5とAntigravityを組み合わせるとどんなことができるのかは、以前自分が書いた別の記事で取り上げているので、よろしければ見てくれると嬉しいです。
【UE5】Antigravityを使ってどんなことができるか試してみた

環境

UE5.7
Antigravity パブリックプレビュー版(Gemini 3 Pro)

Mermaidとは

Mermaidとは、Markdownでグラフを作成できるダイアグラムツールになります。

似たツールにPlantUMLというのがあります。

こちらの記事にて比較があるので、気になる方はこちらの記事を参考にしていただけると助かります。
MermaidとPlantUMLの比較【一目比較】

今回はMermaidの中で使われているフローチャート図に着目して、プラグインを作ることにしました。

何故Mermaidなのか

ことの始まりはあるシェーダーを書きたいと思い、Antigravityに質問をした所から始まりました。

UE5でガラスみたく割れる演出のポストエフェクトの作り方を知りたいです

すると以下のような回答が返ってきました。

ガラスが割れるようなポストプロセスエフェクトの実装計画を作成しました。 画面全体にひび割れと屈折を加えるマテリアルを作成します。

AntigravityはバイナリであるMaterialは作れないので、代わりにMermaidを使ってフローチャート図でマテリアルを作成する手順を以下のように説明をしてくれました

これを見たときに、MermaidはテキストだけどUE5のノードと見立てることができるのでは?と思い、Mermaidからマテリアルを作れるのかどうか気になり、Antigravityに以下の質問をしました。

mermaidからUE5のマテリアルを作れるプラグインなどありますか?また、mermaidからUE5のマテリアルを作れるようなプラグインを自分で作れますか?

すると以下のような返信が来ました。

調査の結果、Mermaidから直接UE5のマテリアルを生成する既存のプラグインは見つかりませんでした。

しかし、2つ目のご質問である「自分で作れるか?」については、「はい、作れます(私達で実装可能です)」。

Mermaidのような「グラフ構造を表すテキスト」を「UE5のマテリアルノード」に変換するのは、技術的に非常に面白いアプローチであり、UE5のPython Editor Scriptingを使えば、比較的容易にプロトタイプを作成できます。

以下に技術的な実現方法の概要をまとめました。

そうして出された提案書が以下になります。

ぱっとみですが、やりたいことが一致してそうなのと、コーディングエージェントとのバイブコーディングなら実装できそうと思い、実装してみることにしました。

やり取り

1~2時間くらいAntigravityと試行錯誤して、以下のような物ができました。

テキストにMermaidで表現できる内容を打ち込んで、ボタンを押したらマテリアルが作成するようになりました。
次の項目からどんなことをしたのか説明していこうと思います。

プラグイン作成

まず提案書を元に、他のプロジェクトでも使えるようにプラグインとして作成しようと思い、以下のプロンプトを打ちました。

Pluginとして使いたいのでPluginでの開発をお願いします

すると以下のような提案書を作成してもらいました。

作られたPluginが以下になります。
Pythonを使うので、PythonScriptPluginも入れてくれました。


{
	"FileVersion": 3,
	"Version": 1,
	"VersionName": "1.0",
	"FriendlyName": "Mermaid Material",
	"Description": "Generates Material Graphs from Mermaid diagrams using Python.",
	"Category": "Editor",
	"CreatedBy": "Antigravity",
	"CreatedByURL": "",
	"DocsURL": "",
	"MarketplaceURL": "",
	"SupportURL": "",
	"CanContainContent": true,
	"IsBetaVersion": true,
	"IsExperimentalVersion": true,
	"Installed": false,
	"Plugins": [
		{
			"Name": "PythonScriptPlugin",
			"Enabled": true
		},
		{
			"Name": "EditorScriptingUtilities",
			"Enabled": true
		}
	]
}

Pythonのスクリプトは、Mermaidの記述をパースするスクリプトと、そのパースした内容からMaterialを作成するスクリプトを用意してもらいました。

Materialを作成するスクリプト
material_generator.py
import unreal
import mermaid_parser

def create_material_from_mermaid(mermaid_text, material_name="M_MermaidGenerated"):
    # 1. Parse Mermaid
    parser = mermaid_parser.MermaidParser()
    nodes_data, edges_data = parser.parse(mermaid_text)
    
    # 2. Create Asset
    asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
    package_path = "/Game/MermaidMaterials"
    
    # Check if directory exists, if not, creating asset might fail or create folder? 
    # AssetTools creates folder automatically usually.
    
    # Make a unique name
    unique_name = asset_tools.create_unique_asset_name(package_path + "/", material_name)[0]
    asset_name = unique_name.split('/')[-1]
    
    material_factory = unreal.MaterialFactoryNew()
    new_material = asset_tools.create_asset(asset_name, package_path, unreal.Material, material_factory)
    
    if not new_material:
        unreal.log_error("Failed to create material")
        return None
        
    # 3. Create Nodes
    # Map friendly names to actual classes or functions
    # This is a simple mapping, can be expanded
    class_map = {
        "TextureSample": unreal.MaterialExpressionTextureSample,
        "Add": unreal.MaterialExpressionAdd,
        "Multiply": unreal.MaterialExpressionMultiply,
        "Constant": unreal.MaterialExpressionConstant,
        "Constant3Vector": unreal.MaterialExpressionConstant3Vector,
        "Constant4Vector": unreal.MaterialExpressionConstant4Vector,
        "ScalarParameter": unreal.MaterialExpressionScalarParameter,
        "VectorParameter": unreal.MaterialExpressionVectorParameter,
        "Sine": unreal.MaterialExpressionSine,
        "Time": unreal.MaterialExpressionTime,
        "Panner": unreal.MaterialExpressionPanner,
        "TexCoord": unreal.MaterialExpressionTextureCoordinate,
    }
    
    created_expressions = {} # {node_id: expression_object}
    
    # Layout config
    start_x = -600
    start_y = 0
    x_step = 250
    y_step = 100
    
    # Traverse nodes to create them
    idx = 0
    import re # Ensure re is imported
    
    for node_id, raw_node_type in nodes_data.items():
        if raw_node_type == "BaseColor":
            continue
            
        # Parse Value: Type(Value)
        node_type = raw_node_type
        node_value = None
        
        # Regex to find Type(Value)
        # Handles cases like Constant3Vector(1,0,0) or ScalarParameter ( 2.0 )
        match = re.match(r'([^\(]+)\s*\(\s*(.+)\s*\)', raw_node_type)
        if match:
            node_type = match.group(1).strip()
            node_value = match.group(2).strip()
            unreal.log(f"Debug: Parsed Value for {node_id}: Type={node_type}, Value={node_value}")
            
        ue_class = class_map.get(node_type)
        if not ue_class:
            unreal.log_warning(f"Unknown node type: {node_type} (Raw: {raw_node_type})")
            continue
            
        expression = unreal.MaterialEditingLibrary.create_material_expression(new_material, ue_class)
        created_expressions[node_id] = expression
        
        # Set Parameter Name
        if isinstance(expression, unreal.MaterialExpressionParameter):
            expression.set_editor_property("ParameterName", node_id)
        
        # Apply Values
        if node_value:
            try:
                # Constant (Scalar)
                if isinstance(expression, unreal.MaterialExpressionConstant):
                    val = float(node_value)
                    expression.r = val
                    
                # Constant3Vector / 4Vector
                elif isinstance(expression, (unreal.MaterialExpressionConstant3Vector, unreal.MaterialExpressionConstant4Vector)):
                    # Expect "R,G,B" or "R,G,B,A"
                    vals = [float(v.strip()) for v in node_value.split(',')]
                    if len(vals) >= 3:
                        a = vals[3] if len(vals) > 3 else 1.0
                        color = unreal.LinearColor(vals[0], vals[1], vals[2], a)
                        expression.constant = color
                        
                # Scalar Parameter
                elif isinstance(expression, unreal.MaterialExpressionScalarParameter):
                    val = float(node_value)
                    expression.set_editor_property("DefaultValue", val)

                    
                # Vector Parameter
                elif isinstance(expression, unreal.MaterialExpressionVectorParameter):
                    vals = [float(v.strip()) for v in node_value.split(',')]
                    if len(vals) >= 3:
                        a = vals[3] if len(vals) > 3 else 1.0
                        color = unreal.LinearColor(vals[0], vals[1], vals[2], a)
                        expression.default_value = color
                        
            except Exception as e:
                unreal.log_warning(f"Failed to parse value for {node_id}: {node_value} - {e}")

        # Simple positioning
        expression.material_expression_editor_x = start_x + (idx * x_step) % 1000
        expression.material_expression_editor_y = start_y + (idx // 4) * y_step
        idx += 1

    # 4. Connect Edges
    unreal.log(f"Debug: Nodes: {nodes_data}")
    unreal.log(f"Debug: Edges: {edges_data}")

    for source_id, target_id in edges_data:
        target_type = nodes_data.get(target_id)
        unreal.log(f"Debug: Processing Edge {source_id} -> {target_id} (TargetType: {target_type})")
        
        # Define supported Material Properties
        property_map = {
            "BaseColor": unreal.MaterialProperty.MP_BASE_COLOR,
            "Metallic": unreal.MaterialProperty.MP_METALLIC,
            "Specular": unreal.MaterialProperty.MP_SPECULAR,
            "Roughness": unreal.MaterialProperty.MP_ROUGHNESS,
            "EmissiveColor": unreal.MaterialProperty.MP_EMISSIVE_COLOR,
            "Opacity": unreal.MaterialProperty.MP_OPACITY,
            "OpacityMask": unreal.MaterialProperty.MP_OPACITY_MASK,
            "Normal": unreal.MaterialProperty.MP_NORMAL,
            "WorldPositionOffset": unreal.MaterialProperty.MP_WORLD_POSITION_OFFSET,
        }

        # Check if target is a Material Property Root Node
        target_prop = property_map.get(target_type) or property_map.get(target_id)
        
        if target_prop is not None:
            source_expr = created_expressions.get(source_id)
            unreal.log(f"Debug: Connecting to Main Node Property: {target_type or target_id}. Source: {source_id}")
            
            if source_expr:
                try:
                    source_pin = ""
                    # Special default pins for specific source types
                    if isinstance(source_expr, unreal.MaterialExpressionTextureSample):
                        source_pin = "RGB"
                    
                    # For Opacity/Roughness/Metallic, usually we want a single channel if it comes from a Texture
                    # But if it sends RGB, it converts to float (grayscale) automatically often, or we might want specific channels.
                    # For now, let's stick to simple logic: TextureSample -> RGB unless specified otherwise.
                    # (Refactoring point: Allow A[R] --> Roughness in parser)
                    
                    unreal.MaterialEditingLibrary.connect_material_property(
                        source_expr, 
                        source_pin, 
                        target_prop
                    )
                except Exception as e:
                    unreal.log_error(f"Error connecting to {target_type or target_id}: {e}")
            continue
            
        source_expr = created_expressions.get(source_id)
        target_expr = created_expressions.get(target_id)
        
        if source_expr and target_expr:
            # Auto-connect first outputs to first inputs (naive approach)
            # You might want to specify pins in mermaid: A[RGB] --> B[A]
            # For now, default "" connects Main Output to Main Input (usually A)
            
            # Track connection counts to assign A/B inputs
            # This requires us to process edges sequentially and track usage
            if 'node_input_counts' not in locals():
                node_input_counts = {}
            
            current_count = node_input_counts.get(target_id, 0)
            node_input_counts[target_id] = current_count + 1
            
            source_pin = ""
            target_pin = ""
            
            # Simple heuristic for common nodes
            if isinstance(source_expr, unreal.MaterialExpressionTextureSample):
                source_pin = "RGB" 
                
            if isinstance(target_expr, (unreal.MaterialExpressionAdd, unreal.MaterialExpressionMultiply)):
                # Assign A or B based on how many times we've connected to this node
                if current_count == 0:
                    target_pin = "A"
                elif current_count == 1:
                    target_pin = "B"
                else:
                    target_pin = "B" # Fallback/Overwrite
            
            unreal.log(f"Connecting {source_id}.{source_pin} -> {target_id}.{target_pin}")
            unreal.MaterialEditingLibrary.connect_material_expressions(source_expr, source_pin, target_expr, target_pin)

    # 5. Recompile
    unreal.MaterialEditingLibrary.recompile_material(new_material)
    
    return asset_name

パースするスクリプト
mermaid_parser.py
import re

class MermaidParser:
    def __init__(self):
        self.nodes = {} # {id: type}
        self.edges = [] # [(from_id, to_id)]

    def parse(self, text):
        self.nodes = {}
        self.edges = []
        
        lines = text.split('\n')
        for line in lines:
            line = line.strip()
            if not line or line.startswith('graph') or line.startswith('%%'):
                continue
            
            # Simple parsing for Node Definition: A[Type]
            # And Connection: A --> B
            
            # Extract nodes first to ensure we know types
            # Regex for ID[Type]
            # Potential patterns:
            # A[TextureSample] --> B[Multiply]
            # A --> B
            # C[Constant]
            
            # Find all node definitions in the line
            # Pattern: Identifier followed by [Type]
            # We assume ID is alphanumeric, Type can be anything inside []
            node_matches = re.finditer(r'([a-zA-Z0-9_]+)\[([^\]]+)\]', line)
            for match in node_matches:
                node_id = match.group(1)
                node_type = match.group(2)
                self.nodes[node_id] = node_type
            
            # Find connections
            if '-->' in line:
                parts = line.split('-->')
                if len(parts) >= 2:
                    source_part = parts[0].strip()
                    target_part = parts[1].strip()
                    
                    # Extract IDs from parts (ignore [Type] if present for connection logic)
                    source_id = self._extract_id(source_part)
                    target_id = self._extract_id(target_part)
                    
                    if source_id and target_id:
                        self.edges.append((source_id, target_id))
                        
        return self.nodes, self.edges

    def _extract_id(self, part):
        # Extract ID from strings like "A[Type]" or "A"
        match = re.search(r'^([a-zA-Z0-9_]+)', part)
        if match:
            return match.group(1)
        return None

このスクリプトを呼び出すために、UE側でEditorUtilityWidgetを作成し、いつでも呼び出せる状態にします。

※EditorUtilityWidgetはバイナリなためここからは自分で処理を書きます

UI実装

どういうUIに作ればいいかAntigravityに提案をしてもらいました。

そうして出来上がったのがこちらのEditorUtilityWidgetになります。

テキスト部分にMermaidのテキストを打ちこんでもらって、ボタンを押したらマテリアルを生成する流れになります。
ボタンを押したときの設定がこちらになります。

最初はExecute Python Commandの内容は提案してもらった以下のコードで実装していました。

import material_generator
import importlib
importlib.reload(material_generator) # 修正を即座に反映させるためにリロード
material_generator.create_material_from_mermaid(r"""{Code}""")

ただ、これはAntigravityとやりとりしているうちにわかったことなのですが、Python側のコード(今回で言うとmaterial_generator)を修正しても、以前のキャッシュが残ってしまうみたいで、更新をした場合importlib.reloadを呼ばないと反映されないみたいでした。(このimportlib.reloadの部分もAntigravityが教えてくれました)

最終的に以下のコードになりました。

import material_generator
import importlib
importlib.reload(material_generator)
material_generator.create_material_from_mermaid(r"""{Code}""")

これでテキスト部分をMermaidで表現できるテキストを打ち込んでもらい、ボタンを押したらコードの内容でマテリアルを作成することが出来ました。

以下のMermaidで記述したテキストで作成したマテリアルがこちらになります。

graph LR
  Red[Constant3Vector(1,0,0)] --> Mul[Multiply]
  Param[ScalarParameter(2.0)] --> Mul
  Mul --> EmissiveColor

ノードの位置が重なっていたり、一部のノードは未対応だったりと、使っていくにはまだまだ課題はありますが、Mermaidで書かれたテキストを元にマテリアルを作成するという目的は達成出来たので満足してます。

また、テキストだけでマテリアルを作れるようになることによって、コーディングエージェントにマテリアルの作成も実装依頼できるのでは?と副次的なできそうとわかったのも収穫でした。

本来ならUnrealでのPythonの使い方(自分は今回やるまでUrenalでのPythonは使ったことがなかった)やMermaidからマテリアルへの変換などを調べながら進めたら作るのに下手したら1日以上くらいかかるんじゃないか?と思っていたものを1~2時間で作れたのが体験として非常に良かったです。
コーディングエージェントのおかげでこういう小さいツールを作るのが簡単になったのはとても嬉しく思います。

Tips

ここからはAntigravityならではのやりとりがあったのでTipsとして紹介をします。

送信した画像からMermaidを作ってもらう

Antigravityはプロンプトと一緒に画像を添付してやり取りすることができます。
その際に、別のMermaidで記載したテキストが動くのか試したかったため、以下の画像を添付した状態で以下のプロンプトを打ちました。

これをmermaidに変更できますか

すると以下のような返答が返ってきました。

はい、その画像をMermaidで表現すると以下のようになります。

これを先程のEditorUtilityWidgetに貼り付けてボタンをクリックしたら無事作れました。
画像の内容をきちんと認識し、そこからテキストを作れるのは、なかなかない体験だったので面白かったです。
今回はMermaidでしたが、添付した画像マテリアルを作りたいなども出来そうだな思いました。

まとめ

バイブコーディングをしながらプラグインとしてツールを作ることが出来ました。

自分がやったのはUIの組み立てとエラーが出た時に調査したくらいで、9割以上はコーディングエージェントに実装してもらいました。
簡単なツールを作れるようになったのは自分の中では大きな出来事でした。

また便利だなと思う機能やこう使ったら作るのが便利になったなどあれば記事を書こうと思います。

ここまで読んでいただきありがとうございました。

Discussion