Substance 3D PainterのPython自動化|書き出しプラグイン

Substance 3D PainterのPython自動化|書き出しプラグイン

Substance 3D Painter には Python の実行環境が組み込まれていて、プロジェクトの操作やテクスチャの書き出し、メニューやパネルの追加までをスクリプトから行えます。テクスチャセットが増えるほど、「毎回同じプリセットで、同じ場所に、同じ設定で書き出す」作業には手作業のミスが入りやすくなり、自動化の効果がはっきり出ます。

本記事では、Painter の Python API を使ったプラグインの作り方を、置き場所とファイル構成、最小のプラグイン、テクスチャの一括書き出し、ドッキングできるパネル、書き出し完了イベントでの後処理の順に実装します。複数のツールをまたぐパイプライン全体の設計は Pythonで横断パイプライン|Maya/Houdini/Painterを繋ぐ設計 で扱っているので、本記事は Painter の中での実装に絞ります。

対象は執筆時点(2026年9月)の最新版である Substance 3D Painter 12.1(最新パッチは 12.1.5)です。コードは Adobe の公式ドキュメントに載っている API だけで書いています。メニューの項目名は、バージョンによって表記が異なる場合があります。

夕宮たいだ

ふぁ……みんな〜、今日は Painter を Python で動かすおはなしだよぉ。書き出しボタンを毎回ぽちぽちするの、そろそろ卒業しよ〜。

目次

前提:バージョンと Python・Qt の対応

ひとことで:10.1 から Qt 6(PySide6)、12.0 から Python 3.13 に変わっています。

Painter の Python プラグインは、アプリに同梱された Python と、画面を作るための Qt の上で動きます。同梱のバージョンはアップデートで変わり、プラグインの書き方にも影響します。公式のリリースノートで確認できる変化は次のとおりです。

Painter のバージョン同梱の PythonQt(Python から使うモジュール)プラグインへの影響
10.0 以前版によって異なる(3.7・3.9 など)Qt 5(PySide2)古いサンプルの多くはこの書き方
10.1〜11.x3.11Qt 6(PySide6)PySide2 で書いたプラグインは修正が必要
12.0〜12.13.13Qt 6.8(PySide6)外部ライブラリは 3.13 用を使う

本記事のコードは PySide6 を前提にしています。10.0 以前の Painter でも同じプラグインを動かしたい場合は、公式の Qt6 Migration にあるとおり、substance_painter.application.version_info() でバージョンを調べて PySide2 と PySide6 を読み分けます。

API の詳しいリファレンスは、Painter の Help > Scripting documentation から開ける同梱のドキュメントが、使っているバージョンに対応した最新版です。Web 上のリファレンスには古い版のまま更新されていないページがあるので、関数が見つからないときは同梱版を確認してください。

Python API の全体像

ひとことで:substance_painter パッケージの下に、機能ごとのモジュールが並んでいます。

Painter の Python API は substance_painter パッケージにまとまっていて、import substance_painter.export のようにモジュール単位で読み込みます。本記事で使うのは次の7つです。

モジュール役割本記事で使う主な関数・クラス
substance_painter.projectプロジェクトを開く・保存する・状態を調べるis_open()、file_path()
substance_painter.texturesetテクスチャセットとスタックを取得するall_texture_sets()、all_stacks()
substance_painter.exportテクスチャを書き出す(Export textures 画面に相当)export_project_textures()、list_project_textures()
substance_painter.uiメニューやドックパネルを追加するadd_action()、add_dock_widget()、delete_ui_element()
substance_painter.event保存や書き出しの完了を受け取るDISPATCHER、ExportTexturesEnded
substance_painter.loggingLog ウィンドウに出力するinfo()、warning()、error()
substance_painter.exceptionPainter 固有の例外ProjectError

このほか、ベイク(baking)、レイヤースタック(layerstack)、シェルフの素材(resource)、表示設定(display)などのモジュールもあります。プラグインの読み込みと停止を管理する substance_painter_plugins は、substance_painter とは別の独立したモジュールです。

夕宮たいだ

