Skip to content

Instantly share code, notes, and snippets.

@GOROman
Created September 2, 2026 14:19
Show Gist options
  • Select an option

  • Save GOROman/c690dd392e0e4c23f546367573b073c8 to your computer and use it in GitHub Desktop.

Select an option

Save GOROman/c690dd392e0e4c23f546367573b073c8 to your computer and use it in GitHub Desktop.
ps1-null-fighter のスキニング(リジッド / 頂点あたりボーン1本)

ps1-null-fighter のスキニング

リポジトリ: GOROman/ps1-null-fighter 調査日: 2026-09-02

結論

線形ブレンドスキン(LBS)ではない。頂点あたりボーン 1 本のリジッドスキン。

頂点はボーンローカル座標で保存され、実行時はボーンごとに GTE の回転・平行移動を一度セットして、そのボーンに属する頂点をまとめて RTPT する。ウェイトの線形結合はしない(PS1 の GTE では重いし、データキャッシュもない)。

v_screen = GTE_RTPT( view * bone_world[b] * v_bone_local )

なぜリジッドか

PS1 の Geometry Transformation Engine は 3 頂点同時の透視変換 (RTPT) と、行列 1 本のロードが基本単位。

  • ボーンごとに view * bone_world を GTE に載せて、その範囲の頂点を流すのが最速
  • 4 ボーン LBS は頂点ごとに 4 回の行列・ベクトル積 + 正規化が要り、GTE のスループットに乗らない
  • src/render.c 冒頭: "Every vertex lives in the local space of exactly one bone"

頂点と三角はボーン順にソート済みなので、pass 1 はボーンの vert_start .. vert_start+vert_count を連続アクセスする。PS1 はデータキャッシュがないので、これが重要。


オフライン変換 (tools/fbx2ps1.py)

入力は 2 系統。どちらも最終的に「頂点 → ボーン 1 本」に落とす。

