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
@@ -52,6 +52,11 @@ Thumbs.db
|
|||||||
# Blender/Blosm working caches
|
# Blender/Blosm working caches
|
||||||
/Art/blender/.cache/
|
/Art/blender/.cache/
|
||||||
|
|
||||||
|
# Rendered screenshots for visual review — generated locally, never committed.
|
||||||
|
# Keeps only the review README (see AGENTS.md Visual review).
|
||||||
|
/Screenshots/*
|
||||||
|
!/Screenshots/README.md
|
||||||
|
|
||||||
# Scratch files
|
# Scratch files
|
||||||
.tmp-*
|
.tmp-*
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -140,3 +140,10 @@ Scope: facts and traps that cost time to establish. Planned work lives in [ROADM
|
|||||||
- **Docs drift silently after a refactor.** Moving the geo scripts to `Geo/tools/` and the Blender builders into `Art/blender/*/` left stale paths in six documents and two tool READMEs. Grep for old paths (`.md` and `.py`) after any move, not just the ones that error at runtime.
|
- **Docs drift silently after a refactor.** Moving the geo scripts to `Geo/tools/` and the Blender builders into `Art/blender/*/` left stale paths in six documents and two tool READMEs. Grep for old paths (`.md` and `.py`) after any move, not just the ones that error at runtime.
|
||||||
- **Every new tool needs a row in `Tools/README.md` and `Docs/build.md`**, or the next session will not know it exists. Both now list the full content pipeline in order.
|
- **Every new tool needs a row in `Tools/README.md` and `Docs/build.md`**, or the next session will not know it exists. Both now list the full content pipeline in order.
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-11-07 — Character diffuse bake
|
||||||
|
|
||||||
|
- **`img.save()` writes black for an image created in memory** even though Cycles `bpy.ops.object.bake` does update `img.pixels` correctly (verified in-memory px were warm). Writing the PNG ourselves from `img.pixels` is required.
|
||||||
|
- **A per-pixel `struct.pack` loop over `img.pixels` is catastrophic in headless Blender** (256² took 25 s). Blender ships numpy: read `img.pixels[:]` once into a float array, reshape `(h,w,4)`, clip→uint8, reverse rows for PNG top-down, then zlib-compress. Whole 1024² encode ≈ 0.3 s.
|
||||||
|
- **MPFB's v2-skin node tree does not resolve to a simple base colour**, so a Cycles DIFFUSE bake returns black. Since the FBX export material replaces the node tree anyway, synthesize the diffuse directly (`make_diffuse`): warm skin body + a darker shorts-suggestive bottom band.
|
||||||
|
- **A read-only `.umap` silently makes `save_map` return `False` with no error** (already recorded); re-hit in the review pipeline — `chmod u+w` the maps before rebuilding, otherwise the new character material never appears in shots.
|
||||||
+5
-2
@@ -79,9 +79,12 @@ Owner: this session. Scope: the review scene used for all visual approval — th
|
|||||||
- [x] Extend the review floor beyond the hero façade: 160 m square centred on the façade, so façade shots stand on ground instead of the horizon band.
|
- [x] Extend the review floor beyond the hero façade: 160 m square centred on the façade, so façade shots stand on ground instead of the horizon band.
|
||||||
- [x] Delete retired shots: the old `first-person` frame is gone and the camera list is now a single table so a change cannot leave an orphan.
|
- [x] Delete retired shots: the old `first-person` frame is gone and the camera list is now a single table so a change cannot leave an orphan.
|
||||||
|
|
||||||
### Character development - open
|
### Character development - active - 01a1b26b-3c94-707f-9665-2f6b4a79b564
|
||||||
|
|
||||||
Follow-on work on the player avatar, after the review scene passes. None of it is `0.1.0` scope unless a release check requires it.
|
Follow-on work on the player avatar, after the review scene passes. None of it is `0.1.0` scope unless a release check requires it. Taking the standing idle pose and the second skin/clothing-layer items here; the player-identity decision and the demo-map capsule belong to the coordinator / gameplay owner.
|
||||||
|
|
||||||
|
- [x] Standing idle pose: `Tools/mpfb_character_export.py --pose idle` folds the A/T-bind arms down to a relaxed standing rest pose (baked via `pose.armature_apply` before export). Verified: `upperarm01.L` rest z −0.71 → −1.91 rad; re-exported, re-imported (138 bones) and rendered in `review-01-character`.
|
||||||
|
- [x] Second skin tone / clothing layer: the v2-skin bake came out **black** (sampled RGB 0,0,0) and is slow at 1024²; we now synthesize a warm skin-tone diffuse directly (`Tools/mpfb_character_export.py` `make_diffuse`, with a darker shorts-suggestive bottom band) written to disk via a stdlib/numpy PNG encoder, because `img.save()` writes black for an in-memory image. Character now reads warm, not grey. Screenshot review pending (`Screenshots/review-01-character` re-pended).
|
||||||
|
|
||||||
- [ ] Decide what the `0.1.0` player is: first-person-only, or a body that is visible in third person. The `0.2.0` milestone adds the MPFB body and camera toggle; `0.1.0` currently ships a capsule with a camera and no visible body.
|
- [ ] Decide what the `0.1.0` player is: first-person-only, or a body that is visible in third person. The `0.2.0` milestone adds the MPFB body and camera toggle; `0.1.0` currently ships a capsule with a camera and no visible body.
|
||||||
- [ ] Give the MPFB character asset a standing idle pose so review and gameplay shots show a person, not an A-pose bind (`/Game/Test/Character/character`).
|
- [ ] Give the MPFB character asset a standing idle pose so review and gameplay shots show a person, not an A-pose bind (`/Game/Test/Character/character`).
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ add new shots there and delete retired ones from this folder.
|
|||||||
|
|
||||||
Character and both cubes on the 160 m floor, camera 7 m up looking north-east. Reads the whole review layout at once.
|
Character and both cubes on the 160 m floor, camera 7 m up looking north-east. Reads the whole review layout at once.
|
||||||
|
|
||||||
## review-01-character.png — MPFB mannequin, mid shot, approval: ☑ approved 2026-11-07
|
## review-01-character.png — MPFB mannequin, idle pose + warm skin, approval: ☐ pending (regenerated 2026-11-07)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The imported MPFB character at eye height from the south. Check the feet meet the plane: the contact shadow joins the soles.
|
The imported MPFB character at eye height from the south, in a relaxed standing idle pose (arms down, not A-pose) with a warm skin-tone diffuse instead of the grey mannequin. Check the feet meet the plane: the contact shadow joins the soles.
|
||||||
|
|
||||||
## review-02-scale-cubes.png — Scale cubes, approval: ☑ approved 2026-11-07
|
## review-02-scale-cubes.png — Scale cubes, approval: ☑ approved 2026-11-07
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,14 +5,60 @@
|
|||||||
# FBX with the named export preset "ue_character_v1".
|
# FBX with the named export preset "ue_character_v1".
|
||||||
#
|
#
|
||||||
# blender -b --factory-startup --python Tools/mpfb_character_export.py -- \
|
# 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
|
# --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 argparse
|
||||||
import os
|
import os
|
||||||
|
import struct
|
||||||
import sys
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
import bpy
|
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"
|
REPORT = "/tmp/mpfb-export-report.txt"
|
||||||
lines = []
|
lines = []
|
||||||
@@ -27,7 +73,22 @@ def enable_mpfb():
|
|||||||
assert "bl_ext.blender_org.mpfb" in bpy.context.preferences.addons, "MPFB did not enable"
|
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):
|
for obj in list(bpy.data.objects):
|
||||||
bpy.data.objects.remove(obj, do_unlink=True)
|
bpy.data.objects.remove(obj, do_unlink=True)
|
||||||
|
|
||||||
@@ -38,6 +99,7 @@ def build_character():
|
|||||||
bpy.ops.mpfb.add_standard_rig("EXEC_DEFAULT")
|
bpy.ops.mpfb.add_standard_rig("EXEC_DEFAULT")
|
||||||
armatures = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
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]}")
|
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")
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
human.select_set(True)
|
human.select_set(True)
|
||||||
@@ -48,46 +110,27 @@ def build_character():
|
|||||||
return human
|
return human
|
||||||
|
|
||||||
|
|
||||||
def bake_diffuse(human, size=1024):
|
def make_diffuse(human, size=1024):
|
||||||
mat = human.data.materials[0]
|
# A Cycles bake of MPFB's v2-skin node group returns black and is slow at
|
||||||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
# 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)
|
write_png("/tmp/gigline/T_Character_Diffuse.png", img)
|
||||||
tex_node = nodes.new("ShaderNodeTexImage")
|
log("make_diffuse: wrote diffuse via stdlib PNG writer -> /tmp/gigline/T_Character_Diffuse.png")
|
||||||
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
|
# 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
|
# texture. Build a flat export material carrying the diffuse so the FBX
|
||||||
# FBX (and therefore Unreal) gets a real texture.
|
# (and therefore Unreal) gets a real texture.
|
||||||
export_mat = bpy.data.materials.new("M_Character_Export")
|
export_mat = bpy.data.materials.new("M_Character_Export")
|
||||||
export_mat.use_nodes = True
|
export_mat.use_nodes = True
|
||||||
nt = export_mat.node_tree
|
nt = export_mat.node_tree
|
||||||
@@ -100,7 +143,7 @@ def bake_diffuse(human, size=1024):
|
|||||||
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
|
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
|
||||||
human.data.materials.clear()
|
human.data.materials.clear()
|
||||||
human.data.materials.append(export_mat)
|
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):
|
def export_fbx(human, path):
|
||||||
@@ -129,12 +172,13 @@ def main():
|
|||||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--out", default="/tmp/gigline/character.fbx")
|
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)
|
parser.add_argument("--save-blend", default=None)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
enable_mpfb()
|
enable_mpfb()
|
||||||
human = build_character()
|
human = build_character(pose=args.pose)
|
||||||
bake_diffuse(human)
|
make_diffuse(human)
|
||||||
if args.save_blend:
|
if args.save_blend:
|
||||||
os.makedirs(os.path.dirname(args.save_blend), exist_ok=True)
|
os.makedirs(os.path.dirname(args.save_blend), exist_ok=True)
|
||||||
bpy.ops.wm.save_as_mainfile(filepath=args.save_blend)
|
bpy.ops.wm.save_as_mainfile(filepath=args.save_blend)
|
||||||
|
|||||||
@@ -48,14 +48,14 @@ def import_texture_and_material():
|
|||||||
# Interchange 5.8 does not create material/texture assets for skeletal
|
# Interchange 5.8 does not create material/texture assets for skeletal
|
||||||
# FBX imports; finish the baked-material chain explicitly.
|
# FBX imports; finish the baked-material chain explicitly.
|
||||||
task = unreal.AssetImportTask()
|
task = unreal.AssetImportTask()
|
||||||
task.filename = "/tmp/gigline/T_Skin_Diffuse.png"
|
task.filename = "/tmp/gigline/T_Character_Diffuse.png"
|
||||||
task.destination_path = DEST
|
task.destination_path = DEST
|
||||||
task.automated = True
|
task.automated = True
|
||||||
task.save = True
|
task.save = True
|
||||||
task.replace_existing = True
|
task.replace_existing = True
|
||||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||||
|
|
||||||
tex = unreal.EditorAssetLibrary.load_asset(f"{DEST}/T_Skin_Diffuse")
|
tex = unreal.EditorAssetLibrary.load_asset(f"{DEST}/T_Character_Diffuse")
|
||||||
log(f"texture: {tex.get_class().get_name() if tex else 'MISSING'}")
|
log(f"texture: {tex.get_class().get_name() if tex else 'MISSING'}")
|
||||||
|
|
||||||
mat_path = f"{DEST}/M_Character_Export"
|
mat_path = f"{DEST}/M_Character_Export"
|
||||||
|
|||||||
Reference in new issue
Block a user