PROCITY/pipeline/merge_ped.py
m3ultra 447188a516 Lane E round 7 (E1): merge ped rigs to ONE draw each (gates roster flip)
Each of the 19 ped rigs carried 1-9 primitives / 1-3 materials (fleet 92 draws,
~116 at the 24-rig near cap). merge_ped.py (Blender) merges each to 1 primitive +
1 atlased material: join meshes -> pack material UVs into a non-overlapping atlas UV
-> EMIT-bake base-colours into one 1024 atlas -> single material. Skinning + skeleton
preserved (65 joints, walk/idle untouched, clips retarget unchanged). Fleet 92->19
draws (1/ped); near-cap 116->24 (79% cut). Verified in vendored GLTFLoader: all 19
load as SkinnedMesh + bind walk; man_suit poses byte-identical to original. Hands to
Lane D (D1) for full validation + full-density numbers, then F flips the roster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:33:03 +10:00

170 lines
6.8 KiB
Python

"""Merge a skinned ped rig's sub-meshes to ONE draw: join meshes → pack all materials into a single
atlas (bake) → one material → one primitive. Skinning (joints/weights) + skeleton are preserved, so
the shared walk.glb/idle.glb clips still retarget. Round-7 E1 (gates D's full-density roster flip).
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python merge_ped.py -- BATCH.json OUT_DIR
BATCH.json = list of {"src": "web/models/peds/x.glb", "name": "x", "atlas": 1024}.
Approach = the bake_lowpoly EMIT trick without the remesh: keep the mesh + armature, give it a
packed atlas UV, route each source material's base-colour→emission via the ORIGINAL UV, bake EMIT
into one atlas image, assign a single material. Falls back to material-merge-only (prims = #mats)
if a ped has no textures to bake.
"""
import bpy, sys, os, json
ARGV = sys.argv[sys.argv.index("--") + 1:]
BATCH, OUT_DIR = ARGV[0], ARGV[1]
os.makedirs(OUT_DIR, exist_ok=True)
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.images, bpy.data.armatures):
for d in list(coll):
if d.users == 0:
coll.remove(d)
def import_join(src):
bpy.ops.import_scene.gltf(filepath=src)
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
bpy.ops.object.select_all(action='DESELECT')
for m in meshes:
m.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
if len(meshes) > 1:
bpy.ops.object.join() # keeps vertex groups (weights) + armature modifier
return bpy.context.view_layer.objects.active
def route_basecolor_to_emission(obj, orig_uv):
"""Each material: sample its base-colour texture via the ORIGINAL uv and feed Emission, so a
bake on the atlas UV captures albedo. Add an explicit UVMap node so the atlas UV (active during
bake) doesn't hijack the texture lookup."""
for m in obj.data.materials:
if not m or not m.use_nodes:
continue
nt = m.node_tree
b = nt.nodes.get("Principled BSDF")
if not b:
continue
uvn = nt.nodes.new("ShaderNodeUVMap"); uvn.uv_map = orig_uv
for n in nt.nodes:
if n.type == 'TEX_IMAGE':
nt.links.new(uvn.outputs["UV"], n.inputs["Vector"])
bc = b.inputs.get("Base Color")
ec = b.inputs.get("Emission Color") or b.inputs.get("Emission")
es = b.inputs.get("Emission Strength")
if ec is None:
continue
if bc and bc.is_linked:
nt.links.new(bc.links[0].from_socket, ec)
elif bc:
ec.default_value = bc.default_value
if es is not None:
es.default_value = 1.0
def merge_one(spec):
wipe()
src = spec["src"] if os.path.isabs(spec["src"]) else os.path.join(os.getcwd(), spec["src"])
obj = import_join(src)
prim_before = "n/a"
mats_before = len(obj.data.materials)
has_tex = any(m and m.use_nodes and any(n.type == 'TEX_IMAGE' for n in m.node_tree.nodes)
for m in obj.data.materials)
size = spec.get("atlas", 1024)
if mats_before <= 1 or not has_tex:
# already single-material (or untextured) — nothing to atlas; export as-is (1 primitive)
out = _export(obj, spec)
return {"name": spec["name"], "file": os.path.basename(out), "materials": mats_before,
"atlased": False, "note": "already single-material" if mats_before <= 1 else "untextured"}
orig_uv = obj.data.uv_layers.active.name
atlas_uv = obj.data.uv_layers.new(name="atlas").name
obj.data.uv_layers.active = obj.data.uv_layers[atlas_uv]
# pack every island of the atlas UV into non-overlapping 0-1 regions (separates the per-material
# islands so their baked pixels don't collide)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.uv.select_all(action='SELECT')
try:
bpy.ops.uv.pack_islands(rotate=False, margin=0.003)
except TypeError:
bpy.ops.uv.pack_islands(margin=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
route_basecolor_to_emission(obj, orig_uv)
img = bpy.data.images.new("ped_atlas", size, size)
for m in obj.data.materials:
if m and m.use_nodes:
tn = m.node_tree.nodes.new("ShaderNodeTexImage"); tn.image = img
m.node_tree.nodes.active = tn # bake target per material
scn = bpy.context.scene
scn.render.engine = 'CYCLES'
try:
scn.cycles.device = 'GPU'
except Exception:
pass
scn.cycles.samples = 4
scn.render.bake.margin = max(4, size // 128)
scn.render.bake.use_selected_to_active = False
obj.data.uv_layers.active = obj.data.uv_layers[atlas_uv]
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.bake(type='EMIT', use_clear=True)
# single atlas material, atlas UV only
newmat = bpy.data.materials.new("ped_merged"); newmat.use_nodes = True
nt = newmat.node_tree
bsdf = nt.nodes.get("Principled BSDF")
tex = nt.nodes.new("ShaderNodeTexImage"); tex.image = img
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
obj.data.materials.clear()
obj.data.materials.append(newmat)
for uv in list(obj.data.uv_layers):
if uv.name != atlas_uv:
obj.data.uv_layers.remove(uv)
out = _export(obj, spec)
return {"name": spec["name"], "file": os.path.basename(out), "materials_before": mats_before,
"atlased": True, "atlas_px": size}
def _export(obj, spec):
out = os.path.join(OUT_DIR, os.path.basename(spec["src"]))
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
# select the armature too so the skin exports
for o in bpy.data.objects:
if o.type == 'ARMATURE':
o.select_set(True)
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', use_selection=True,
export_yup=True, export_skins=True, export_animations=False,
export_image_format='WEBP', export_image_quality=90)
return out
def main():
specs = json.load(open(BATCH))
results = []
for spec in specs:
try:
r = merge_one(spec)
results.append(r)
print(f"OK {r['file']:32} atlased={r.get('atlased')} mats={r.get('materials_before', r.get('materials'))}")
except Exception as e:
print(f"ERR {spec.get('name')}: {e}")
results.append({"name": spec.get("name"), "error": str(e)})
json.dump({"results": results}, open(os.path.join(OUT_DIR, "_merge_results.json"), "w"), indent=2)
print(f"MERGE_DONE ok={sum('error' not in r for r in results)}/{len(specs)}")
main()