kikuta@dev:~$cat ./notes/blender-python-modular-characters.md
·13 min·kikuta

Blender Pythonでキャラクターを量産する:Base・Socket・Parts・Definition

共通Body、安定Socket、再利用Part、Form Definitionから多数のキャラクターGLBを生成する構成。

Slime Mercenariesでは、職業・進化形・敵を増やすたびに3Dモデルを一から作らないよう、Blender Python側を部品化しています。

現在の普通のスライムは、概念的に次の構成です。

canonical Base Slime
+ stable sockets
+ reusable parts
+ per-form definition
= standalone GLB

Tier 1スライムの生成モデル

この記事では、キャラクターをPythonで量産するときに「何を共通化し、何をDefinitionへ残すか」を実装ベースで整理します。

body generatorを職業ごとにコピーしない

スライムにはSword、Shield、Bow、Magic、Rogue、Gunなどの系統があります。さらに上位形ではBlademaster、Berserker、Sniper、Archmageなどへ分岐します。

ここで sword.py、shield.py、bow.py のそれぞれにbody生成処理をコピーすると、目の位置や体型を直すたびに全fileを修正することになります。そのためordinary formは一つのcanonical bodyを共有しています。

def create_body(
    root,
    material,
    *,
    scale=(1.0, 1.0, 1.0),
):
    bpy.ops.mesh.primitive_uv_sphere_add(
        segments=40,
        ring_count=24,
        radius=1.0,
    )

    body = bpy.context.active_object

    for vertex in body.data.vertices:
        point = deform_base_vertex(vertex.co)
        vertex.co = point

元はUV sphereですが、各vertexを deform_base_vertex で変形して、低く広いjelly silhouetteへ変えています。職業差をbody topologyで作るのではなく、主に装備とmotionで作ります。

Shape KeyもBaseで一度だけ作る

bodyには共通のmorph targetを持たせています。

Basis
Squash
Stretch
LeanLeft
LeanRight
WobbleLeft
WobbleRight
HitLeft
HitRight

例えばSquashはBasisの各vertexから生成します。

add_shape(
    "Squash",
    lambda point: Vector((
        point.x * (
            1.24 - 0.04 * min(1.0, point.z / BODY_HEIGHT)
        ),
        point.y * 1.18,
        BODY_BASE_Z
            + max(0.0, point.z - BODY_BASE_Z) * 0.70,
    )),
)

職業ごとに別のSquash meshを作らないため、runtime animation側も同じ名前のmorphを扱えます。形状の微修正もBase側へ一度入れれば全ordinary formへ反映できます。

Socketをruntime APIとして固定する

キャラクター生成では、mesh名よりsocket名の方が長く使う契約になります。

Base Slimeは次のattachment pointを常に持ちます。

HeadSocket
FrontLeftSocket
FrontRightSocket
BackSocket
WeaponSocket
OffhandSocket
ProjectileOrigin
SpellOrigin

これらはEmpty objectとして生成します。

transforms = {
    "HeadSocket": (
        (0.0, -0.02, 1.06),
        (0.0, 0.0, 0.0),
    ),
    "WeaponSocket": (
        (0.0, 0.0, 0.0),
        (0.0, 0.0, 0.0),
    ),
    "ProjectileOrigin": (
        (0.0, -0.92, 0.60),
        (0.0, 0.0, 0.0),
    ),
    "SpellOrigin": (
        (0.0, -0.92, 0.70),
        (0.0, 0.0, 0.0),
    ),
}

runtimeは「Sword meshがどこにあるか」ではなく、WeaponSocketやProjectileOriginを契約として利用できます。モデル内部の造形を変えても、socket contractを保てばgame codeへの波及を抑えられます。

BuildContextに共通依存をまとめる

Part builderへscene全体を直接触らせるのではなく、必要な共通要素をBuildContextで渡します。

@dataclass(slots=True)
class BuildContext:
    root: bpy.types.Object
    sockets: dict[str, bpy.types.Object]
    materials: dict[str, bpy.types.Material]

Materialもcontext経由でcacheします。

def material(
    self,
    name,
    color,
    *,
    roughness,
    metallic=0.0,
):
    existing = self.materials.get(name)
    if existing is not None:
        return existing

    material = make_material(...)
    self.materials[name] = material
    return material

Part側が同名Materialを大量に生成したり、別のrootへ勝手にobjectをぶら下げたりする余地を減らせます。

Definitionは小さく保つ

