Segmentation: - Face-based exclusive assignment (no overlap, no caps needed) - Hips split from torso (pelvis as own segment) - Torso_upper independent (spine_03 + clavicle, not a variant) - Clavicle moved from arm_upper to torso_upper Hair pipeline: - Solidify modifier (20mm) for scalp hair volume - Normal recalculation before solidify for consistent direction - Eyebrows/facial hair NOT solidified - Fixed mask generation (was outputting black, now white) Clothing pipeline: - Solidify modifier (25mm) for clothing thickness - Peasant shoes added from Fantasy pack Tooling: - convert_outfit.py with solidify support - inspect_glb.py, check_hair_symmetry.py for debugging - Segment reference distribution locked - Q-063 footsteps VFX filed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
249 lines
9.8 KiB
Python
249 lines
9.8 KiB
Python
"""
|
|
blender_process_hair.py
|
|
Usage: tooling/blender --background --python tooling/blender_process_hair.py -- <source_base_dir> <hair_out_dir> <facial_hair_out_dir> <eyebrows_out_dir>
|
|
|
|
Converts Quaternius Source tier hairstyle GLTF files (rigged to Head bone) into
|
|
production GLBs with mask PNG sidecars.
|
|
|
|
Source base dir: .../Hairstyles/Rigged to Head Bone/glTF (Godot -Unreal)/
|
|
|
|
Output:
|
|
hair/ {key}.glb + {key}_mask.png (hair styles)
|
|
facial_hair/ {key}.glb + {key}_mask.png (beard, moustache, mutton_chops)
|
|
eyebrows/ {key}.glb (no mask — used as-is, tinted by shader)
|
|
|
|
Naming conventions from architecture doc (D-164):
|
|
Quaternius name -> file key
|
|
Hair_Bob -> bob
|
|
Hair_Buns -> buns
|
|
Hair_BuzzedFemale -> buzzed_female
|
|
Hair_Long -> long
|
|
Hair_LongDreads -> long_dreads
|
|
Hair_Ponytail_2 -> ponytail_f
|
|
Hair_Balding -> balding
|
|
Hair_Buzzed -> buzzed
|
|
Hair_Dreads -> dreads
|
|
Hair_Mohawk -> mohawk
|
|
Hair_Ponytail -> ponytail
|
|
Hair_SimpleParted -> simple_parted
|
|
Hair_SlickBack -> slick_back
|
|
Hair_Beard -> beard [facial_hair/]
|
|
Hair_Moustache -> moustache [facial_hair/]
|
|
Hair_MuttonChops -> mutton_chops [facial_hair/]
|
|
Eyebrows_Female -> female [eyebrows/]
|
|
Eyebrows_Regular -> regular [eyebrows/]
|
|
Eyebrows_Teen -> teen [eyebrows/]
|
|
Eyebrows_Thick -> thick [eyebrows/]
|
|
|
|
Also produces bald.glb (minimal empty mesh placeholder for 'no hair' slot).
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import bpy
|
|
|
|
# (source_gltf_relative_to_base, output_key, output_dir_tag)
|
|
# dir_tag: "hair", "facial_hair", "eyebrows"
|
|
HAIR_MANIFEST = [
|
|
# --- Female hairstyles ---
|
|
("Female/Hair_Bob.gltf", "bob", "hair"),
|
|
("Female/Hair_Buns.gltf", "buns", "hair"),
|
|
("Female/Hair_BuzzedFemale.gltf", "buzzed_female", "hair"),
|
|
("Female/Hair_Long.gltf", "long", "hair"),
|
|
("Female/Hair_LongDreads.gltf", "long_dreads", "hair"),
|
|
("Female/Hair_Ponytail_2.gltf", "ponytail_f", "hair"),
|
|
# --- Male hairstyles ---
|
|
("Male/Hair_Balding.gltf", "balding", "hair"),
|
|
("Male/Hair_Buzzed.gltf", "buzzed", "hair"),
|
|
("Male/Hair_Dreads.gltf", "dreads", "hair"),
|
|
("Male/Hair_Mohawk.gltf", "mohawk", "hair"),
|
|
("Male/Hair_Ponytail.gltf", "ponytail", "hair"),
|
|
("Male/Hair_SimpleParted.gltf", "simple_parted", "hair"),
|
|
("Male/Hair_SlickBack.gltf", "slick_back", "hair"),
|
|
# --- Facial hair ---
|
|
("Male/Hair_Beard.gltf", "beard", "facial_hair"),
|
|
("Male/Hair_Moustache.gltf", "moustache", "facial_hair"),
|
|
("Male/Hair_MuttonChops.gltf", "mutton_chops", "facial_hair"),
|
|
# --- Eyebrows ---
|
|
("Female/Eyebrows_Female.gltf", "female", "eyebrows"),
|
|
("Female/Eyebrows_Teen.gltf", "teen", "eyebrows"),
|
|
("Male/Eyebrows_Regular.gltf", "regular", "eyebrows"),
|
|
("Male/Eyebrows_Thick.gltf", "thick", "eyebrows"),
|
|
]
|
|
|
|
|
|
def convert_gltf_to_glb(gltf_path: str, glb_path: str, solidify: bool = False) -> None:
|
|
"""Import a GLTF file and re-export as GLB with embedded textures."""
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
|
|
print(f" Loading: {os.path.basename(gltf_path)}")
|
|
bpy.ops.import_scene.gltf(filepath=gltf_path)
|
|
|
|
objects = list(bpy.context.scene.objects)
|
|
meshes = [o for o in objects if o.type == 'MESH']
|
|
arms = [o for o in objects if o.type == 'ARMATURE']
|
|
print(f" Objects: {len(meshes)} meshes, {len(arms)} armatures")
|
|
|
|
# Strip animation data (hair has no runtime animation)
|
|
for obj in objects:
|
|
if obj.animation_data:
|
|
obj.animation_data_clear()
|
|
for action in list(bpy.data.actions):
|
|
bpy.data.actions.remove(action)
|
|
|
|
# Solidify hair meshes — gives flat hair cards 3D thickness so they don't
|
|
# clip through the scalp. Makes hair look like a hair-shaped shell.
|
|
# Only applied to scalp hair, not eyebrows or facial hair.
|
|
HAIR_THICKNESS = 0.020 # 20mm outward thickness
|
|
if solidify:
|
|
for m in meshes:
|
|
# Skip icospheres and non-hair utility objects
|
|
if m.name.startswith("Icosphere") or len(m.data.vertices) < 50:
|
|
continue
|
|
bpy.context.view_layer.objects.active = m
|
|
m.select_set(True)
|
|
# Recalculate normals to ensure consistent outward direction
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='SELECT')
|
|
bpy.ops.mesh.normals_make_consistent(inside=False)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
# Apply solidify
|
|
sol_mod = m.modifiers.new(name="Solidify", type='SOLIDIFY')
|
|
sol_mod.thickness = HAIR_THICKNESS
|
|
sol_mod.offset = 1.0 # grow outward only
|
|
sol_mod.use_rim = True # fill edges for closed shell
|
|
bpy.ops.object.modifier_apply(modifier=sol_mod.name)
|
|
m.select_set(False)
|
|
print(f" Solidified: {m.name} ({HAIR_THICKNESS*1000:.0f}mm outward)")
|
|
|
|
# Keep armature parenting and skin data intact — the Godot compositor
|
|
# will load the skinned mesh and add it to the shared skeleton at runtime.
|
|
bpy.ops.object.select_all(action='SELECT')
|
|
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=glb_path,
|
|
use_selection=True,
|
|
export_format='GLB',
|
|
export_animations=False,
|
|
export_yup=True,
|
|
export_image_format='AUTO',
|
|
export_texcoords=True,
|
|
export_normals=True,
|
|
export_skins=True,
|
|
export_materials='EXPORT',
|
|
)
|
|
size = os.path.getsize(glb_path)
|
|
print(f" GLB exported: {os.path.basename(glb_path)} ({size:,} bytes)")
|
|
|
|
|
|
def make_mask_png(mask_path: str, width: int = 64, height: int = 64) -> None:
|
|
"""Generate a solid-white mask PNG (entire mesh = tintable region).
|
|
|
|
Solid white = fully tintable. Small size is fine — the shader just samples
|
|
white everywhere. The mask format supports greyscale bands for multi-region
|
|
recoloring when needed in future.
|
|
"""
|
|
import struct
|
|
import zlib
|
|
|
|
# Build a minimal white PNG manually — no Blender image API issues
|
|
def make_png(w, h):
|
|
def chunk(ctype, data):
|
|
c = ctype + data
|
|
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
|
|
header = b'\x89PNG\r\n\x1a\n'
|
|
ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 0, 0, 0, 0)) # 8-bit greyscale
|
|
raw = b''
|
|
for _ in range(h):
|
|
raw += b'\x00' + b'\xff' * w # filter byte + white pixels
|
|
idat = chunk(b'IDAT', zlib.compress(raw))
|
|
iend = chunk(b'IEND', b'')
|
|
return header + ihdr + idat + iend
|
|
|
|
with open(mask_path, 'wb') as f:
|
|
f.write(make_png(width, height))
|
|
print(f" Mask saved: {os.path.basename(mask_path)} ({width}x{height} white)")
|
|
|
|
|
|
def make_bald_placeholder(glb_path: str) -> None:
|
|
"""Create a minimal empty GLB for the 'no hair' slot."""
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
# Create a single-vertex mesh with no faces (zero-poly placeholder)
|
|
mesh_data = bpy.data.meshes.new("bald")
|
|
mesh_data.from_pydata([(0.0, 0.0, 0.0)], [], [])
|
|
mesh_data.update()
|
|
obj = bpy.data.objects.new("bald", mesh_data)
|
|
bpy.context.collection.objects.link(obj)
|
|
bpy.ops.object.select_all(action='SELECT')
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=glb_path,
|
|
use_selection=True,
|
|
export_format='GLB',
|
|
export_animations=False,
|
|
export_yup=True,
|
|
export_image_format='AUTO',
|
|
export_materials='EXPORT',
|
|
)
|
|
size = os.path.getsize(glb_path)
|
|
print(f" bald.glb placeholder ({size:,} bytes)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
argv = sys.argv
|
|
if "--" not in argv:
|
|
print("Usage: tooling/blender --background --python tooling/blender_process_hair.py -- <source_base_dir> <hair_out_dir> <facial_hair_out_dir> <eyebrows_out_dir>")
|
|
sys.exit(1)
|
|
|
|
args = argv[argv.index("--") + 1:]
|
|
if len(args) < 4:
|
|
print("ERROR: Provide source_base_dir, hair_out_dir, facial_hair_out_dir, eyebrows_out_dir")
|
|
sys.exit(1)
|
|
|
|
source_base = args[0]
|
|
hair_out = args[1]
|
|
fhair_out = args[2]
|
|
eyebrows_out = args[3]
|
|
|
|
out_dirs = {"hair": hair_out, "facial_hair": fhair_out, "eyebrows": eyebrows_out}
|
|
for d in out_dirs.values():
|
|
os.makedirs(d, exist_ok=True)
|
|
|
|
results = []
|
|
errors = []
|
|
|
|
for (gltf_rel, key, tag) in HAIR_MANIFEST:
|
|
gltf_path = os.path.join(source_base, gltf_rel)
|
|
out_dir = out_dirs[tag]
|
|
|
|
if not os.path.exists(gltf_path):
|
|
errors.append(f" MISSING: {gltf_path}")
|
|
continue
|
|
|
|
print(f"\n[{tag}] {key}")
|
|
glb_path = os.path.join(out_dir, key + ".glb")
|
|
# Only solidify scalp hair — not facial hair or eyebrows
|
|
convert_gltf_to_glb(gltf_path, glb_path, solidify=(tag == "hair"))
|
|
|
|
# Eyebrows: no mask (tinted directly by shader, no recolor mask needed)
|
|
if tag != "eyebrows":
|
|
mask_path = os.path.join(out_dir, key + "_mask.png")
|
|
make_mask_png(mask_path)
|
|
|
|
results.append((tag, key, os.path.getsize(glb_path)))
|
|
|
|
# bald.glb placeholder
|
|
print(f"\n[hair] bald (placeholder)")
|
|
bald_path = os.path.join(hair_out, "bald.glb")
|
|
make_bald_placeholder(bald_path)
|
|
results.append(("hair", "bald", os.path.getsize(bald_path)))
|
|
|
|
print("\n=== Hairstyle import complete ===")
|
|
for tag, key, size in results:
|
|
print(f" [{tag}] {key}.glb ({size:,} bytes)")
|
|
|
|
if errors:
|
|
print("\nMISSING FILES:")
|
|
for e in errors:
|
|
print(e)
|
|
sys.exit(1)
|