A. 既にスキン済みの FBX(character.fbx

FBX の Deformer/Cluster から Indexes / Weights / Transform / TransformLink を読む。

# tools/fbx2ps1.py  Scene.__init__
self.clusters[b.bone_index] = (indexes, weights, Transform, TransformLink)

build_skin() は頂点 × ボーンのウェイト行列を足し込み、argmax(dominant bone) を取る。ウェイト 0 の頂点は bone 0 に落とす。

dominant = np.argmax(weights, axis=1)

バインドポーズでのボーンローカル化:

p_world = MeshGlobal * v_mesh
p_local = inv(TransformLink) * p_world
p_ps1   = C_FBX_TO_PS1 * p_local * 4096

TransformLink はボーンのバインドワールド(Blender / FBX SDK どちらもここが TL)。コメントどおり、Cluster の Transform が Blender 流 (TL^-1 * MeshGlobal) でも SDK 流 (MeshGlobal) でも、使う式は同じ。

法線も同じボーンの回転だけで回す(平行移動なし)。

B. 非リグメッシュ(schoolgirl + --autorig

Tripo 由来の静的メッシュ。ボーンもウェイトもないので、高さ比率で部位を切って 11 本のバイペッドを置く。

root (hips)
  spine
    head
    upperarm_L → forearm_L
    upperarm_R → forearm_R
  thigh_L → shin_L
  thigh_R → shin_R

autorig.assign_bones() の判定順(手が太ももに吸われないよう腕が先):

  1. 腕帯: hand_min_y ≤ y < shoulder かつ |x| が大きい → 上腕 / 前腕
  2. y < hip → 太もも / すね
  3. y ≥ neck → 頭
  4. y ≥ chest → spine
  5. 残り → root

孤立頂点は隣接の多数決を 3 回回して直す。手・足のボーンは無い(IK のエフェクタだけ EFFECTORS で仮想配置)。

関節位置は身長 H に対する比率(autorig.RIG)。例: 股 0.47H、膝 0.24H、肘 0.58H、首 0.78H

アニメは idle / walk / run / wave / punch を手続き生成(SynthAnim)。FBX の AnimStack は使わない。

リジッド分割の WIP(未コミット)

dominant だけだと、関節をまたぐ三角が曲がったとき伸びる / 千切れる。対策:

  1. meshopt.plane_cut — 膝・股・肘・胸・首の水平面で三角を切る
  2. meshopt.split_by_bone(position, bone) ごとに頂点を複製し、パーツ間で頂点を共有しない
  3. 予定: bodygen.generate_body — 頭以外を旋盤メッシュで作り直す

character.fbx 側は FBX のスキンを dominant にしただけなので、元ウェイトが関節で混ざっていると、そこが硬い継ぎ目になる。


座標系と単位

FBX PS1
右手系 Y-up(schoolgirl)または Z-up X 右、Y 下、Z 画面奥
変換 C_YUP_TO_PS1 = diag(1, -1, -1) 回転なので winding は保つ
単位 1.0 ≒ 1 m 4096 が 1.0(int16 に収まる)

固定小数はどこでも 4096 = 1.0(クォータニオン、法線、行列)。

ルートモーションは変換時に落とす。平行移動アニメのボーンについて、ワールド XZ をバインド位置にピンし、その場で動くようにする。


ランタイムバイナリ PS1Msrc/model.h

ModelVert  16B   x,y,z (int16, bone-local) / bone / nx,ny,nz / uv
ModelTri   16B   i0,i1,i2 / bone (flat 法線の空間) / tex / nx,ny,nz / flags
ModelBone  18B   parent / flags / trans_slot / vert_start,count / tri_start,count / tx,ty,tz

頂点はボーンでソートされ、ModelBone.vert_start / vert_count が連続レンジ。三角もボーン順(flat シェーディング時にライト行列の張り替えを減らす)。

フレームデータ(15 fps でサンプル):

nbones × quaternion (int16 x4)
ntrans × translation (int16 x3)   # BONE_FLAG_TRANS のボーンだけ

回転は毎ボーン毎フレーム。平行移動はバインド tx,ty,tz がデフォルトで、ルートや IK 用に動くボーンだけスロットを持つ。


実行時(src/anim.c + src/render.c

ポーズ

  1. 隣接フレームのクォータニオンを shortest-path nlerp(t は 0..255)
  2. quat_to_matrix(固定小数、ノルムは整数平方根)
  3. 親から pose_compose: 回転は GTE MulMatrix0、平行移動は ApplyMatrixLV
world[b] = world[parent] * local[b]

IK モードは world を直接書き換えて pose_refresh で子孫を組み直す。頂点スキン自体は同じ(まだボーン 1 本)。

描画 pass 1(頂点)

ボーン b について:

mv = view * world[b]
gte_SetRotMatrix(mv); gte_SetTransMatrix(mv)
gte_SetLightMatrix(light_mtx * mv)     # 法線もボーン空間
for verts of bone: RTPT → screen xy, z; NCT → RGB

結果は vcache[vertex]。Gouraud は頂点法線、Flat は三角の面法線(ModelTri.bone の空間)。

描画 pass 2(プリミティブ)

キャッシュから POLY_GT3 / POLY_FT3 を組む。NCLIP で裏面を捨て(スカートは TRI_FLAG_DOUBLE_SIDED で巻いて描く)、平均 Z で OT へ。


FBX mesh / clusters          autorig (高さで切断)
        \                         /
         \                       /
          dominant bone per vertex
                    |
         inv(bind) * v  →  bone-local int16
                    |
              PS1M model.bin
                    |
         pose_eval: nlerp q → world[b]
                    |
         render: GTE  (view * world[b]) * v_local

ファイル対応

ファイル 役割
tools/fbx2ps1.py build_skin / convert Cluster → dominant、bone-local 化、ソート
tools/autorig.py 非リグの部位分割と手続きスケルトン
tools/meshopt.py plane_cut / split_by_bone 関節で殻を分ける(WIP)
src/model.h PS1M レイアウト
src/anim.c nlerp + 親チェーン
src/render.c ボーン単位 GTE スキン
src/ik.c 2 ボーン IK(world を後から上書き)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment