2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pyxel 3 の 3D(pyxel.cube)を実機とソースで確かめた ── GLB・VRM・サウンドのハマりどころ

2
Last updated at Posted at 2026-11-09

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(commit 882f9d2、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 を一覧表示します。

メニュー
00: 27/27 PASS

検証デモ一式は Code & Magic で公開しています。ブラウザで開けば、本記事の結果をその場で再現できます。

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 の色は読み込み時に「その時点のエンジンのパレット」で量子化されるので、作ったはずの色番号に一致しなくなります。

01

左:スライス代入で 16 番にミント色を足してから読み込んだ GLB は col_img=16(狙いどおり)。右:普通の代入で 17 番にピンクを足したつもりの GLB は、17 ではなく col_img=8 になる(エンジンは古いパレットのまま)。

2. 材質の色は「×255 して最も近い色」。sRGB 変換はない

baseColorFactor は、各成分を 255 倍して四捨五入し、パレットの中で RGB の二乗距離が最小の色番号 に置き換えられます。sRGB とリニアの変換は行いません。

したがって、パレットの 16 進カラーをそのまま hex/255 で書けば、狙った番号に必ず一致します。逆に、多くのエクスポーターがするように sRGB をリニアに変換した値を書くと、暗い別の色にずれます。

02

上段: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)。シェーディングは常にフラットなので、滑らかな頂点法線を用意しても見た目は変わりません。

03

4. alphaMode:MASK は colkey 次第、BLEND は不透明

  • BLEND は警告を出して 不透明 として描かれます。VRM の目元(まつ毛、ハイライトなど)は BLEND のことが多いので、黒い板のようになります。MASK(alphaCutoff: 0.5)に書き換えると直ります。
  • MASK では、透明な画素を「不透明な画素が1つも使っていないパレット番号(colkey)」で表します。
    • colkey を指定しなければ、空いている番号が自動で選ばれます。
    • 指定した colkey がテクスチャで使われている色と重なれば、警告を出して別の番号に替えます。
    • 全パレット番号がテクスチャで使われているときだけ、透過が無効になります。

10

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

04

パーツを分けるときは、各パーツが使う頂点だけを持つバッファに作り直してください。デモ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)は 初期姿勢に戻る。
  • チャネルがないパーツ:前のモーションの値がそのまま残る。

05

B(squash の後に、ルートだけを動かす hop)では、Body が潰れたままになります。対策は、どのモーションにも、動かす可能性のある全パーツのチャネルを最低1本入れておくこと です(C)。

6.2 int16 の回転トラックが使える

glTF 本体の仕様どおり、回転の出力は SHORT + normalized: true でも読めます(float32 の半分のサイズ)。角度の差は最大 0.04° です。後述のモーションパックはこれで小さくしています。

06

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 を足すと 前に 振れます。直感と逆なので、手でオイラー角を書くとほぼ確実に間違えます。実際、ボウリングの投球モーションがボールを背中側に持ち、後ろへ投げる動きになっていました。

07

なお 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)                                    # いちばん低い足を床に着ける

12

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

08

回転を int16 にし、変化の少ないキーを間引き(許容誤差 0.35°)、zlib で圧縮しているので、218 本が 1.28 MB に収まりました。Code & Magic の「1ファイル 3 MB」の制限の中で、ほぼすべての CC0 モーションを使えます。

実例

この方式の前身にあたる、VRM アバターと CC0 モーションを使った 3D 格闘ゲームです。

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](いま鳴らしている音の中での秒数)です。

resume=True

  • 戻る位置は 「中断した位置」ではなく、「鳴らし続けていたら今いるはずの位置」 です。上の図では、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 は守られる)。

resume=False

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 個別規約
2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?