kikuta@dev:~$cat ./notes/blender-python-export-contracts.md
·12 min·kikuta

BlenderからGLB / FBXへ:ゲーム用3DアセットのExport契約を固定する

Node、Socket、Bone、Axis、Scale、export対象をコードで固定し、BrowserとUnityへ安全に渡すasset pipeline。

Blenderでモデルを作れても、ゲーム側へ正しく渡せなければasset pipelineとしては不十分です。

実運用では、形状そのものより次のような境界で不具合が起きます。

  • scaleが違う
  • forward axisが違う
  • 必要nodeがexportされていない
  • preview cameraまで混入した
  • hidden meshが欠けた
  • rig bone名が変わった
  • runtimeが期待するsocket名が消えた

3D将棋とSlime Mercenariesでは、GLB / FBX exportを単なる「保存形式」ではなく、ゲームとのAPI境界として扱っています。

GLBではruntime nodeを固定する

Slime Mercenariesのenemy GLBには、familyが違っても共通nodeを持たせています。

EnemyRoot
BodyRoot
FaceRoot
Eye_L
Eye_R
AttackOrigin
EffectOrigin
GroundOrigin

Mushroom familyなら追加でStem、Cap、Mouthなどがあります。

runtimeはgeometryの頂点構造ではなく、こうしたsemantic nodeへ依存します。

AttackOrigin
  -> projectile / hit effect start

EffectOrigin
  -> VFX anchor

GroundOrigin
  -> ground-relative effect

meshを作り直しても、このcontractを保てばruntime側は変わりません。

Sourceの名前とruntimeの意味を分ける

Sword SlimeにはWeaponSocketがあります。

重要なのは、

"剣meshの名前"
!=
"武器を取り付ける意味的なanchor"

であることです。

geometry名を直接APIにすると、造形変更がgame code変更へ波及します。

socketやoriginをsemantic APIにしておけば、剣が一つのmeshから複数partへ分割されてもruntime contractを維持できます。

GLB export optionも固定する

Slime GLBは次のprofileで出しています。

bpy.ops.export_scene.gltf(
    filepath=str(output_path),
    export_format="GLB",
    export_yup=True,
    export_apply=False,
    export_morph=True,
    export_morph_normal=False,
    export_morph_tangent=False,
    export_cameras=False,
    export_lights=False,
)

ポイントは「毎回GUIで設定する」のではなく、export option自体をsourceへ含めることです。

Shape Keyをruntimeで使うためexport_morphを有効にし、preview camera/lightは不要なので除外する、という意図がGit履歴へ残ります。

Unity FBXではaxis変換を明示する

3D将棋のFBXではUnity向けにexport条件を固定しています。

bpy.ops.export_scene.fbx(
    filepath=str(path),
    use_selection=True,
    object_types={
        "EMPTY",
        "MESH",
        "ARMATURE",
    },
    apply_unit_scale=True,
    apply_scale_options="FBX_SCALE_UNITS",
    use_space_transform=True,
    bake_space_transform=False,
    axis_forward="-Z",
    axis_up="Y",
    use_mesh_modifiers=True,
    add_leaf_bones=False,
    bake_anim=False,
    embed_textures=False,
)

「Blenderで正面に見える」だけでは不十分です。

engineへimportした後のforward / up / scaleをexport profileとして固定します。

Export前に対象hierarchyを選び直す

3D将棋ではscene全体を無条件exportしません。

objects = descendants(root)

bpy.ops.object.select_all(
    action="DESELECT"
)

for obj in objects:
    obj.hide_viewport = False
    obj.hide_render = False
    obj.hide_set(False)
    obj.select_set(True)

hidden stateのglyphもruntime assetには必要なので、export前に明示的に対象へ戻します。

逆にpreview用objectはroot hierarchyへ入れないためexportされません。

「混入してはいけないもの」も検証する

兵士exportでは、旧盤Geometryがsceneへ残っていたら失敗させます。

if any(
    obj.name == "Board_9x9"
    or obj.name.startswith("Grid_X_")
    or obj.name.startswith("Grid_Y_")
    for obj in bpy.context.scene.objects
):
    raise RuntimeError(
        "Legacy board geometry exists "
        "in the soldier scene."
    )

必要objectが存在するかだけでなく、存在してはいけないobjectもcontractにできます。

このチェックは、以前に盤と兵士のgenerator責務が混ざっていたことへの再発防止です。

Bone contractもexport前に止める

rigged soldierはUnity Humanoidが必要とするbone集合をexport前に確認します。

required = {
    "Hips",
    "Spine",
    "Head",
    "UpperArm_L",
    "LowerArm_L",
    "Hand_L",
    "UpperArm_R",
    "LowerArm_R",
    "Hand_R",
    "UpperLeg_L",
    "LowerLeg_L",
    "Foot_L",
    "UpperLeg_R",
    "LowerLeg_R",
    "Foot_R",
}

不足していればFBXを書きません。

missing = (
    required
    - set(
        armature.data.bones.keys()
    )
)

if missing:
    raise RuntimeError(
        "Humanoid export blocked; "
        f"missing bones: {sorted(missing)}"
    )

engine import後のerrorを、asset build時のerrorへ前倒ししています。

盤の座標contractはasset acceptanceにする

3D将棋の盤はゲームロジックと強く結びついているため、acceptance条件も明示しています。

Board bounds ≈ (9.50, 0.56, 9.50)
Cell_5_5 = Unity origin
Adjacent cell distance = 1.0
file +1 = +X
rank +1 = -Z

さらに、

Board_Base = 9.5 x 9.5 x 0.55
Horizontal Grid = 10
Vertical Grid = 10
Marker混入なし
Preview Camera/Light/Ground混入なし

まで確認します。

「見た目が盤っぽい」ではなく、gameplay coordinate systemの一部としてassetを検証します。

Runtime scaleでごまかしすぎない

UnityやThree.js側でscale、rotation、offsetを補正すれば見た目を合わせることはできます。

ただし補正が大量になると、「正しいsource scaleがどれか」が分からなくなります。

runtime scaleはcamera上の見せ方として必要な場合もありますが、

  • unit convention
  • forward axis
  • origin
  • socket位置

のような意味的contractまでruntime補正へ押し込まないようにしています。

GLBとFBXで共通している考え方

形式は違っても、pipelineの考え方は同じです。

項目GLB / BrowserFBX / Unity
RootSlimeRoot / EnemyRootSoldier / Armature root
AnchorSocket / OriginBone / Empty
AxisglTF Y-up-Z forward / Y up
MorphShape Keyをexport必要に応じて別管理
Animationruntime motionも利用Humanoid / Generic clip
Preview objectexportしないexportしない
Validationnode / boundsbone / hierarchy / scale

ゲーム側にとって重要なのはファイル形式より、import後にもsemantic contractが残っていることです。

Asset pipelineをAPI設計として考える

最終的に役立った考え方は、Blenderからのexportを「ファイル保存」と考えないことでした。

Blender source
   |
   | asset API
   v
GLB / FBX
   |
   | runtime contract
   v
Three.js / Unity

APIならrequired field、forbidden field、type、version、validationを考えます。

3D assetにも同じように、

  • required node
  • parent relation
  • axis
  • bounds
  • bone name
  • animation ownership

を持たせると、モデル数が増えても壊れ方を限定しやすくなりました。