Pyxel 3.0.0 で、ソフトウェア描画の 3D 機能 pyxel.cube が入りました。GLB を読み込み、ノードの階層とキーフレームアニメーションをそのまま扱えます。
この 3D 機能で VRM アバターの格闘ゲームなどを作るうちに、「ドキュメントに書かれていないが、知らないとハマる挙動」がいくつも見つかりました。本記事では、それらを 実機で動く検証デモ と Rust のソースコード の両方で確かめた結果をまとめます。
この記事で分かること
-
pyxel.colors = [...]はエラーなしで効かない →pyxel.colors[:] = [...]で代入する - MML の音量
Vは 0〜127(v15だと約12%の音量になる) -
pyxel.play(..., resume=True)で、効果音の後に BGM へ戻せる - GLB の材質の色は sRGB 変換なしで、そのまま最も近いパレット色になる
- VRM を骨ごとに分割して動かす方法と、218本のモーションを 1.28 MB に収める方法
検証環境
- Pyxel 3.0.0(PyPI 版)、ソースは
kitao/pyxelの main(commit882f9d2、2026-11-06) - 実行環境 1:Code & Magic(ブラウザ / Chrome、Pyodide)、MacBook Pro M5 Max
- 実行環境 2:macOS ネイティブ(同じ Mac)
- 実行環境 3:Linux + Xvfb(画面なしの自動テスト)
本文中の ms は、特に断りのない限り Code & Magic(Chrome) での値です。
- この記事の内容は Pyxel 3.0.0 時点 のものです。今後のバージョンで変わる可能性があります。
- 調べものや検証は AI と一緒に行いました。記事の画面は、すべて私が Code & Magic でデモを動かして撮ったものです。
検証の方法
1つの知見につき1つのデモを作り、画面1枚で結果が分かるようにしました(全13本)。デモ00は、27項目を自動で確認して PASS/FAIL を一覧表示します。
検証デモ一式は Code & Magic で公開しています。ブラウザで開けば、本記事の結果をその場で再現できます。
- pyxel.cube 検証デモ集(Code & Magic)
- 操作:上下キーで選び、Z / Space で実行、Mでメニューに戻る
GLB はすべて、デモの起動時に Python(struct と zlib だけ)で組み立てています。外部ツールに頼らないので、「GLB にこう書いたら、Pyxel ではこうなる」を 1対1 で確かめられます。
1. pyxel.colors = [...] は、エラーなしで「効かない」
独自のパレットを使うとき、pyxel.colors に 普通に代入してはいけません。
pyxel.colors[:] = MY_PALETTE # ○ スライス代入:エンジンのパレットが変わる
pyxel.colors = MY_PALETTE # × 例外は出ないが、ただの list に置き換わるだけ
普通に代入すると、type(pyxel.colors) が Colors から list に変わります。それ以降、エンジンはその list を見ません。GLB の色は読み込み時に「その時点のエンジンのパレット」で量子化されるので、作ったはずの色番号に一致しなくなります。
左:スライス代入で 16 番にミント色を足してから読み込んだ GLB は col_img=16(狙いどおり)。右:普通の代入で 17 番にピンクを足したつもりの GLB は、17 ではなく col_img=8 になる(エンジンは古いパレットのまま)。
2. 材質の色は「×255 して最も近い色」。sRGB 変換はない
baseColorFactor は、各成分を 255 倍して四捨五入し、パレットの中で RGB の二乗距離が最小の色番号 に置き換えられます。sRGB とリニアの変換は行いません。
したがって、パレットの 16 進カラーをそのまま hex/255 で書けば、狙った番号に必ず一致します。逆に、多くのエクスポーターがするように sRGB をリニアに変換した値を書くと、暗い別の色にずれます。
上段:hex/255(すべて一致)。下段:srgb_to_linear(hex/255) では 9→4、11→3、12→5 などにずれます。
3. 法線は「三角形ごとに1つ」に平均される
GLB の NORMAL は読み込まれますが、三角形の3頂点の法線が 平均されて、面に1つ になります(glb_parser.rs の authored_normals_to_flat_normals)。シェーディングは常にフラットなので、滑らかな頂点法線を用意しても見た目は変わりません。
4. alphaMode:MASK は colkey 次第、BLEND は不透明
-
BLENDは警告を出して 不透明 として描かれます。VRM の目元(まつ毛、ハイライトなど)は BLEND のことが多いので、黒い板のようになります。MASK(alphaCutoff: 0.5)に書き換えると直ります。 -
MASKでは、透明な画素を「不透明な画素が1つも使っていないパレット番号(colkey)」で表します。-
colkeyを指定しなければ、空いている番号が自動で選ばれます。 - 指定した
colkeyがテクスチャで使われている色と重なれば、警告を出して別の番号に替えます。 - 全パレット番号がテクスチャで使われているときだけ、透過が無効になります。
-
A:空きあり → 透過。B:16色すべて使用 → 無効。C:colkey=8 が衝突 → 自動で差し替え。D:BLEND → 不透明。
5. パーツ間で POSITION を共有すると、パーツごとに複製される
1つの大きな頂点バッファを、多数のパーツ(プリミティブ)がインデックスで分け合う GLB があります。VRM を骨ごとに分割すると、素直に作ればこの形になります。Pyxel は読み込み時に プリミティブごとにバッファ全体をコピー するので、頂点数がパーツ数倍に膨らみます。
| 60パーツ × 400頂点(同じ形状) | 保持される頂点数 | 描画時間(Chrome) |
|---|---|---|
| POSITION を共有 | 1,440,000 | 19.16 ms |
| パーツごとに分ける | 24,000 | 2.34 ms |
パーツを分けるときは、各パーツが使う頂点だけを持つバッファに作り直してください。デモ00 でも条件を変えて(40パーツ × 300頂点)同じ比較をしています。共有ありの描画時間は、Mac ネイティブで 7.89 ms(共有なしは 1.40 ms)、Chrome で 8.40 ms でした。ブラウザでもネイティブとほとんど変わりません。速さを決めるのは、頂点の総数とパーツの数です。
6. アニメーションの仕様
6.1 apply_motion は「チャネルのあるパーツ」しか触らない
Node.apply_motion(motion, frame) が更新するのは、そのモーションにチャネルがあるパーツだけです(motion.rs の sample())。
- チャネルが1本でもあるパーツ:残りの成分(T/R/S)は 初期姿勢に戻る。
- チャネルがないパーツ:前のモーションの値がそのまま残る。
B(squash の後に、ルートだけを動かす hop)では、Body が潰れたままになります。対策は、どのモーションにも、動かす可能性のある全パーツのチャネルを最低1本入れておくこと です(C)。
6.2 int16 の回転トラックが使える
glTF 本体の仕様どおり、回転の出力は SHORT + normalized: true でも読めます(float32 の半分のサイズ)。角度の差は最大 0.04° です。後述のモーションパックはこれで小さくしています。
6.3 その他(デモ00で確認)
-
Motion.lengthの単位は フレーム(秒 ×Mesh.from_glb(fps=…))。fpsはpyxel.initと揃える。 - CUBICSPLINE は glTF 標準のエルミート補間(接線 × dt)。
-
Motion()は直接作れない(GLB からのみ)。 -
Motion は、その Node を作ったのと同じ Mesh から読んだものしか適用できない(
motion must come from the same Mesh as the Node.from_mesh tree)。7.4 節で、これを回避する方法を説明します。
7. VRM アバターを Pyxel で動かす
Pyxel はスキニングを無視します。そこで VRM は、各三角形を最も影響の大きい骨に割り当て、骨ごとの剛体パーツに分割 して使います(288パーツ、1.56 MB)。
7.1 座標の約束(ここを間違えると腕が逆に曲がる)
VRM 0.x(VRoid)を分割したモデルは、次のとおりです。
- −Z を向いている(キャラの右手側が +X、上が +Y)
- 全ノードの初期回転が単位回転、スケールが 1
このため、下ろした腕に rx > 0 を足すと 前に 振れます。直感と逆なので、手でオイラー角を書くとほぼ確実に間違えます。実際、ボウリングの投球モーションがボールを背中側に持ち、後ろへ投げる動きになっていました。
なお Quat.from_euler / Mat4.from_euler は外因性 XYZ(= Rz·Ry·Rx、単位は度)、Mat4.compose は T·R·S、A*B は B を先に適用します。
7.2 オイラー角を使わない:体の向きを基準に方向を指定する+IK
解決策として、ポーズを キャラの体の向き(前・上・右)を基準にした方向ベクトル で書き、手の位置は2本の骨の IK で決めるようにしました。
b = PoseBuilder(avatar)
b.spine(forward=20, twist=12) # 前屈20°、右へ12°ひねる
b.aim("J_Bip_R_UpperArm", body_dir(fwd=-0.85, up=-1), palm=FWD) # 腕を後ろ下へ、手のひらは前
b.reach("R", wrist_pos, pole=body_dir(up=-1, right=0.6), palm=UP) # 手首を目標位置へ(2ボーン IK)
pose = b.build(ground_fit=True) # いちばん低い足を床に着ける
7.3 CC0 の VRMA モーションをリターゲットする
open-vrma が配布している CC0 のモーション(Quaternius Universal Animation Library 1/2、KayKit Character Animations)を、分割した VRM に移しました。元データの初期姿勢は VRM 1.0 の正規化 T ポーズ(+Z 向き)なので、Y 軸まわりに 180° の基底変換をはさみます。
R_avatar_world = C · (R_src_world · R_src_rest_world⁻¹) · Cᵀ (C = RotY(180°))
hips_pos = rest + C · Δpos · (アバターの腰の高さ / 元データの腰の高さ)
接地は、全クリップに共通の補正(中央値 +4.1 cm)をかけ、元データと床との関係を保ちます。クリップごとに「最も低い足を床に合わせる」と、倒れる・寝る・座る・泳ぐモーションが数十 cm〜数 m 浮いたり沈んだりします。元データ自体の腰の高さが不連続な一部(KayKit の床で動くモーション13本)だけは、フレームごとに床に着けました。
7.4 218 モーションを 1.28 MB に:モーションパック
6.3 節のとおり、Motion は同じ Mesh の Node にしか適用できません。そこで次の形に分けました。
| ファイル | 中身 | サイズ |
|---|---|---|
haruka_body.tmx |
アバター本体(GLB)+パレット+骨の一覧 | 1.80 MB |
haruka_motions_1.tmx |
骨格は同じで形状だけ極小にした GLB + 218 モーション | 1.28 MB |
実行時は、モーションパックを別の Mesh として読み込みます。モーションは見えない Node で再生し、動いた 52 本の骨の姿勢を本体にコピー します。
pn.apply_motion(motion, frame) # 見えない Node(パック側の Mesh)で再生
for src, dst in bone_pairs: # 52本
dst.transform = src.transform # 本体へコピー
| 指標(Chrome) | 値 |
|---|---|
| 読み込み(本体+パック 2本、218モーション) | 297 ms |
| 6体の再生(コピー込み) | 0.15 ms/フレーム |
| 描画(VRM 6体) | 17.1 ms/フレーム |
| 本体に直接入れた場合との関節位置の差 | 0.0 m |
回転を int16 にし、変化の少ないキーを間引き(許容誤差 0.35°)、zlib で圧縮しているので、218 本が 1.28 MB に収まりました。Code & Magic の「1ファイル 3 MB」の制限の中で、ほぼすべての CC0 モーションを使えます。
実例
この方式の前身にあたる、VRM アバターと CC0 モーションを使った 3D 格闘ゲームです。
- Code & Magic:VRM CYBER FIGHTER '95
8. サウンド
8.1 MML の音量 V は 0〜127(既定は 100)
mml_parser.rs の定義は RANGE_VOLUME=(0,127)、DEFAULT_VOLUME=100 です。旧来の 0〜15 の感覚で v15 と書くと、最大の約12%の音量になり、かなり小さくなります。v128 は 'V' exceeds maximum 127 のエラーになります。WAV にして測ると、振幅は V に比例していました(v13:230、v50:884、v100:1770、v127:2247 RMS)。
参考:Q は 0〜100、O は −1〜9。旧形式の Sound.volumes は 0〜7 のままです。
8.2 pyxel.play(ch, snd, resume=True) で、効果音の後に BGM に戻る
効果音(SE)を BGM と同じチャンネルで鳴らしても、resume=True なら終わった後に前のサウンドへ戻ります。グラフは play_pos(0)[1](いま鳴らしている音の中での秒数)です。
- 戻る位置は 「中断した位置」ではなく、「鳴らし続けていたら今いるはずの位置」 です。上の図では、BGM の 2.9 秒の位置で 0.3 秒の SE を鳴らし、BGM は約 3.2 秒から再開しています(
channel.rsでtotal_elapsed_clocksから再開している)。 -
resume=Falseなら、SE の後は無音になります(play_posがNone)。 - 何も鳴っていないチャンネルで
resume=Trueを指定しても、戻る先はありません。 - 再開待ちの間に次の resume 付き SE を鳴らしても、最初に保存したサウンドは上書きされません(SE を連打しても BGM は守られる)。
play_pos(ch) の戻り値の1つ目は サウンド番号ではなく、再生中のシーケンスの中での位置 です。「いま BGM が鳴っているか」をこれで判定することはできません。
9. 小ネタ(デモ00で確認)
| 項目 | 事実 |
|---|---|
Primitive.sphere(r) |
U 座標が最大 1.1619 まで出る。UV はクランプされるので、約58°分が端の画素で塗られる |
Shading |
8段階。レベル5は元の色そのもの。光の向きの既定値は (0.5, −1, −0.8)。build() で手動の上書きは消える |
Node.on_collide |
素の Node では読み取り専用。継承して上書きする |
Node.tags |
set でなければならない(list は TypeError) |
Node.forward |
ローカル(親の回転は含まない) |
まとめ
| 確かめた事実(実機+ソース) | 確かめていないこと |
|---|---|
| パレットはスライス代入、色は ×255 で最も近い色、法線は面ごと、MASK と colkey の規則、POSITION 共有の複製 | デカール、平面投影の影、depth_offset の詳細 |
| モーションのチャネルの範囲、int16 トラック、Motion は同じ Mesh だけ | スマホのブラウザでの性能 |
| VRM の座標の約束、リターゲット、モーションパック(Chrome で 297 ms / 0.15 ms) | 他の VRM モデル(VRM 1.0 の書き出し)での検証 |
MML の V は 0〜127、resume=True は時間を同期して再開 |
後編について
記事の後編もございます。
Pyxel 3 の 3D(pyxel.cube)で PS1・PS2 時代の技法をさらに8個試した ── 低解像度は効かず、部屋の切り捨ては効いた
最後まで読んでいただき、ありがとうございました。
ライセンス・出典
- Pyxel:MIT License(kitao/pyxel)
- モーション:Quaternius Universal Animation Library 1/2、KayKit Character Animations(いずれも CC0 1.0)。VRMA への変換:open-vrma(CC0 1.0)
- アバター:VRoid Studio サンプルモデル「AvatarSample_A」(pixiv Inc.)を骨ごとに分割・変換して使用。このアバターは CC0 ではありません(モーションだけが CC0 です)。クレジット表記は不要ですが、派生した VRM のライセンスを CC0 に設定することなどは禁止されています。利用条件:VRoid のサンプルモデルの利用条件、VRoid Studio 個別規約













