kikuta@dev:~$cat ./notes/blender-python-assets-as-code.md
·12 min·kikuta

Blenderの3DアセットをPythonコードで管理する

3D将棋とSlime Mercenariesで、.blendを正本にせずBlender Pythonから盤・キャラクター・GLB/FBXを再生成する設計。

3Dモデルをコード生成する一番の利点は、「速く作れる」ことではありません。

形状・寸法・命名・出力条件をレビュー可能なSource of Truthとして残せることです。

3D将棋とSlime Mercenariesでは、主要なゲーム用3DアセットをBlenderのGUI操作だけで管理せず、Pythonから生成しています。

Blender + Pythonで生成している3D将棋の駒

この記事では、Blender Pythonを採用した理由と、実際に運用して分かった「コード化する範囲」「生成物の扱い」「headless buildの設計」を整理します。

.blendを正本にすると何が困るか

.blendを手で編集して保存する運用自体は普通です。

問題になるのは、ゲームロジックと3Dアセットの間に数値契約がある場合です。

3D将棋では、盤面ロジックが次の座標系を前提にしています。

9 x 9 cells
1 cell = 1.0 unit
Cell_5_5 = world origin
file +1 = +X
rank +1 = -Z

盤の上面も Z = 0 に固定しています。

この状態でGUI上のScaleや位置を手で修正すると、見た目はほぼ同じでもゲーム側との契約だけがずれることがあります。

実際、初期の兵士生成スクリプトには旧仕様の「1マス = 2 units」相当の盤情報が残っており、後から確定した1マス=1unitの仕様と衝突しました。

この経験から、盤と兵士のGenerator自体も分けています。

generate_shogi_board.py
  -> board geometry / grid / material / coordinate contract

generate_assets.py
  -> miniature soldier geometry / glyph / material

「同じBlender sceneで使うから」という理由だけで、ライフサイクルの違うassetを一つのgeneratorへ入れないようにしています。

寸法をdataclassへ集約する

盤ではGeometryの定数を BoardSpec に集約しています。

@dataclass(frozen=True)
class BoardSpec:
    cells: int = 9
    cell_size: float = 1.0
    outer_margin: float = 0.25
    board_thickness: float = 0.55
    board_bevel: float = 0.04
    board_bevel_segments: int = 2
    grid_width: float = 0.025
    grid_height: float = 0.008

    @property
    def play_size(self) -> float:
        return self.cells * self.cell_size

    @property
    def board_size(self) -> float:
        return self.play_size + self.outer_margin * 2.0

盤サイズは結果として、

play size  = 9.0
board size = 9.5

になります。

数値をGeometry生成処理へ散らさないことで、仕様変更時に「どこか一つだけ古いscaleが残る」可能性を減らせます。

生成開始時にsceneを空にする

headless buildでは「現在開いているscene」を前提にしません。

毎回、生成開始時にsceneを初期化します。

def clear_scene() -> None:
    bpy.ops.object.select_all(action="SELECT")
    bpy.ops.object.delete(use_global=False)

3D将棋の兵士generatorでは、さらに未使用datablockも削除しています。

for collection in (
    bpy.data.meshes,
    bpy.data.curves,
    bpy.data.materials,
    bpy.data.cameras,
    bpy.data.lights,
):
    for datablock in list(collection):
        if datablock.users == 0:
            collection.remove(datablock)

Blenderでは同じscriptを何度も試していると、Material.001 や古いMesh datablockが残ることがあります。

再生成可能なassetにするなら、同じinputから同じsceneを作る方へ寄せた方が扱いやすくなります。

Preview用objectとexport対象を分ける

生成scriptでは、モデルだけでなくpreview cameraやlightも作れます。

ただし、それらをそのままruntime assetへ混ぜません。

3D将棋の盤では役割ごとにCollectionを分けています。

ShogiBoard_Asset
ShogiBoard_Markers
ShogiBoard_Preview

出力時にはasset collectionだけを対象にします。

Slime MercenariesのGLB exportでも明示的にcamera/lightを除外しています。

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

「sceneを作るために必要なもの」と「ゲームへ渡すもの」は同じではありません。

Procedural MaterialもSourceに含める

3D将棋の盤では外部wood textureを使わず、Blender nodeだけで木目を作っています。

理由は見た目の豪華さではなく依存管理です。

Texture Coordinate
  -> Mapping
  -> Noise Texture
  -> Color Ramp
  -> Principled BSDF

Noise
  -> Bump
  -> Principled Normal

この構成なら、画像textureのパス切れ、UV差分、ライセンス確認といった別の管理対象が増えません。

初期Playableでは、PBR assetを増やすより「盤と兵の寸法をいつでも再生成できる」ことを優先しました。

もちろん、すべてのゲームでexternal textureを避けるべきという意味ではありません。

コード生成の利点よりtexture authoringの価値が高くなった時点で、依存を追加すればよいというだけです。

生成物を正本にしない

3D将棋では次を生成物として扱います。

.blend
.glb
preview .png
Unity FBX

Source of TruthはPythonです。

Python source
    ↓
.blend
    ↓
GLB / FBX / Preview
    ↓
Game runtime

ただしUnityへ実際にimportするFBXとその .meta は、Playableを再現するためGit管理しています。

ここは少し区別が必要です。

  • 形状変更の正本: Blender Python
  • runtime repositoryで必要なderivative: FBX + meta
  • ローカルで再生成できる中間物: outputのblend / preview

全部をGit管理しない、全部を無視する、の二択ではありません。

Headlessで生成できる状態をAcceptanceにする

3D将棋の盤は次のように生成します。

/Applications/Blender.app/Contents/MacOS/Blender \
  --background \
  --python blender/scripts/generate_shogi_board.py

スライムも同じです。

/Applications/Blender.app/Contents/MacOS/Blender \
  --background \
  --python tools/blender/slimes/build.py -- \
  --slug sword \
  --output public/assets/slimes/sword.glb

GUIでしか再現できない操作を残さないことで、model生成をbuild stepとして扱えます。

これは自動化だけでなく、別の人や別のマシンでも同じassetを作れるかという設計チェックになります。

ただし、見た目までコードだけで評価しない

Python生成を正本にしても、visual qualityはscriptの正しさだけでは決まりません。

特にゲーム用assetでは、

  • 実際のcamera distance
  • mobile画面でのpixel size
  • animation中のsilhouette
  • weaponとbodyの重なり
  • faceの見え方

を人間が見る必要があります。

そのため実運用では、

Python source
  -> headless generation
  -> structural validation
  -> turntable / gallery render
  -> game engine
  -> gameplay camera QA

までを一つの流れとして扱っています。

後の記事で、このvalidationとvisual QAも詳しく扱います。

Blender Pythonが向いていたケース

二つのプロジェクトを通じて、特に効果が大きかったのは次の種類のassetでした。

Assetコード化が効いた理由
9×9の盤寸法とゲーム座標の契約が強い
同系統キャラクターBase形状やsocketを共有できる
多数の敵バリエーションDefinitionの差分を小さくできる
Rigbone名をUnity contractとして固定できる
Animationkeyframe区間やroot不変条件を検証できる
Exportaxis / node / material条件を固定できる

逆に、一点物の複雑なsculptをコードで無理に表現する価値は低い場合があります。

目的は「Blenderの全操作をPythonへ置き換える」ことではありません。

再生成性とゲーム側契約に価値がある部分だけ、コードを正本にする。

これが今のところ一番実用的でした。