虎の穴ラボ技術ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

0からAIで作るBlenderアドオン

こんにちは、とらのあなラボの吉岡です。

はじめに

私は普段趣味でBlenderで3Dモデリングをしているのですがその中で少し不便に感じる事があり、
それを解決するアドオンをAI(GeminiとGitHub Copilot)で解決できるか試してみた結果のまとめになります。

※記事中でBlenderや3Dモデリングの用語が出てきますが、そんなのあるんだぁ程度で読んでもらえれば大丈夫です。

私がモデリングをする上で困っていたことは大きく2点あり

  1. よく使うfbxファイル(3Dモデルの汎用的なファイル形式)をインポートする際にオブジェクト配置と同じ「Shift + a」のショートカットで読みでみたい
  2. Nゴン(3Dモデルの五角形以上の面)の発生にすぐ気づきたい

というものです。
Blenderのアドオン開発にはpythonとBlender用のライブラリを使用しますが
そのどちらとも深い知識がないため今回はAIを使用して解決できないかと試してみました。

1. Gemini編

1-1 コードの生成

まず今回作りたいアドオンに必要な機能を提案してGeminiにコードを生成してもらいます。

プロンプト例

fbx読み込みのアドオン

あなたはBlenderのアドオン開発者です。以下の条件に基づいて、Blenderのアドオンコードを生成してください。
現在Blenderのアドオンを開発しようとしています。そこで以下の条件に沿ったアドオンのコードを生成してください

アドオンの概要
1. オブジェクトモードでShift + A を押した際に特定のフォルダにある fbxを選択肢として表示する。
2. 選択したfbxファイルを現在開いてるシーンにインポートする。
3. fbxが置いてあるフォルダは "フォルダのパス" とする
4. インポートする際に、インポートオプションはデフォルトのままとする。
5. アドオンの名前は "FBX Importer" とする。

なおこのアドオンはWindows上でBlender4.3で動作することを前提としています。

Nゴン発見のアドオン

あなたはBlenderのアドオンを作るエンジニアです。これから提示する条件に従ったアドオンを開発してください
条件
- オブジェクトモードのNキーで展開されるサイドメニューに項目を追加
- チェックボックスをメニュー内に配置して項目名を「Nゴンをカラー」としてください
- 動作するBlenderのバージョンは4.3としてください
このチェックボックス「Nゴンをカラー」を選択した状態でオブジェクトを編集モードにした際五角形以上の面を赤色で表示する機能を持つアドオンを作ってください

この2つのプロンプトでそれぞれそれっぽいpythonのスクリプトをGeminiは生成してくれました。

fbx読み込み

import bpy
import os
from bpy_extras.io_utils import ImportHelper

# ====================================================================
# 1. アドオン情報
# ====================================================================
bl_info = {
    "name": "Local FBX Importer",
    "author": "Your Name", # あなたの名前に変更してください
    "version": (1, 0),
    "blender": (4, 3, 0), # Blender 4.3 をターゲット
    "location": "Object Mode > Add Menu (Shift+A)",
    "description": "Imports FBX models from a specified local directory.",
    "warning": "",
    "doc_url": "",
    "category": "Import-Export",
}

# ====================================================================
# 2. 設定
# ====================================================================
# ★★★ ここをあなたのFBXファイルがあるフォルダのパスに変更してください ★★★
FBX_DIR = "C:\\path\\to\\your\\fbx\\folder"

