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:
parent
15e2cc0a9f
commit
447188a516
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,3 +11,4 @@ pipeline/.genprops/
|
||||
web/assets/models/
|
||||
pipeline/.peds_orig/
|
||||
pipeline/.props_orig/
|
||||
pipeline/.peds_r6/
|
||||
|
||||
@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
**E1 done** — `publish.py` now merges into `_published.json` (no more R5 clobber); `--verify` +
|
||||
|
||||
@ -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
|
||||
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.
|
||||
|
||||
## Round 7 — E1: ped sub-mesh merge to one draw (gates the roster flip)
|
||||
|
||||
The 19 ped rigs carried **1–9 primitives / 1–3 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
103
pipeline/_peds_merge.json
Normal 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
97
pipeline/batch_peds.json
Normal 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
169
pipeline/merge_ped.py
Normal 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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user