[UE5.8][C++]遂に完成したC++プラグインUE用HLSL.EditorのMaterial作成機能

もうこれでノードスパゲッティから解放される

導入方法

0,以下のリンクを1つダウンロード (特に理由がなければ最新版使ってください)

furcraeaHLSLEditor_UE5.8.0V9.zip UE5.8に対応しました。
https://drive.google.com/file/d/14nWd_4X9uQvt6KjFlmgRbYp2LUYSth8o/view?usp=sharing

furcraeaHLSLEditor_UE5.7.4V8.zip float4出力に対応しました。
https://drive.google.com/file/d/1PaBHAT9O4m6DC6UceUtwrj5o8N9qrrhn/view?usp=sharing

furcraeaHLSLEditor_UE5.7.4V7.zip ポストプロセスマテリアル対応
https://drive.google.com/file/d/1ieBRb5zpW36LGldmREROGcWJ8WZot1Xg/view?usp=sharing

furcraeaHLSLEditor_UE5.7.4V6_2.zip コーディングBugFix
https://drive.google.com/file/d/1BzMiQt4dXcu8p-dIfkyH3v1SRVSKjiCn/view?usp=sharing

furcraeaHLSLEditor_UE5.7.4V6.zip コーディングBugFix
https://drive.google.com/file/d/1APYn65u1yo3xkJlDFmqJ2yuCE5zHViqc/view?usp=sharing

furcraeaHLSLEditor_UE5.7.4V5.zip 関数インライン化によるコンパイラ整理
https://drive.google.com/file/d/1YiUHmR1g6d9WwvAtd1rGADsiNZEXRMNX/view?usp=sharing

furcraeaHLSLEditor_UE5.7.4.zip (V4) FragmentShader とVertexShader(Optional)に分離
https://drive.google.com/file/d/1gInP7W5hFB3pwlwbrbkekSzC8xOwusap/view?usp=sharing

furcraeaHLSLEditor_UE5.7.3V4.zip ユーザーが作った)CodeMaterialAssetと同じフォルダに変更
https://drive.google.com/file/d/1XhVZgV6FRiyGOOquqO5nJVFXbmg_8DA-/view?usp=sharing

furcraeaHLSLEditor_UE5.7.3V3.zip Projects と RenderCore を bBuildEditorをガード外に移動
https://drive.google.com/file/d/1Qmz5Y_uT0VGKkrk1wskvw2DRbnND6or4/view?
usp=drive_link

furcraeaHLSLEditor_UE5.7.3V2.zip 1つしかCodeMatUser.ushがない不備修正
https://drive.google.com/file/d/1CSXmSebk7hLM02qM5jKDv2ndNJ4jhUi7/view?usp=sharing

1,Pluginsフォルダにzipを解凍して入れてください。

  パスの例:UEプロジェクトフォルダ\Plugins\furcraeaHLSLEditor\Shaders

2,プラグインで「furcraeaHLSLEditor」を有効化

使用方法

コンテンツブラウザで右クリック>Miscellaneous>Code Material Assetを作成

できた New Code Material AssetをダブルクリックでHLSL編集画面が開く

Saveをクリックすると。。。GeneratedフォルダーにM_NewCodeMaterialAssetが作成されます。>CodeMaterialAssetと同じフォルダーに保存するように変更しました。


まだまだ、1つしかCodeMatUser.ushがないので一つしかマテリアルが成立しないなどの不備があるので更新していく予定です。<-以下の画像のように修正済みです。

furcraeaHLSLEditorをGitHub で公開しました。
https://github.com/nobolu-ootsuka-unrealengine/furcraeaHLSLEditor/commits?author=nobolu-ootsuka-unrealengine

修正履歴



1つしかCodeMatUser.ushがないので一つしかマテリアルが成立しないなどの不備を修正

■furcraeaHLSLEditor_UE5.7.3V2

Projects と RenderCore を bBuildEditorをガード外に移動(ランタイムでも必要なモジュールのため)


■furcraeaHLSLEditor_UE5.7.3V3

CodeMaterialAsset が吐くMaterialのフォルダを (固定)Generated から (ユーザーが作った)CodeMaterialAssetと同じフォルダに変更した

CodeMaterialCompiler.cpp に2箇所変更しました。

① MakeMaterialPackagePath 関数(生成先パスを決定)

  • 変更前:/Game/Generated/M_xxx 固定
  • 変更後:Asset->GetOutermost()->GetName() でアセット自身のパッケージパスを取得し、FPackageName::GetLongPackagePath()
    でフォルダ部分を抜き出して {同じフォルダ}/M_xxx を返す ② CreateOrLoadMaterial の MakeDirectory 呼び出し
  • 変更前:MakeDirectory(“/Game/Generated”) 固定
  • 変更後:MakeDirectory(FPackageName::GetLongPackagePath(MakeMaterialPackagePath(Asset))) —
    生成先パスからフォルダを算出して作成 これにより、例えば Content/0_furcraeaTokyo/CodeMaterialAsset/NewCodeMaterialAsset に保存された CodeMaterialAsset
    に対しては、生成マテリアルも同じ Content/0_furcraeaTokyo/CodeMaterialAsset/M_NewCodeMaterialAsset に出力されます。

■furcraeaHLSLEditor_UE5.7.3V4

アウトライン表示用のVertexNormalを拡張のために

FragmentShader と VertexShader(Optional)に分離しました!

安定性のためにincludeを辞めました。

furcraeaHLSLEditor_UE5.7.4V5.zip


ルール: FragmentShaderCode / VertexShaderCode には 関数定義を書かない。ヘルパーが必要なら全てインライン化する。
コンパイラの整理

  • ExtractHelperFunctions + エスケープトリック (将来の一般的ヘルパー対応) は保持
    // @param float3 ColorC = 0.90,0.20,1.20//でBPでパラメーター化

furcraeaHLSLEditor_UE5.7.4V6.zip


・日本語コメントがあるとVertex Shader側のコードカラーリングが効いてない不具合を修正
・こんどは両ペインで改行が入力できないを解決
・ユーザーが .ush のボディ内で Phase * Time のように Time を直接書いても View.GameTime として展開され、HLSL
コンパイルエラー → クラッシュが防がれます。

furcraeaHLSLEditor_UE5.7.4V7.zip

ポストプロセスマテリアルを CodeMaterialAssetで作れるようになりました。
Domain が「Post Process」の時に Blendable Location ドロップダウンが横に表示されます:

CustomDepth のアウトライン系には Before Tonemapping が一般的です。

自動生成パラメーターにテクスチャが指定できるようになりました。
// ===== Fragment Shader =====
// @param Texture2D AlbedoTex
// @param Texture2D HatchTex = /Game/3Dnchu/Textures/T_Stroke-SD11.T_Stroke-SD11
// @param float HatchTiling = 10.0

なぜか裏面だけに色が乗ってる場合

● Vertex Shader Code に何か入っているはずです。

Vertex Shader Code が空でないとプラグインが「シェル法アウトライン用」と判断して:

  • TwoSided = true
  • BlendMode = Masked
  • OpacityMask = TwoSidedSign × -1(裏面のみ表示) を自動設定します。これが原因です。 修正手順:
  1. CodeMaterialAsset エディタで Vertex Shader Code を完全に空 にする
  2. Save をクリックして再生成 Vertex Shader Code が空になると:
  • TwoSided = false
  • BlendMode = Opaque
  • OpacityMask なし → 表面だけに通常表示

furcraeaHLSLEditor_UE5.7.4V8.zip

 float4出力に対応しました。

furcraeaHLSLEditor_UE5.8.0V9.zip

UE5.8に対応しました。

UE 自動シーケンサー作成 キャラ 自動レンダリングのpythonを作った

I created a Python script for Unreal Engine that automatically generates sequences and renders characters.

“H:\SandBox\UE583SequenceRender\run_auto_render.bat”

@echo off
rem Auto Sequence Render: launch UE -> build sequences -> render (MRQ) -> MP4 -> quit
rem Usage: run_auto_render.bat [config.json]
setlocal EnableExtensions

rem ===================== Settings (edit per PC) =====================
rem Leave empty for auto-detect. Write WITHOUT extra quotes inside:  set "X=C:\path"
rem   USER_UE_EDITOR : UnrealEditor.exe (needed for source/custom engine builds)
rem   USER_PROJECT   : .uproject to open (default: UE583SequenceRender next to this bat)
set "USER_UE_EDITOR="
set "USER_PROJECT="
rem ==================================================================

set "ROOT=%~dp0"
set "PROJECT=%ROOT%UE583SequenceRender\UE583SequenceRender.uproject"
if defined USER_PROJECT set "PROJECT=%USER_PROJECT%"
if defined USER_UE_EDITOR set "UE_EDITOR=%USER_UE_EDITOR%"
rem Strip quotes in case they were written as  set X="C:\path"
set "PROJECT=%PROJECT:"=%"
if defined UE_EDITOR set "UE_EDITOR=%UE_EDITOR:"=%"
for %%p in ("%PROJECT%") do set "PROJ_DIR=%%~dpp"
set "SCRIPT=%ROOT%auto_sequence_render.py"
set "CONFIG=%~1"
if "%CONFIG%"=="" set "CONFIG=sequence_jobs.json"
set "DIAG=%ROOT%run_auto_render_last.txt"

if not exist "%PROJECT%" goto :err_project
if not exist "%SCRIPT%" goto :err_script

rem --- Engine version from the .uproject (EngineAssociation) ---
set "UE_VER="
for /f "usebackq delims=" %%v in (`powershell -NoProfile -Command "(Get-Content -LiteralPath $env:PROJECT -Raw | ConvertFrom-Json).EngineAssociation"`) do set "UE_VER=%%v"
if "%UE_VER%"=="" set "UE_VER=5.8"

rem --- Find UnrealEditor.exe: env UE_EDITOR -> Epic Launcher install list -> common paths ---
if defined UE_EDITOR if exist "%UE_EDITOR%" goto :found_ue
set "UE_EDITOR="
for /f "usebackq delims=" %%i in (`powershell -NoProfile -Command "$f='C:\ProgramData\Epic\UnrealEngineLauncher\LauncherInstalled.dat'; if (Test-Path $f) { ((Get-Content $f -Raw | ConvertFrom-Json).InstallationList | Where-Object { $_.AppName -eq ('UE_' + $env:UE_VER) } | Select-Object -First 1).InstallLocation }"`) do set "UE_ROOT=%%i"
if defined UE_ROOT set "UE_EDITOR=%UE_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe"
if defined UE_EDITOR if exist "%UE_EDITOR%" goto :found_ue
for %%d in ("C:\Program Files\Epic Games" "D:\Program Files\Epic Games" "E:\Program Files\Epic Games" "C:\Epic Games" "D:\Epic Games") do (
  if exist "%%~d\UE_%UE_VER%\Engine\Binaries\Win64\UnrealEditor.exe" set "UE_EDITOR=%%~d\UE_%UE_VER%\Engine\Binaries\Win64\UnrealEditor.exe"
)
if defined UE_EDITOR if exist "%UE_EDITOR%" goto :found_ue
goto :err_ue

:found_ue
rem UE treats backslashes in -ExecutePythonScript as escapes (\U...), so pass forward slashes
set "SCRIPT_FS=%SCRIPT:\=/%"
set "CONFIG_FS=%CONFIG:\=/%"
set "PROJECT_FS=%PROJECT:\=/%"

> "%DIAG%" echo [run_auto_render] %DATE% %TIME%
>>"%DIAG%" echo UE_VER    = %UE_VER%
>>"%DIAG%" echo UE_EDITOR = %UE_EDITOR%
>>"%DIAG%" echo PROJECT   = %PROJECT_FS%
>>"%DIAG%" echo SCRIPT    = %SCRIPT_FS%
>>"%DIAG%" echo CONFIG    = %CONFIG_FS%
type "%DIAG%"

powershell -NoProfile -Command "if ($env:PROJECT -match '[^\x00-\x7F]') { exit 1 }"
if errorlevel 1 echo [WARN] The project path contains non-ASCII characters. If UE opens the Project Browser, move the folder to an ASCII-only path such as C:\UEWork\

rem -noxgeshadercompile: compile shaders locally (an idle IncrediBuild/XGE install can stall "Compiling Shaders")
echo Launching Unreal Editor... (render runs automatically, editor quits when done)
"%UE_EDITOR%" "%PROJECT_FS%" -ExecutePythonScript="%SCRIPT_FS% --config %CONFIG_FS% --quit" -noxgeshadercompile -log
echo Done. Output: %PROJ_DIR%Saved\AutoRender
goto :eof

:err_project
echo [ERROR] .uproject not found. Keep this folder layout:
echo   ^<folder^>\run_auto_render.bat
echo   ^<folder^>\auto_sequence_render.py
echo   ^<folder^>\sequence_jobs.json
echo   ^<folder^>\UE583SequenceRender\UE583SequenceRender.uproject
echo Looked for: "%PROJECT%"
echo Or set USER_PROJECT at the top of this bat.
pause
exit /b 1

:err_script
echo [ERROR] auto_sequence_render.py not found next to this bat: "%SCRIPT%"
pause
exit /b 1

:err_ue
echo [ERROR] UnrealEditor.exe for UE %UE_VER% not found.
echo Set it manually, e.g.:  set "UE_EDITOR=C:\Program Files\Epic Games\UE_%UE_VER%\Engine\Binaries\Win64\UnrealEditor.exe"
pause
exit /b 1

“H:\SandBox\UE583SequenceRender\auto_sequence_render.py”

# -*- coding: utf-8 -*-
"""
UE 5.8 自動シーケンサー作成 + キャラクター自動レンダリング

使い方:
  (A) エディタ内:  Output Log の Python で
        import auto_sequence_render as asr; asr.main()
      または  py "H:/SandBox/UE583SequenceRender/auto_sequence_render.py" --config sequence_jobs.json
  (B) バッチ起動:  run_auto_render.bat  (エディタを起動 → 生成 → レンダー → 終了)
  (C) 選択して撮影:  コンテンツブラウザで BP_ThirdPersonCharacter 等 (+任意で AnimSequence) を選択、
      またはレベル上のキャラを選択 → 右クリック「自動撮影」 (init_unreal.py がメニュー登録)
        import auto_sequence_render as asr; asr.run_selected()

引数:
  --config <path>   ジョブ定義 JSON (省略時はこの .py と同じフォルダの sequence_jobs.json, 無ければ DEFAULT_CONFIG)
  --no-render       シーケンス生成のみ
  --quit            レンダー完了後にエディタを終了
"""
import json
import glob
import math
import os
import re
import shutil
import subprocess
import sys

import unreal

