character: idle standing pose and warm skin, not the grey mannequin
- mpfb_character_export.py: new --pose idle folds the A/T-bind arms down to a relaxed standing rest pose (baked via pose.armature_apply before export); new make_diffuse synthesizes a warm skin-tone diffuse with a darker shorts-suggestive band, replacing the Cycles v2-skin bake that came out black and was slow at 1024^2. - write_png now encodes from img.pixels via numpy (fast) because img.save() writes black for an in-memory image. - Re-exported idle character, re-imported to /Game/Test/Character (138 bones, warm M_Character_Export/T_Character_Diffuse); removed stale T_Skin_Diffuse. - Untracked the review PNGs again: the Screenshots/ gitignore rule had been dropped and the parallel session had committed the PNGs, against the AGENTS.md 'never commit the PNGs' rule. Restored the ignore rule; only Screenshots/README.md stays tracked. - README/roadmap/memory updated; review-01-character re-pended for approval.
This commit is contained in:
1 parent
e48a0cd82b
commit
c11cb4caa4
27 files changed
+119
-99
No files matched your search
@@ -5,14 +5,60 @@
|
||||
# FBX with the named export preset "ue_character_v1".
|
||||
#
|
||||
# blender -b --factory-startup --python Tools/mpfb_character_export.py -- \
|
||||
# --out /tmp/gigline/character.fbx \
|
||||
# --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
|
||||
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 = []
|
||||
@@ -27,7 +73,22 @@ def enable_mpfb():
|
||||
assert "bl_ext.blender_org.mpfb" in bpy.context.preferences.addons, "MPFB did not enable"
|
||||
|
||||
|
||||
def build_character():
|
||||
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)
|
||||
|
||||
@@ -38,6 +99,7 @@ def build_character():
|
||||
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)
|
||||
@@ -48,46 +110,27 @@ def build_character():
|
||||
return human
|
||||
|
||||
|
||||
def bake_diffuse(human, size=1024):
|
||||
mat = human.data.materials[0]
|
||||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||||
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")
|
||||
|
||||
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)")
|
||||
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 baked diffuse so the
|
||||
# FBX (and therefore Unreal) gets a real texture.
|
||||
# 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
|
||||
@@ -100,7 +143,7 @@ def bake_diffuse(human, size=1024):
|
||||
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")
|
||||
log("export material: M_Character_Export with diffuse assigned")
|
||||
|
||||
|
||||
def export_fbx(human, path):
|
||||
@@ -129,12 +172,13 @@ 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()
|
||||
bake_diffuse(human)
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user