モジュールがいっぱいで、むずかしいねぇ……。でも最初は project・textureset・export・ui の4つを押さえれば、だいたい書けるんだぁ。

プラグインの置き場所とフォルダ構成

ひとことで:ドキュメントフォルダの python\plugins に、start_plugin と close_plugin を持つファイルを置きます。

置き場所

Python のプラグインは、ユーザーのドキュメントフォルダの中にある python フォルダに置きます(Painter 7.2 以降の場所)。

  • Windows:C:\Users\(ユーザー名)\Documents\Adobe\Adobe Substance 3D Painter\python\plugins
  • macOS:/Users/(ユーザー名)/Documents/Adobe/Adobe Substance 3D Painter/python/plugins
  • Linux:/home/(ユーザー名)/Documents/Adobe/Adobe Substance 3D Painter/python/plugins

同じ階層には JavaScript プラグイン用のフォルダもあります。Python のファイルは必ず python の下に置きます。

python フォルダの3つのサブフォルダ

python フォルダの中には、役割の違う3つのサブフォルダがあります。

フォルダ役割置くもの
pluginsPython メニューから有効・無効を切り替えるプラグインツール本体
startupPainter の起動時に必ず読み込まれるモジュール常に効かせたいイベント処理など
modulesプラグイン同士で共有する部品共通の関数や設定の読み込み処理

3つとも Python の検索パス(sys.path)に自動で追加されるので、modules に置いたファイルは plugins 側から普通に import できます。plugins と startup のモジュールは、読み込まれた直後に呼ばれる start_plugin() と、外される前に呼ばれる close_plugin() の2つの関数を持つ決まりです。プラグインは1つの .py ファイルでも、__init__.py を持つフォルダ(パッケージ)でもかまいません。

チームで共有する場所を足す

各自のドキュメントフォルダにコピーして配る方式では、更新のたびに配り直しが必要です。環境変数 SUBSTANCE_PAINTER_PLUGINS_PATH に共有フォルダを指定すると、Painter はそこからもプラグインを読み込みます。指定するのは plugins・startup・modules の3つを持つ親フォルダで、この3つ以外の場所に置いたスクリプトは無視されます。Git で管理しているフォルダを指定すれば、ツールの更新をバージョン管理の流れに乗せられます。詳しくは公式の Loading external plugins を参照してください。

Substance 3D Painter の Python プラグインのフォルダ構成と読み込み元
図1:プラグインは python フォルダの3つのサブフォルダから読み込まれる

有効化と再読み込み

ファイルを置いてから Painter を起動(起動中なら再起動)すると、plugins のプラグインがメインメニューの Python に並びます。項目から有効(enable)にすると start_plugin() が、無効(disable)にすると close_plugin() が呼ばれます。コードを書き換えたときは、同じ項目の reload で読み直せます(メニューの見た目はバージョンによって表記が異なります)。手順は公式の Creating a Python plugin にもまとまっています。

最小のプラグインを書く

ひとことで:File メニューに項目を1つ足し、押すと Log ウィンドウにメッセージが出るだけのプラグインです。

まずは動作確認用の最小構成です。python\plugins に oyasumi_hello.py という名前で保存します。

# oyasumi_hello.py : Substance 3D Painter 10.1 以降(PySide6)用
from PySide6 import QtGui

import substance_painter.logging
import substance_painter.ui

# 追加した UI 部品を覚えておき、close_plugin() でまとめて消す
plugin_widgets = []

def say_hello():
    substance_painter.logging.info("oyasumi_hello: プラグインが動いています")

def start_plugin():
    action = QtGui.QAction("おやすみ:動作確認", triggered=say_hello)
    substance_painter.ui.add_action(substance_painter.ui.ApplicationMenu.File, action)
    plugin_widgets.append(action)

def close_plugin():
    for widget in plugin_widgets:
        substance_painter.ui.delete_ui_element(widget)
    plugin_widgets.clear()

Painter を再起動し、Python メニューから oyasumi_hello を有効にすると、File メニューに「おやすみ:動作確認」が増えます。押したあと Help > Show log で Log ウィンドウを開くと、メッセージが出ています。

