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:
zwitschi committed 2026-11-07 14:11:06 +01:00
1 parent e48a0cd82b
commit c11cb4caa4
27 files changed
+119 -99

No files matched your search

+5
View File
@@ -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.
+7
View File
@@ -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
View File
@@ -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`).
+3 -3
View File
@@ -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)
![MPFB mannequin, mid shot](review-01-character.png) ![MPFB mannequin, idle pose + warm skin](review-01-character.png)
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.
+86 -42
View File
@@ -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)
+2 -2
View File
@@ -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"