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>
This commit is contained in:
m3ultra 2026-07-15 00:33:03 +10:00
parent 15e2cc0a9f
commit 447188a516
25 changed files with 406 additions and 0 deletions

1
.gitignore vendored
View File

@ -11,3 +11,4 @@ pipeline/.genprops/
web/assets/models/ web/assets/models/
pipeline/.peds_orig/ pipeline/.peds_orig/
pipeline/.props_orig/ pipeline/.props_orig/
pipeline/.peds_r6/

View File

@ -1,5 +1,23 @@
# LANE E — cross-lane notes # LANE E — cross-lane notes
## Round 7
**→ Lane D (D1 — the flip gate): merged peds ready to validate.** `web/models/peds/*.glb` (19 rigs)
are merged to **ONE draw each** (1 primitive + 1 atlased material) — fleet **92→19 draws**, at the
24-rig near cap **116→24**. Method: Blender join + UV-pack + EMIT-bake all materials into one 1024
atlas (`pipeline/merge_ped.py`). **Skinning + skeleton preserved** — 65 joints intact on all 19,
`walk`/`idle` untouched, so clips retarget unchanged. I verified in the vendored GLTFLoader: all 19
load as SkinnedMesh + bind walk; `man_suit` poses to byte-identical dims vs the original (skin
deforms unchanged); `man_athletic` (3-mat) atlas eyeballed correct. Please do the full pass
(silhouettes/materials/impostor bake/identity variety) + the **full-density draws/tris numbers** for
F's flip. Pre-merge peds backed up (gitignored) at `pipeline/.peds_r6/` if you need a diff; per-ped
in `pipeline/_peds_merge.json`. **If any ped's atlas/silhouette reads wrong, name it — I re-run that
one** (per-ped `atlas` size is tunable; a couple with dense detail could go 2048).
**E2 (GODVERSE stock pack) — building against C's schema** (their task 1). Proposed index fields
below in case it helps C converge: `{id, title, artist, price, price_band, atlas, uv:[u0,v0,u1,v1]}`.
Adjusting to C's LANE_C_NOTES contract when posted.
## Round 6 ## Round 6
**E1 done** — `publish.py` now merges into `_published.json` (no more R5 clobber); `--verify` + **E1 done** — `publish.py` now merges into `_published.json` (no more R5 clobber); `--verify` +

View File