# ====================================================================
# 3. オペレータ定義
# ====================================================================
class OBJECT_OT_local_fbx_importer(bpy.types.Operator, ImportHelper):
    """ローカルフォルダからFBXモデルを読み込みます"""
    bl_idname = "object.local_fbx_importer"
    bl_label = "ローカルからモデル読み込み"
    bl_options = {'REGISTER', 'UNDO'}

    # ファイルブラウザ用のプロパティ
    filename_ext = ".fbx"
    filter_glob: bpy.props.StringProperty(
        default="*.fbx",
        options={'HIDDEN'},
        maxlen=255,
    )

    def execute(self, context):
        if not os.path.isdir(FBX_DIR):
            self.report({'ERROR'}, f"指定されたFBXフォルダが見つかりません: {FBX_DIR}")
            return {'CANCELLED'}

        # ファイルブラウザの初期パスを設定
        self.filepath = os.path.join(FBX_DIR, self.filepath)

        # ファイルが存在することを確認
        if not os.path.exists(self.filepath):
            self.report({'ERROR'}, f"選択されたFBXファイルが見つかりません: {self.filepath}")
            return {'CANCELLED'}

        try:
            # FBXファイルをインポート
            bpy.ops.import_scene.fbx(filepath=self.filepath)
            self.report({'INFO'}, f"FBXファイルを読み込みました: {os.path.basename(self.filepath)}")
        except Exception as e:
            self.report({'ERROR'}, f"FBXファイルの読み込み中にエラーが発生しました: {e}")
            return {'CANCELLED'}

        return {'FINISHED'}

    def invoke(self, context, event):
        # ファイルブラウザを開く際の初期ディレクトリを設定
        # FBX_DIR が有効なパスでない場合はキャンセル
        if not os.path.isdir(FBX_DIR):
            self.report({'ERROR'}, f"指定されたFBXフォルダが見つかりません: {FBX_DIR}")
            return {'CANCELLED'}

        context.window_manager.fileselect_add(self)
        context.window_manager.fileselect_last_path = FBX_DIR # Blender 4.x の推奨される設定方法
        return {'RUNNING_MODAL'}

# ====================================================================
# 4. メニューへの追加
# ====================================================================
def add_local_fbx_importer_menu(self, context):
    """Shift+Aメニューに項目を追加します"""
    if context.mode == 'OBJECT':
        self.layout.operator(OBJECT_OT_local_fbx_importer.bl_idname, text="ローカルからモデル読み込み", icon='MESH_CUBE')

# ====================================================================
# 5. アドオン登録・登録解除
# ====================================================================
def register():
    bpy.utils.register_class(OBJECT_OT_local_fbx_importer)
    bpy.types.VIEW3D_MT_add.append(add_local_fbx_importer_menu)
    print("Local FBX Importer registered.")

def unregister():
    bpy.utils.unregister_class(OBJECT_OT_local_fbx_importer)
    bpy.types.VIEW3D_MT_add.remove(add_local_fbx_importer_menu)
    print("Local FBX Importer unregistered.")

if __name__ == "__main__":
    register()

Nゴン発見アドオン

import bpy
import bgl_utils
from bpy.props import BoolProperty
from bpy_extras import view3d_utils
import gpu
from gpu_extras.batch import batch_for_shader

# シェーダーの定義
shader_2d_flat = gpu.shader.from_code_vert_frag(
    """
    #version 330 core
    uniform mat4 ModelViewProjectionMatrix;
    in vec3 position;
    void main() {
      gl_Position = ModelViewProjectionMatrix * vec4(position, 1.0);
    }
    """,
    """
    #version 330 core
    uniform vec4 color;
    out vec4 FragColor;
    void main() {
      FragColor = color;
    }
    """
)

def draw_ngons_callback(self, context):
    if not context.object or context.object.mode != 'EDIT' or not context.scene.ngon_color_properties.enable_ngon_color:
        return

    obj = context.object
    mesh = obj.data

    if not mesh.is_editmode:
        return

    # メッシュデータを更新
    bpy.ops.object.mode_set(mode='OBJECT')
    bpy.ops.object.mode_set(mode='EDIT')

    gpu.state.depth_test_set('LESS_EQUAL')
    gpu.state.blend_set('ALPHA')

    shader = shader_2d_flat
    batch_verts = []
    batch_indices = []

    # Nゴンを検出して頂点データを収集
    for poly in mesh.polygons:
        if len(poly.vertices) >= 5: # 五角形以上の面
            # 各面を構成する三角形に分解して描画
            for i in range(1, len(poly.vertices) - 1):
                batch_verts.append(obj.matrix_world @ mesh.vertices[poly.vertices[0]].co)
                batch_verts.append(obj.matrix_world @ mesh.vertices[poly.vertices[i]].co)
                batch_verts.append(obj.matrix_world @ mesh.vertices[poly.vertices[i+1]].co)
                batch_indices.append([len(batch_verts) - 3, len(batch_verts) - 2, len(batch_verts) - 1])

    if batch_verts:
        batch = batch_for_shader(shader, 'TRIS', {"position": batch_verts})
        shader.bind()
        shader.uniform_float("color", (1.0, 0.0, 0.0, 0.5))  # 赤色、半透明
        shader.uniform_matrix("ModelViewProjectionMatrix", context.region_data.perspective_matrix)
        batch.draw(shader)

    gpu.state.depth_test_set('NONE')
    gpu.state.blend_set('NONE')

