この記事でやること
Blender 5.x で、以前のスクリプトにある action.fcurves を実行すると次のエラーで止まります。
AttributeError: 'Action' object has no attribute 'fcurves'
Blender 5.0 で旧Action API(action.fcurves / action.groups / action.id_root)が削除されたためです(Blender 5.0 Python API リリースノート)。Fカーブは今、アクションの「スロット」ごとの channelbag の中にあります。
この記事では、次の順で手を動かします。
- 立方体に動きを付けるスクリプトを用意する
- 旧書き方で
AttributeErrorを再現する - channelbag 経由で読み直す(読む・書き換える)
- glb に書き出し、three.js で再生する
キーフレームを読めないと、書き出す前に「動きが意図どおりか」をコードで確かめられません。そのため、読む → 直す → 書き出す → 再生する、の順に並べています。
動作環境
| 項目 | バージョン |
|---|---|
| Blender | 5.2.2 LTS(2026-09-15 リリース。5.2 は2028年7月までサポート) |
| three.js | 0.170.0(CDN の importmap で読み込み) |
| OS | macOS(Homebrew 版の blender コマンド) |
Blender はすべて blender -b(--background、画面なしで起動)で動かしています。
1. 動きを付けるスクリプト
立方体を 1〜60 フレームで下から上げつつ、Z 軸まわりに1回転させます。書き出しは後で別スクリプトにするため、ここではシーンを作るだけです。
import bpy
# 空のシーンから始める
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.render.fps = 30
scene.frame_start, scene.frame_end = 1, 60
bpy.ops.mesh.primitive_cube_add(size=1)
cube = bpy.context.active_object
cube.name = "Logo"
# 1フレーム目:下に沈めた状態
cube.location = (0, 0, -2)
cube.rotation_euler = (0, 0, 0)
cube.keyframe_insert("location", frame=1)
cube.keyframe_insert("rotation_euler", frame=1)
# 60フレーム目:原点まで上げて1回転(2π ≒ 6.2832)
cube.location = (0, 0, 0)
cube.rotation_euler = (0, 0, 6.2832)
cube.keyframe_insert("location", frame=60)
cube.keyframe_insert("rotation_euler", frame=60)
keyframe_insert() は 5.x でもそのまま使えます。壊れるのは、できたアクションの 中身を読みに行く側 です。
2. 旧書き方で AttributeError を再現する
4.x 以前のスクリプトでよく見る書き方です。
import bpy
obj = bpy.data.objects["Logo"]
action = obj.animation_data.action
print("action:", action.name, "slot:", obj.animation_data.action_slot)
# 旧API:アクションから直接Fカーブを取る
for fc in action.fcurves:
print(fc.data_path, fc.array_index, round(fc.evaluate(30), 3))
-P は書いた順に実行されるので、シーンを作ってから続けて読ませます。
blender -b --python-exit-code 1 -P make_scene.py -P old_api.py
echo "exit=$?"
Blender 5.2.2 での実際の出力です。
Traceback (most recent call last):
File ".../old_api.py", line 8, in <module>
for fc in action.fcurves:
^^^^^^^^^^^^^^
AttributeError: 'Action' object has no attribute 'fcurves'
action: LogoAction slot: <bpy_struct, ActionSlot("OBLogo") at 0x7ac4e4dd8>
Error: script failed, file: 'old_api.py', exiting.
exit=1
アクション自体は取れていて、action_slot には OBLogo というスロットが入っています。消えたのは fcurves の置き場所だけ です。
--python-exit-code を付ける理由
同じコマンドから --python-exit-code 1 を外すと、トレースバックが出ても終了コードは 0 でした(手元で確認)。CI やシェルスクリプトから回すときにエラーを見落とすので、付けておくのをおすすめします(コマンドライン引数)。
| コマンド | 終了コード |
|---|---|
blender -b -P make_scene.py -P old_api.py |
0 |
blender -b --python-exit-code 1 -P make_scene.py -P old_api.py |
1 |
5.x の Action に何が残っているか
hasattr() で確認すると、こうなっていました。
import bpy
a = bpy.data.objects["Logo"].animation_data.action
for name in ("fcurves", "groups", "id_root", "layers", "slots"):
print(name, hasattr(a, name))
print("slots:", [(s.identifier, s.target_id_type) for s in a.slots])
fcurves False
groups False
id_root False
layers True
slots True
slots: [('OBLogo', 'OBJECT')]
構造を図にすると次のとおりです。
Action
└─ layers[0]
└─ strips[0](type: KEYFRAME)
└─ channelbag(slot) ← スロットごとにここへFカーブが入る
├─ fcurves
└─ groups("Object Transforms" など)
id_root の代わりに、スロット側の target_id_type(ここでは OBJECT)でどの種類のデータ向けかがわかります。
3. channelbag 経由で読み直す
layers[0].strips[0].channelbag(slot) と手でたどることもできますが、bpy_extras.anim_utils にスロットから channelbag を引くヘルパーがあります(bpy_extras.anim_utils)。
import bpy
from bpy_extras import anim_utils
obj = bpy.data.objects["Logo"]
ad = obj.animation_data
# 5.x:Fカーブは「アクション × スロット」で決まる channelbag にある
bag = anim_utils.action_get_channelbag_for_slot(ad.action, ad.action_slot)
for fc in bag.fcurves:
# evaluate(frame) はそのフレームでのカーブの値を返す
print(fc.data_path, fc.array_index, round(fc.evaluate(30), 3))
blender -b --python-exit-code 1 -P make_scene.py -P read_keys.py
location 0 0.0
location 1 0.0
location 2 -1.025
rotation_euler 0 0.0
rotation_euler 1 0.0
rotation_euler 2 3.062
旧コードからの書き換えは、実質この1行です。
- for fc in action.fcurves:
+ bag = anim_utils.action_get_channelbag_for_slot(ad.action, ad.action_slot)
+ for fc in bag.fcurves:
30フレーム目は 1〜60 のほぼ中間ですが、Z 位置は -1.025、回転は 3.062(π ≒ 3.142 より少し手前)です。キーの補間が既定のベジェ(始まりと終わりがゆっくりになる曲線)なので、ちょうど半分の値にはなりません。FCurve.evaluate(frame) で途中の値を直接取れるので、書き出す前に「この時点でどこにいるか」を数値で確認できます(FCurve)。
Fカーブを書き換える・足す
channelbag の fcurves からは、新しいFカーブも作れます。拡大の動きを足して、すべてのキーを等速(LINEAR)にそろえてみます。
import bpy
from bpy_extras import anim_utils
obj = bpy.data.objects["Logo"]
ad = obj.animation_data
bag = anim_utils.action_get_channelbag_for_slot(ad.action, ad.action_slot)
# scale X/Y/Z のFカーブを足す(0.2倍 → 等倍)
for i in range(3):
fc = bag.fcurves.new("scale", index=i, group_name="Object Transforms")
fc.keyframe_points.insert(1, 0.2)
fc.keyframe_points.insert(60, 1.0)
# すべてのキーを等速(LINEAR)にそろえる
for fc in bag.fcurves:
for kp in fc.keyframe_points:
kp.interpolation = "LINEAR"
for fc in bag.fcurves:
print(fc.data_path, fc.array_index, round(fc.evaluate(30), 3))
location 0 0.0
location 1 0.0
location 2 -1.017
rotation_euler 0 0.0
rotation_euler 1 0.0
rotation_euler 2 3.088
scale 0 0.593
scale 1 0.593
scale 2 0.593
等速にしたので、30フレーム目の Z 位置は -2 + 2 × 29/59 ≒ -1.017 と計算どおりの値になりました。ベジェのとき(-1.025)と比べると、補間の違いが数値で確認できます。
4. glb に書き出す
書き出しも別スクリプトに分けておくと、-P の並びで「作る → 直す → 書き出す」を組み替えられます。
import sys
import bpy
# 「--」より後ろの引数だけを受け取る(Blender自身の引数と混ざらないように)
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
out = argv[0] if argv else "logo.glb"
bpy.ops.export_scene.gltf(
filepath=out,
export_format="GLB",
export_yup=True, # +Y を上にする(既定でTrue。明示しておく)
export_animation_mode="SCENE", # シーン全体の動きを1つのクリップにまとめる
export_force_sampling=True, # 毎フレームの値を焼き込む
export_cameras=False,
export_lights=False,
export_draco_mesh_compression_enable=True, # Draco圧縮(既定はオフ)
)
print("exported:", out)
blender -b --python-exit-code 1 \
-P make_scene.py -P edit_keys.py -P export_glb.py -- logo.glb
書き出し設定で効いてくるところ
| 引数 | この記事の値 | 理由 |
|---|---|---|
export_animation_mode |
"SCENE" |
既定の Actions モードでは、オブジェクトに付いているアクションか、NLA に stash(保管)したアクションだけが書き出されます。シーンのタイムラインどおりに1本にまとめたいので SCENE にしています |
export_force_sampling |
True |
キーの間も毎フレームの値として書き出します(キー2個ずつの動きが、下の確認では60キーになっています) |
export_yup |
True |
glTF は +Y が上の座標系です |
export_draco_mesh_compression_enable |
True |
既定はオフ。圧縮レベルは 0〜6 で指定できます |
出典は glTF 2.0 アドオンのマニュアル と export_scene の API です。アニメーションとして書き出される対象は、オブジェクト・ボーンのトランスフォームとシェイプキーです。
中身を確かめる
glb の先頭にある JSON チャンクを Python で読むと、何が書き出されたかを確認できます。
import json
import struct
import sys
data = open(sys.argv[1], "rb").read()
# 先頭12バイトがヘッダー、続く4バイトがJSONチャンクの長さ
json_len = struct.unpack("<I", data[12:16])[0]
gltf = json.loads(data[20:20 + json_len])
print("extensions:", gltf.get("extensionsUsed"))
for anim in gltf.get("animations", []):
paths = [c["target"]["path"] for c in anim["channels"]]
times = gltf["accessors"][anim["samplers"][0]["input"]]
print(anim["name"], paths, "keys:", times["count"], "end(s):", times["max"])
python3 inspect_glb.py logo.glb
extensions: ['KHR_draco_mesh_compression']
Logo ['translation', 'rotation', 'scale'] keys: 60 end(s): [2]
足した scale も含め、3種類の動きが 60 キー・2秒のクリップとして入っています。
手元で比べたファイルサイズは次のとおりです(scale を足す前の、立方体1個のシーン)。
| 設定 | サイズ |
|---|---|
| Draco なし | 4,316 bytes |
| Draco あり | 3,704 bytes |
立方体1個ではアニメーションのデータが大半なので差は小さいですが、頂点の多いモデルほど効いてきます。そのかわり、Draco は読み込む端末側でデコードの時間が増えます(DRACOLoader)。
座標の対応
Blender は Z が上、glTF は +Y が上です(glTF 2.0 仕様 §3.4)。export_yup=True で書き出すと、Blender の (x, y, z) は three.js では (x, z, -y) になります。
実際に Draco なしで書き出した glb のノードを見ると、Blender で location = (0, 0, -2) だった立方体は translation: [0, -2, 0] になっていました。three.js 側でカメラ位置などを決めるときは、この置き換えで考えます。
1フレーム目が 0 秒にならない
上の glb では、最初のキーの時刻が 0.0333… 秒(= 1/30 秒)でした。フレーム番号 ÷ fps がそのまま時刻になるため、1フレーム目から始めると先頭にわずかな空きができます。frame_start と最初のキーを 0 フレームにして書き出すと、最初のキーは 0 秒になりました。気になる場合はこちらにそろえてください。
5. three.js で再生する
importmap で three.js を読み込みます。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<style>
html, body { margin: 0; height: 100%; background: #111; }
canvas { display: block; }
button { position: fixed; left: 16px; bottom: 16px; font-size: 16px; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
} }
</script>
</head>
<body>
<button id="replay" type="button">もう一度再生</button>
<script type="module" src="main.js"></script>
</body>
</html>
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 1, 6);
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 3));
// Draco圧縮したglbはDRACOLoaderを渡さないと読めない
const draco = new DRACOLoader().setDecoderPath(
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/libs/draco/"
);
const loader = new GLTFLoader().setDRACOLoader(draco);
const gltf = await loader.loadAsync("logo.glb");
scene.add(gltf.scene);
// Blenderで付けた動きは gltf.animations に AnimationClip として入っている
const mixer = new THREE.AnimationMixer(gltf.scene);
const actions = gltf.animations.map((clip) => {
const action = mixer.clipAction(clip);
action.setLoop(THREE.LoopOnce, 1); // 1回だけ再生
action.clampWhenFinished = true; // 最後のフレームで止めておく
return action.play();
});
// LoopOnceで終わったアクションは、reset() してから play() し直す
document.getElementById("replay").addEventListener("click", () => {
actions.forEach((action) => action.reset().play());
});
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
mixer.update(clock.getDelta()); // 前フレームからの経過秒数で進める
renderer.render(scene, camera);
});
ポイントは3つです。
-
Draco 圧縮した glb は
setDRACOLoader()が必須 です。渡さないと読み込みに失敗します(GLTFLoader)。 -
AnimationMixerは自分では進みません。毎フレームupdate(経過秒数)を呼びます(AnimationMixer)。 -
LoopOnceで再生し終えたアクションは、play()だけでは動きません。reset().play()で先頭に戻してから再生します。
index.html と main.js と logo.glb を同じフォルダに置き、python3 -m http.server などのローカルサーバー経由で開きます(file:// で直接開くと、モジュールや glb の読み込みがブラウザに止められます)。
まとめ
- Blender 5.x で
action.fcurvesがAttributeErrorになるのは、旧Action API が 5.0 で削除されたため - Fカーブは
anim_utils.action_get_channelbag_for_slot(action, slot)で取った channelbag のfcurvesにある。読み・追加とも同じ場所から行える -
FCurve.evaluate(frame)で途中の値を数値で確かめてから書き出すと、補間の違いも見落としにくい - 書き出しは
export_animation_mode="SCENE"+export_force_sampling=True。Draco を使うなら three.js 側でsetDRACOLoader()を忘れない -
blender -bで回すなら--python-exit-code 1を付け、スクリプトのエラーを終了コードで受け取る
なお、three.js は記事執筆時点の最新が r186 ですが、この記事のコードは 0.170.0 で動作を確認しています。
この流れを Claude Code から動かし、Web ページに載せるところまでの全体像は「Claude CodeでBlenderを動かしWebに載せる|glb書き出しとthree.js再生」で解説しています。
SEOスコアチェックツール: Direbase(ディレベース) — RINIAディレクターツール。
Web制作・SEO関連の技術情報サイト: CodeQuest.work