@ -261,3 +261,21 @@ Both 3k tris, verified loading in the vendored GLTFLoader, published, `validate
Provenance: MeshGod-generated (thriftgod_hero) — synthetic, web-ok. **Note:** also resolved Lane B's Provenance: MeshGod-generated (thriftgod_hero) — synthetic, web-ok. **Note:** also resolved Lane B's
B3 ask — `novelty_record` (was 26.5k, spiking the busy view) is now 8k via the E1 bake. No speculative B3 ask — `novelty_record` (was 26.5k, spiking the busy view) is now 8k via the E1 bake. No speculative
props added; further depot growth is available on request once C's dig / D's roster name concrete needs. props added; further depot growth is available on request once C's dig / D's roster name concrete needs.
## Round 7 — E1: ped sub-mesh merge to one draw (gates the roster flip)
The 19 ped rigs carried **19 primitives / 13 materials each = 92 draws** for the fleet (≈116 at
the 24-rig near cap) — the optimisation D's memo gates full-density roster on. `pipeline/merge_ped.py`
(headless Blender) merges each ped to **ONE primitive + one atlased material**: join meshes → pack
every material's UV islands into a non-overlapping atlas UV → **EMIT-bake each source material's
base-colour into one 1024 atlas** (route base-colour→emission via the ORIGINAL uv so the atlas UV
doesn't hijack the lookup) → single material. **Skinning + skeleton fully preserved** (65 joints
intact on all 19, `JOINTS_0`/`WEIGHTS_0` kept; `walk`/`idle` untouched).
Result: **fleet 92 → 19 draws (1/ped)**; at the 24-rig near cap **116 → 24 draws** (79% cut). Size
5M → 4M. Verified in the vendored `GLTFLoader`: all 19 load with `isSkinnedMesh` + bind the shared
`walk.glb` clip; `man_suit` (2-mat) poses to **byte-identical dimensions vs the original**
(109.038 × 62.5) proving the skin deforms unchanged; `man_athletic` (3-mat, most complex atlas)
eyeballed correct (skin/top/pants regions all right). Pre-merge decimated peds backed up (gitignored)
in `pipeline/.peds_r6/`; per-ped in `pipeline/_peds_merge.json`. → **Lane D validates (D1)**
full silhouette/material/impostor/identity pass + full-density numbers — then **F flips the roster**.

103
pipeline/_peds_merge.json Normal file
View File

@ -0,0 +1,103 @@
{
"peds": [
{
"file": "comical_boy_01.glb",
"size_before_kb": 121,
"size_after_kb": 125
},
{
"file": "comical_luchador_01.glb",
"size_before_kb": 159,
"size_after_kb": 163
},
{
"file": "man_athletic_01.glb",
"size_before_kb": 429,
"size_after_kb": 288
},
{
"file": "man_bouncer_01.glb",
"size_before_kb": 183,
"size_after_kb": 187
},
{
"file": "man_casual_01.glb",
"size_before_kb": 179,
"size_after_kb": 184
},
{
"file": "man_casual_02.glb",
"size_before_kb": 203,
"size_after_kb": 200
},
{
"file": "man_casual_03.glb",
"size_before_kb": 220,
"size_after_kb": 197
},
{
"file": "man_dj_streetwear_01.glb",
"size_before_kb": 291,
"size_after_kb": 239
},
{
"file": "man_elder_01.glb",
"size_before_kb": 200,
"size_after_kb": 203
},
{
"file": "man_grunge_01.glb",
"size_before_kb": 402,
"size_after_kb": 277
},
{
"file": "man_soldier_ww2_01.glb",
"size_before_kb": 326,
"size_after_kb": 244
},
{
"file": "man_suit_01.glb",
"size_before_kb": 436,
"size_after_kb": 303
},
{
"file": "man_worker_hivis_01.glb",
"size_before_kb": 463,
"size_after_kb": 279
},
{
"file": "woman_athleisure_01.glb",
"size_before_kb": 395,
"size_after_kb": 267
},
{
"file": "woman_business_01.glb",
"size_before_kb": 357,
"size_after_kb": 255
},
{
"file": "woman_casual_01.glb",
"size_before_kb": 351,
"size_after_kb": 396
},
{
"file": "woman_casual_02.glb",
"size_before_kb": 390,
"size_after_kb": 284
},
{
"file": "woman_chef_01.glb",
"size_before_kb": 336,
"size_after_kb": 339
},
{
"file": "woman_smart_01.glb",
"size_before_kb": 397,
"size_after_kb": 251
}
],
"fleet_draws_before": 92,
"fleet_draws_after": 19,
"near_cap_before": 116,
"near_cap_after": 24
}

97
pipeline/batch_peds.json Normal file
View File

@ -0,0 +1,97 @@
[
{
"src": "pipeline/.peds_r6/comical_boy_01.glb",
"name": "comical_boy_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/comical_luchador_01.glb",
"name": "comical_luchador_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/man_athletic_01.glb",
"name": "man_athletic_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/man_bouncer_01.glb",
"name": "man_bouncer_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/man_casual_01.glb",
"name": "man_casual_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/man_casual_02.glb",
"name": "man_casual_02",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/man_casual_03.glb",
"name": "man_casual_03",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/man_dj_streetwear_01.glb",
"name": "man_dj_streetwear_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/man_elder_01.glb",
"name": "man_elder_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/man_grunge_01.glb",
"name": "man_grunge_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/man_soldier_ww2_01.glb",
"name": "man_soldier_ww2_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/man_suit_01.glb",
"name": "man_suit_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/man_worker_hivis_01.glb",
"name": "man_worker_hivis_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/woman_athleisure_01.glb",
"name": "woman_athleisure_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/woman_business_01.glb",
"name": "woman_business_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/woman_casual_01.glb",
"name": "woman_casual_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/woman_casual_02.glb",
"name": "woman_casual_02",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/woman_chef_01.glb",
"name": "woman_chef_01",
"atlas": 1024
},
{
"src": "pipeline/.peds_r6/woman_smart_01.glb",
"name": "woman_smart_01",
"atlas": 1024
}
]

169
pipeline/merge_ped.py Normal file
View File

@ -0,0 +1,169 @@
"""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()

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.