class NgonColorProperties(bpy.types.PropertyGroup):
    enable_ngon_color: BoolProperty(
        name="Nゴンをカラー",
        description="編集モードで五角形以上の面を赤色で表示します",
        default=False,
        update=lambda self, context: update_ngon_color_display(self, context)
    )

def update_ngon_color_display(self, context):
    # ドローハンドラの登録・解除
    if context.scene.ngon_color_properties.enable_ngon_color:
        bpy.app.handlers.draw_post_view.append(draw_ngons_callback)
    else:
        if draw_ngons_callback in bpy.app.handlers.draw_post_view:
            bpy.app.handlers.draw_post_view.remove(draw_ngons_callback)

    # ビューポートを再描画して変更を反映
    if context.area:
        for space in context.area.spaces:
            if space.type == 'VIEW_3D':
                space.tag_redraw()

class VIEW3D_PT_NgonColorPanel(bpy.types.Panel):
    bl_label = "Nゴン設定"
    bl_idname = "VIEW3D_PT_ngon_color_panel"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "ツール" # サイドメニューのカテゴリ

    def draw(self, context):
        layout = self.layout
        scene = context.scene
        props = scene.ngon_color_properties

        row = layout.row()
        row.prop(props, "enable_ngon_color")

classes = (
    NgonColorProperties,
    VIEW3D_PT_NgonColorPanel,
)

def register():
    for cls in classes:
        bpy.utils.register_class(cls)
    bpy.types.Scene.ngon_color_properties = bpy.props.PointerProperty(type=NgonColorProperties)

    # Blender起動時にすでにONの場合に備えてハンドラを登録
    if bpy.context.scene.ngon_color_properties.enable_ngon_color:
        if draw_ngons_callback not in bpy.app.handlers.draw_post_view:
            bpy.app.handlers.draw_post_view.append(draw_ngons_callback)


def unregister():
    # ハンドラが登録されていれば解除
    if draw_ngons_callback in bpy.app.handlers.draw_post_view:
        bpy.app.handlers.draw_post_view.remove(draw_ngons_callback)

    del bpy.types.Scene.ngon_color_properties
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)

if __name__ == "__main__":
    register()

しかし、このスクリプトをBlenderに読み込ませようとすると
fbx読み込みアドオンでは実行時にエラーとなり
Nゴン発見アドオンではそもそもアドオンを読み込めないなど問題がありました。
そこで引き続きGeminiを使って修正を行っていきました。

1-2 エラー対応

Blenderのコンソールに出力されたエラーをコピーしてGeminiに投げてみます。

プロンプト

このエラーが表示されました
エラーを読んで先程のコードを修正してください。
DEBUG: execute called. self.filepath:
~~ 略 ~~

Gemini出力

ユーザーが提供したBlenderアドオンのコードとエラーメッセージを確認しました。

エラーメッセージは PermissionError: [Errno 13] Permission denied: 'ファイルパス となっており、これはPythonが指定されたパス ファイルパス をファイルとして開こうとした際に、アクセス権がないために拒否されたことを示しています。

解決策
1. `invoke`メソッドをコメントアウトする
~~ 略 ~~

このように出てきたエラーを何度かGeminiに投げて、それっぽいコードの提案をしてもらいましたが解決には至らず、
最終的にはBlenderの設定初期化や再インストールなどを提案してきて完全に「詰み」の状態になりました。
諦めて自分で修正をするべきか悩んでいたタイミングで、一旦別のAIに投げてみようと思いつきます。