# ---------------------------------------------------------------------------
# デフォルト設定 (JSON が無い場合に使用)
# ---------------------------------------------------------------------------
DEFAULT_CONFIG = {
    "map": "/Game/ThirdPerson/Lvl_ThirdPerson",
    "sequence_dir": "/Game/AutoSequences",
    "output_dir": "{project_dir}/Saved/AutoRender",
    "fps": 30,
    "resolution": [1920, 1080],
    "output_format": "png",          # png / jpg / exr / bmp
    # レンダー後に ffmpeg で連番 → MP4 (H.264) に変換
    "mp4": {
        "enabled": True,
        "ffmpeg": "",                # 空なら PATH / 既知の場所から自動検出
        "codec": "libx264",
        "crf": 18,                   # 小さいほど高画質 (18 ≒ 見た目ロスレス)
        "preset": "medium",
        "keep_images": True,         # False なら変換成功後に連番画像を削除
    },
    "spatial_samples": 1,
    "temporal_samples": 1,
    "warmup_frames": 10,
    "jobs": [
        {
            "name": "SEQ_Manny_Attack",
            "characters": [
                {
                    "mesh": "/Game/Characters/Mannequins/Meshes/SKM_Manny_Simple",
                    "anims": [
                        "/Game/Characters/Mannequins/Anims/Unarmed/MM_Idle",
                        "/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_01",
                        "/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_02",
                    ],
                    "location": [0, 0, 0],
                    "yaw": 0,
                }
            ],
            "camera": {"type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8,
                   "radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35},
        },
    ],
    # 選択撮影 (run_selected) 用テンプレート。アニメを選択していればそちらを優先
    "selection_job": {
        "name_prefix": "SEQ_Auto_",
        "anims": [
            "/Game/Characters/Mannequins/Anims/Unarmed/MM_Idle",
            "/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_01",
        ],
        "duration_frames_without_anims": 150,
        "camera": {"type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8,
                   "radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35},
    },
}

_RENDER_STATE = {}  # executor を GC から守るためのグローバル参照


# ---------------------------------------------------------------------------
# ユーティリティ
# ---------------------------------------------------------------------------
def log(msg):
    unreal.log("[AutoSeq] " + str(msg))


def warn(msg):
    unreal.log_warning("[AutoSeq] " + str(msg))


def project_dir():
    return os.path.abspath(unreal.Paths.project_dir())


def load_asset(path):
    asset = unreal.EditorAssetLibrary.load_asset(path)
    if asset is None:
        raise RuntimeError("アセットが見つかりません: " + path)
    return asset


def vec(v):
    return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))


def parse_args(argv):
    args = {"config": None, "render": True, "quit": False, "selected": False}
    i = 0
    while i < len(argv):
        a = argv[i]
        if a == "--config" and i + 1 < len(argv):
            args["config"] = argv[i + 1]
            i += 1
        elif a == "--no-render":
            args["render"] = False
        elif a == "--quit":
            args["quit"] = True
        elif a == "--selected":
            args["selected"] = True
        i += 1
    return args


def load_config(path=None):
    if path is None:
        candidate = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sequence_jobs.json")
        path = candidate if os.path.exists(candidate) else None
    if path is None:
        log("JSON が無いため DEFAULT_CONFIG を使用")
        return dict(DEFAULT_CONFIG)
    if not os.path.isabs(path):
        path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path)
    with open(path, "r", encoding="utf-8") as f:
        cfg = json.load(f)
    merged = dict(DEFAULT_CONFIG)
    merged.update(cfg)
    log("設定読み込み: " + path)
    return merged


def anim_length_seconds(anim):
    try:
        return float(anim.get_play_length())
    except Exception:
        return float(unreal.AnimationLibrary.get_sequence_length(anim))


def get_channels(section):
    try:
        return section.get_all_channels()
    except AttributeError:
        return section.get_channels()


def binding_id(sequence, binding):
    try:
        return unreal.MovieSceneSequenceExtensions.get_portable_binding_id(sequence, sequence, binding)
    except Exception:
        return sequence.get_binding_id(binding)


# ---------------------------------------------------------------------------
# シーケンス生成
# ---------------------------------------------------------------------------
def create_sequence_asset(seq_dir, name):
    full = "{}/{}".format(seq_dir, name)
    if unreal.EditorAssetLibrary.does_asset_exist(full):
        unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
        unreal.EditorAssetLibrary.delete_asset(full)
    tools = unreal.AssetToolsHelpers.get_asset_tools()
    seq = tools.create_asset(name, seq_dir, unreal.LevelSequence, unreal.LevelSequenceFactoryNew())
    if seq is None:
        raise RuntimeError("LevelSequence 作成失敗: " + full)
    return seq


def add_spawnable(sequence, actor):
    """UE5.5+ はシーケンスを Sequencer で開いた状態で Subsystem 経由で追加する。"""
    binding = None
    try:
        subsystem = unreal.get_editor_subsystem(unreal.LevelSequenceEditorSubsystem)
        binding = subsystem.add_spawnable_from_instance(sequence, actor)
    except Exception as e:
        warn("Subsystem で spawnable 追加失敗: {}".format(e))
    if binding is None or not binding.is_valid():
        binding = sequence.add_spawnable_from_instance(actor)  # 旧 API フォールバック
    remove_auto_tracks(binding)
    return binding


def remove_auto_tracks(binding):
    """Sequencer がバインド時に自動追加する空の Transform トラックを削除。
    残すと自前の Transform トラックと平均ブレンドされ、位置・向きが半分になる。"""
    for t in list(binding.find_tracks_by_type(unreal.MovieScene3DTransformTrack)):
        binding.remove_track(t)


def cleanup_auto_bindings(sequence):
    """UE 5.8 が SkeletalMesh に自動追加する FK Control Rig トラックを削除し、空になった子バインドも消す。"""
    cr_cls = getattr(unreal, "MovieSceneControlRigParameterTrack", None)
    if cr_cls is None:
        return
    # BP はメッシュの子バインド、SkeletalMeshActor はアクターのバインド自体に付くので全バインドを見る
    for b in list(sequence.get_bindings()):
        for t in list(b.find_tracks_by_type(cr_cls)):
            b.remove_track(t)
            log("  自動追加の Control Rig トラックを削除: {}".format(b.get_display_name()))
    for b in list(sequence.get_possessables()):
        if not b.get_tracks() and not b.get_child_possessables():
            b.remove()


def add_transform_keys(binding, keys, linear=False):
    """keys: [(frame, location(Vector), rotation(Rotator)), ...]
    linear=True で等速 (カメラの周回用)"""
    track = binding.add_track(unreal.MovieScene3DTransformTrack)
    section = track.add_section()
    section.set_range(keys[0][0], keys[-1][0] + 1)
    ch = get_channels(section)
    interp = unreal.MovieSceneKeyInterpolation.LINEAR if linear else unreal.MovieSceneKeyInterpolation.AUTO
    for frame, loc, rot in keys:
        fn = unreal.FrameNumber(int(frame))
        for idx, val in ((0, loc.x), (1, loc.y), (2, loc.z),
                         (3, rot.roll), (4, rot.pitch), (5, rot.yaw)):
            ch[idx].add_key(fn, float(val), 0.0, unreal.MovieSceneTimeUnit.DISPLAY_RATE, interp)
    return section


def capsule_half_height(actor):
    comp = actor.get_component_by_class(unreal.CapsuleComponent)
    return float(comp.get_scaled_capsule_half_height()) if comp else 0.0


def character_height(actor):
    """キャラの身長 (cm)。Character はカプセル、それ以外は SkeletalMesh のバウンズから。"""
    hh = capsule_half_height(actor)
    if hh > 0:
        return hh * 2.0
    comp = actor.get_component_by_class(unreal.SkeletalMeshComponent)
    if comp:
        try:
            bmin, bmax = comp.get_local_bounds()
            return float(bmax.z - bmin.z) * float(comp.get_world_scale().z)
        except Exception:
            pass
    return 180.0


def facing_yaw(actor):
    """キャラ正面の yaw。Mannequin メッシュ単体は +Y 向きなので +90。"""
    yaw = actor.get_actor_rotation().yaw
    return yaw + 90.0 if isinstance(actor, unreal.SkeletalMeshActor) else yaw


def snap_to_floor(feet, up=500.0, down=5000.0):
    """足元 XY の真下の床に Z を合わせる (床や台の中に埋まらないように)。"""
    world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
    start = unreal.Vector(feet.x, feet.y, feet.z + up)
    end = unreal.Vector(feet.x, feet.y, feet.z - down)
    try:
        trace = unreal.TraceTypeQuery.ECC_VISIBILITY
    except AttributeError:
        trace = unreal.TraceTypeQuery.TRACE_TYPE_QUERY1
    hit = unreal.SystemLibrary.line_trace_single(world, start, end, trace, False, [],
                                                 unreal.DrawDebugTrace.NONE, True)
    if hit:
        z = hit.to_tuple()[4].z
        if abs(z - feet.z) > 1.0:
            log("  床にスナップ: Z {:.0f} -> {:.0f}".format(feet.z, z))
        return unreal.Vector(feet.x, feet.y, z)
    warn("  床が見つかりません (Z はそのまま): {}".format(feet))
    return feet


def add_character(sequence, actor_sub, char_cfg, fps, fill_frames=0):
    """char_cfg のキャラ指定:
         "actor": レベル上の Actor (possessable として使用)
         "blueprint": BP パス (spawnable)
         "mesh": SkeletalMesh パス (SkeletalMeshActor の spawnable)
       "location" は足元の位置。
    戻り値: (終了フレーム, 足元位置, 正面 yaw, 身長)
    """
    if char_cfg.get("actor") is not None:
        actor = char_cfg["actor"]
        binding = sequence.add_possessable(actor)
        binding.set_display_name(actor.get_actor_label())
        remove_auto_tracks(binding)
    else:
        if char_cfg.get("location") in (None, "auto"):
            spot, ps_yaw = _player_start_spot(actor_sub)
            feet = vec(spot)
            yaw = float(char_cfg.get("yaw", ps_yaw))
        else:
            feet = vec(char_cfg["location"])
            yaw = float(char_cfg.get("yaw", 0))
        if char_cfg.get("snap_to_floor", True):
            feet = snap_to_floor(feet)
        rot = unreal.Rotator(0.0, 0.0, yaw)
        if char_cfg.get("blueprint"):
            cls = unreal.EditorAssetLibrary.load_blueprint_class(char_cfg["blueprint"])
            if cls is None:
                raise RuntimeError("Blueprint クラスを読み込めません: " + char_cfg["blueprint"])
            actor = actor_sub.spawn_actor_from_class(cls, feet, rot)
            hh = capsule_half_height(actor)
            if hh > 0:  # Character はカプセル中心が原点なので持ち上げる
                actor.set_actor_location(feet + unreal.Vector(0, 0, hh), False, False)
            name = cls.get_name().replace("_C", "")
        else:
            mesh = load_asset(char_cfg["mesh"])
            actor = actor_sub.spawn_actor_from_class(unreal.SkeletalMeshActor, feet, rot)
            comp = actor.skeletal_mesh_component
            try:
                comp.set_skeletal_mesh_asset(mesh)
            except AttributeError:
                comp.set_skeletal_mesh(mesh)
            name = mesh.get_name()
        binding = add_spawnable(sequence, actor)
        binding.set_display_name(name)

    loc = actor.get_actor_location()
    rot = actor.get_actor_rotation()
    feet = loc - unreal.Vector(0, 0, capsule_half_height(actor))
    face = facing_yaw(actor)
    height = character_height(actor)
    if char_cfg.get("actor") is None:
        actor_sub.destroy_actor(actor)

    # アニメーションを順番に並べる (Force Custom Mode で AnimBP を上書き)
    # fill_frames より短ければアニメ列をループして埋める (途中で T ポーズにならないように)
    cursor = 0
    anims = [load_asset(a) if isinstance(a, str) else a for a in char_cfg.get("anims", [])]
    if anims:
        anim_track = binding.add_track(unreal.MovieSceneSkeletalAnimationTrack)
        playlist = list(anims)
        total = sum(max(1, int(round(anim_length_seconds(a) * fps))) for a in anims)
        while fill_frames and total < fill_frames:
            playlist += anims
            total += sum(max(1, int(round(anim_length_seconds(a) * fps))) for a in anims)
        for anim in playlist:
            frames = max(1, int(round(anim_length_seconds(anim) * fps)))
            if fill_frames and len(playlist) > len(anims):
                if cursor >= fill_frames:
                    break
                frames = min(frames, fill_frames - cursor)
            section = anim_track.add_section()
            section.set_range(cursor, cursor + frames)
            params = section.get_editor_property("params")
            params.set_editor_property("animation", anim)
            params.set_editor_property("force_custom_mode", bool(char_cfg.get("force_custom_mode", True)))
            section.set_editor_property("params", params)
            log("  anim {} : {} - {}".format(anim.get_name(), cursor, cursor + frames))
            cursor += frames

    end = max(cursor, int(char_cfg.get("duration_frames", 1)), fill_frames)
    add_transform_keys(binding, [(0, loc, rot), (end - 1, loc, rot)])
    return end, feet, face, height


def look_at(cam_loc, target):
    d = target - cam_loc
    horiz = math.sqrt(d.x * d.x + d.y * d.y)
    yaw = math.degrees(math.atan2(d.y, d.x))
    pitch = math.degrees(math.atan2(d.z, horiz))
    return unreal.Rotator(0.0, pitch, yaw)  # (roll, pitch, yaw)


def cine_half_tans(cine):
    """シネカメラの (水平, 垂直) 半画角の tan をフィルムバックと焦点距離から求める。"""
    try:
        fb = cine.get_editor_property("filmback")
        sensor_w = float(fb.get_editor_property("sensor_width"))
        sensor_h = float(fb.get_editor_property("sensor_height"))
    except Exception:
        sensor_w, sensor_h = 23.76, 13.365  # 16:9 DSLR 既定
    focal = float(cine.get_editor_property("current_focal_length"))
    return (sensor_w * 0.5) / focal, (sensor_h * 0.5) / focal


def fit_distance(tan_h, tan_v, subject_height, subject_spread, margin):
    """全身 (身長 × margin) と横幅が画面に収まるカメラ距離を画角から算出。"""
    # 被写体の奥行き (周回中に手前へ来る分) を足しておく
    d_vertical = (subject_height * margin * 0.5) / tan_v + subject_spread
    d_horizontal = (subject_spread * margin) / tan_h + subject_spread
    return max(d_vertical, d_horizontal)


