The e2e asset check now completes: PASS on all six steps - empty maps 9 s, demo build 10 s, review build 9 s, captures 80 s, judge 388 s, review file. Four breakages from the folder move had to be fixed first: - ue_placement and ue_common are siblings of the scripts that import them, but the rewrite put only the tools root on sys.path, so every Unreal commandlet died with ModuleNotFoundError. Both paths are inserted now. - render_screenshots.sh resolved the repository root one level up, so commandlet paths became Tools/Tools/... and the capture step failed in 0 s. - the driver treated a result file left by an earlier run as success, which is how a ten-second 'demo build' passed without building. Result files are unlinked before each step and child output streams to a per-step log file rather than a pipe. - two editors on one project hang instead of failing, so the driver refuses to start while one is running. Also corrects ue_recreate_maps.py: LevelEditorSubsystem.new_level() refuses a path whose package is still in memory and delete_asset() leaves the file on disk, measured create->delete->create returning False. The step now loads each map, destroys every actor, saves, and reports the count it left (0), which is the property the builders actually need. Open question recorded in TODO.md: the rebuilt map holds 409 actors and 613 KB against 600 and 886 KB before, and the ~190 missing ones look like the facade-kit and theatre-trim placements that run as separate steps.
183 lines
6.7 KiB
Python
183 lines
6.7 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/blender/mpfb_character_export.py -- \
|
|
# --out /tmp/gigline/character.fbx --pose idle \
|
|
# --save-blend Art/blender/character/character-<date>.blend
|
|
|
|
# --pose applies one of the named standing poses to the standard rig and bakes
|
|
# it into the rest pose before export, so the caller-controlled vertex
|
|
# weights still deform the exported bind correctly (pose | none | idle).
|
|
|
|
import argparse
|
|
import os
|
|
import struct
|
|
import sys
|
|
import zlib
|
|
|
|
import bpy
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
|
|
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
|
|
import fbx_export_preset as fbx_preset # noqa: E402
|
|
|
|
import numpy
|
|
|
|
from mathutils import Euler
|
|
|
|
|
|
def write_png(path, img):
|
|
# Write image pixels to PNG. `img.save()` writes black for an in-memory
|
|
# image with no backing file even though the bake updates img.pixels, and a
|
|
# per-pixel stdlib loop is far too slow (256^2 took 25 s). numpy makes the
|
|
# whole encode ~0.3 s for 1024^2; Blender ships numpy.
|
|
w, h = img.size
|
|
arr = numpy.asarray(img.pixels[:], dtype=numpy.float32).reshape(h, w, 4)
|
|
rgba = (numpy.clip(arr, 0.0, 1.0) * 255.0 + 0.5).astype(numpy.uint8)[::-1]
|
|
byt = rgba.tobytes()
|
|
raw = bytearray()
|
|
for y in range(h):
|
|
raw.append(0) # filter type 0
|
|
raw += byt[y * w * 4:(y + 1) * w * 4]
|
|
|
|
def chunk(tag, data):
|
|
c = struct.pack(">I", len(data)) + tag + data
|
|
c += struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)
|
|
return c
|
|
|
|
ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)
|
|
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
|
|
png += chunk(b"IDAT", zlib.compress(bytes(raw))) + chunk(b"IEND", b"")
|
|
with open(path, "wb") as f:
|
|
f.write(png)
|
|
|
|
IDLE_POSE = {
|
|
# Brings the A/T bind arms down into a relaxed standing position.
|
|
# Values are local-pose euler deltas; verified 2026-11-07.
|
|
"shoulder01.L": (0.0, -0.15, -0.03),
|
|
"shoulder01.R": (0.0, 0.15, 0.03),
|
|
"upperarm01.L": (0.0, 0.0, -1.55),
|
|
"upperarm01.R": (0.0, 0.0, 1.55),
|
|
"lowerarm01.L": (0.0, 0.0, -0.10),
|
|
"lowerarm01.R": (0.0, 0.0, 0.10),
|
|
}
|
|
|
|
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 apply_pose(arm, name="idle"):
|
|
if name == "none":
|
|
return
|
|
table = IDLE_POSE
|
|
bpy.context.view_layer.objects.active = arm
|
|
bpy.ops.object.mode_set(mode="POSE")
|
|
for bone_name, eul in table.items():
|
|
bone = arm.pose.bones[bone_name]
|
|
bone.rotation_mode = "XYZ"
|
|
bone.rotation_euler = Euler(eul)
|
|
bpy.ops.pose.armature_apply()
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
log(f"pose: applied '{name}' to rest pose")
|
|
|
|
|
|
def build_character(pose="idle"):
|
|
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]}")
|
|
apply_pose(armatures[0], pose)
|
|
|
|
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 make_diffuse(human, size=1024):
|
|
# A Cycles bake of MPFB's v2-skin node group returns black and is slow at
|
|
# 1024^2 headless. We ship a flat export material anyway (the node tree
|
|
# does not map to FBX), so synthesize a warm skin-tone diffuse directly.
|
|
img = bpy.data.images.new("T_Character_Diffuse", size, size)
|
|
body = (0.72, 0.52, 0.38, 1.0) # warm skin
|
|
cloth = (0.30, 0.34, 0.40, 1.0) # grey-blue, bottom band to suggest clothing
|
|
band = int(size * 0.25)
|
|
pixels = []
|
|
for y in range(size):
|
|
tone = cloth if y < band else body
|
|
pixels += list(tone) * size
|
|
img.pixels[:] = pixels
|
|
log(f"make_diffuse: synthesized {size}x{size} body/shorts-suggestive diffuse")
|
|
|
|
write_png("/tmp/gigline/T_Character_Diffuse.png", img)
|
|
log("make_diffuse: wrote diffuse via stdlib PNG writer -> /tmp/gigline/T_Character_Diffuse.png")
|
|
|
|
# MPFB's v2 skin is a node-group tree the FBX exporter cannot map to a
|
|
# texture. Build a flat export material carrying the 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 diffuse 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.
|
|
fbx_preset.export_character(path)
|
|
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("--pose", choices=("none", "idle"), default="idle")
|
|
parser.add_argument("--save-blend", default=None)
|
|
args = parser.parse_args(argv)
|
|
|
|
enable_mpfb()
|
|
human = build_character(pose=args.pose)
|
|
make_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()
|