Sword Slimeのdefinitionは、body差分と使うpartだけです。

DEFINITION = SlimeDefinition(
    slug="sword",
    display_name="Sword Slime",
    body_color=(0.045, 0.48, 0.96, 1.0),
    body_material_name="SlimeBlue",
    motion_profile="sword",
)

def build_parts(ctx: BuildContext) -> None:
    create_basic_sword(ctx)

Blademasterでもbody generatorは変えません。

DEFINITION = SlimeDefinition(
    slug="blademaster",
    display_name="Blademaster Slime",
    body_color=(0.045, 0.48, 0.96, 1.0),
    body_material_name="SlimeBlue",
    motion_profile="blademaster",
)

def build_parts(ctx: BuildContext) -> None:
    create_blademaster_scarf(ctx)
    create_blademaster_sword(ctx)

強い形態だからといってbodyを巨大化させず、weapon、scarf、attack motionなどで差を作ります。Tier差を作る責務をbody scaleだけに寄せないためです。

central registryを増やさない

フォーム数が増えると、中央の巨大registryへ全slugを追加したくなります。現在のslime builderではslugからmoduleを直接importします。

def load_definition(slug: str):
    module_name = slug.replace("-", "_")

    module = importlib.import_module(
        f"slimes.definitions.{module_name}"
    )

    definition = getattr(
        module,
        "DEFINITION",
        None,
    )

    build_parts = getattr(
        module,
        "build_parts",
        None,
    )

    return definition, build_parts

新しいformを追加するときは definitions/new_form.py を追加すればよく、全formが並ぶ中央fileを編集する必要がありません。

複数キャラクターを並列で制作するときに、共有registryのmerge conflictが減るという効果もあります。

敵は「family grammar + definition」にする

敵はさらにfamily単位で共通化しています。

family body grammar
+ per-form definition
= standalone enemy GLB

Mushroom familyでは、cap profile、body profile、eye style、cap scale、stem scale、spot layout、spore pouch、boss sproutなどをDefinitionへ渡します。

@dataclass(frozen=True)
class MushroomDefinition:
    slug: str
    cap_color: tuple[float, float, float, float]
    stem_color: tuple[float, float, float, float]
    cap_scale: tuple[float, float, float]
    stem_scale: tuple[float, float, float]
    cap_height: float
    cap_profile: str = "button"
    body_profile: str = "bean"

同じキノコでも、単純なscale差ではなく形状grammarを切り替えます。

button + bean
puff   + dumpling
bell   + lantern
reishi + boss

Great Mushroomのgallery確認

Great Mushroomは通常キノコの拡大ではなく、reishi型の多層cap、Belly、boss sproutsなど専用構造を持ちます。

Family builderもdynamicに解決する

敵側も全slugの中央registryを持たず、family moduleをimportしてbuilder contractを解決します。

def resolve_family_builder(family: str):
    module = importlib.import_module(
        f"enemies.families.{family}"
    )

    definition_type = getattr(
        module,
        "DEFINITION_TYPE",
    )
    build = getattr(
        module,
        "build_enemy",
    )

    return EnemyFamilyBuilder(
        family=family,
        definition_type=definition_type,
        build=build,
    )

各family moduleがDEFINITION_TYPEとbuild_enemyを公開すれば、Mushroom、Leaf、Flower、Critterなどを同じbuild commandへ接続できます。

Sourceでは共通化し、runtimeではstandalone GLBにする

ここは重要なトレードオフです。

Source側ではbodyやpartsを共有していますが、runtimeでは一体ごとに独立したGLBを書き出します。

source:
  Base + Parts + Definition

runtime:
  sword.glb
  shield.glb
  blademaster.glb
  ...

runtimeまで部品合成方式にすると、load順、attachment、material共有、instance lifecycle、animation anchorなどの複雑さが増えます。

現在の規模では、Source reuseは欲しい一方でruntime compositionは不要です。そのためbuild時に組み立ててstandalone assetへ落としています。

共通化しすぎない

Base化は強力ですが、すべてをparameter化すると巨大な万能generatorになります。

Great Mushroomのように、同じfamilyでも専用geometryやsecondary pivotが必要なformがあります。

分け方としては次を基準にしています。

同じ意味を持つ構造
  -> Base / Partへ

見た目が似ているだけで
runtime上の役割が違う構造
  -> Form / Family固有へ

Pythonでモデルを量産するときに重要なのはparameter数を増やすことではなく、runtimeが依存する安定部分だけを共通化することでした。