def add_camera(sequence, actor_sub, cam_cfg, center, end_frame, base_yaw=0.0,
               subject_height=180.0, subject_spread=40.0, aspect=16.0 / 9.0):
    """center: 足元中心。キャラの全身を中心に捉えて周回する。
    cam_cfg["camera_actor"]: "cine" (CineCameraActor, 既定) / "camera" (通常の CameraActor)"""
    kind = str(cam_cfg.get("camera_actor", "cine")).lower()
    margin = float(cam_cfg.get("framing_margin", 1.15))

    if kind == "camera":
        # 通常カメラ: 水平 FOV 指定。被写界深度 (ボケ) なし
        cam_actor = actor_sub.spawn_actor_from_class(unreal.CameraActor, center, unreal.Rotator())
        comp = cam_actor.get_component_by_class(unreal.CameraComponent)
        fov = float(cam_cfg.get("fov", 60))
        comp.set_editor_property("field_of_view", fov)
        tan_h = math.tan(math.radians(fov) * 0.5)
        tan_v = tan_h / aspect
    else:
        cam_actor = actor_sub.spawn_actor_from_class(unreal.CineCameraActor, center, unreal.Rotator())
        comp = cam_actor.get_cine_camera_component()
        comp.set_editor_property("current_focal_length", float(cam_cfg.get("focal_length", 35)))
        tan_h, tan_v = cine_half_tans(comp)

    radius_cfg = cam_cfg.get("radius", "auto")
    if radius_cfg in (None, "auto"):
        radius = fit_distance(tan_h, tan_v, subject_height, subject_spread, margin)
    else:
        radius = float(radius_cfg)

    if kind != "camera":
        focus = comp.get_editor_property("focus_settings")
        if cam_cfg.get("depth_of_field", True):
            # キャラにピントを合わせ、背景をぼかす
            focus.set_editor_property("focus_method", unreal.CameraFocusMethod.MANUAL)
            focus.set_editor_property("manual_focus_distance", radius)
            if cam_cfg.get("aperture") is not None:
                comp.set_editor_property("current_aperture", float(cam_cfg["aperture"]))
        else:
            focus.set_editor_property("focus_method", unreal.CameraFocusMethod.DISABLE)
        comp.set_editor_property("focus_settings", focus)

    binding = add_spawnable(sequence, cam_actor)
    actor_sub.destroy_actor(cam_actor)
    binding.set_display_name("AutoCamera")
    log("  camera actor: {}".format("CameraActor" if kind == "camera" else "CineCameraActor"))

    # 注視点 = 体の中心 (身長の半分) → 頭からつま先までが画面中央に入る
    target = center + unreal.Vector(0, 0, subject_height * float(cam_cfg.get("target_ratio", 0.5)))
    elev = math.radians(float(cam_cfg.get("elevation", 5)))   # 少し見下ろす角度
    horiz = radius * math.cos(elev)
    cam_z = radius * math.sin(elev)
    # 0 度 = キャラの正面 (base_yaw 方向) から撮る
    start = base_yaw + float(cam_cfg.get("start_angle", 0))
    sweep = float(cam_cfg.get("sweep", 360)) if cam_cfg.get("type", "orbit") == "orbit" else 0.0
    log("  camera: 距離 {:.0f}cm  身長 {:.0f}cm  回転 {:.0f}°".format(radius, subject_height, sweep))

    # 10 度刻みのリニアキーで等速回転
    key_count = max(2, int(math.ceil(abs(sweep) / 10.0)) + 1)
    keys = []
    prev_yaw = None
    for i in range(key_count):
        t = i / float(key_count - 1)
        ang = math.radians(start + sweep * t)
        pos = target + unreal.Vector(math.cos(ang) * horiz, math.sin(ang) * horiz, cam_z)
        rot = look_at(pos, target)
        # yaw の ±180 跨ぎで回転が飛ばないよう連続化
        if prev_yaw is not None:
            while rot.yaw - prev_yaw > 180:
                rot.yaw -= 360
            while rot.yaw - prev_yaw < -180:
                rot.yaw += 360
        prev_yaw = rot.yaw
        keys.append((int(round(t * (end_frame - 1))), pos, rot))
    add_transform_keys(binding, keys, linear=True)

    cut_track = sequence.add_track(unreal.MovieSceneCameraCutTrack)
    cut = cut_track.add_section()
    cut.set_range(0, end_frame)
    cut.set_camera_binding_id(binding_id(sequence, binding))


def build_sequence(cfg, job):
    fps = int(cfg["fps"])
    seq = create_sequence_asset(cfg["sequence_dir"], job["name"])
    seq.set_display_rate(unreal.FrameRate(fps, 1))
    unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(seq)

    actor_sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
    end_frame = 1
    centers = []
    heights = []
    base_yaw = None
    # 1 周にかける最低秒数 (アニメが短くても回転が速すぎないように)
    fill = int(float(job.get("camera", {}).get("orbit_seconds", 0)) * fps)
    for char_cfg in job["characters"]:
        e, feet, face, h = add_character(seq, actor_sub, char_cfg, fps, fill_frames=fill)
        end_frame = max(end_frame, e)
        centers.append(feet)
        heights.append(h)
        if base_yaw is None:
            base_yaw = face

    if job.get("duration_frames"):
        end_frame = int(job["duration_frames"])

    center = unreal.Vector(sum(c.x for c in centers) / len(centers),
                           sum(c.y for c in centers) / len(centers),
                           sum(c.z for c in centers) / len(centers))
    # 横幅 = 中心から一番遠いキャラまでの距離 + 体の半径 (腕を広げても収まる程度)
    spread = max(math.hypot(c.x - center.x, c.y - center.y) for c in centers) + 60.0
    add_camera(seq, actor_sub, job.get("camera", {}), center, end_frame, base_yaw or 0.0,
               subject_height=max(heights), subject_spread=spread,
               aspect=float(cfg["resolution"][0]) / float(cfg["resolution"][1]))

    seq.set_playback_start(0)
    seq.set_playback_end(end_frame)
    try:
        seq.set_view_range_start(0)
        seq.set_view_range_end(end_frame / float(fps))
        seq.set_work_range_start(0)
        seq.set_work_range_end(end_frame / float(fps))
    except Exception:
        pass

    cleanup_auto_bindings(seq)
    unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
    unreal.EditorAssetLibrary.save_loaded_asset(seq)
    log("シーケンス作成: {} ({} frames @ {}fps)".format(seq.get_path_name(), end_frame, fps))
    return seq


# ---------------------------------------------------------------------------
# Movie Render Queue
# ---------------------------------------------------------------------------
_FORMATS = {
    "png": "MoviePipelineImageSequenceOutput_PNG",
    "jpg": "MoviePipelineImageSequenceOutput_JPG",
    "exr": "MoviePipelineImageSequenceOutput_EXR",
    "bmp": "MoviePipelineImageSequenceOutput_BMP",
}


_MP4_DEFAULTS = DEFAULT_CONFIG["mp4"]


def mp4_settings(cfg):
    s = dict(_MP4_DEFAULTS)
    s.update(cfg.get("mp4") or {})
    return s


def find_ffmpeg(cfg):
    path = mp4_settings(cfg).get("ffmpeg")
    if path and os.path.exists(path):
        return path
    found = shutil.which("ffmpeg")
    if found:
        return found
    for p in (r"C:\python3.11.0\ffmpeg.exe", r"C:\ffmpeg\bin\ffmpeg.exe",
              r"C:\Program Files\ffmpeg\bin\ffmpeg.exe"):
        if os.path.exists(p):
            return p
    return None


def encode_mp4(cfg, seq_name, image_dir, ext, out_path):
    """{seq_name}.{0000}.{ext} の連番を MP4 に変換。成功で True。"""
    ffmpeg = find_ffmpeg(cfg)
    if not ffmpeg:
        warn("ffmpeg が見つかりません。config の mp4.ffmpeg にパスを指定してください")
        return False

    pat = re.compile(re.escape(seq_name) + r"\.(\d+)\." + ext + "$", re.IGNORECASE)
    files = sorted(f for f in glob.glob(os.path.join(image_dir, "*." + ext)) if pat.search(os.path.basename(f)))
    if not files:
        warn("連番画像が見つかりません: {}".format(image_dir))
        return False
    first = pat.search(os.path.basename(files[0])).group(1)

    s = mp4_settings(cfg)
    cmd = [
        ffmpeg, "-y", "-hide_banner", "-loglevel", "error",
        "-framerate", str(int(cfg["fps"])),
        "-start_number", str(int(first)),
        "-i", os.path.join(image_dir, "{}.%0{}d.{}".format(seq_name, len(first), ext)),
        "-c:v", s["codec"], "-crf", str(s["crf"]), "-preset", s["preset"],
        "-pix_fmt", "yuv420p",
        "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2",   # yuv420p は偶数解像度が必要
        "-movflags", "+faststart",
        out_path,
    ]
    log("MP4 変換: " + " ".join('"{}"'.format(c) if " " in c else c for c in cmd))
    flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
    r = subprocess.run(cmd, capture_output=True, text=True, creationflags=flags)
    if r.returncode != 0:
        warn("ffmpeg 失敗 ({}): {}".format(r.returncode, r.stderr.strip()))
        return False
    log("MP4 出力: {} ({} frames)".format(out_path, len(files)))

    if not s.get("keep_images", True):
        for f in files:
            os.remove(f)
    return True


def set_keep_alive(value):
    try:
        unreal.EditorPythonScripting.set_keep_python_script_alive(bool(value))
    except Exception:
        pass


def queue_render(cfg, sequences, quit_after=False, map_path=None):
    mrq = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
    if mrq.is_rendering():
        warn("既にレンダー中のためスキップします")
        return
    queue = mrq.get_queue()
    queue.delete_all_jobs()

    map_soft = unreal.SoftObjectPath(map_path or cfg["map"])
    out_root = cfg["output_dir"].replace("{project_dir}", project_dir().replace("\\", "/"))
    fmt = cfg.get("output_format", "png")
    fmt_cls = getattr(unreal, _FORMATS.get(fmt, _FORMATS["png"]))
    want_mp4 = bool(mp4_settings(cfg).get("enabled"))
    # EXR は ffmpeg 入力に向かないので、MP4 用に PNG も併せて出力
    mp4_ext = fmt if fmt in ("png", "jpg", "bmp") else "png"
    encode_list = []

    for seq in sequences:
        job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob)
        job.job_name = seq.get_name()
        job.sequence = unreal.SoftObjectPath(seq.get_path_name())
        job.map = map_soft

        conf = job.get_configuration()
        out = conf.find_or_add_setting_by_class(unreal.MoviePipelineOutputSetting)
        out.output_directory = unreal.DirectoryPath("{}/{}".format(out_root, seq.get_name()))
        out.output_resolution = unreal.IntPoint(int(cfg["resolution"][0]), int(cfg["resolution"][1]))
        out.file_name_format = "{sequence_name}.{frame_number}"
        out.zero_pad_frame_numbers = 4

        conf.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase)
        conf.find_or_add_setting_by_class(fmt_cls)
        if want_mp4 and mp4_ext != fmt:
            conf.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG)
        if want_mp4:
            encode_list.append((seq.get_name(), out.output_directory.path))

        # GameMode を MRQ 用 (既定: MoviePipelineGameMode) に上書き
        # → プレイヤーポーン (BP_ThirdPersonCharacter) や HUD が映り込まない
        conf.find_or_add_setting_by_class(unreal.MoviePipelineGameOverrideSetting)

        aa = conf.find_or_add_setting_by_class(unreal.MoviePipelineAntiAliasingSetting)
        aa.spatial_sample_count = int(cfg.get("spatial_samples", 1))
        aa.temporal_sample_count = int(cfg.get("temporal_samples", 1))
        aa.engine_warm_up_count = int(cfg.get("warmup_frames", 10))
        aa.render_warm_up_frames = True
        log("レンダージョブ追加: {} -> {}".format(seq.get_name(), out.output_directory.path))

    def _on_finished(executor, success):
        log("レンダー完了 success={}  出力: {}".format(success, out_root))
        _RENDER_STATE.clear()
        if success and encode_list:
            with unreal.ScopedSlowTask(len(encode_list), "MP4 に変換中...") as task:
                task.make_dialog()
                for name, img_dir in encode_list:
                    task.enter_progress_frame(1, "MP4 変換: " + name)
                    encode_mp4(cfg, name, img_dir, mp4_ext, "{}/{}.mp4".format(out_root, name))
        set_keep_alive(False)
        if quit_after:
            unreal.SystemLibrary.quit_editor()

    executor = mrq.render_queue_with_executor(unreal.MoviePipelinePIEExecutor)
    executor.on_executor_finished_delegate.add_callable_unique(_on_finished)
    _RENDER_STATE["executor"] = executor
    # -ExecutePythonScript 起動時は、スクリプトが返った次のフレームでエディタが終了してしまう。
    # レンダー (非同期) が終わるまでエディタを生かしておく。
    set_keep_alive(True)
    log("レンダー開始 ({} jobs)".format(len(sequences)))


# ---------------------------------------------------------------------------
# 選択から自動撮影
# ---------------------------------------------------------------------------
def _editor_world():
    return unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()


def _player_start_spot(actor_sub):
    """PlayerStart の足元位置と yaw。無ければ原点。"""
    for a in actor_sub.get_all_level_actors():
        if isinstance(a, unreal.PlayerStart):
            feet = a.get_actor_location() - unreal.Vector(0, 0, capsule_half_height(a))
            return [feet.x, feet.y, feet.z], a.get_actor_rotation().yaw
    return [0, 0, 0], 0.0


def collect_selection(cfg):
    """レベル選択アクター → possessable、コンテンツブラウザの BP / SkeletalMesh → spawnable。
    同時に選択された AnimSequence はアニメとして使う。"""
    actor_sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
    targets = []
    anims = []

    for asset in unreal.EditorUtilityLibrary.get_selected_assets():
        if isinstance(asset, unreal.AnimSequence):
            anims.append(asset)
        elif isinstance(asset, unreal.Blueprint):
            targets.append({"blueprint": asset.get_path_name().split(".")[0], "_name": asset.get_name()})
        elif isinstance(asset, unreal.SkeletalMesh):
            targets.append({"mesh": asset.get_path_name().split(".")[0], "_name": asset.get_name()})

    if not targets:
        for a in actor_sub.get_selected_level_actors():
            if a.get_component_by_class(unreal.SkeletalMeshComponent):
                targets.append({"actor": a, "_name": a.get_actor_label()})

    if targets and any("actor" not in t for t in targets):
        spot, yaw = _player_start_spot(actor_sub)
        for t in targets:
            if "actor" not in t:
                t["location"] = cfg["selection_job"].get("location", spot)
                t["yaw"] = cfg["selection_job"].get("yaw", yaw)
    return targets, anims


def run_selected(config_path=None, render=True):
    cfg = load_config(config_path)
    sel_cfg = dict(DEFAULT_CONFIG["selection_job"])
    sel_cfg.update(cfg.get("selection_job", {}))
    cfg["selection_job"] = sel_cfg

    targets, anims = collect_selection(cfg)
    if not targets:
        unreal.EditorDialog.show_message(
            "自動撮影", "キャラクター (Blueprint / SkeletalMesh / レベル上のアクター) を選択してください。",
            unreal.AppMsgType.OK)
        return []

    anim_list = anims or sel_cfg.get("anims", [])
    jobs = []
    for t in targets:
        char = {k: v for k, v in t.items() if not k.startswith("_")}
        char["anims"] = anim_list
        if not anim_list:
            char["duration_frames"] = int(sel_cfg.get("duration_frames_without_anims", 150))
        jobs.append({
            "name": sel_cfg.get("name_prefix", "SEQ_Auto_") + t["_name"],
            "characters": [char],
            "camera": sel_cfg.get("camera", {}),
        })

    world = _editor_world()
    sequences = [build_sequence(cfg, job) for job in jobs]
    if render:
        queue_render(cfg, sequences, map_path=world.get_path_name())
    return sequences


