- Static-mesh scale test passes: 1 m = 100.00 cm, origin preserved, axis mapping east→+X / north→−Y / up→+Z (named presets ue_metric_v1 both sides). - MPFB 2.0.18 acceptance passes: generated human + standard 137-bone rig with weights + v2 skin baked to 1024² diffuse; imports as SkeletalMesh with Skeleton, Material, and Texture2D in /Game/Test/Character. - MPFB headless requires XDG_CONFIG_HOME at the Flatpak config tree; documented in Art/blender/README.md, probe in Tools/addon_compat_probe.py. - Docs/build.md completed: fresh-checkout build, packaging, headless checks. - Docs/setup.md reconciled with reality; ROADMAP/bootstrap closed out.
149 lines
5.1 KiB
Python
149 lines
5.1 KiB
Python
# MPFB character export for the Blender -> Unreal pipeline (0.1.0 acceptance).
|
|
#
|
|
# Generates a default human, adds the standard deform rig with weights,
|
|
# materializes the v2 skin, bakes its diffuse to a texture, and exports
|
|
# FBX with the named export preset "ue_character_v1".
|
|
#
|
|
# blender -b --factory-startup --python Tools/mpfb_character_export.py -- \
|
|
# --out /tmp/gigline/character.fbx \
|
|
# --save-blend Art/blender/character/character-<date>.blend
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
import bpy
|
|
|
|
REPORT = "/tmp/mpfb-export-report.txt"
|
|
lines = []
|
|
|
|
|
|
def log(msg):
|
|
lines.append(str(msg))
|
|
|
|
|
|
def enable_mpfb():
|
|
bpy.ops.preferences.addon_enable(module="bl_ext.blender_org.mpfb")
|
|
assert "bl_ext.blender_org.mpfb" in bpy.context.preferences.addons, "MPFB did not enable"
|
|
|
|
|
|
def build_character():
|
|
for obj in list(bpy.data.objects):
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
|
|
bpy.ops.mpfb.create_human("EXEC_DEFAULT")
|
|
human = bpy.context.active_object
|
|
log(f"create_human: {human.name}, {len(human.data.vertices)} verts, dims={[round(d, 3) for d in human.dimensions]}")
|
|
|
|
bpy.ops.mpfb.add_standard_rig("EXEC_DEFAULT")
|
|
armatures = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
|
log(f"add_standard_rig: {len(armatures)} armature(s), bones={[len(a.data.bones) for a in armatures]}")
|
|
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
human.select_set(True)
|
|
bpy.context.view_layer.objects.active = human
|
|
bpy.ops.mpfb.create_v2_skin("EXEC_DEFAULT")
|
|
mats = [m.name for m in human.data.materials]
|
|
log(f"create_v2_skin: materials={mats}")
|
|
return human
|
|
|
|
|
|
def bake_diffuse(human, size=1024):
|
|
mat = human.data.materials[0]
|
|
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
|
|
|
img = bpy.data.images.new("T_Skin_Diffuse", size, size)
|
|
tex_node = nodes.new("ShaderNodeTexImage")
|
|
tex_node.image = img
|
|
nodes.active = tex_node
|
|
tex_node.select = True
|
|
|
|
bsdf = next((n for n in nodes if n.type == "BSDF_PRINCIPLED"), None)
|
|
bsdf_input = bsdf.inputs["Base Color"] if bsdf else None
|
|
if bsdf is not None:
|
|
for link in list(links):
|
|
if link.to_node == bsdf and link.to_socket == bsdf_input:
|
|
links.remove(link)
|
|
links.new(tex_node.outputs["Color"], bsdf_input)
|
|
|
|
scene = bpy.context.scene
|
|
scene.render.engine = "CYCLES"
|
|
scene.cycles.device = "CPU"
|
|
scene.cycles.samples = 1
|
|
scene.render.bake.use_pass_direct = False
|
|
scene.render.bake.use_pass_indirect = False
|
|
scene.render.bake.use_pass_color = True
|
|
scene.render.bake.margin = 8
|
|
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
human.select_set(True)
|
|
bpy.context.view_layer.objects.active = human
|
|
bpy.ops.object.bake(type="DIFFUSE", pass_filter={"COLOR"}, use_clear=True)
|
|
img.filepath = "/tmp/gigline/T_Skin_Diffuse.png"
|
|
img.filepath_raw = img.filepath
|
|
img.file_format = "PNG"
|
|
img.save()
|
|
log(f"bake: baked {size}x{size} diffuse -> {img.filepath} (filepath now set for FBX)")
|
|
|
|
# MPFB's v2 skin is a node-group tree the FBX exporter cannot map to a
|
|
# texture. Build a flat export material carrying the baked diffuse so the
|
|
# FBX (and therefore Unreal) gets a real texture.
|
|
export_mat = bpy.data.materials.new("M_Character_Export")
|
|
export_mat.use_nodes = True
|
|
nt = export_mat.node_tree
|
|
nt.nodes.clear()
|
|
out = nt.nodes.new("ShaderNodeOutputMaterial")
|
|
bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
|
|
tex = nt.nodes.new("ShaderNodeTexImage")
|
|
tex.image = img
|
|
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
|
|
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
|
|
human.data.materials.clear()
|
|
human.data.materials.append(export_mat)
|
|
log("export material: M_Character_Export with baked texture assigned")
|
|
|
|
|
|
def export_fbx(human, path):
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
# Named export preset "ue_character_v1" — change only via a new preset name.
|
|
bpy.ops.export_scene.fbx(
|
|
filepath=path,
|
|
use_selection=False,
|
|
object_types={"ARMATURE", "MESH"},
|
|
apply_unit_scale=True,
|
|
apply_scale_options="FBX_SCALE_NONE",
|
|
axis_forward="-Y",
|
|
axis_up="Z",
|
|
use_space_transform=True,
|
|
use_mesh_modifiers=True,
|
|
add_leaf_bones=False,
|
|
path_mode="COPY",
|
|
embed_textures=False,
|
|
mesh_smooth_type="OFF",
|
|
bake_space_transform=False,
|
|
)
|
|
log(f"export: FBX preset ue_character_v1 -> {path}")
|
|
|
|
|
|
def main():
|
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--out", default="/tmp/gigline/character.fbx")
|
|
parser.add_argument("--save-blend", default=None)
|
|
args = parser.parse_args(argv)
|
|
|
|
enable_mpfb()
|
|
human = build_character()
|
|
bake_diffuse(human)
|
|
if args.save_blend:
|
|
os.makedirs(os.path.dirname(args.save_blend), exist_ok=True)
|
|
bpy.ops.wm.save_as_mainfile(filepath=args.save_blend)
|
|
log(f"save: {args.save_blend}")
|
|
export_fbx(human, args.out)
|
|
|
|
with open(REPORT, "w") as f:
|
|
f.write("\n".join(lines) + "\n")
|
|
|
|
|
|
main()
|