押さえるポイントは3つです。

  • メニュー項目(QAction)は、PySide6 では QtGui にあります。PySide2 時代の QtWidgets.QAction のままでは動きません。
  • 追加した部品はリストに残しておき、close_plugin() で delete_ui_element() に渡して消します。プラグインを止めたときに UI が残らないようにするためで、公式のサンプルも同じ作りです。
  • 利用者に見せたいメッセージは substance_painter.logging で出します。info()・warning()・error() の出力は Log ウィンドウに表示されます。
夕宮たいだ

古い記事の PySide2 のサンプルをそのまま写すの、絶対ダメだよ! 10.1 より新しい Painter だと、読み込みの時点で止まっちゃうんだぁ。

テクスチャ書き出しを自動化する

ひとことで:Export textures 画面の設定を辞書で書き、export_project_textures() に渡します。

書き出し設定のキー

substance_painter.export.export_project_textures() は、Export textures 画面で行う書き出しをスクリプトから実行する関数です。設定は JSON と同じ形の辞書で渡します。よく使うキーは次の5つです。

キー内容例
exportPath書き出し先のフォルダ"D:/project/textures"
exportShaderParamsシェーダーの設定を JSON にも書き出すかFalse
defaultExportPreset使う書き出しプリセットの URL後述のコードで取得
exportList書き出す対象。テクスチャセット名か「セット名/スタック名」[{"rootPath": "Body"}]
exportParameters形式やビット深度を上書きするルールのリスト後述のコードを参照

exportParameters のルールは上から順に評価され、条件(filter)に合うテクスチャに、パラメーター(parameters)を上書きします。filter を省くと、すべてのテクスチャが対象です。上書きできるパラメーターは、fileFormat(形式)、bitDepth(ビット深度)、dithering(ディザリング)、sizeLog2(解像度を2の何乗かで指定。11 なら 2048)、paddingAlgorithm(パディングの方式)、dilationDistance(パディングの幅)です。パディングの方式は passthrough・color・transparent・diffusion・infinite から選びます。

書き出しプリセットには、最初から入っている「PBR Metallic Roughness」などのほか、Export textures 画面で作ってシェルフに保存したチーム用のプリセットも使えます。プリセットのファイル名には $textureSet や $udim などの変数が使えます。ORM パックのようなチャンネルの詰め方をプリセットとして設計する方法は、Substance 3D Painterのチャンネルパック で解説しています。

全テクスチャセットを一括で書き出す

プロジェクト内のすべてのテクスチャセットを、名前で指定したプリセットで、.spp と同じ場所の textures フォルダに書き出す関数です。ここから先の3つのコードは、上から順に1つのファイル(python\plugins\oyasumi_export.py)に並べるとプラグインとして完成します。ファイル冒頭の import は、この節でまとめて書いておきます。

# oyasumi_export.py : Substance 3D Painter 10.1 以降(PySide6)用
import json
import os

from PySide6 import QtWidgets

import substance_painter.event
import substance_painter.exception
import substance_painter.export
import substance_painter.logging
import substance_painter.project
import substance_painter.textureset
import substance_painter.ui

def find_preset_url(preset_name):
    """シェルフにある書き出しプリセットを名前で探し、URL を返す"""
    for preset in substance_painter.export.list_resource_export_presets():
        if preset.resource_id.name == preset_name:
            return preset.resource_id.url()
    return None