# ---------------------------------------------------------------------------
def main(argv=None):
    args = parse_args(sys.argv[1:] if argv is None else argv)
    if args["selected"]:
        return run_selected(args["config"], args["render"])

    cfg = load_config(args["config"])

    level_sub = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
    level_sub.load_level(cfg["map"])

    sequences = [build_sequence(cfg, job) for job in cfg["jobs"]]

    if args["render"]:
        queue_render(cfg, sequences, quit_after=args["quit"])
    elif args["quit"]:
        unreal.SystemLibrary.quit_editor()
    return sequences


if __name__ == "__main__":
    main()

“H:\SandBox\UE583SequenceRender\sequence_jobs.json”

{
  "map": "/Game/ThirdPerson/Lvl_ThirdPerson",
  "sequence_dir": "/Game/AutoSequences",
  "output_dir": "{project_dir}/Saved/AutoRender",
  "fps": 30,
  "resolution": [
    1920,
    1080
  ],
  "output_format": "png",
  "mp4": {
    "enabled": true,
    "ffmpeg": "",
    "codec": "libx264",
    "crf": 18,
    "preset": "medium",
    "keep_images": true
  },
  "spatial_samples": 1,
  "temporal_samples": 1,
  "warmup_frames": 10,
  "selection_job": {
    "name_prefix": "SEQ_Auto_",
    "anims": [
      "/Game/Characters/Mannequins/Anims/Unarmed/MM_Idle",
      "/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_01"
    ],
    "duration_frames_without_anims": 150,
    "camera": { "camera_actor": "camera", "depth_of_field": true, "type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8, "radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35 }
  },
  "jobs": [
    {
      "name": "SEQ_BP_ThirdPerson",
      "characters": [
        {
          "blueprint": "/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter",
          "anims": [
            "/Game/Characters/Mannequins/Anims/Unarmed/MM_Idle",
            "/Game/Characters/Mannequins/Anims/Unarmed/Jump/MM_Dash"
          ],
          "location": [0, 0, 0],
          "yaw": 0
        }
      ],
      "camera": { "camera_actor": "camera", "depth_of_field": true, "type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8, "radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35 }
    },
    {
      "name": "SEQ_Manny_Attack",
      "characters": [
        {
          "mesh": "/Game/Characters/Mannequins/Meshes/SKM_Manny_Simple",
          "anims": [
            "/Game/Characters/Mannequins/Anims/Unarmed/MM_Idle",
            "/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_01",
            "/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_02",
            "/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_Attack_03"
          ],
          "location": [0, 0, 0],
          "yaw": 0
        }
      ],
      "camera": { "camera_actor": "camera", "depth_of_field": true, "type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8, "radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35 }
    },
    {
      "name": "SEQ_Duo_Reaction",
      "characters": [
        {
          "mesh": "/Game/Characters/Mannequins/Meshes/SKM_Manny_Simple",
          "anims": [
            "/Game/Characters/Mannequins/Anims/Unarmed/Attack/MM_ChargedAttack"
          ],
          "location": [0, -80, 0],
          "yaw": 90
        },
        {
          "mesh": "/Game/Characters/Mannequins/Meshes/SKM_Quinn_Simple",
          "anims": [
            "/Game/Characters/Mannequins/Anims/Rifle/HitReact/MM_HitReact_Front_Hvy_01",
            "/Game/Characters/Mannequins/Anims/Death/MM_Death_Back_01"
          ],
          "location": [0, 80, 0],
          "yaw": -90
        }
      ],
      "camera": { "camera_actor": "cine", "depth_of_field": true, "type": "orbit", "sweep": 360, "start_angle": 0, "orbit_seconds": 8, "radius": "auto", "framing_margin": 1.15, "elevation": 5, "focal_length": 35 }
    }
  ]
}

“H:\SandBox\UE583SequenceRender\UE583SequenceRender\Content\Python\init_unreal.py”

# -*- coding: utf-8 -*-
# エディタ起動時に自動実行され、「自動撮影」メニューを登録する。
# auto_sequence_render.py / sequence_jobs.json はプロジェクトの 1 つ上 (run_auto_render.bat と同じフォルダ) に置く。
import os
import sys

import unreal

TOOL_DIR = os.path.abspath(os.path.join(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()), ".."))
if TOOL_DIR not in sys.path:
    sys.path.append(TOOL_DIR)

_COMMAND = (
    "import importlib, auto_sequence_render as asr; "
    "importlib.reload(asr); asr.run_selected()"
)

_MENUS = [
    "ContentBrowser.AssetContextMenu",   # コンテンツブラウザでアセットを右クリック
    "LevelEditor.ActorContextMenu",      # レベル上のアクターを右クリック
    "LevelEditor.MainMenu.Tools",        # メインメニュー Tools
]


def register_menus():
    menus = unreal.ToolMenus.get()
    for menu_name in _MENUS:
        menu = menus.extend_menu(menu_name)
        if menu is None:
            continue
        menu.add_section("AutoSequenceRender", "Auto Sequence Render")
        entry = unreal.ToolMenuEntry(
            name="AutoSequenceRender_Selected",
            type=unreal.MultiBlockType.MENU_ENTRY,
        )
        entry.set_label("自動撮影 (シーケンス作成 + レンダー)")
        entry.set_tool_tip("選択中のキャラクター (BP / SkeletalMesh / レベルのアクター) でシーケンスを作成し、Movie Render Queue でレンダーします。AnimSequence も選択するとそのアニメを使用します。")
        entry.set_string_command(unreal.ToolMenuStringCommandType.PYTHON, "", _COMMAND)
        menu.add_menu_entry("AutoSequenceRender", entry)
    menus.refresh_all_widgets()
    unreal.log("[AutoSeq] 自動撮影メニューを登録しました (スクリプト: {})".format(TOOL_DIR))


register_menus()

以下のものが必要です。

部品役割ライセンス
unreal モジュール(UE 5.8 に標準で入っている Python API)シーケンスの作成、キャラやカメラの配置、レンダーUnreal Engine EULA
├ PythonScriptPluginUE で Python を動かす同上(UE 同梱)
├ SequencerScriptingシーケンサーの操作同上
└ MovieRenderPipelineMovie Render Queue でのレンダー同上
Python 標準ライブラリ(json, math, os, re, glob, shutil, subprocess)設定の読み込み、計算、ファイル操作PSF ライセンス(商用利用 OK)
ffmpeg(C:\python3.11.0\ffmpeg.exe)PNG 連番を MP4 に変換GPL v3(gyan.dev のビルドで、libx264 を含む)

https://www.gyan.dev/ffmpeg/builds

「release builds」の中にある ffmpeg-release-essentials.zip を選んでください。

https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip

[maya] melでリファレンスをオブジェクトとして読み込み

[Maya] Loading a reference as an object using MEL

Reference_to_Object.mel

global proc PrefixHierarchyNames2(string $filename)
{
    //string $roots[] = `ls -sl -dag -long`;
    string $roots[] = `ls -sl -dag`;
    print("-----------------$roots--------------------\n");
    print($roots);
    print("-----------------$roots--------------------\n");
    if (size($roots) == 0)
    {
        warning "対象のルートノードを選択してください。";
        return;
    }
    
    string $nodes[];
    
    // 選択されたルート
    for ($root in $roots)
    {
        $nodes[size($nodes)] = $root;

        // 子孫を取得
        string $children[] = `listRelatives
            -allDescendents
            -fullPath
            -type "transform"
            $root`;

        for ($child in $children)
            $nodes[size($nodes)] = $child;
    }

    // 深い階層から処理  //for ($node in $roots)
    for ($node in $nodes)
    {//////////////////////////////////////////////////////////
    
        if (!`objExists $node`)
            continue;
    
    
        string $NodeNameArr[];
        $NodeNameArr = stringToStringArray($node, ":");
    
        //string $NewName = $NodeNameArr[1];
        string $NewName = substituteAllString($node, $NodeNameArr[0]+":", "");
        print("$node= "+$node+" >>> $NewName= "+$NewName+"\n");
        print("Hit $NodeNameArr[0]= "+$NodeNameArr[0]+" === $filename="+$filename+"\n");
        if($NodeNameArr[0] == $filename||"|"+$NodeNameArr[0] == $filename){
            
            //lockNode -l off $node;
            rename $node $NewName;
            
        }
    
    }//////////////////////////////////////////////////////////
}


proc Reference_to_Object(){
    string $refs[] = `file -q -list`;
    string $scenePath = `file -q -sceneName`;
    print($scenePath);
    for ($refFile in $refs)
    {
        string $ext = fileExtension($refFile);

        if($ext == "mb" ||$ext == "ma"){
            print("$refFile = "+$refFile+"\n");
            print("$ext = "+$ext+"\n");
            print("$refFile= "+$refFile+"\n");
            //string $filename = `basename $refFile ""`;
            string $dotExt = "."+$ext;
            string $filename = basename($refFile, $dotExt);
            print("$filename= "+$filename+"\n");
            if($scenePath==$refFile){
                print("シーンのファイル名と同じなので却下"+"\n");
            }else{
                //refNodeの取得
                string $refNode = `referenceQuery -referenceNode $refFile`;
                print("$refNode= "+$refNode+"\n");
                //file -reference -referenceNode "pc001aRN" $refFile;
                //file -importReference $refNode;
                //リファレンスをオブジェクトとして読み込み
                file -importReference $refFile;
 
                {//////////////////////////////////////////////////////////
                    string $topNodes[] = `ls -assemblies`;
                    print($topNodes);
                    select -cl ;
                    for ($nodeName in $topNodes){
                        if (gmatch($nodeName, "*:*") == 0) {
                            print ("ヒット(コロンなし): " + $nodeName + "\n");
                        }else{
                            print ("ヒット(コロンあり): " + $nodeName + "\n");
                            select -r $nodeName ;
                            PrefixHierarchyNames2($filename);
                        }
                    }
                    
                }//////////////////////////////////////////////////////////
             //  
            }//else end/////////////////////////////////////////////////

        }
    }
}
Reference_to_Object();

[UE6.0.0]UE6.0.0をgithubからビルドして起動してVerse使ってみた!

[UE6.0.0] I built UE6.0.0 from GitHub and launched it!

ビルドに時間がかかったのでビルドして寝た。

起動にも時間がかかるので、

なんか出るので設定した。

シェーダーモデル6(SM6)は Nanite アセットを使用するために必要です
[プロジェクト設定]>[プラットフォーム]>[Windows]>[D3D12ターゲットシェーダー形式]で有効にします
Nanite アセットはこれが有効になるまで正しく表示されません

を設定

プロジェクトの起動に3時間ぐらいかかった。。シェーダーコンパイル13900個

/Game/ThirdPerson/Blueprints/BP_ThirdPersonCharacter.BP_ThirdPersonCharacterはそのままだった。

Verseも見当たらないしブループリントもそのままだった

とおもったのでue6 verse tutorialを検索するとポジ太さんのページに

https://zenn.dev/posita33/books/ue5_posilab_ue_research_and_development/viewer/category_010_ue6_verse_setup

Pluginを有効にする

Pluginsを開きます。Verse検索したら出てきました。

  • Verse
  • Verse Colors
  • Verse Engine
  • Verse Experimental
  • Verse Gameplay Debug
  • Verse JSON
  • Verse Persona Metadata
  • Verse Print
  • Verse Restricted
  • Verse Simulation
  • Verse Simulation Metadata
  • Verse SocalSynergy
  • Verse Spatial Math
  • Verse Tags
  • Verse Interface
  • Verse Modifier
  • Verse Module Independence A
  • Verse Module Independence B
  • Verse VM

Bata版のやつはやめておいた

EntityFrameworkで検索してEntityFramework関連のPluginを有効にします。

  • EntityFramework
  • EntityFrameworkEditorTests
  • EntityFrameworkTest

「Solaris」を有効にします。

「Unreal Engine Experimental」を有効にしないとVerseのPrintでOutputlogに文字が出力されません。

再起動した。

これが出たがYesにする以外なかった。

DefaultEngine.iniにConsoleVariablesを設定する
“H:\GitHub_H\UnrealEngine\UnrealEngine\Engine\Config\DefaultEngine.ini”

Verseツールチェーンを起動するために、ini設定を有効にします。
UEFNではデフォルトに組み込まれている設定を手動でDefaultEngine.iniに追加します。

「DefaultEngine.ini」を開いて、以下のコードを末尾に追加します。

[ConsoleVariables]
; --- TEDS (Typed Element Data Storage) 関連の設定(クラッシュの原因) ---
; Forces the default outliner to use TEDS.
Valkyrie.UseTedsOutliner=1

TEDS.Enable=1
TEDS.Feature.Folders=1
TEDS.Feature.Folders.TypedElements=1
TEDS.UI.SetOutlinerPurpose=HybridOutliner
TEDS.UI.UseTedsOutlinerFolders=1
TEDS.RevisionControl.AutoPopulateState=1
TEDS.UI.UseNewRevisionControlWidgets=1
TEDS.UI.Outliner.ShowTedsColumnFilters=1
TEDS.UI.UseNewWidgets=1
TEDS.Feature.PickerEnabled=1
;TEDS.Feature.UnloadedActors=1
TEDS.UI.EnableTedsContentBrowser=1
TEDS.UI.EnableTestContentSource=1
TEDS.AssetDataStorage=1
TEDS.TedsAssetDataFactory=1
TEDS.AssetDataStorage.Metadata=1
Teds.Feature.Alerts=1
TEDS.Feature.ActorCompatibility.ActorComponents.Enable=1
TEDS.UI.UseDefaultAttributeBindings=1
TEDS.Feature.Layers=1

; --- Scene Graph & Entity 関連の設定 ---
; Disables using an Actor as a proxy for the prefab/entity and just uses scene graph.
SceneGraph.EntityProxyActors.Enabled=1
;SceneGraph.EnableCardLayout=1
SceneGraph.SelectTransformComponentByDefault=1
Entity.EnablePrefabEditMode=1
DetailsPanel.Overrides.EnableResetToDefault=1
EntityDetailsDisplayManager.LegacyTrimPropertyPathImplementation=1

; One-file-per-entity (external packages per entity). Read-only: startup-only, requires restart.
; OneFilePerEntityEnabled is the master; the other two are no-ops unless it is on.
Entity.OneFilePerEntityEnabled=1
Entity.OneFilePerEntityOnAllInstancesEnabled=1
Entity.OneFilePerPrefabEntityEnabled=1
EntityEditor.EnableAssetComponentSupport=1
EntityPrefabEditor.EnablePrefabThumbnails=1

; --- IDO 関連の設定 ---
; Enables thumbnail rendering and many other Prefab/Entity things
IDO.Enable=1

Verseを保存するためのPluginを作成する

現時点のue6-mainではVerseをContentsフォルダーに作成できないです。
UEFNのGameFeaturePluginに一番近いらしい「GameFeaturePlugin」を作成して、upluginに設定を追加します。

Puguinsを開きます。

“H:\SandBox\UE600TP\UE600TP\Plugins\GameFeatures\VerseSandbox\VerseSandbox.uplugin”

「(プロジェクト名).uplugin」をテキストエディターで開いて設定を追加します。

	"CanContainVerse": true,
	"VersePath": "/localhost/VerseSandbox",
	"VerseScope": "InternalUser",
	"VerseVersion": 0,
	"EnableVerseAssetReflection": true,
	"EnableSceneGraph": true

Pluginの設定を有効にするためにプロジェクトを開きなおします。

Pluginが有効になっていなかったら、Pluginsを開いて、作成した「VerseSandbox」を有効にしてプロジェクトを再起動します。

「Verse Explorer」を開きます。

「Verse Explorer」に「/localhost/(プラグイン名)」となっていれば設定が成功しています。

失敗した

EntityFramework と Solaris のプラグインを有効にして、[ConsoleVariables] の設定を追加したことで、VerseSandbox が読み込まれるようになりました。EntityFrameworkEditorTests は無効にしましたが、テスト用なので問題ありません。

出た

Verseを作成する

「/localhost/(プラグイン名)」を右クリックし、「Create Verse」を選択します。

Scene Graph ComponentのVerseを作成します。

  • Scene Graph Componentを選択する
  • 名前に「hello_world_component」を設定する
  • 「Create」ボタンをクリックする

作成したVerseをダブルクリックするとVS Codeで編集できます。

Print(“OnBeginSimulation”)の下に、Print(“Hello, World!”)を追加します。

H:\SandBox\UE600TP\UE600TP\Plugins\GameFeatures\VerseSandbox\Content\hello_world_component.verse

# Copyright Epic Games, Inc. All Rights Reserved.

using { /Verse.org }
using { /Verse.org/Native }
using { /Verse.org/SceneGraph }
using { /Verse.org/Simulation }

# A Verse-authored component that can be added to entities
hello_world_component<public> := class<final_super>(component):

    # A custom variable you can expose to the editor
    @editable
    var MyCustomInt<public>:int = 10
  
    # Runs when the component should start simulating in a running game.
    OnBeginSimulation<override>():void =
        # Run OnBeginSimulation from the parent class before
        # running this component's OnBeginSimulation logic
        (super:)OnBeginSimulation()

        # TODO: Place logic to run when the component starts simulating here
        Print("OnBeginSimulation")
        Print("Hello, World!")
    # Runs when the component should start simulating in a running game.
    # Can be suspended throughout the lifetime of the component. Suspensions
    # will be automatically cancelled when the component is disposed or the
    # game ends.
    OnSimulate<override>()<suspends>:void =
        # TODO: Place simple suspends logic to run for this component here
        Print("OnSimulate")

ソースコードに問題がないか「Compile Verse」をクリックします。

VerseプラグインをVSCodeに追加する

FortniteをインストールしたフォルダーのVSCodeフォルダーを、UnrealEngineをビルドしたフォルダにコピーします。

D:\Program Files\Epic Games\Fortnite

H:\GitHub_H\UnrealEngine\UnrealEngine

Entity Prefabを作成する

Prefabを作成するフォルダーを作成します。
右クリックメニューから「Entity Prefab」を選択します。

いずれかの方法でPrefabを作成し、名前を「Prefab_HelloWorld」に設定します。

  • 「New prefab」
  • 「entity」を選択して、「Select」をクリック

「Prefab_HelloWorld」を開き、Componentに先ほど新規作成したVerse「hello_world_component」を追加します。

作成したPrefab「Prefab_HelloWorld」をViewportに追加します。!

現状でOutlinerにPrefabを表示するには、以下の設定にしておきます。
SceneGraph.EntityProxyActors.Enabled=1

ソースコードに問題がないか「Compile Verse」をクリックします。

PIEを起動すると、Print文で定義した「Hello,World!」が出力されます。
BlueprintのPrintStringノードのようにVieportに左上と、OutputLogに出力されます。

LogVerse: OnBeginSimulation
LogVerse: Hello, World!
LogVerse: OnSimulate

Prefabにmesh_Componentを追加してCubeを表示する

EngineのBasicShapesからCubeをPluginのContentsフォルダーにコピーします。

CubeがPluginのContentsフォルダーにコピーされたことを確認したら、「Compile Verse」をクリックします。

Prefab「Prefab_HelloWorld」を開きます。
mesh_Componentを追加しようとすると、Cubeを選択できるようになります。

ubeを選択すると、mesh_componentとしてCubeが追加されます。!

BlueprintのStaticMeshComponentと違いProjectの中のすべてのStaticMeshを選ぶような選び方ができないようです。

Prefabの変更はすでに配置しているPrefabに反映されます。

[UE5.4.4]プロシージャルな動く水のコースティクス マテリアル関数をフローマップによって流れ方向をコントロールできるようにしました。

[UE5.4.4] I enabled flow direction control via flow maps for the procedural, animated water caustics material function.

MF_Caustics

M_FlowMap textureObject と TextureSampleはLinea Clolorにしないとエラー出ます。

テクスチャ設定はこれ VectorDisplacementmap (RGBA8)

結果

参考

https://gamemakers.jp/article/2024_10_18_82738

[python] Autodesk Python FBXSDKでFBX MotionViewer作ってみた

Autodesk Python FBXSDKは以下

https://aps.autodesk.com/developer/overview/fbx-sdk

Autodesk Python FBXSDK をFBX SDK 2020.3.11 Python (exe – 2800KB)を上記からダウンロード

exeに沿ってインストールする。

で早速ソースコード

motion_viewer.py

"""FBX SDK (Python) で読み込んだモーションを PyVista + Qt で再生するシンプルなビューア

使い方:
    python motion_viewer.py [motion.fbx | motions_dir]
    ウィンドウに FBX ファイル / フォルダーをドラッグ&ドロップするとリストに追加される

操作:
    リスト     : クリック / Up/Down でアニメーション切り替え
    Space      : 再生 / 一時停止
    Left/Right : 1 フレーム戻る / 進む
    M          : メッシュ表示 ON / OFF
    スライダー : フレーム移動
"""
import os
import sys
import time
from pathlib import Path

os.environ.setdefault("QT_API", "pyside6")  # Qt は LGPL の PySide6 を使う (PyQt は GPL)

import fbx
import numpy as np
import pyvista as pv
from pyvistaqt import QtInteractor
from qtpy import QtCore, QtGui, QtWidgets


def load_scene(path):
    manager = fbx.FbxManager.Create()
    manager.SetIOSettings(fbx.FbxIOSettings.Create(manager, fbx.IOSROOT))
    importer = fbx.FbxImporter.Create(manager, "")
    if not importer.Initialize(str(path), -1, manager.GetIOSettings()):
        error = importer.GetStatus().GetErrorString()
        manager.Destroy()
        raise RuntimeError(error)
    scene = fbx.FbxScene.Create(manager, "")
    importer.Import(scene)
    importer.Destroy()
    # UE と同じ Z-up / センチメートルに揃える (UE の Y 軸はこの座標の -Y)
    fbx.FbxAxisSystem.MayaZUp.ConvertScene(scene)
    fbx.FbxSystemUnit.cm.ConvertScene(scene)
    fbx.FbxGeometryConverter(manager).Triangulate(scene, True)
    return manager, scene


def to_np(m):
    """FbxAMatrix -> numpy 4x4 (列ベクトル形式: M @ v)"""
    return np.array([[m.Get(r, c) for c in range(4)] for r in range(4)]).T


def collect(node, bones, meshes, parent=None):
    """スケルトンを (ノード, 親ボーンの番号 or None)、メッシュをノードのリストに集める"""
    if node.GetSkeleton():
        bones.append((node, parent))
        parent = len(bones) - 1
    if node.GetMesh():
        meshes.append(node)
    for i in range(node.GetChildCount()):
        collect(node.GetChild(i), bones, meshes, parent)


def read_mesh(node):
    """頂点・三角形と、スキンの影響ノード / オフセット行列 / ウェイトを読む"""
    mesh = node.GetMesh()
    verts = np.array([[p[0], p[1], p[2], 1.0] for p in mesh.GetControlPoints()])
    faces = np.array([[3] + [mesh.GetPolygonVertex(i, k) for k in range(3)]
                      for i in range(mesh.GetPolygonCount())]).ravel()

    links, offsets, weights = [], [], []
    skin = mesh.GetDeformer(0) if mesh.GetDeformerCount() else None
    if skin and hasattr(skin, "GetClusterCount"):
        for i in range(skin.GetClusterCount()):
            cluster = skin.GetCluster(i)
            if not cluster.GetLink():
                continue
            bind, bind_link = fbx.FbxAMatrix(), fbx.FbxAMatrix()
            cluster.GetTransformMatrix(bind)
            cluster.GetTransformLinkMatrix(bind_link)
            w = np.zeros(len(verts))
            w[list(cluster.GetControlPointIndices())] = cluster.GetControlPointWeights()
            links.append(cluster.GetLink())
            offsets.append(np.linalg.inv(to_np(bind_link)) @ to_np(bind))
            weights.append(w)
    if not links:  # スキンなし: ノード自身に追従
        links, offsets, weights = [node], [np.eye(4)], [np.ones(len(verts))]

    weights = np.array(weights).T
    weights /= np.maximum(weights.sum(axis=1, keepdims=True), 1e-8)
    return verts, faces, links, np.array(offsets), weights


def bake_matrices(scene, nodes):
    """全フレームのノードのグローバル行列を計算する -> mats[f, i] = 4x4"""
    stack = scene.GetSrcObject(fbx.FbxCriteria.ObjectType(fbx.FbxAnimStack.ClassId), 0)
    mode = scene.GetGlobalSettings().GetTimeMode()
    fps = fbx.FbxTime.GetFrameRate(mode)
    first = last = 0
    if stack:  # アニメーションなしなら 1 フレームだけ
        scene.SetCurrentAnimationStack(stack)
        span = stack.GetLocalTimeSpan()
        first = span.GetStart().GetFrameCount(mode)
        last = span.GetStop().GetFrameCount(mode)

    mats = []
    t = fbx.FbxTime()
    for f in range(first, last + 1):
        t.SetFrame(f, mode)
        mats.append([to_np(node.EvaluateGlobalTransform(t)) for node in nodes])
    return np.array(mats).reshape(-1, len(nodes), 4, 4), fps


class Motion:
    """FBX 1 ファイル分のデータ (FBX SDK のオブジェクトは持たない)"""

    def __init__(self, path):
        manager, scene = load_scene(path)
        bones, mesh_nodes = [], []
        collect(scene.GetRootNode(), bones, mesh_nodes)

        # 評価するノードをまとめ、各メッシュの影響ノードを番号で持つ
        nodes = [node for node, _ in bones]
        self.meshes = []
        for mesh_node in mesh_nodes:
            verts, faces, links, offsets, weights = read_mesh(mesh_node)
            index = list(range(len(nodes), len(nodes) + len(links)))
            nodes += links
            self.meshes.append((verts, faces, index, offsets, weights))
        self.links = [(i, p) for i, (_, p) in enumerate(bones) if p is not None]
        self.n_bones = len(bones)

        self.mats, self.fps = bake_matrices(scene, nodes)
        manager.Destroy()
        self.n_frames = len(self.mats)

    def skin(self, f, mesh):
        verts, _, index, offsets, weights = mesh
        m = np.einsum("vb,bij->vij", weights, self.mats[f, index] @ offsets)
        return np.einsum("vij,vj->vi", m, verts)[:, :3]

    def bone_points(self, f):
        return self.mats[f, :self.n_bones, :3, 3]


def find_fbx(path):
    if path.is_dir():
        return sorted(p for p in path.rglob("*") if p.suffix.lower() == ".fbx")
    return [path] if path.suffix.lower() == ".fbx" else []


def make_grid(size=500, step=50):
    """床 (Z=0) のグリッド線と中心線 (単位 cm)"""
    def lines(values):
        pts = [p for v in values for p in ([v, -size, 0], [v, size, 0], [-size, v, 0], [size, v, 0])]
        cells = np.array([[2, i, i + 1] for i in range(0, len(pts), 2)]).ravel()
        return pv.PolyData(np.array(pts, float), lines=cells)
    return lines([v for v in range(-size, size + 1, step) if v]), lines([0])


def make_ue_axes():
    """UE と同じ左手系の軸表示 (X 前 / Y 右 / Z 上) -> Y 矢印を反転"""
    flip_y = np.diag([1.0, -1.0, 1.0, 1.0])
    axes = pv.AxesActor()
    axes.SetUserMatrix(pv.vtkmatrix_from_array(flip_y))
    return axes


ABOUT_TEXT = """\
<h3>FBX Motion Viewer</h3>
<p>FBX のスケルトン / スキンメッシュのアニメーションを再生するビューア<br>
Copyright (c) 2026 furcraea / MIT License</p>
<p><b>Autodesk FBX SDK</b><br>
This software contains Autodesk® FBX® code developed by Autodesk, Inc. Copyright 2019
Autodesk, Inc. All rights, reserved. Such code is provided "as is" and Autodesk, Inc. disclaims any
and all warranties, whether express or implied, including without limitation the implied warranties
of merchantability, fitness for a particular purpose or non-infringement of third party rights. In no
event shall Autodesk, Inc. be liable for any direct, indirect, incidental, special, exemplary, or
consequential damages (including, but not limited to, procurement of substitute goods or services;
loss of use, data, or profits; or business interruption) however caused and on any theory of
liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising
in any way out of such code.</p>
<p><b>Qt for Python (PySide6)</b><br>
GNU LGPL v3 の下で、改変せずに利用しています。</p>
<p>そのほかのライブラリのライセンスは <a href="{licenses}">THIRD_PARTY_LICENSES.md</a>
と LICENSES フォルダーを参照してください。</p>
"""


