【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を作成するスクリプト
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
パースするスクリプト
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