リポジトリ: 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 はデータキャッシュがないので、これが重要。
入力は 2 系統。どちらも最終的に「頂点 → ボーン 1 本」に落とす。
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) でも、使う式は同じ。
法線も同じボーンの回転だけで回す(平行移動なし)。
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() の判定順(手が太ももに吸われないよう腕が先):
- 腕帯:
hand_min_y ≤ y < shoulderかつ|x|が大きい → 上腕 / 前腕 y < hip→ 太もも / すねy ≥ neck→ 頭y ≥ chest→ spine- 残り → root
孤立頂点は隣接の多数決を 3 回回して直す。手・足のボーンは無い(IK のエフェクタだけ EFFECTORS で仮想配置)。
関節位置は身長 H に対する比率(autorig.RIG)。例: 股 0.47H、膝 0.24H、肘 0.58H、首 0.78H。
アニメは idle / walk / run / wave / punch を手続き生成(SynthAnim)。FBX の AnimStack は使わない。
dominant だけだと、関節をまたぐ三角が曲がったとき伸びる / 千切れる。対策:
meshopt.plane_cut— 膝・股・肘・胸・首の水平面で三角を切るmeshopt.split_by_bone—(position, bone)ごとに頂点を複製し、パーツ間で頂点を共有しない- 予定:
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 をバインド位置にピンし、その場で動くようにする。
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 用に動くボーンだけスロットを持つ。
- 隣接フレームのクォータニオンを shortest-path nlerp(
tは 0..255) quat_to_matrix(固定小数、ノルムは整数平方根)- 親から
pose_compose: 回転は GTEMulMatrix0、平行移動はApplyMatrixLV
world[b] = world[parent] * local[b]
IK モードは world を直接書き換えて pose_refresh で子孫を組み直す。頂点スキン自体は同じ(まだボーン 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 の空間)。
キャッシュから 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 を後から上書き) |