class Viewer(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("FBX Motion Viewer")
        self.resize(1280, 800)
        self.setAcceptDrops(True)

        self.motion = None
        self.polys, self.mesh_actors, self.bone_actors, self.skeleton = [], [], [], None
        self.pos, self.playing, self.last = 0.0, True, time.perf_counter()

        # 左: アニメーションリスト / 右: 3D ビュー + タイムバー
        self.list = QtWidgets.QListWidget()
        self.list.currentRowChanged.connect(self.open_motion)
        self.plotter = QtInteractor(self)
        self.plotter.set_background("black")
        self.plotter.add_orientation_widget(make_ue_axes())
        grid, center = make_grid()
        self.plotter.add_mesh(grid, color="#3a3a3a")
        self.plotter.add_mesh(center, color="#707070", line_width=2)
        self.play_button = QtWidgets.QPushButton("Pause")
        self.play_button.clicked.connect(self.toggle_play)
        self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
        self.slider.valueChanged.connect(self.on_slider)
        self.label = QtWidgets.QLabel("FBX / フォルダーをドロップ")
        about_button = QtWidgets.QPushButton("About")
        about_button.clicked.connect(self.show_about)
        for w in (self.play_button, self.slider, about_button):
            w.setFocusPolicy(QtCore.Qt.NoFocus)

        bar = QtWidgets.QHBoxLayout()
        bar.addWidget(self.play_button)
        bar.addWidget(self.slider, 1)
        bar.addWidget(self.label)
        bar.addWidget(about_button)
        right = QtWidgets.QWidget()
        layout = QtWidgets.QVBoxLayout(right)
        layout.addWidget(self.plotter.interactor, 1)
        layout.addLayout(bar)
        splitter = QtWidgets.QSplitter()
        splitter.addWidget(self.list)
        splitter.addWidget(right)
        splitter.setSizes([300, 980])
        self.setCentralWidget(splitter)

        for key, func in (("Space", self.toggle_play), ("Left", lambda: self.step(-1)),
                          ("Right", lambda: self.step(1)), ("M", self.toggle_mesh)):
            QtWidgets.QShortcut(key, self, func)

        self.timer = QtCore.QTimer(self)
        self.timer.timeout.connect(self.tick)
        self.timer.start(16)

    # --- ドラッグ&ドロップ ---
    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()

    def dropEvent(self, event):
        self.set_paths([Path(url.toLocalFile()) for url in event.mimeData().urls()])

    def set_paths(self, paths):
        self.list.clear()
        for path in paths:
            for f in find_fbx(path):
                name = str(f.relative_to(path.parent)) if path.is_dir() else f.name
                item = QtWidgets.QListWidgetItem(name)
                item.setData(QtCore.Qt.UserRole, str(f))
                item.setToolTip(str(f))
                self.list.addItem(item)
        if self.list.count():
            self.list.setCurrentRow(0)
            self.list.setFocus()

    # --- 読み込みと描画 ---
    def open_motion(self, row):
        if row < 0:
            return
        path = Path(self.list.item(row).data(QtCore.Qt.UserRole))
        self.plotter.remove_actor(self.mesh_actors + self.bone_actors)
        self.polys, self.mesh_actors, self.bone_actors, self.skeleton = [], [], [], None
        first = self.motion is None
        QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
        try:
            self.motion = Motion(path)
        except Exception as e:
            self.motion = None
            self.label.setText(f"読み込み失敗: {e}")
            return
        finally:
            QtWidgets.QApplication.restoreOverrideCursor()
        motion = self.motion
        print(f"{path}  bones: {motion.n_bones}  frames: {motion.n_frames}  fps: {motion.fps:.2f}")

        for mesh in motion.meshes:
            poly = pv.PolyData(motion.skin(0, mesh), mesh[1])
            self.polys.append(poly)
            self.mesh_actors.append(self.plotter.add_mesh(poly, color="lightgray"))
        if motion.links:
            lines = np.array([[2, c, p] for c, p in motion.links]).ravel()
            self.skeleton = pv.PolyData(motion.bone_points(0), lines=lines)
            self.bone_actors.append(self.plotter.add_mesh(self.skeleton, color="red", line_width=3))

        self.setWindowTitle(f"FBX Motion Viewer - {path.name}")
        self.slider.setRange(0, motion.n_frames - 1)
        self.pos = 0.0
        self.draw()
        if first:  # カメラは最初の 1 回だけキャラクターの正面 (UE の +Y 側) に合わせる
            pts = np.vstack([p.points for p in self.polys] + [motion.bone_points(0)])
            bounds = np.ravel([pts.min(axis=0), pts.max(axis=0)], order="F")
            self.plotter.view_vector((0.5, -1, 0.3), viewup=(0, 0, 1), bounds=bounds)

    def frame(self):
        return int(self.pos) % self.motion.n_frames

    def draw(self):
        motion, f = self.motion, self.frame()
        for poly, mesh in zip(self.polys, motion.meshes):
            poly.points = motion.skin(f, mesh)
        if self.skeleton is not None:
            self.skeleton.points = motion.bone_points(f)
        self.slider.blockSignals(True)
        self.slider.setValue(f)
        self.slider.blockSignals(False)
        self.label.setText(f"{f + 1} / {motion.n_frames}")
        self.plotter.render()

    # --- 再生操作 ---
    def tick(self):
        now = time.perf_counter()
        dt, self.last = now - self.last, now
        if self.motion and self.playing and not self.slider.isSliderDown():
            self.pos += dt * self.motion.fps
            self.draw()

    def on_slider(self, value):
        if self.motion:
            self.pos = value
            self.draw()

    def step(self, delta):
        if self.motion:
            self.set_playing(False)
            self.pos = self.frame() + delta
            self.draw()

    def set_playing(self, playing):
        self.playing = playing
        self.play_button.setText("Pause" if playing else "Play")

    def toggle_play(self):
        self.set_playing(not self.playing)

    def show_about(self):
        licenses = Path(__file__).with_name("THIRD_PARTY_LICENSES.md")
        text = ABOUT_TEXT.format(licenses=QtCore.QUrl.fromLocalFile(str(licenses)).toString())
        QtWidgets.QMessageBox.about(self, "About FBX Motion Viewer", text)

    def toggle_mesh(self):
        for actor in self.mesh_actors:
            actor.SetVisibility(not actor.GetVisibility())
        self.plotter.render()

    def closeEvent(self, event):
        self.timer.stop()
        self.plotter.close()
        super().closeEvent(event)


def set_dark_theme(app):
    """Qt ウィジェット全体をダークテーマにする"""
    app.setStyle("Fusion")
    palette = QtGui.QPalette()
    for role, color in (("Window", "#2b2b2b"), ("WindowText", "#dddddd"), ("Base", "#1e1e1e"),
                        ("AlternateBase", "#2b2b2b"), ("Text", "#dddddd"), ("Button", "#3a3a3a"),
                        ("ButtonText", "#dddddd"), ("Highlight", "#2f65ca"),
                        ("HighlightedText", "#ffffff"), ("ToolTipBase", "#1e1e1e"),
                        ("ToolTipText", "#dddddd")):
        palette.setColor(getattr(QtGui.QPalette, role), QtGui.QColor(color))
    app.setPalette(palette)


def set_dark_title_bar(widget):
    """Windows 10/11 のタイトルバーを暗くする"""
    if sys.platform == "win32":
        import ctypes
        value = ctypes.c_int(1)  # DWMWA_USE_IMMERSIVE_DARK_MODE = 20
        ctypes.windll.dwmapi.DwmSetWindowAttribute(
            int(widget.winId()), 20, ctypes.byref(value), ctypes.sizeof(value))


def main():
    app = QtWidgets.QApplication(sys.argv)
    set_dark_theme(app)
    viewer = Viewer()
    set_dark_title_bar(viewer)
    viewer.show()
    if len(sys.argv) > 1:
        viewer.set_paths([Path(a) for a in sys.argv[1:]])
    app.exec_()


if __name__ == "__main__":
    main()

FBX SDK を入れたときに実際に置かれた本体は、C:\python3.11.0\Lib\site-packages\ にある fbx.cp311-win_amd64.pyd(約 6 MB)の1ファイルだけです。

方法1:.pyd だけをコピーする
次の条件がそろえば、このファイルをコピーするだけで動きます。

  • 別の PC の Python が 3.11 の 64bit 版であること(ファイル名の cp311-win_amd64 は「この環境専用」という意味です)
  • fbx.cp311-win_amd64.pyd を、その Python の site-packages か、motion_viewer.py と同じフォルダーに置くこと
  • Microsoft Visual C++ ランタイムが入っていること。たいていの Windows PC には入っています。import fbx で DLL のエラーが出たときは「Microsoft Visual C++ 再頒布可能パッケージ」を入れてください。

fbx(FBX SDK の wheel)FBX の読み込み(import fbx)
pyvista3D 表示
pyvistaqtPyVista の 3D 表示を Qt のウィンドウに埋め込む
PySide6ウィンドウ、リスト、スライダーなどの画面部品
matplotlibpyvista が必要としているので、アンインストールはできません。

これで入ります。
python -m pip install fbx-2020.3.11-cp311-none-win_amd64.whl pyvista pyvistaqt PySide6

実行用バッチ
run_viewer.bat

@echo off
setlocal

rem Python for FBX SDK (e.g. C:\Python310\python.exe)
set PYTHON=C:\python3.11.0\python.exe

rem Drop FBX files or folders onto the viewer window
"%PYTHON%" "%~dp0motion_viewer.py" %*
if errorlevel 1 pause

run_viewer.bat の set PYTHON= の行を、別の PC の Python の場所に書き換えてください。

[Unity2022.3.22f1]VRC Fury Toggle To Slider Converter

撮影途中でスライダーで微妙な表情が作りたいとのリクエストから作った。

フェイシャルの登録量がすごいのでToggle から Sliderにコンバートする必要が出た

D:\Github\ENRAI_CLO3D_UnityGit\VRChat_Dev_Prj\Assets\Editor\BlendShapeToggleBuilder\ToggleSliderConverter.cs

using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

// 既に登録済みの VRCFury Toggle (BlendShape 切り替え) を、スライダー (Use a Slider (Radial)) に一括変換する。
// Toggle の型は internal なので SerializedObject で content.slider / defaultSliderValue を書き換える。
public class ToggleSliderConverter : EditorWindow
{
    class Row
    {
        public Component component;
        public string path;
        public bool slider;
        public bool hasBlendShape;
        public bool selected = true;
    }

    [SerializeField] GameObject _target;
    [SerializeField] string _pathFilter = "Face/";
    [SerializeField] bool _blendShapeOnly = true;
    [SerializeField] float _defaultPercent;
    [SerializeField] bool _inactiveAtZero;
    List<Row> _rows = new List<Row>();
    Vector2 _scroll;

    [MenuItem("Tools/VRCFury/Toggle Slider Converter")]
    static void Open()
    {
        GetWindow<ToggleSliderConverter>("Toggle → Slider");
    }

    void OnEnable()
    {
        OnSelectionChange();
        Refresh();
    }

    void OnFocus()
    {
        Refresh();
    }

    // シーン上のオブジェクトを選ぶと、そのアバター最上位 (root) が Target になる
    void OnSelectionChange()
    {
        var go = Selection.activeGameObject;
        if (go != null && !EditorUtility.IsPersistent(go) && go.transform.root.gameObject != _target)
        {
            _target = go.transform.root.gameObject;
            Refresh();
        }
        Repaint();
    }

    void Refresh()
    {
        var old = _rows.ToDictionary(r => r.component, r => r.selected);
        _rows = new List<Row>();
        if (_target == null) return;
        foreach (var c in _target.GetComponentsInChildren<Component>(true))
        {
            if (!BlendShapeToggleBuilder.TryGetToggleMenuPath(c, out var path)) continue;
            var content = new SerializedObject(c).FindProperty("content");
            var row = new Row
            {
                component = c,
                path = path,
                slider = content.FindPropertyRelative("slider").boolValue,
                hasBlendShape = HasBlendShapeAction(content),
            };
            if (old.TryGetValue(c, out var sel)) row.selected = sel;
            _rows.Add(row);
        }
    }

    static bool HasBlendShapeAction(SerializedProperty content)
    {
        var actions = content.FindPropertyRelative("state")?.FindPropertyRelative("actions");
        if (actions == null) return false;
        for (var i = 0; i < actions.arraySize; i++)
            if (actions.GetArrayElementAtIndex(i).managedReferenceFullTypename.EndsWith("BlendShapeAction")) return true;
        return false;
    }

    IEnumerable<Row> Visible()
    {
        return _rows.Where(r => r.component != null
                                && (!_blendShapeOnly || r.hasBlendShape)
                                && (string.IsNullOrEmpty(_pathFilter) || r.path.StartsWith(_pathFilter)));
    }

    void OnGUI()
    {
        EditorGUI.BeginChangeCheck();
        _target = (GameObject)EditorGUILayout.ObjectField(
            new GUIContent("Target Object", "この GameObject 以下の VRCFury Toggle を対象にする。シーン上のオブジェクトを選ぶと自動で root が入る"),
            _target, typeof(GameObject), true);
        if (EditorGUI.EndChangeCheck()) Refresh();

        if (_target == null || EditorUtility.IsPersistent(_target))
        {
            EditorGUILayout.HelpBox("Target Object にシーン上のアバターを指定してください。", MessageType.Info);
            return;
        }

        _pathFilter = EditorGUILayout.TextField(new GUIContent("Menu Path Filter", "この文字列で始まる Menu Path だけ表示 (空なら全部)"), _pathFilter);
        _blendShapeOnly = EditorGUILayout.Toggle("BlendShape を含む Toggle のみ", _blendShapeOnly);
        _defaultPercent = EditorGUILayout.Slider(
            new GUIContent("Default %", "スライダーの初期位置。VRChat で起動した直後のこの値になる"), _defaultPercent, 0f, 100f);
        _inactiveAtZero = EditorGUILayout.Toggle(
            new GUIContent("Inactive at 0%", "VRCFury の「Passive when 0」相当。0% のときは Toggle 自体を無効扱いにする"), _inactiveAtZero);

        var visible = Visible().ToList();
        using (new EditorGUILayout.HorizontalScope())
        {
            EditorGUILayout.LabelField($"Toggle {visible.Count} 個 (スライダー {visible.Count(r => r.slider)} 個)", EditorStyles.boldLabel);
            if (GUILayout.Button("全選択", GUILayout.Width(60))) visible.ForEach(r => r.selected = true);
            if (GUILayout.Button("全解除", GUILayout.Width(60))) visible.ForEach(r => r.selected = false);
            if (GUILayout.Button("Refresh", GUILayout.Width(70))) Refresh();
        }

        using (var scroll = new EditorGUILayout.ScrollViewScope(_scroll))
        {
            _scroll = scroll.scrollPosition;
            foreach (var r in visible)
            {
                using (new EditorGUILayout.HorizontalScope())
                {
                    r.selected = EditorGUILayout.ToggleLeft(r.path, r.selected);
                    GUILayout.Label(r.slider ? "Slider" : "Toggle", EditorStyles.miniLabel, GUILayout.Width(45));
                    if (GUILayout.Button("→", GUILayout.Width(22))) EditorGUIUtility.PingObject(r.component.gameObject);
                }
            }
        }

        var count = visible.Count(r => r.selected);
        using (new EditorGUI.DisabledScope(count == 0))
        using (new EditorGUILayout.HorizontalScope())
        {
            if (GUILayout.Button($"スライダーにする ({count})", GUILayout.Height(28))) Apply(visible.Where(r => r.selected), true);
            if (GUILayout.Button($"トグルに戻す ({count})", GUILayout.Height(28))) Apply(visible.Where(r => r.selected), false);
        }
    }

    void Apply(IEnumerable<Row> rows, bool toSlider)
    {
        Undo.IncrementCurrentGroup();
        Undo.SetCurrentGroupName(toSlider ? "Toggles to Sliders" : "Sliders to Toggles");
        var changed = 0;
        foreach (var r in rows.ToList())
        {
            var so = new SerializedObject(r.component);
            var content = so.FindProperty("content");
            content.FindPropertyRelative("slider").boolValue = toSlider;
            if (toSlider)
            {
                content.FindPropertyRelative("defaultSliderValue").floatValue = _defaultPercent / 100f;
                content.FindPropertyRelative("sliderInactiveAtZero").boolValue = _inactiveAtZero;
            }
            // SerializedObject 経由なので Undo・プレハブのオーバーライドも Unity が記録する
            if (so.ApplyModifiedProperties()) changed++;
            r.slider = toSlider;
        }

        EditorSceneManager.MarkSceneDirty(_target.scene);
        Debug.Log($"[ToggleSliderConverter] '{_target.name}' の Toggle を {changed} 個 {(toSlider ? "スライダー" : "トグル")} にしました", _target);
    }
}

[Unity2022.3.22f1]UnlitAlbedShader for Toon Charactor



Unityで黒にしてるのは
最低値を定義/設定するためです。

D:\Github\ENRAI_CLO3D_UnityGit\VRChat_Dev_Prj\Assets\Shaders\UnlitAlbedo.shader

// ライティング・影・トーンマップ等の影響を受けず、テクスチャの色をそのまま出力する Unlit シェーダー。
// Built-in RP / VRChat (Single Pass Instanced 対応)。
Shader "Custom/UnlitAlbedo"
{
    Properties
    {
        _MainTex ("Albedo (RGBA)", 2D) = "white" {}
        _Color ("Color (multiply)", Color) = (1, 1, 1, 1)
        _Brightness ("Brightness (multiply)", Float) = 1
        _AmbientInfluence ("Ambient Color Influence (0 = texture as is, 1 = x ambient light color)", Float) = 0
        _AmbientCustom ("Ambient Custom (0 = world ambient color, 1 = RGB below)", Float) = 0
        _AmbientR ("Ambient Custom R", Float) = 0
        _AmbientG ("Ambient Custom G", Float) = 0
        _AmbientB ("Ambient Custom B", Float) = 0
        [Enum(UnityEngine.Rendering.CullMode)] _Cull ("Cull", Float) = 2
        _Cutoff ("Alpha Cutoff (0 = off)", Range(0, 1)) = 0
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "Queue"="Geometry" "IgnoreProjector"="True" }

        Pass
        {
            // ShadeSH9 (環境光/ライトプローブ) の値は ForwardBase パスでのみ確実に渡される
            Tags { "LightMode"="ForwardBase" }
            Cull [_Cull]
            ZWrite On

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_instancing
            #include "UnityCG.cginc"

            sampler2D _MainTex;
            float4 _MainTex_ST;
            fixed4 _Color;
            float _Brightness;
            float _AmbientInfluence;
            float _AmbientCustom;
            float _AmbientR;
            float _AmbientG;
            float _AmbientB;
            half _Cutoff;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float2 uv : TEXCOORD0;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            v2f vert(appdata v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_OUTPUT(v2f, o);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv) * _Color;
                if (_Cutoff > 0) clip(col.a - _Cutoff);
                // 環境光の色 (ワールドのライティング設定 / ライトプローブ) を、_AmbientInfluence の割合だけ乗算で混ぜる
                // 法線を使わず (0,0,0) で評価すると SH の定数項だけ = 向きに依存しない環境光の平均色 1 色になる
                float3 worldAmbient = ShadeSH9(float4(0, 0, 0, 1));
                // _AmbientCustom > 0 でワールドの環境光の代わりに、自分で選んだ RGB を使う (VRChat のスライダーで 3 値を動かす)
                float3 ambient = lerp(worldAmbient, float3(_AmbientR, _AmbientG, _AmbientB), saturate(_AmbientCustom));
                float3 rgb = col.rgb * lerp(float3(1, 1, 1), ambient, saturate(_AmbientInfluence));
                return fixed4(rgb * _Brightness, 1);
            }
            ENDCG
        }
    }

    Fallback Off
}