2 GitHub Copilot

まずはエラーで動かない fbx読み込みアドオンの対応から進めます。 VSCode上でGeminiが一番最初に生成したスクリプトを開き、先ほどGeminiに投げたエラーの解決依頼をGitHub Copilotのチャットに投げてみました。

プロンプト

このエラーが表示されました
エラーを読んで先程のコードを修正してください。
DEBUG: execute called. self.filepath:
~~ 略 ~~

Copilotの回答

このエラーの原因は、ファイル選択ダイアログで「フォルダ」自体を選択してしまい、fbxファイルを選択していないためです。
その結果、self.filepath が「フォルダのパス」になり、Blenderがフォルダをfbxファイルとして開こうとして失敗しています。

修正例
以下のように修正してください。
~~ 略 ~~

明らかにGeminiとは違うアプローチでの解決方法を提案してくれました。
再度Blenderに読み込ませるとこのエラーは無事解決していました。
同じ要領で他にも出てきたエラーをGitHub Copilotにお願いすると特段詰まることもなくエラーを解消していきました。

このことを踏まえてNゴン発見アドオンではエラー解決を最初からGeminiではなくGitHub Copilotに投げてみました。

プロンプト

このファイルをBlenderのアドオンとして読み込ませても表示されません。原因はわかりますか?

出力

主な原因は、Blenderのドローハンドラの登録方法が間違っていることです。
bpy.app.handlers.draw_post_viewはBlender 2.8以降では存在しません。
Blenderの3Dビューでカスタム描画を行うには、bpy.types.SpaceView3D.draw_handler_addを使う必要があります。

~~ 略 ~~

# ...existing code...

_draw_handler = None  # グローバル変数でハンドラを管理

def draw_ngons_callback():
    context = bpy.context
    if not context.object or context.object.mode != 'EDIT' or not context.scene.ngon_color_properties.enable_ngon_color:
        return

    obj = context.object
    mesh = obj.data

    if not mesh.is_editmode:
        return

    gpu.state.depth_test_set('LESS_EQUAL')
    gpu.state.blend_set('ALPHA')

    shader = shader_2d_flat
    batch_verts = []
    ~~ 略 ~~

こちらも具体的な原因と修正のコードを提案してくれました。
そもそも一番最初に指定したBlenderの実行環境に即していないコードを生成されていたことがわかりました。このことから、Geminiの精度を過信していたことに気づかされました。

残りのエラーもここまでの方法でGitHub Copilotに聞いてみると難なく解消されていきました。

成果物

最終的なコードと動作例になります。

FBXインポート アドオン

import bpy
import os
from bpy.types import Menu

# アドオン情報
bl_info = {
    "name": "FBX Importer",
    "author": "Your Name (開発者名をここに入力してください)", # 開発者名を適切に設定してください
    "version": (1, 0),
    "blender": (4, 3, 0),
    "location": "Add > FBX Importer",
    "description": "Imports FBX files from a specified directory.",
    "warning": "",
    "doc_url": "",
    "category": "Import",
}

# FBXファイルが置かれているディレクトリ
FBX_MODULE_DIR = "ファイルパス"

class FBX_IMPORTER_OT_import_fbx(bpy.types.Operator):
    """指定されたFBXファイルをインポートします"""
    bl_idname = "fbx_importer.import_fbx"
    bl_label = "Import FBX"
    bl_options = {'REGISTER', 'UNDO'}

    filepath: bpy.props.StringProperty(
        name="File Path",
        subtype='FILE_PATH',
    )

    def execute(self, context):
        if not self.filepath:
            self.report({'ERROR'}, "No FBX file selected.")
            return {'CANCELLED'}

        if not os.path.exists(self.filepath):
            self.report({'ERROR'}, f"File not found: {self.filepath}")
            return {'CANCELLED'}

        try:
            # FBXをデフォルトオプションでインポート
            bpy.ops.import_scene.fbx(filepath=self.filepath)
            self.report({'INFO'}, f"Successfully imported: {os.path.basename(self.filepath)}")
        except Exception as e:
            self.report({'ERROR'}, f"Failed to import FBX: {e}")
            return {'CANCELLED'}

        return {'FINISHED'}

