#!/usr/bin/env python3 """Build the low-poly, skinned NPC base mesh used by Step Line. The mesh, armature, UVs and neutral material maps are authored procedurally in Blender and exported to `assets/models/generated/npc_base.glb`. Godot imports the GLB as a PackedScene, tints the material slots per avatar, and drives the armature bones (see `src/game/npc_avatar.gd`). The procedural capsule rig stays as the offline fallback when the GLB is missing. The tool runs inside Blender (the flatpak sandbox cannot see `/tmp`, so every path must be absolute): flatpak run org.blender.Blender --background \ --python "$PWD/tools/npc_pipeline.py" -- build python3 tools/npc_pipeline.py prepare # layer AI fabric albedos (Pillow) python3 tools/npc_pipeline.py palette # derive outfit colours -> palette.json python3 tools/npc_pipeline.py audit Contract (frozen in docs/plan-graphics-v0.6.md + ADR-0013): * scene root "Rig" with a skinned mesh and an armature * bones: Head, HipL/R, KneeL/R, ShoulderL/R, ElbowL/R, HandL/R (plus Hips/Spine/Neck as parents) * material slots: skin, cloth, pants, accessory, hair * neutral albedo + normal + packed ORM maps, albedo tinted at runtime * <= 6000 triangles / 4000 vertices, UVs in [0,1], feet at y ~= 0 """ import argparse import json import math import struct import sys import zlib from pathlib import Path ROOT = Path(__file__).resolve().parent.parent GLB_PATH = ROOT / "assets" / "models" / "generated" / "npc_base.glb" BLEND_PATH = ROOT / "assets" / "models" / "npc" / "npc_base.blend" TEX_DIR = ROOT / "assets" / "textures" / "npc" SLOTS = ["skin", "cloth", "pants", "accessory", "hair"] BONES = [ "Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR", "ElbowL", "ElbowR", "HandL", "HandR", ] MAX_TRIANGLES = 6000 MAX_VERTICES = 4000 TEX_SIZE = 128 AI_DIR = TEX_DIR / "ai" # Base material slots that get an AI fabric albedo (`prepare` layers them over # the procedural weave). Outer layers (jacket / vest / coat / dress) have no # material slot yet; Phase 3 adds them and can reuse this override mechanism. AI_FABRICS = {"cloth": "shirt", "pants": "trousers"} AI_FABRICS_ALL = ["shirt", "trousers", "jacket", "vest", "coat", "dress"] try: # Blender's Python has bpy; the audit command works without it. import bpy # type: ignore except ImportError: # pragma: no cover - exercised outside Blender bpy = None # --------------------------------------------------------------------------- # Texture helpers (stdlib only: Blender ships no Pillow) # --------------------------------------------------------------------------- SLOT_STYLE = { # base sRGB tone, weave frequency, roughness, metallic, AO strength "skin": ((222, 184, 150), 0.05, 150, 0, 0.10), "cloth": ((232, 232, 228), 0.30, 220, 0, 0.18), "pants": ((70, 78, 92), 0.22, 235, 0, 0.20), "accessory": ((46, 48, 56), 0.12, 120, 180, 0.22), "hair": ((58, 44, 34), 0.18, 170, 0, 0.16), } def _png(width: int, height: int, rgba: bytes) -> bytes: rows = bytearray() stride = width * 4 for y in range(height): rows.append(0) rows.extend(rgba[y * stride:(y + 1) * stride]) def chunk(tag: bytes, data: bytes) -> bytes: return (struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + chunk(b"IEND", b"")) def _fabric_pattern(slot: str, x: int, y: int) -> float: """A repeatable 0..1 height/weave signal per slot.""" u = x / TEX_SIZE v = y / TEX_SIZE if slot == "cloth": return 0.5 + 0.5 * math.sin(u * math.tau * 22.0) * math.sin(v * math.tau * 18.0) if slot == "pants": return 0.5 + 0.5 * math.sin((u + v) * math.tau * 26.0) if slot == "skin": return 0.5 + 0.5 * math.sin(u * math.tau * 7.0 + v * math.tau * 11.0) if slot == "hair": return 0.5 + 0.5 * math.sin((u * 1.7 - v) * math.tau * 30.0) return 0.5 + 0.5 * math.sin(u * math.tau * 9.0) * math.sin(v * math.tau * 9.0) def write_slot_textures() -> None: """Write neutral sRGB albedo, tangent normal and packed ORM for each slot.""" TEX_DIR.mkdir(parents=True, exist_ok=True) for slot in SLOTS: base, freq, roughness, metallic, ao_strength = SLOT_STYLE[slot] albedo = bytearray(TEX_SIZE * TEX_SIZE * 4) normal = bytearray(TEX_SIZE * TEX_SIZE * 4) orm = bytearray(TEX_SIZE * TEX_SIZE * 4) fabric = AI_FABRICS.get(slot) use_ai_albedo = fabric is not None and (AI_DIR / f"{fabric}.png").is_file() for y in range(TEX_SIZE): for x in range(TEX_SIZE): pattern = _fabric_pattern(slot, x, y) # Baked AO approximation: darker in creases (pattern valleys) and # toward the bottom of the tile. ao = 1.0 - ao_strength * (1.0 - pattern) - 0.08 * (y / TEX_SIZE) r = min(255, int(base[0] * ao)) g = min(255, int(base[1] * ao)) b = min(255, int(base[2] * ao)) offset = (y * TEX_SIZE + x) * 4 albedo[offset:offset + 4] = bytes((r, g, b, 255)) dx = _fabric_pattern(slot, (x + 1) % TEX_SIZE, y) - pattern dy = _fabric_pattern(slot, x, (y + 1) % TEX_SIZE) - pattern nx = 128 + int(dx * 90.0) ny = 128 + int(dy * 90.0) normal[offset:offset + 4] = bytes((max(0, min(255, nx)), max(0, min(255, ny)), 255, 255)) orm[offset:offset + 4] = bytes((int(ao * 255), roughness, metallic, 255)) # The prepared AI fabric replaces the procedural albedo; the baked AO now # lives in the ORM R channel, so the albedo can stay unshaded. if not use_ai_albedo: (TEX_DIR / f"{slot}_albedo.png").write_bytes(_png(TEX_SIZE, TEX_SIZE, albedo)) (TEX_DIR / f"{slot}_normal.png").write_bytes(_png(TEX_SIZE, TEX_SIZE, normal)) (TEX_DIR / f"{slot}_orm.png").write_bytes(_png(TEX_SIZE, TEX_SIZE, orm)) kept = [s for s, f in AI_FABRICS.items() if (AI_DIR / f"{f}.png").is_file()] print(f"wrote {len(SLOTS) * 3} textures to {TEX_DIR}" + (f" (kept AI albedo for {', '.join(kept)})" if kept else "")) # --------------------------------------------------------------------------- # Blender build # --------------------------------------------------------------------------- def _material(slot: str): mat = bpy.data.materials.new(slot) mat.use_nodes = True nodes = mat.node_tree.nodes links = mat.node_tree.links bsdf = nodes.get("Principled BSDF") base, _, roughness, metallic, _ = SLOT_STYLE[slot] bsdf.inputs["Base Color"].default_value = ( base[0] / 255.0, base[1] / 255.0, base[2] / 255.0, 1.0) bsdf.inputs["Roughness"].default_value = roughness / 255.0 bsdf.inputs["Metallic"].default_value = metallic / 255.0 albedo = nodes.new("ShaderNodeTexImage") albedo.image = bpy.data.images.load(str(TEX_DIR / f"{slot}_albedo.png")) albedo.image.colorspace_settings.name = "sRGB" links.new(albedo.outputs["Color"], bsdf.inputs["Base Color"]) normal_tex = nodes.new("ShaderNodeTexImage") normal_tex.image = bpy.data.images.load(str(TEX_DIR / f"{slot}_normal.png")) normal_tex.image.colorspace_settings.name = "Non-Color" normal_map = nodes.new("ShaderNodeNormalMap") links.new(normal_tex.outputs["Color"], normal_map.inputs["Color"]) links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"]) return mat def _assign(obj, material, bone: str) -> None: obj.data.materials.append(material) group = obj.vertex_groups.new(name=bone) group.add(list(range(len(obj.data.vertices))), 1.0, "REPLACE") def _cube(name, size, loc, material, bone) -> None: bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc) obj = bpy.context.object obj.name = name obj.scale = (size[0], size[1], size[2]) bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) _assign(obj, material, bone) def _cylinder(name, radius, depth, loc, material, bone, vertices=8) -> None: bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=depth, location=loc) obj = bpy.context.object obj.name = name _assign(obj, material, bone) def _sphere(name, radius, loc, material, bone, segments=12, rings=8, scale=(1.0, 1.0, 1.0), rotation=(0.0, 0.0, 0.0)) -> None: bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=rings, radius=radius, location=loc) obj = bpy.context.object obj.name = name obj.scale = scale obj.rotation_euler = rotation bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) _assign(obj, material, bone) def _bone(edit_bones, name, head, tail, parent=None) -> None: bone = edit_bones.new(name) bone.head = head bone.tail = tail if parent is not None: bone.parent = parent def build_character() -> None: bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete(use_global=False) for collection in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials): for datablock in list(collection): if datablock.users == 0: collection.remove(datablock) write_slot_textures() mats = {slot: _material(slot) for slot in SLOTS} parts = [] # Body (Blender is Z-up; the glTF exporter converts to Godot Y-up. The face # points toward Blender -Y, which becomes Godot +Z like the fallback rig). _cube("Pelvis", (0.36, 0.23, 0.22), (0, 0, 0.71), mats["pants"], "Hips") _cube("Torso", (0.42, 0.26, 0.56), (0, 0, 1.10), mats["cloth"], "Spine") _cube("Shoulders", (0.50, 0.26, 0.12), (0, 0, 1.38), mats["cloth"], "Spine") _cylinder("Neck", 0.07, 0.15, (0, 0, 1.47), mats["skin"], "Neck", vertices=8) _sphere("Head", 0.17, (0, 0, 1.63), mats["skin"], "Head", scale=(1.0, 0.95, 1.08)) _sphere("Hair", 0.178, (0, 0.01, 1.69), mats["hair"], "Head", scale=(1.0, 1.0, 0.82)) for side in (-1.0, 1.0): _sphere("Ear", 0.04, (0.165 * side, 0.0, 1.62), mats["skin"], "Head", segments=8, rings=6) _sphere("Eye", 0.027, (0.058 * side, -0.155, 1.655), mats["accessory"], "Head", segments=8, rings=6) _cube("Brow", (0.065, 0.02, 0.014), (0.058 * side, -0.158, 1.705), mats["hair"], "Head") _cube("Mouth", (0.085, 0.02, 0.014), (0, -0.155, 1.575), mats["accessory"], "Head") for side in (-1.0, 1.0): x = 0.27 * side _cylinder("UpperArm", 0.075, 0.30, (x, 0, 1.21), mats["cloth"], "ShoulderL" if side < 0 else "ShoulderR") _cylinder("Forearm", 0.065, 0.28, (x, 0, 0.92), mats["cloth"], "ElbowL" if side < 0 else "ElbowR") _sphere("Hand", 0.06, (x, 0, 0.745), mats["skin"], "HandL" if side < 0 else "HandR", segments=10, rings=6) hx = 0.12 * side _cylinder("Thigh", 0.10, 0.36, (hx, 0, 0.46), mats["pants"], "HipL" if side < 0 else "HipR") _cylinder("Shin", 0.085, 0.30, (hx, 0, 0.15), mats["pants"], "KneeL" if side < 0 else "KneeR") _cube("Shoe", (0.16, 0.29, 0.10), (hx, 0.04, 0.05), mats["accessory"], "KneeL" if side < 0 else "KneeR") parts = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] bpy.ops.object.select_all(action="DESELECT") for obj in parts: obj.select_set(True) bpy.context.view_layer.objects.active = parts[0] bpy.ops.object.join() mesh = bpy.context.object mesh.name = "npc_base" bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_all(action="SELECT") bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=0.02) bpy.ops.object.mode_set(mode="OBJECT") # Neutral flat shading keeps the low-poly read; smoothing is not needed. for polygon in mesh.data.polygons: polygon.use_smooth = False arm_data = bpy.data.armatures.new("Armature") arm = bpy.data.objects.new("Armature", arm_data) bpy.context.scene.collection.objects.link(arm) bpy.context.view_layer.objects.active = arm bpy.ops.object.mode_set(mode="EDIT") bones = arm_data.edit_bones _bone(bones, "Hips", (0, 0, 0.62), (0, 0, 0.82)) _bone(bones, "Spine", (0, 0, 0.82), (0, 0, 1.40), bones["Hips"]) _bone(bones, "Neck", (0, 0, 1.40), (0, 0, 1.52), bones["Spine"]) _bone(bones, "Head", (0, 0, 1.52), (0, 0, 1.82), bones["Neck"]) for side, suffix in ((-1.0, "L"), (1.0, "R")): x = 0.27 * side _bone(bones, f"Shoulder{suffix}", (x, 0, 1.38), (x, 0, 1.06), bones["Spine"]) _bone(bones, f"Elbow{suffix}", (x, 0, 1.06), (x, 0, 0.78), bones[f"Shoulder{suffix}"]) _bone(bones, f"Hand{suffix}", (x, 0, 0.78), (x, 0, 0.68), bones[f"Elbow{suffix}"]) hx = 0.12 * side _bone(bones, f"Hip{suffix}", (hx, 0, 0.62), (hx, 0, 0.30), bones["Hips"]) _bone(bones, f"Knee{suffix}", (hx, 0, 0.30), (hx, 0, 0.0), bones[f"Hip{suffix}"]) bpy.ops.object.mode_set(mode="OBJECT") mesh.parent = arm modifier = mesh.modifiers.new("Armature", "ARMATURE") modifier.object = arm for obj in bpy.context.scene.objects: obj.select_set(True) bpy.context.view_layer.objects.active = mesh GLB_PATH.parent.mkdir(parents=True, exist_ok=True) bpy.ops.export_scene.gltf( filepath=str(GLB_PATH), export_format="GLB", use_selection=False, export_apply=False, export_animations=False, export_skins=True, export_yup=True, export_materials="EXPORT", export_image_format="AUTO", ) BLEND_PATH.parent.mkdir(parents=True, exist_ok=True) bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH)) print(f"wrote {GLB_PATH} and {BLEND_PATH}") # --------------------------------------------------------------------------- # Audit (stdlib only) # --------------------------------------------------------------------------- def _read_accessor(gltf, binary, index): accessor = gltf["accessors"][index] width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[accessor["type"]] fmt = {5126: "f", 5125: "I", 5123: "H", 5121: "B"}[int(accessor["componentType"])] view = gltf["bufferViews"][accessor["bufferView"]] offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0)) stride = int(view.get("byteStride", width * struct.calcsize(fmt))) values = [] for i in range(int(accessor["count"])): row = struct.unpack_from("<" + fmt * width, binary, offset + i * stride) values.append(row[0] if width == 1 else row) return values def audit_glb(path: Path = GLB_PATH) -> dict: issues = [] if not path.is_file(): return {"ok": False, "issues": ["missing npc_base.glb"], "triangles": 0, "vertices": 0, "bones": []} data = path.read_bytes() try: magic, version, length = struct.unpack_from(" 0.03: issues.append(f"feet not on the ground (min y {min_y:.3f})") if triangles > MAX_TRIANGLES: issues.append(f"triangle budget exceeded ({triangles} > {MAX_TRIANGLES})") if vertices > MAX_VERTICES: issues.append(f"vertex budget exceeded ({vertices} > {MAX_VERTICES})") if not BLEND_PATH.is_file(): issues.append("editable .blend source missing") return {"ok": not issues, "issues": issues, "triangles": triangles, "vertices": vertices, "bones": bones} # --------------------------------------------------------------------------- # AI fabric preparation (Pillow; runs outside Blender) # --------------------------------------------------------------------------- def prepare_albedos() -> list: """Layer the committed AI fabric albedos over the procedural slot albedos. For each slot in AI_FABRICS, resize `assets/textures/npc/ai/.png` to TEX_SIZE and write it as `_albedo.png`. A later `build` then keeps that albedo (write_slot_textures skips it) and still regenerates the procedural normal + packed ORM maps, so the AI colour sits on top of the baked normal/AO detail. """ from PIL import Image TEX_DIR.mkdir(parents=True, exist_ok=True) written = [] for slot, fabric in AI_FABRICS.items(): src = AI_DIR / f"{fabric}.png" if not src.is_file(): print(f"skip {slot}: {src.relative_to(ROOT)} missing (procedural albedo stays)") continue with Image.open(src) as image: image.load() image.convert("RGB").resize( (TEX_SIZE, TEX_SIZE), Image.Resampling.LANCZOS ).save(TEX_DIR / f"{slot}_albedo.png") written.append(f"{slot}<={fabric}") print("prepared albedos: " + (", ".join(written) if written else "none")) return written def write_palette() -> dict: """Derive one representative colour per AI fabric and write palette.json. Reuses model_pipeline.dominant_colors so the colours come from the same centre-crop + saturation ranking as the prop concepts. Phase 3 can consume these as deterministic, committed outfit base colours. """ import model_pipeline from PIL import Image palette = {} for fabric in AI_FABRICS_ALL: src = AI_DIR / f"{fabric}.png" if not src.is_file(): continue with Image.open(src) as image: image.load() colors = model_pipeline.dominant_colors(image, count=1) if not colors: continue r, g, b = colors[0] palette[fabric] = { "rgb": [round(c, 4) for c in (r, g, b)], "hex": f"#{int(round(r * 255)):02x}{int(round(g * 255)):02x}" f"{int(round(b * 255)):02x}", } (TEX_DIR / "palette.json").write_text( json.dumps(palette, indent=2, sort_keys=True) + "\n", encoding="utf-8") print(f"wrote {TEX_DIR / 'palette.json'}") return palette def list_contract() -> int: print("npc_base contract") print(f" glb: {GLB_PATH.relative_to(ROOT)}") print(f" blend: {BLEND_PATH.relative_to(ROOT)}") print(f" slots: {', '.join(SLOTS)}") print(f" bones: {', '.join(BONES)} (+ Hips/Spine/Neck)") print(f" budget: <= {MAX_TRIANGLES} tris / {MAX_VERTICES} verts") return 0 def main(argv=None) -> int: raw = list(argv if argv is not None else sys.argv[1:]) if "--" in raw: raw = raw[raw.index("--") + 1:] parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("list") sub.add_parser("build") sub.add_parser("prepare") sub.add_parser("palette") audit = sub.add_parser("audit") audit.add_argument("--json", action="store_true") args = parser.parse_args(raw) if args.command == "list": return list_contract() if args.command == "build": if bpy is None: print("build must run inside Blender:\n flatpak run org.blender.Blender " "--background --python %s -- build" % Path(__file__).resolve(), file=sys.stderr) return 2 build_character() result = audit_glb() print(json.dumps(result, indent=2)) return 0 if result["ok"] else 1 if args.command == "prepare": prepare_albedos() return 0 if args.command == "palette": write_palette() return 0 result = audit_glb() if args.json: print(json.dumps(result, indent=2)) else: print(f"{'OK ' if result['ok'] else 'BAD'} npc_base.glb " f"tris={result['triangles']} verts={result['vertices']} " f"bones={len(result['bones'])}") for issue in result["issues"]: print(" -", issue) return 0 if result["ok"] else 1 if __name__ == "__main__": sys.exit(main())