def export_all_texture_sets(preset_name="PBR Metallic Roughness"):
    if not substance_painter.project.is_open():
        substance_painter.logging.warning("プロジェクトが開かれていません")
        return None

    spp_path = substance_painter.project.file_path()
    if not spp_path:  # 一度も保存していないプロジェクトでは None が返る
        substance_painter.logging.warning("先にプロジェクトを保存してください")
        return None

    preset_url = find_preset_url(preset_name)
    if preset_url is None:
        substance_painter.logging.error(f"書き出しプリセットが見つかりません: {preset_name}")
        return None

    export_dir = os.path.join(os.path.dirname(spp_path), "textures").replace("\\", "/")
    os.makedirs(export_dir, exist_ok=True)

    # スタックごとに rootPath を作る(str(stack) は「セット名」か「セット名/スタック名」)
    export_list = [
        {"rootPath": str(stack)}
        for texture_set in substance_painter.textureset.all_texture_sets()
        for stack in texture_set.all_stacks()
    ]

    config = {
        "exportShaderParams": False,
        "exportPath": export_dir,
        "defaultExportPreset": preset_url,
        "exportList": export_list,
        "exportParameters": [
            {
                "parameters": {
                    "fileFormat": "png",
                    "bitDepth": "8",
                    "dithering": True,
                    "paddingAlgorithm": "infinite",
                }
            }
        ],
    }

    try:
        planned = substance_painter.export.list_project_textures(config)
        count = sum(len(files) for files in planned.values())
        substance_painter.logging.info(f"{count} 枚を書き出します: {export_dir}")
        result = substance_painter.export.export_project_textures(config)
    except (substance_painter.exception.ProjectError, ValueError) as error:
        substance_painter.logging.error(f"書き出しに失敗しました: {error}")
        return None

    if result.status != substance_painter.export.ExportStatus.Success:
        substance_painter.logging.warning(result.message)
    return result.textures

処理の流れと、公式ドキュメントに沿った注意点です。

  • project.file_path() は、一度も保存していないプロジェクトでは None を返します。保存場所を基準に書き出し先を決めるので、未保存なら止めています。
  • list_resource_export_presets() の各要素の resource_id から、プリセットの名前と URL を取り出せます。
  • str(stack) は、スタックのないテクスチャセットではセット名、スタックがある場合は「セット名/スタック名」になり、そのまま rootPath に使えます。
  • 本番の前に list_project_textures() で書き出される予定のファイルを取得し、枚数を Log に出しています。設定が間違っていれば、ここで ValueError になります。
  • export_project_textures() は、失敗すると例外を出します。戻り値の status が Error になることはなく、途中でキャンセルされた場合は Cancelled になります。
  • 戻り値の textures は、「(テクスチャセット名, スタック名)」をキーに、書き出したファイルのパスのリストを持つ辞書です。
Painter の書き出し自動化プラグインの処理の流れ
図2:書き出す前に予定を確認し、完了イベントでファイル一覧を残す

ドッキングできるパネルを作る

ひとことで:QWidget を作って add_dock_widget() に渡すと、Painter のパネルとして並びます。

書き出し関数をボタン1つで呼べるように、パネルを作ります。前の節のコードの下に続けて書きます。