class FBX_IMPORTER_MT_add_menu(Menu):
    """Shift + A (追加) メニューにFBXファイル一覧を表示するメニュー"""
    bl_idname = "FBX_IMPORTER_MT_add_menu"
    bl_label = "FBX Importer"

    def draw(self, context):
        layout = self.layout

        if not os.path.isdir(FBX_MODULE_DIR):
            layout.label(text=f"Directory not found: {FBX_MODULE_DIR}", icon='ERROR')
            return

        fbx_files = [f for f in os.listdir(FBX_MODULE_DIR) if f.lower().endswith(('.fbx'))]

        if not fbx_files:
            layout.label(text="No FBX files found in the directory.", icon='INFO')
            return

        for fbx_file in sorted(fbx_files):
            filepath = os.path.join(FBX_MODULE_DIR, fbx_file)
            op = layout.operator(FBX_IMPORTER_OT_import_fbx.bl_idname, text=fbx_file)
            op.filepath = filepath

def menu_func_add_object(self, context):
    """オブジェクト追加メニューに関数を追加"""
    self.layout.menu(FBX_IMPORTER_MT_add_menu.bl_idname)

def register():
    bpy.utils.register_class(FBX_IMPORTER_OT_import_fbx)
    bpy.utils.register_class(FBX_IMPORTER_MT_add_menu)
    bpy.types.VIEW3D_MT_add.append(menu_func_add_object)

def unregister():
    bpy.types.VIEW3D_MT_add.remove(menu_func_add_object)
    bpy.utils.unregister_class(FBX_IMPORTER_MT_add_menu)
    bpy.utils.unregister_class(FBX_IMPORTER_OT_import_fbx)

if __name__ == "__main__":
    register()

fbxインポートアドオン

Nゴン発見アドオン

bl_info = {
    "name": "Nゴン表示",
    "author": "Your Name",
    "version": (1, 0),
    "blender": (2, 80, 0),
    "location": "View3D > Sidebar > ツール",
    "description": "編集モードでNゴンを赤色で表示します",
    "warning": "",
    "category": "3D View"
}

import bpy
from bpy.props import BoolProperty
from bpy_extras import view3d_utils
import gpu
from gpu_extras.batch import batch_for_shader

# シェーダーの定義
vertex_shader = '''
    uniform mat4 ModelViewProjectionMatrix;
    in vec3 position;
    void main() {
        gl_Position = ModelViewProjectionMatrix * vec4(position, 1.0);
    }
'''

fragment_shader = '''
    uniform vec4 color;
    out vec4 FragColor;
    void main() {
        FragColor = color;
    }
'''

shader = gpu.types.GPUShader(vertex_shader, fragment_shader)

_draw_handler = None  # グローバル変数でハンドラを管理

def draw_ngons_callback():
    context = bpy.context
    if not context.object or context.object.mode != 'EDIT' or not context.scene.ngon_color_properties.enable_ngon_color:
        return

    obj = context.object
    mesh = obj.data

    if not mesh.is_editmode:
        return

    gpu.state.depth_test_set('LESS_EQUAL')
    gpu.state.blend_set('ALPHA')

    batch_verts = []

    for poly in mesh.polygons:
        if len(poly.vertices) >= 5:
            for i in range(1, len(poly.vertices) - 1):
                batch_verts.append(obj.matrix_world @ mesh.vertices[poly.vertices[0]].co)
                batch_verts.append(obj.matrix_world @ mesh.vertices[poly.vertices[i]].co)
                batch_verts.append(obj.matrix_world @ mesh.vertices[poly.vertices[i+1]].co)

    if batch_verts:
        batch = batch_for_shader(shader, 'TRIS', {"position": batch_verts})
        shader.bind()
        shader.uniform_float("color", (1.0, 0.0, 0.0, 0.5))
        region_data = context.region_data
        if region_data:
            shader.uniform_float("ModelViewProjectionMatrix", region_data.perspective_matrix)
        batch.draw(shader)

    gpu.state.depth_test_set('NONE')
    gpu.state.blend_set('NONE')

