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) |
| pyvista | 3D 表示 |
| pyvistaqt | PyVista の 3D 表示を Qt のウィンドウに埋め込む |
| PySide6 | ウィンドウ、リスト、スライダーなどの画面部品 |
| matplotlib | pyvista が必要としているので、アンインストールはできません。 |
これで入ります。
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 の場所に書き換えてください。