class ExportPanel(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        self.setObjectName("oyasumi_export_panel")  # 配置の保存と復元に使われる
        self.setWindowTitle("一括書き出し")

        self.preset_edit = QtWidgets.QLineEdit("PBR Metallic Roughness")
        export_button = QtWidgets.QPushButton("すべてのテクスチャセットを書き出す")
        export_button.clicked.connect(self.on_export)

        layout = QtWidgets.QVBoxLayout(self)
        layout.addWidget(QtWidgets.QLabel("書き出しプリセット名"))
        layout.addWidget(self.preset_edit)
        layout.addWidget(export_button)
        layout.addStretch()

    def on_export(self):
        export_all_texture_sets(self.preset_edit.text().strip())

add_dock_widget() に渡したウィジェットは、Painter のほかのパネルと同じようにドッキングできます。公式ドキュメントによると、ウィジェットに重複しない objectName を付けておくと、ドックの位置と大きさの保存と復元に使われます。windowIcon を設定しておくと、閉じたパネルを開き直すためのボタンにもなります。

夕宮たいだ

ほら、ボタン1個でぜんぶのテクスチャセットを書き出せると便利でしょ? プリセット名を打ち間違えても、Log にちゃんと理由が出るようにしてあるんだぁ。

書き出し完了イベントで後処理を自動化する

ひとことで:ExportTexturesEnded を受け取り、書き出したファイルの一覧を manifest.json に残します。

書き出しのあとに、エンジンへの取り込みスクリプトや検証ツールが読むファイル一覧を残しておくと、後工程が「どのファイルが最新か」を探さずに済みます。Painter は書き出しが終わると ExportTexturesEnded イベントを出すので、これを受け取って manifest.json を書きます。このイベントは、Python からの書き出しでも、画面からの書き出しでも発生します。最後に、プラグインの開始と停止の関数もここにまとめます。

def on_export_ended(event):
    """書き出しが終わったら、書き出したファイルの一覧を manifest.json に残す"""
    if event.status != substance_painter.export.ExportStatus.Success:
        return
    manifest = {}
    for (texture_set_name, stack_name), files in event.textures.items():
        key = f"{texture_set_name}/{stack_name}" if stack_name else texture_set_name
        manifest[key] = files
    all_files = [path for files in manifest.values() for path in files]
    if not all_files:
        return
    manifest_path = os.path.join(os.path.dirname(all_files[0]), "manifest.json")
    with open(manifest_path, "w", encoding="utf-8") as f:
        json.dump(manifest, f, ensure_ascii=False, indent=2)
    substance_painter.logging.info(f"manifest を書き出しました: {manifest_path}")

plugin_widgets = []

def start_plugin():
    panel = ExportPanel()
    substance_painter.ui.add_dock_widget(panel)
    plugin_widgets.append(panel)
    substance_painter.event.DISPATCHER.connect(
        substance_painter.event.ExportTexturesEnded, on_export_ended
    )

def close_plugin():
    substance_painter.event.DISPATCHER.disconnect(
        substance_painter.event.ExportTexturesEnded, on_export_ended
    )
    for widget in plugin_widgets:
        substance_painter.ui.delete_ui_element(widget)
    plugin_widgets.clear()

イベントを受け取る関数は、substance_painter.event.DISPATCHER.connect() で登録します。公式ドキュメントにあるとおり、登録した関数は弱参照で保持され、関数がどこからも参照されなくなると自動で登録が外れます。モジュールの直下に定義した関数はモジュールが参照し続けるので外れませんが、ラムダ式や関数の中で作った関数を渡すと、すぐに外れてしまいます。強い参照で持たせたい場合は connect_strong() を使い、close_plugin() の中で disconnect() します。

夕宮たいだ

ほよ? 登録した関数、どこからも参照されてないと勝手に外れちゃうんだぁ。ラムダで書いて「動かない……」ってなるやつだねぇ。

Painter の外から動かす(リモートスクリプト)

ひとことで:起動オプションを付けると、外部の Python から HTTP でコードを送って実行できます。

Painter を --enable-remote-scripting を付けて起動すると、外部のプログラムから Python や JavaScript のコードを送って実行させる受け口が開きます。Windows での起動例は次のとおりです(インストール先は環境によって異なります)。

"C:\Program Files\Adobe\Adobe Substance 3D Painter\Adobe Substance 3D Painter.exe" --enable-remote-scripting

公式ガイド Remote control / headless の見本では、ポートの既定値は 60041 で、Base64 でエンコードしたコードを JSON に包み、/run.json に POST します。Painter が起動しきってから、外部の Python で次のように送ります。標準ライブラリだけで書けます。

# painter_remote.py : Painter の外(ふつうの Python 3)で実行する
import base64
import json
import urllib.request

def run_in_painter(code, host="localhost", port=60041):
    payload = json.dumps({"python": base64.b64encode(code.encode("utf-8")).decode("ascii")})
    request = urllib.request.Request(
        f"http://{host}:{port}/run.json",
        data=payload.encode("utf-8"),
        headers={"Content-Type": "application/json", "Accept": "application/json"},
    )
    with urllib.request.urlopen(request, timeout=3600) as response:
        return response.read().decode("utf-8").rstrip()

if __name__ == "__main__":
    run_in_painter("import substance_painter")
    print(run_in_painter("substance_painter.__version__"))

戻り値は文字列で返ってきます。Painter の中のオブジェクトをそのまま受け取ることはできないので、必要な情報は Painter 側で文字列や JSON に直してから返します。公式ガイドでも勧められているとおり、よく使う処理は startup フォルダのモジュールに関数として用意しておき、外からはその関数を呼ぶ1行だけを送るようにすると、送るコードを短く保てます。

外からプロジェクトを開いた直後の Painter は処理中で、すぐには保存などを受け付けません。待ち方は次の「よくある失敗と対処」で説明します。

夕宮たいだ

ぁぅ……外からコードを実行できるってことは、どんなコードでも実行できちゃうってことなんだよねぇ。必要なときだけ有効にして起動してねぇ。

よくある失敗と対処

ひとことで:Qt のバージョン、置き場所、再読み込み、書き出し設定、処理中の状態の5つで大半を説明できます。

プラグインが Python メニューに出ない・違うファイルが読まれる

  • python\plugins ではなく、JavaScript 用のフォルダやドキュメントフォルダの直下に置いている
  • ファイルを置いたあと、Painter を再起動していない
  • 共有フォルダを使う場合に、SUBSTANCE_PAINTER_PLUGINS_PATH が plugins フォルダそのものを指している(正しくは3つのフォルダを持つ親フォルダ)
  • 同じ名前のモジュールを plugins と startup の両方に置いている(plugins 側が優先されます)

PySide2 を読み込めないというエラーが出る

10.1 以降の Painter は Qt 6 なので、PySide6 から読み込みます。QAction の場所が QtGui に移ったほか、exec_() が exec() に、ショートカットの組み合わせが + から | に変わるなど、細かい違いがあります。古いプラグインを移すときは、公式の Qt6 Migration の一覧と突き合わせます。複数のバージョンで動かす場合は、substance_painter.application.version_info() の値が (10, 1, 0) より小さいかどうかで、読み込むモジュールを分けます。

コードを直したのに反映されない

パッケージ(フォルダ)形式のプラグインを reload しても、importlib.reload() と同じ仕組みで読み直されるのはパッケージ本体(__init__.py)だけで、中のサブモジュールは古いままです。プラグインに reload_plugin() という関数を用意しておくと、停止してから再開するまでの間に呼ばれるので、その中で importlib.reload() を使ってサブモジュールを読み直します。

書き出しで ValueError が出る

  • rootPath のテクスチャセット名が、画面上の名前と違う(名前を変更したあと、古い名前を書いたままにしている)
  • JSON でプリセットを自作し、形式やビット深度が決まらないマップが残っている。公式ドキュメントでは、すべてのマップのパラメーターが最終的に決まっていないと設定が無効になるとされています。exportParameters にフィルターなしの既定ルールを1つ置いておくと防げます
  • 原因は例外のメッセージに書かれています。except で受けて Log に出しておくと、すぐに読めます

開いた直後の書き出しや保存が失敗する

プロジェクトを開いた直後や、ベイク・書き出しの最中は、Painter が処理中(busy)の状態で、保存などを受け付けません。スクリプトで続けて処理するときは、substance_painter.project.execute_when_not_busy() に関数を渡して手が空いてから実行させるか、プロジェクトが編集できる状態になったことを知らせる ProjectEditionEntered イベントを待ってから書き出します。

外部のライブラリを読み込めない

Painter は同梱の Python(12.x では 3.13)で動くため、別のバージョンの Python に入れたパッケージはそのままでは使えません。同じバージョン向けにインストールし、公式ガイド Using external modules のとおり、環境変数 PYTHONPATH でその場所を指定します。

チェックリスト

  • [ ] Painter のバージョンと、同梱の Python・Qt のバージョンを確認した
  • [ ] プラグインを python\plugins(常に読み込むものは startup)に置いた
  • [ ] start_plugin() と close_plugin() があり、追加した UI を close_plugin() で消している
  • [ ] チームへの配布は SUBSTANCE_PAINTER_PLUGINS_PATH で共有フォルダを指している
  • [ ] 書き出しの前に list_project_textures() で対象を確認し、Log に残している
  • [ ] 例外(ProjectError・ValueError)と戻り値の status の両方を確認している
  • [ ] イベントの関数はモジュールの直下に定義し、close_plugin() で登録を外している
  • [ ] リモートスクリプトは、必要なときだけ有効にして起動している

次に読む記事

夕宮たいだ

ふぁ……これで Painter のプラグイン、ひと通り作れるはずだよぉ。まずは書き出しボタン1個からでいいから、少しずつ育てていこうねぇ。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

目次