""" blender_process_hair.py Usage: tooling/blender --background --python tooling/blender_process_hair.py -- 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 -- ") 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)