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

この記事では、キャラクターを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は通常キノコの拡大ではなく、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が依存する安定部分だけを共通化することでした。