class NgonColorProperties(bpy.types.PropertyGroup):
    enable_ngon_color: BoolProperty(
        name="Nゴンをカラー",
        description="編集モードで五角形以上の面を赤色で表示します",
        default=False,
        update=lambda self, context: update_ngon_color_display(self, context)
    )

def update_ngon_color_display(self, context):
    global _draw_handler
    # 既存のハンドラを解除
    if _draw_handler is not None:
        bpy.types.SpaceView3D.draw_handler_remove(_draw_handler, 'WINDOW')
        _draw_handler = None

    # 有効化時のみ新規登録
    if context.scene.ngon_color_properties.enable_ngon_color:
        _draw_handler = bpy.types.SpaceView3D.draw_handler_add(
            draw_ngons_callback, (), 'WINDOW', 'POST_VIEW'
        )

    # ビューポートを再描画
    for area in bpy.context.screen.areas:
        if area.type == 'VIEW_3D':
            area.tag_redraw()

def mesh_update_handler(scene):
    """メッシュの更新をモニタリングするハンドラ"""
    if scene.ngon_color_properties.enable_ngon_color:
        for area in bpy.context.screen.areas:
            if area.type == 'VIEW_3D':
                area.tag_redraw()

class VIEW3D_PT_NgonColorPanel(bpy.types.Panel):
    bl_label = "Nゴン設定"
    bl_idname = "VIEW3D_PT_ngon_color_panel"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "Nゴン消すやつ" # サイドメニューのカテゴリ

    def draw(self, context):
        layout = self.layout
        scene = context.scene
        props = scene.ngon_color_properties

        row = layout.row()
        row.prop(props, "enable_ngon_color")

classes = (
    NgonColorProperties,
    VIEW3D_PT_NgonColorPanel,
)

def register():
    for cls in classes:
        bpy.utils.register_class(cls)
    bpy.types.Scene.ngon_color_properties = bpy.props.PointerProperty(type=NgonColorProperties)

    # メッシュ更新ハンドラを登録
    bpy.app.handlers.depsgraph_update_post.append(mesh_update_handler)

    # bpy.context.sceneの直接参照を避ける
    def setup_handler():
        if hasattr(bpy.context, "scene"):
            if bpy.context.scene.ngon_color_properties.enable_ngon_color:
                global _draw_handler
                _draw_handler = bpy.types.SpaceView3D.draw_handler_add(
                    draw_ngons_callback, (), 'WINDOW', 'POST_VIEW'
                )

    # タイマーを使用して遅延実行
    bpy.app.timers.register(setup_handler, first_interval=0.1)

def unregister():
    global _draw_handler
    if _draw_handler is not None:
        bpy.types.SpaceView3D.draw_handler_remove(_draw_handler, 'WINDOW')
        _draw_handler = None

    # メッシュ更新ハンドラを解除
    bpy.app.handlers.depsgraph_update_post.remove(mesh_update_handler)

    del bpy.types.Scene.ngon_color_properties
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)

if __name__ == "__main__":
    register()

ngon発見アドオン

まとめ

今回はWebアプリなどではなく特殊な実行環境で使うコードをAIのみで開発を行うことができました。
実際に作っていく中でわかった点としては

  • 0 → 1の生成ではGeminiは有効だが不具合修正や細かい調整などにはGitHub Copilotを使ったほうが効率的だった
  • バージョン差のある環境では動作バージョンを指定したとしても古いコードを提案される事がある
  • Blenderを使う上でWebアプリのようなクリックやスクロールといった簡単な操作では完結しない複雑な操作が発生するため操作感ややりたいことをうまく伝える必要がある

問題点や改善が必要なところはありますが、個人で使うものとしては満足のいく仕上がりになりました。
みなさんも普段ちょっと不便に思っていることを解決するプラグインやアドオンの開発にAIを活用してみるのはいかがでしょうか?

採用情報

虎の穴ラボでは一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧ください。
toranoana-lab.co.jp