VRC Furyでの設定例

albedShaderBrightness

albedShaderAmbient

albedShaderAmbientCustom

albedShaderAmbientR

albedShaderAmbientG

albedShaderAmbientB

っていうのが面倒くさいのでツールにした

D:\Github\ENRAI_CLO3D_UnityGit\VRChat_Dev_Prj\Assets\Editor\BlendShapeToggleBuilder\AlbedBrightnessSliderBuilder.cs

using System;
using System.Collections.Generic;
using System.Linq;
using com.vrcfury.api;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

// 指定シェーダー (既定 Custom/UnlitAlbedo) を使うレンダラーを集め、そのシェーダーの Float プロパティ
// (_Brightness / _AmbientInfluence など) を 0%→100% で動かす VRCFury スライダー Toggle
// (Use a Slider (Radial)) を、一覧のチェックした行ぶんまとめて生成する。
// PublicApi に Material Property アクションが無いので、そこだけリフレクションで追加する。
public class AlbedBrightnessSliderBuilder : EditorWindow
{
    const string ActionTypeName = "VF.Model.StateAction.MaterialPropertyAction";

    [Serializable]
    class Entry
    {
        public bool enabled = true;
        public string property;
        public string menuPath;
        public float valueAt100 = 1f;
        public float defaultPercent; // スライダーの初期位置 (0〜100%)。VRChat での起動時の値になる
    }

    // 一覧の初期内容を変えたら上げる。保存済みウィンドウの古い一覧を作り直すため
    const int EntriesVersion = 2;

    [SerializeField] GameObject _target;
    [SerializeField] string _shaderName = "Custom/UnlitAlbedo";
    [SerializeField] int _entriesVersion;
    [SerializeField] List<Entry> _entries = new List<Entry>();
    List<Renderer> _renderers = new List<Renderer>();
    Vector2 _scroll;

    [MenuItem("Tools/VRCFury/Albed Brightness Slider Builder")]
    static void Open()
    {
        GetWindow<AlbedBrightnessSliderBuilder>("Albed Sliders");
    }

    void OnEnable()
    {
        if (_entries == null || _entries.Count == 0 || _entriesVersion != EntriesVersion) ResetEntries();
        OnSelectionChange();
    }

    void ResetEntries()
    {
        _entries = DefaultEntries();
        _entriesVersion = EntriesVersion;
    }

    static List<Entry> DefaultEntries()
    {
        return new List<Entry>
        {
            new Entry { property = "_Brightness", menuPath = "albedShaderBrightness", defaultPercent = 100 },
            new Entry { property = "_AmbientInfluence", menuPath = "albedShaderAmbient" },
            new Entry { property = "_AmbientCustom", menuPath = "albedShaderAmbientCustom" },
            new Entry { property = "_AmbientR", menuPath = "albedShaderAmbientR", defaultPercent = 100 },
            new Entry { property = "_AmbientG", menuPath = "albedShaderAmbientG", defaultPercent = 100 },
            new Entry { property = "_AmbientB", menuPath = "albedShaderAmbientB", defaultPercent = 100 },
        };
    }

    // シーン上のオブジェクトを選ぶと、そのアバター最上位 (root) が Target になる
    void OnSelectionChange()
    {
        var go = Selection.activeGameObject;
        if (go != null && !EditorUtility.IsPersistent(go) && go.transform.root.gameObject != _target)
        {
            _target = go.transform.root.gameObject;
            Refresh();
        }
        Repaint();
    }

    void Refresh()
    {
        _renderers = _target == null
            ? new List<Renderer>()
            : _target.GetComponentsInChildren<Renderer>(true)
                .Where(r => r.sharedMaterials.Any(UsesShader))
                .ToList();
    }

    bool UsesShader(Material m)
    {
        return m != null && m.shader != null && m.shader.name == _shaderName;
    }

    static Type FindActionType()
    {
        return AppDomain.CurrentDomain.GetAssemblies()
            .Select(a => a.GetType(ActionTypeName, false))
            .FirstOrDefault(t => t != null);
    }

    void OnGUI()
    {
        EditorGUI.BeginChangeCheck();
        _target = (GameObject)EditorGUILayout.ObjectField(
            new GUIContent("Target Object", "Toggle を追加する GameObject (アバター最上位)。シーン上のオブジェクトを選ぶと自動で入る"),
            _target, typeof(GameObject), true);
        _shaderName = EditorGUILayout.TextField("Shader Name", _shaderName);
        if (EditorGUI.EndChangeCheck()) Refresh();

        var targetOk = _target != null && !EditorUtility.IsPersistent(_target);
        if (!targetOk)
        {
            EditorGUILayout.HelpBox("Target Object にシーン上の GameObject を指定してください。", MessageType.Info);
            return;
        }

        if (GUILayout.Button("Refresh")) Refresh();
        EditorGUILayout.LabelField($"'{_shaderName}' を使うレンダラー {_renderers.Count} 個", EditorStyles.boldLabel);
        foreach (var r in _renderers) EditorGUILayout.LabelField("  " + r.name);

        EditorGUILayout.Space();
        EditorGUILayout.LabelField("スライダー (チェックした行を追加)", EditorStyles.boldLabel);
        using (var scroll = new EditorGUILayout.ScrollViewScope(_scroll))
        {
            _scroll = scroll.scrollPosition;
            Entry remove = null;
            foreach (var e in _entries)
            {
                using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
                {
                    using (new EditorGUILayout.HorizontalScope())
                    {
                        e.enabled = EditorGUILayout.ToggleLeft(e.property, e.enabled, EditorStyles.boldLabel);
                        if (GUILayout.Button("×", GUILayout.Width(22))) remove = e;
                    }
                    e.property = EditorGUILayout.TextField("Property", e.property);
                    e.menuPath = EditorGUILayout.TextField("Menu Path", e.menuPath);
                    e.valueAt100 = EditorGUILayout.FloatField(
                        new GUIContent("Value at 100%", "スライダー 100% のときのプロパティ値 (0% はマテリアルの現在値)"), e.valueAt100);
                    e.defaultPercent = EditorGUILayout.Slider(
                        new GUIContent("Default %", "スライダーの初期位置。VRChat で起動した直後のこの値になる"), e.defaultPercent, 0f, 100f);
                    DrawWarnings(e);
                }
            }
            if (remove != null) _entries.Remove(remove);
        }

        using (new EditorGUILayout.HorizontalScope())
        {
            if (GUILayout.Button("+ 行を追加")) _entries.Add(new Entry { property = "_Property", menuPath = "albedShader" });
            if (GUILayout.Button("初期値に戻す")) ResetEntries();
        }

        var count = _entries.Count(e => e.enabled);
        using (new EditorGUI.DisabledScope(_renderers.Count == 0 || count == 0))
        {
            if (GUILayout.Button($"Add Slider Toggles ({count})", GUILayout.Height(28))) Build();
        }
    }

    void DrawWarnings(Entry e)
    {
        if (string.IsNullOrEmpty(e.property)) return;
        var mats = _renderers.SelectMany(r => r.sharedMaterials).Where(UsesShader).Distinct().ToList();

        var missing = mats.Where(m => !m.HasProperty(e.property)).Select(m => m.name).ToList();
        if (missing.Count > 0)
            EditorGUILayout.HelpBox(
                $"次のマテリアルには {e.property} がありません (シェーダーの更新待ちか名前違い):\n" + string.Join(", ", missing),
                MessageType.Error);

        // 0% はマテリアルの現在値になるので、0 でないと「スライダー 0% = 0.0」にならない
        var nonZero = mats.Where(m => m.HasProperty(e.property) && m.GetFloat(e.property) != 0f).Select(m => m.name).ToList();
        if (nonZero.Count > 0)
            EditorGUILayout.HelpBox(
                $"次のマテリアルの {e.property} が 0 ではありません。スライダー 0% はこの値になります:\n" + string.Join(", ", nonZero),
                MessageType.Warning);
    }

    static void SetField(object obj, string name, object value)
    {
        obj.GetType().GetField(name).SetValue(obj, value);
    }

    void Build()
    {
        var actionType = FindActionType();
        if (actionType == null)
        {
            EditorUtility.DisplayDialog("Albed Sliders", $"VRCFury の {ActionTypeName} が見つかりません。VRCFury のバージョンが違う可能性があります。", "OK");
            return;
        }

        var target = _target;
        var entries = _entries.Where(e => e.enabled && !string.IsNullOrEmpty(e.property) && !string.IsNullOrEmpty(e.menuPath)).ToList();
        if (entries.Count == 0) return;

        // 同じ Menu Path の Toggle が既にあれば、置き換えるかスキップするか選ぶ
        var paths = new HashSet<string>(entries.Select(e => e.menuPath.Trim('/', ' ')));
        var existing = target.GetComponents<Component>()
            .Where(c => BlendShapeToggleBuilder.TryGetToggleMenuPath(c, out var p) && paths.Contains(p)).ToList();

        Undo.IncrementCurrentGroup();
        Undo.SetCurrentGroupName("Add Albed Sliders");

        var skip = new HashSet<string>();
        if (existing.Count > 0)
        {
            var choice = EditorUtility.DisplayDialogComplex("Albed Sliders",
                $"'{target.name}' に同じ Menu Path の Toggle が既に {existing.Count} 個あります。", "置き換える", "キャンセル", "既存はそのまま(重複はスキップ)");
            if (choice == 1) return;
            foreach (var c in existing)
            {
                BlendShapeToggleBuilder.TryGetToggleMenuPath(c, out var p);
                // プレハブ本体側にあるコンポーネントはインスタンスから削除できないのでスキップ扱い
                if (choice == 2 || PrefabUtility.GetCorrespondingObjectFromSource(c) != null)
                    skip.Add(p);
                else
                    Undo.DestroyObjectImmediate(c);
            }
        }

        var latestVersion = actionType.GetMethod("GetLatestVersion").Invoke(Activator.CreateInstance(actionType), null);
        var added = new List<string>();
        foreach (var e in entries)
        {
            var path = e.menuPath.Trim('/', ' ');
            if (skip.Contains(path)) continue;

            var before = new HashSet<Component>(target.GetComponents<Component>());
            var toggle = FuryComponents.CreateToggle(target);
            toggle.SetMenuPath(path);
            toggle.SetSlider();
            var vf = target.GetComponents<Component>().First(c => c != null && !before.Contains(c));
            Undo.RegisterCreatedObjectUndo(vf, "Add Albed Sliders");

            var so = new SerializedObject(vf);
            var actions = so.FindProperty("content").FindPropertyRelative("state").FindPropertyRelative("actions");
            foreach (var r in _renderers)
            {
                var action = Activator.CreateInstance(actionType);
                actionType.GetProperty("Version").SetValue(action, latestVersion);
                SetField(action, "renderer2", r.gameObject);
                SetField(action, "affectAllMeshes", false);
                SetField(action, "propertyName", e.property);
                SetField(action, "value", e.valueAt100); // propertyType は既定の Float
                actions.arraySize++;
                actions.GetArrayElementAtIndex(actions.arraySize - 1).managedReferenceValue = action;
            }
            so.FindProperty("content").FindPropertyRelative("defaultSliderValue").floatValue = e.defaultPercent / 100f;
            so.ApplyModifiedProperties();
            added.Add($"{path} ({e.property})");
        }

        EditorUtility.SetDirty(target);
        EditorSceneManager.MarkSceneDirty(target.scene);
        Debug.Log($"[AlbedBrightnessSliderBuilder] '{target.name}' にスライダー Toggle を {added.Count} 個追加しました " +
                  $"(スキップ {skip.Count} 個 / {_renderers.Count} レンダラー): {string.Join(", ", added)}", target);
    }
}

[Unity2022.3.22f1]VRC Fury ToggleコンポーネントのBlendShapeのトグルボタンをスライダーへ変換する C# Editor拡張

[Unity2022.3.22f1] C# Editor Extension to Convert VRC Fury Toggle Component BlendShape Toggles into Sliders

using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

// 既に登録済みの VRCFury Toggle (BlendShape 切り替え) を、スライダー (Use a Slider (Radial)) に一括変換する。
// Toggle の型は internal なので SerializedObject で content.slider / defaultSliderValue を書き換える。
public class ToggleSliderConverter : EditorWindow
{
    class Row
    {
        public Component component;
        public string path;
        public bool slider;
        public bool hasBlendShape;
        public bool selected = true;
    }

