1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Blender 5.xで action.fcurves が AttributeError になる|channelbagでFカーブを読み、glbをthree.jsで再生する

1
Posted at

この記事でやること

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 の中にあります。

この記事では、次の順で手を動かします。

  1. 立方体に動きを付けるスクリプトを用意する
  2. 旧書き方で AttributeError を再現する
  3. channelbag 経由で読み直す(読む・書き換える)
  4. 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回転させます。書き出しは後で別スクリプトにするため、ここではシーンを作るだけです。

make_scene.py
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 以前のスクリプトでよく見る書き方です。

old_api.py
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() で確認すると、こうなっていました。

probe.py
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)。

read_keys.py
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)にそろえてみます。

edit_keys.py
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 の並びで「作る → 直す → 書き出す」を組み替えられます。

export_glb.py
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 で読むと、何が書き出されたかを確認できます。

inspect_glb.py
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 を読み込みます。

index.html
<!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>
main.js
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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?