[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 の場所に書き換えてください。

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です