    [SerializeField] GameObject _target;
    [SerializeField] string _pathFilter = "Face/";
    [SerializeField] bool _blendShapeOnly = true;
    [SerializeField] float _defaultPercent;
    [SerializeField] bool _inactiveAtZero;
    List<Row> _rows = new List<Row>();
    Vector2 _scroll;

    [MenuItem("Tools/VRCFury/Toggle Slider Converter")]
    static void Open()
    {
        GetWindow<ToggleSliderConverter>("Toggle → Slider");
    }

    void OnEnable()
    {
        OnSelectionChange();
        Refresh();
    }

    void OnFocus()
    {
        Refresh();
    }

    // シーン上のオブジェクトを選ぶと、そのアバター最上位 (root) が Target になる
    void OnSelectionChange()
    {
        var go = Selection.activeGameObject;
        if (go != null && !EditorUtility.IsPersistent(go) && go.transform.root.gameObject != _target)
        {
            _target = go.transform.root.gameObject;
            Refresh();
        }
        Repaint();
    }

    void Refresh()
    {
        var old = _rows.ToDictionary(r => r.component, r => r.selected);
        _rows = new List<Row>();
        if (_target == null) return;
        foreach (var c in _target.GetComponentsInChildren<Component>(true))
        {
            if (!BlendShapeToggleBuilder.TryGetToggleMenuPath(c, out var path)) continue;
            var content = new SerializedObject(c).FindProperty("content");
            var row = new Row
            {
                component = c,
                path = path,
                slider = content.FindPropertyRelative("slider").boolValue,
                hasBlendShape = HasBlendShapeAction(content),
            };
            if (old.TryGetValue(c, out var sel)) row.selected = sel;
            _rows.Add(row);
        }
    }

    static bool HasBlendShapeAction(SerializedProperty content)
    {
        var actions = content.FindPropertyRelative("state")?.FindPropertyRelative("actions");
        if (actions == null) return false;
        for (var i = 0; i < actions.arraySize; i++)
            if (actions.GetArrayElementAtIndex(i).managedReferenceFullTypename.EndsWith("BlendShapeAction")) return true;
        return false;
    }

    IEnumerable<Row> Visible()
    {
        return _rows.Where(r => r.component != null
                                && (!_blendShapeOnly || r.hasBlendShape)
                                && (string.IsNullOrEmpty(_pathFilter) || r.path.StartsWith(_pathFilter)));
    }

    void OnGUI()
    {
        EditorGUI.BeginChangeCheck();
        _target = (GameObject)EditorGUILayout.ObjectField(
            new GUIContent("Target Object", "この GameObject 以下の VRCFury Toggle を対象にする。シーン上のオブジェクトを選ぶと自動で root が入る"),
            _target, typeof(GameObject), true);
        if (EditorGUI.EndChangeCheck()) Refresh();

        if (_target == null || EditorUtility.IsPersistent(_target))
        {
            EditorGUILayout.HelpBox("Target Object にシーン上のアバターを指定してください。", MessageType.Info);
            return;
        }

        _pathFilter = EditorGUILayout.TextField(new GUIContent("Menu Path Filter", "この文字列で始まる Menu Path だけ表示 (空なら全部)"), _pathFilter);
        _blendShapeOnly = EditorGUILayout.Toggle("BlendShape を含む Toggle のみ", _blendShapeOnly);
        _defaultPercent = EditorGUILayout.Slider(
            new GUIContent("Default %", "スライダーの初期位置。VRChat で起動した直後のこの値になる"), _defaultPercent, 0f, 100f);
        _inactiveAtZero = EditorGUILayout.Toggle(
            new GUIContent("Inactive at 0%", "VRCFury の「Passive when 0」相当。0% のときは Toggle 自体を無効扱いにする"), _inactiveAtZero);

        var visible = Visible().ToList();
        using (new EditorGUILayout.HorizontalScope())
        {
            EditorGUILayout.LabelField($"Toggle {visible.Count} 個 (スライダー {visible.Count(r => r.slider)} 個)", EditorStyles.boldLabel);
            if (GUILayout.Button("全選択", GUILayout.Width(60))) visible.ForEach(r => r.selected = true);
            if (GUILayout.Button("全解除", GUILayout.Width(60))) visible.ForEach(r => r.selected = false);
            if (GUILayout.Button("Refresh", GUILayout.Width(70))) Refresh();
        }

        using (var scroll = new EditorGUILayout.ScrollViewScope(_scroll))
        {
            _scroll = scroll.scrollPosition;
            foreach (var r in visible)
            {
                using (new EditorGUILayout.HorizontalScope())
                {
                    r.selected = EditorGUILayout.ToggleLeft(r.path, r.selected);
                    GUILayout.Label(r.slider ? "Slider" : "Toggle", EditorStyles.miniLabel, GUILayout.Width(45));
                    if (GUILayout.Button("→", GUILayout.Width(22))) EditorGUIUtility.PingObject(r.component.gameObject);
                }
            }
        }

        var count = visible.Count(r => r.selected);
        using (new EditorGUI.DisabledScope(count == 0))
        using (new EditorGUILayout.HorizontalScope())
        {
            if (GUILayout.Button($"スライダーにする ({count})", GUILayout.Height(28))) Apply(visible.Where(r => r.selected), true);
            if (GUILayout.Button($"トグルに戻す ({count})", GUILayout.Height(28))) Apply(visible.Where(r => r.selected), false);
        }
    }

    void Apply(IEnumerable<Row> rows, bool toSlider)
    {
        Undo.IncrementCurrentGroup();
        Undo.SetCurrentGroupName(toSlider ? "Toggles to Sliders" : "Sliders to Toggles");
        var changed = 0;
        foreach (var r in rows.ToList())
        {
            var so = new SerializedObject(r.component);
            var content = so.FindProperty("content");
            content.FindPropertyRelative("slider").boolValue = toSlider;
            if (toSlider)
            {
                content.FindPropertyRelative("defaultSliderValue").floatValue = _defaultPercent / 100f;
                content.FindPropertyRelative("sliderInactiveAtZero").boolValue = _inactiveAtZero;
            }
            // SerializedObject 経由なので Undo・プレハブのオーバーライドも Unity が記録する
            if (so.ApplyModifiedProperties()) changed++;
            r.slider = toSlider;
        }

        EditorSceneManager.MarkSceneDirty(_target.scene);
        Debug.Log($"[ToggleSliderConverter] '{_target.name}' の Toggle を {changed} 個 {(toSlider ? "スライダー" : "トグル")} にしました", _target);
    }
}

[Unity2022.3.22f1]FbxのBlendShapeをVRC Fury Toggleへ変換

[Unity 2022.3.22f1] Converting FBX BlendShapes to VRC Fury

D:\Github\ENRAI_CLO3D_UnityGit\VRChat_Dev_Prj\Assets\Editor\BlendShapeToggleBuilder\BlendShapeToggleBuilder.asmdef

{
  "name": "BlendShapeToggleBuilder",
  "rootNamespace": "",
  "references": [
    "com.vrcfury.api"
  ],
  "includePlatforms": [
    "Editor"
  ],
  "excludePlatforms": [],
  "allowUnsafeCode": false,
  "overrideReferences": false,
  "precompiledReferences": [],
  "autoReferenced": false,
  "defineConstraints": [
    "VRCF_AVATARS"
  ],
  "versionDefines": [
    {
      "name": "com.vrchat.avatars",
      "expression": "",
      "define": "VRCF_AVATARS"
    }
  ],
  "noEngineReferences": false
}

D:\Github\ENRAI_CLO3D_UnityGit\VRChat_Dev_Prj\Assets\Editor\BlendShapeToggleBuilder\BlendShapeToggleBuilder.cs

using System.Collections.Generic;
using com.vrcfury.api;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

// SkinnedMeshRenderer の BlendShape 数だけ VRCFury Toggle を生成し、
// 指定 GameObject の VRCFury コンポーネント一覧へ追加する。
// Menu Path は "<MenuRoot>/<BlendShape名>"(例: Face/eyeSmile)。
public class BlendShapeToggleBuilder : EditorWindow
{
    [SerializeField] SkinnedMeshRenderer _smr;
    [SerializeField] GameObject _target;
    [SerializeField] string _menuRoot = "Face";
    [SerializeField] string _stripPrefix = "";
    [SerializeField] float _value = 100f;

    [MenuItem("Tools/VRCFury/BlendShape Toggle Builder")]
    static void Open()
    {
        GetWindow<BlendShapeToggleBuilder>("BlendShape Toggles");
    }

    void OnEnable()
    {
        OnSelectionChange();
    }

    void OnSelectionChange()
    {
        if (Selection.activeGameObject == null) return;
        var smr = Selection.activeGameObject.GetComponent<SkinnedMeshRenderer>();
        if (smr == null || smr.sharedMesh == null || smr.sharedMesh.blendShapeCount == 0) return;
        if (smr != _smr) SetRenderer(smr);
        Repaint();
    }

    void SetRenderer(SkinnedMeshRenderer smr)
    {
        _smr = smr;
        _target = null;
        _stripPrefix = DetectPrefix(smr != null ? smr.sharedMesh : null);
    }

    // Maya の blendShape ノード名 ("CLO_Face_Mesh_bs_miya75." など) が全 BlendShape に共通なら、それを返す
    static string DetectPrefix(Mesh mesh)
    {
        if (mesh == null || mesh.blendShapeCount == 0) return "";
        var first = mesh.GetBlendShapeName(0);
        var dot = first.IndexOf('.');
        if (dot < 0) return "";
        var prefix = first.Substring(0, dot + 1);
        for (var i = 1; i < mesh.blendShapeCount; i++)
            if (!mesh.GetBlendShapeName(i).StartsWith(prefix)) return "";
        return prefix;
    }

    void OnGUI()
    {
        EditorGUI.BeginChangeCheck();
        var smr = (SkinnedMeshRenderer)EditorGUILayout.ObjectField("Face Renderer", _smr, typeof(SkinnedMeshRenderer), true);
        if (EditorGUI.EndChangeCheck()) SetRenderer(smr);
        _target = (GameObject)EditorGUILayout.ObjectField(
            new GUIContent("Target Object", "Toggle を追加する GameObject。空ならアバター最上位(root)"), _target, typeof(GameObject), true);
        _menuRoot = EditorGUILayout.TextField("Menu Root", _menuRoot);
        _stripPrefix = EditorGUILayout.TextField(
            new GUIContent("Strip Prefix", "メニュー名から取り除く先頭文字列(Face Renderer を選ぶと自動検出)。空ならBlendShape名そのまま"), _stripPrefix);
        _value = EditorGUILayout.Slider("BlendShape Value", _value, 0f, 100f);

        var mesh = _smr != null ? _smr.sharedMesh : null;
        if (mesh == null)
        {
            EditorGUILayout.HelpBox("BlendShape を持つ SkinnedMeshRenderer を選択してください。", MessageType.Info);
            return;
        }

        EditorGUILayout.HelpBox(
            $"BlendShape {mesh.blendShapeCount} 個 → '{TargetObject().name}' に Toggle を {mesh.blendShapeCount} 個追加\n例: {MenuPath(mesh.GetBlendShapeName(0))}",
            MessageType.None);

        if (GUILayout.Button("Add Toggles", GUILayout.Height(28))) Build(mesh);
    }

    GameObject TargetObject() => _target != null ? _target : _smr.transform.root.gameObject;

    string Label(string shape)
    {
        if (!string.IsNullOrEmpty(_stripPrefix) && shape.StartsWith(_stripPrefix) && shape.Length > _stripPrefix.Length)
            return shape.Substring(_stripPrefix.Length);
        return shape;
    }

    string MenuPath(string shape)
    {
        var root = _menuRoot.Trim('/', ' ');
        return root.Length == 0 ? Label(shape) : root + "/" + Label(shape);
    }

    // VRCFury の型は internal なので、SerializedObject 経由で Toggle の Menu Path を読む
    internal static bool TryGetToggleMenuPath(Component c, out string path)
    {
        path = null;
        if (c == null || c.GetType().Name != "VRCFury") return false;
        var content = new SerializedObject(c).FindProperty("content");
        if (content == null || content.propertyType != SerializedPropertyType.ManagedReference) return false;
        if (!content.managedReferenceFullTypename.EndsWith("VF.Model.Feature.Toggle")) return false;
        path = content.FindPropertyRelative("name")?.stringValue;
        return path != null;
    }

    void Build(Mesh mesh)
    {
        var target = TargetObject();
        var count = mesh.blendShapeCount;

        var wanted = new HashSet<string>();
        for (var i = 0; i < count; i++) wanted.Add(MenuPath(mesh.GetBlendShapeName(i)));

        // 同じ Menu Path の Toggle が既にあれば、置き換えるかスキップするか選ぶ
        var existing = new Dictionary<Component, string>();
        foreach (var c in target.GetComponents<Component>())
            if (TryGetToggleMenuPath(c, out var p) && wanted.Contains(p)) existing[c] = p;

        Undo.IncrementCurrentGroup();
        Undo.SetCurrentGroupName("Add BlendShape Toggles");

        var skip = new HashSet<string>();
        if (existing.Count > 0)
        {
            var choice = EditorUtility.DisplayDialogComplex("BlendShape Toggles",
                $"'{target.name}' に同じ Menu Path の Toggle が既に {existing.Count} 個あります。", "置き換える", "キャンセル", "重複はスキップ");
            if (choice == 1) return;
            foreach (var kv in existing)
            {
                // プレハブ本体側にあるコンポーネントはインスタンスから削除できないのでスキップ扱い
                if (choice == 2 || PrefabUtility.GetCorrespondingObjectFromSource(kv.Key) != null)
                    skip.Add(kv.Value);
                else
                    Undo.DestroyObjectImmediate(kv.Key);
            }
        }

        var before = new HashSet<Component>(target.GetComponents<Component>());
        var added = 0;
        try
        {
            for (var i = 0; i < count; i++)
            {
                var shape = mesh.GetBlendShapeName(i);
                var path = MenuPath(shape);
                EditorUtility.DisplayProgressBar("BlendShape Toggles", path, (float)i / count);
                if (skip.Contains(path)) continue;

                var toggle = FuryComponents.CreateToggle(target);
                toggle.SetMenuPath(path);
                toggle.GetActions().AddBlendshape(shape, _value, _smr);
                added++;
            }
        }
        finally
        {
            EditorUtility.ClearProgressBar();
        }

        // API は AddComponent するだけなので、Undo で戻せるよう追加分を登録する
        foreach (var c in target.GetComponents<Component>())
            if (c != null && !before.Contains(c)) Undo.RegisterCreatedObjectUndo(c, "Add BlendShape Toggles");

        EditorUtility.SetDirty(target);
        EditorSceneManager.MarkSceneDirty(target.scene);
        Debug.Log($"[BlendShapeToggleBuilder] '{target.name}' に VRCFury Toggle を {added} 個追加しました (スキップ {skip.Count} 個 / 例: {MenuPath(mesh.GetBlendShapeName(0))})", target);
    }
}