Lane E round 5 (E1): bake 6 hero props to <=8k tris (288k->48k), live on depot

pipeline/bake_lowpoly.py — Blender headless: voxel-remesh -> decimate -> Cycles
base-colour bake onto fresh UVs (R3 lesson: simplify-only floors on TRELLIS
shell-soup; the re-bake is the point). drinks_fridge/milkshake_mixer/arcade_cabinet/
listening_booth/novelty_record/counter_till all 25-77k -> <=8k, ~0.9M each. Every
prop eyeballed (docs/shots/laneE/props_baked.png) + verified loading in vendored
GLTFLoader; footprints unchanged (no C re-map needed). Re-published; validate --depot
0 err. Originals gitignored in pipeline/.props_orig/. Unblocks C's interior re-measure
+ F's v1.1 tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-14 20:56:07 +10:00
parent aa9c140eca
commit c52246444d
15 changed files with 367 additions and 47 deletions

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ node_modules/
pipeline/.genprops/
web/assets/models/
pipeline/.peds_orig/
pipeline/.props_orig/

View File

@ -4,6 +4,21 @@
Reviewed against `docs/LANES/LANE_E_ASSETS.md` + `docs/LANES/ROUND3_INSTRUCTIONS.md`. Nothing in
other lanes' files was touched.*
## Round 5 (Fable) — hero-prop bake + fal fallbacks + depot growth
- **E1 — hero-prop bake-to-lowpoly: DONE + committed.** New `pipeline/bake_lowpoly.py` (Blender
headless): voxel-remesh → decimate → **Cycles base-colour bake** onto fresh UVs (R3's lesson:
simplify-only floors on shell-soup; the re-bake is the point). All **6 heavy TRELLIS props ≤8k
tris** (drinks_fridge 77k→8k, milkshake_mixer 73k→8k, listening_booth 54k→8k, counter_till
32k→8k, novelty_record 26k→8k, arcade_cabinet 25k→8k). **Fleet 288k→48k tris, 18M→5.2M.** Each
eyeballed (`docs/shots/laneE/props_baked.png` — fridge needed a coarser voxel re-bake, rest clean)
+ verified loading in the vendored GLTFLoader. Re-published; `validate --depot` 0 err. Originals
gitignored in `pipeline/.props_orig/`. → Lane C re-measures the record interior (was ~273k).
- **E3 — fal.ai fallbacks (authorized, decision #4):** glass-case + bus-shelter (+ optional
magazine-rack) via fal Hunyuan v2, ≤2 attempts each, costs logged in AUDIT. *(in progress)*
- **E2 — depot growth for v2:** ~812 library props serving C's dig / D's roster / B's windows.
*(in progress)*
## Round 4 (Fable) — ped decimation (gate-3 critical path) + asset fixes
- **E1 — ped decimation: DONE + committed** (`518678d`, ref `lane-e/round4-peds`). The 19 ped rigs

View File

@ -1,4 +1,33 @@
# LANE E — cross-lane notes (round 4)
# LANE E — cross-lane notes
## Round 5
### → Lane C: hero props BAKED to ≤8k tris — re-measure the record interior
The 6 heavy TRELLIS props are re-baked and **live on the depot** (same filenames, footprints
unchanged, so **no re-map needed** — they upgrade in place):
| prop | tris before → after |
|---|---|
| drinks_fridge | 77k → 8.0k |
| milkshake_mixer | 73k → 8.0k |
| listening_booth | 54k → 8.0k |
| counter_till | 32k → 8.0k |
| novelty_record | 26k → 8.0k |
| arcade_cabinet | 25k → 8.0k |
**Fleet 288k → 48k tris.** Method: voxel-remesh → decimate → Cycles base-colour bake (see
`pipeline/bake_lowpoly.py`, AUDIT R5). Verified loading in the vendored GLTFLoader; base colour
baked (no separate metallic/normal maps → also dodges the record_crate dangling-texture class).
**Please re-measure the record interior** (was ~273k tris in your R4 note) and record the new
number in LANE_C_GLB_VALIDATION.md — it should drop by ~most of the prop weight.
### → Fable: E1 (bake) status
DONE + committed. 6 props ≤8k, published, `validate --depot` 0 err, qa gate green. Feeds C's
re-measure + your v1.1 tag. E3 (fal glass-case/bus-shelter) + E2 (depot growth) next.
---
## Round 4
## → Lane D (task D2): decimated peds ready to validate

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -194,3 +194,28 @@ re-measures gate 3 (~24-rig near cap × ≤3k ≈ ≤67k fleet contribution).
props). **Provenance: 100% synthetic/on-device** (FLUX.2-klein-4B + TRELLIS.2 on the M3 Ultra), no
third-party source — cleanest licence zone. Replaces the R3 4 m till-less counter for the interior
counter slot (Lane C maps `counter``counter_till`); the balcao 4 m `counter` stays for long-bar use.
## Round 5 — E1: hero-prop bake-to-lowpoly (un-parked)
The 6 heavy TRELLIS hero props were **25k77k tris** (shell-soup — simplify-only floors there,
R3's lesson). Rebuilt topology + re-baked base colour via `pipeline/bake_lowpoly.py` (Blender
headless): **voxel-remesh → triangulate → decimate ≤8k → smart-UV → Cycles DIFFUSE(COLOR) bake
HP→LP → WebP export**. All **≤8k tris**, base colour baked onto fresh UVs, self-illuminated a
touch (matches normalize). Originals backed up (gitignored) at `pipeline/.props_orig/`; batch spec
`pipeline/batch_bake.json`; results `pipeline/_bake_results.json`.
| prop | tris before → after | voxel | size on disk | note |
|---|---|---|---|---|
| drinks_fridge | 77047 → 8000 | 0.03 | 5.3M → 0.9M | hollow glass-door box — needed a coarser voxel (0.012 grabbed the internal shelves into wireframe) |
| milkshake_mixer | 73492 → 7991 | 0.005 | 4.7M → 0.9M | small part, fine voxel |
| listening_booth | 53502 → 7986 | 0.012 | 3.4M → 0.9M | panel + stool |
| counter_till | 32226 → 7999 | 0.012 | 1.9M → 0.9M | timber counter + till |
| novelty_record | 26486 → 8000 | 0.014 | 1.8M → 0.9M | thick disc + base |
| arcade_cabinet | 25142 → 8000 | 0.012 | 1.5M → 0.8M | boxy cabinet |
Fleet: **288k → 48k tris**, **18M → 5.2M**. Every one **eyeballed** (contact sheet
`docs/shots/laneE/props_baked.png` — no melted labels/faces; the fridge re-bake was the only redo)
and **verified loading in the vendored `GLTFLoader`** (tris ≤8k, baked texture present).
Re-published to the depot (`validate --depot` 0 errors). **Provenance unchanged: 100% synthetic/
on-device** (FLUX.2-klein-4B + TRELLIS.2 + Blender bake, no third-party source). → Lane C
re-measures the record interior (was ~273k tris).

View File

@ -5,16 +5,16 @@
"file": "procity_fit_listening_booth_01.glb",
"out": "pipeline/_normalized/procity_fit_listening_booth_01.glb",
"thumb": "web/assets/thumbs/procity_fit_listening_booth_01.png",
"tri_before": 146946,
"tri_after": 53578,
"tri_before": 53502,
"tri_after": 7986,
"size_m": [
0.879,
1.6,
0.71
0.876,
1.595,
0.703
],
"footprint": [
0.88,
0.71
0.7
]
},
{
@ -22,16 +22,16 @@
"file": "procity_fit_drinks_fridge_01.glb",
"out": "pipeline/_normalized/procity_fit_drinks_fridge_01.glb",
"thumb": "web/assets/thumbs/procity_fit_drinks_fridge_01.png",
"tri_before": 191640,
"tri_after": 77412,
"tri_before": 77047,
"tri_after": 8000,
"size_m": [
0.849,
1.6,
0.962
0.842,
1.591,
0.932
],
"footprint": [
0.85,
0.96
0.84,
0.93
]
},
{
@ -39,12 +39,12 @@
"file": "procity_fit_arcade_cabinet_01.glb",
"out": "pipeline/_normalized/procity_fit_arcade_cabinet_01.glb",
"thumb": "web/assets/thumbs/procity_fit_arcade_cabinet_01.png",
"tri_before": 196336,
"tri_after": 25143,
"tri_before": 25142,
"tri_after": 8000,
"size_m": [
0.75,
1.7,
0.853
0.749,
1.692,
0.85
],
"footprint": [
0.75,
@ -56,12 +56,12 @@
"file": "procity_fit_milkshake_mixer_01.glb",
"out": "pipeline/_normalized/procity_fit_milkshake_mixer_01.glb",
"thumb": "web/assets/thumbs/procity_fit_milkshake_mixer_01.png",
"tri_before": 185106,
"tri_after": 73498,
"tri_before": 73492,
"tri_after": 7991,
"size_m": [
0.381,
0.5,
0.271
0.378,
0.498,
0.268
],
"footprint": [
0.38,
@ -73,15 +73,15 @@
"file": "procity_street_novelty_record_01.glb",
"out": "pipeline/_normalized/procity_street_novelty_record_01.glb",
"thumb": "web/assets/thumbs/procity_street_novelty_record_01.png",
"tri_before": 193253,
"tri_after": 26506,
"tri_before": 26486,
"tri_after": 8000,
"size_m": [
1.909,
2.0,
0.934
1.896,
1.992,
0.933
],
"footprint": [
1.91,
1.9,
0.93
]
},
@ -90,16 +90,16 @@
"file": "procity_fit_counter_till_01.glb",
"out": "/tmp/counter_norm/procity_fit_counter_till_01.glb",
"thumb": "web/assets/thumbs/procity_fit_counter_till_01.png",
"tri_before": 195285,
"tri_after": 32234,
"tri_before": 32226,
"tri_after": 7999,
"size_m": [
1.609,
1.15,
0.642
1.608,
1.146,
0.606
],
"footprint": [
1.61,
0.64
0.61
]
}
],

200
pipeline/bake_lowpoly.py Normal file
View File

@ -0,0 +1,200 @@
"""PROCITY hero-prop bake-to-lowpoly — turn a heavy TRELLIS shell-soup GLB into a game-ready
8k-tri prop with its base colour BAKED onto fresh UVs.
Runs headless on this box's Blender:
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python bake_lowpoly.py -- BATCH.json OUT_DIR [THUMB_DIR]
Why bake (R3's lesson): raw TRELLIS meshes are dense multi-shell scans; plain decimate /
meshopt-simplify floors at 2577k because they can't merge across the shells. So we throw the
topology away and rebuild it:
1. import the normalized HP (already metres / +Y up / base-origin / textured) bake SOURCE
2. voxel-remesh a copy one watertight manifold surface (no more shell-soup)
3. decimate the remesh to the tri budget (8k) LP
4. smart-UV unwrap LP
5. Cycles bake DIFFUSE(COLOR) HP LP's new UVs → a single baked base-colour texture
6. export LP GLB (WebP, embedded, self-illuminated a touch like normalize.py) + 256 thumbnail
BATCH.json: [ {"src":"pipeline/_normalized/procity_fit_drinks_fridge_01.glb",
"id":"drinks_fridge", "category":"fit",
"voxel":0.012, "tris":8000, "img":1024} , ... ]
"""
import bpy, sys, os, json, math
from mathutils import Vector
ARGV = sys.argv[sys.argv.index("--") + 1:]
BATCH, OUT_DIR = ARGV[0], (ARGV[1] if len(ARGV) > 1 else "/tmp/procity_bake")
THUMB_DIR = ARGV[2] if len(ARGV) > 2 else os.path.join(OUT_DIR, "thumbs")
os.makedirs(OUT_DIR, exist_ok=True); os.makedirs(THUMB_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):
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()
obj = bpy.context.view_layer.objects.active
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
return obj
def bounds(obj):
bb = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
mn = Vector((min(v.x for v in bb), min(v.y for v in bb), min(v.z for v in bb)))
mx = Vector((max(v.x for v in bb), max(v.y for v in bb), max(v.z for v in bb)))
return mn, mx
def make_lowpoly(hp, voxel, tris):
"""Duplicate HP → voxel remesh → decimate to budget → smart UV. Returns the LP object."""
bpy.ops.object.select_all(action='DESELECT')
hp.select_set(True); bpy.context.view_layer.objects.active = hp
bpy.ops.object.duplicate()
lp = bpy.context.view_layer.objects.active
lp.name = "LP"
# strip HP's materials off the LP — it gets one fresh baked material
lp.data.materials.clear()
rm = lp.modifiers.new("remesh", 'REMESH')
rm.mode = 'VOXEL'; rm.voxel_size = voxel; rm.adaptivity = 0.0
bpy.ops.object.modifier_apply(modifier=rm.name)
# triangulate so len(polygons) == triangle count → the decimate ratio targets triangles honestly
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.quads_convert_to_tris()
bpy.ops.object.mode_set(mode='OBJECT')
n = len(lp.data.polygons)
if n > tris:
dm = lp.modifiers.new("dec", 'DECIMATE')
dm.ratio = max(0.005, tris / n)
bpy.ops.object.modifier_apply(modifier=dm.name)
# UVs
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.uv.smart_project(island_margin=0.02, angle_limit=math.radians(66))
bpy.ops.object.mode_set(mode='OBJECT')
return lp
def bake_color(hp, lp, img_size):
img = bpy.data.images.new("baked", img_size, img_size)
mat = bpy.data.materials.new("baked_mat"); mat.use_nodes = True
nt = mat.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"])
lp.data.materials.clear(); lp.data.materials.append(mat)
nt.nodes.active = tex # the active image node = bake target
scn = bpy.context.scene
scn.render.engine = 'CYCLES'
try:
scn.cycles.device = 'GPU'
except Exception:
pass
scn.cycles.samples = 4
scn.render.bake.use_selected_to_active = True
scn.render.bake.cage_extrusion = 0.06
scn.render.bake.max_ray_distance = 0.12
scn.render.bake.margin = max(4, img_size // 128)
bpy.ops.object.select_all(action='DESELECT')
hp.select_set(True); lp.select_set(True)
bpy.context.view_layer.objects.active = lp # active = bake TARGET
bpy.ops.object.bake(type='DIFFUSE', pass_filter={'COLOR'}, use_clear=True)
# self-illuminate a touch so it reads in any viewer (matches normalize.py bright)
ec = bsdf.inputs.get("Emission Color") or bsdf.inputs.get("Emission")
if ec is not None:
nt.links.new(tex.outputs["Color"], ec)
es = bsdf.inputs.get("Emission Strength")
if es is not None:
es.default_value = 0.28
return img
def base_origin(obj):
mn, mx = bounds(obj)
obj.location -= Vector(((mn.x + mx.x) / 2, (mn.y + mx.y) / 2, mn.z))
bpy.ops.object.transform_apply(location=True)
def render_thumb(lp, png_name):
scn = bpy.context.scene
mn, mx = bounds(lp); ctr = (mn + mx) / 2
size = max((mx - mn).x, (mx - mn).y, (mx - mn).z) or 1.0
for o in list(bpy.data.objects):
if o.type in ('CAMERA', 'LIGHT'):
bpy.data.objects.remove(o, do_unlink=True)
w = bpy.data.worlds.get("tw") or bpy.data.worlds.new("tw")
scn.world = w; w.use_nodes = True
w.node_tree.nodes["Background"].inputs[1].default_value = 1.05
bpy.ops.object.light_add(type='AREA', location=(ctr.x + size, ctr.y - size, mx.z + size))
bpy.context.object.data.energy = 800 * size; bpy.context.object.data.size = 6
bpy.ops.object.camera_add(); cam = bpy.context.object; scn.camera = cam
r = size * 1.9
cam.location = Vector((ctr.x + r * 0.8, ctr.y - r, ctr.z + size * 0.6))
cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler()
scn.render.engine = 'BLENDER_WORKBENCH'
scn.display.shading.light = 'STUDIO'; scn.display.shading.color_type = 'TEXTURE'
scn.display.shading.show_cavity = True
scn.view_settings.view_transform = 'Standard'
scn.render.film_transparent = True
scn.render.resolution_x = scn.render.resolution_y = 256
p = os.path.join(THUMB_DIR, png_name); scn.render.filepath = p
bpy.ops.render.render(write_still=True)
return p
def bake_one(spec):
wipe()
src = spec["src"] if os.path.isabs(spec["src"]) else os.path.join(os.getcwd(), spec["src"])
hp = import_join(src)
tri_before = len(hp.data.polygons)
lp = make_lowpoly(hp, spec.get("voxel", 0.02), spec.get("tris", 8000))
bake_color(hp, lp, spec.get("img", 1024))
bpy.data.objects.remove(hp, do_unlink=True) # HP done; export LP only
base_origin(lp)
lp.select_set(True); bpy.context.view_layer.objects.active = lp
name = f"procity_{spec['category']}_{spec['id']}_01.glb"
out = os.path.join(OUT_DIR, name)
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', export_yup=True,
use_selection=True, export_image_format='WEBP', export_image_quality=88)
thumb = render_thumb(lp, name.replace(".glb", ".png"))
mn, mx = bounds(lp)
return {"file": name, "tri_before": tri_before, "tri_after": len(lp.data.polygons),
"size_m": [round(mx.x - mn.x, 3), round(mx.z - mn.z, 3), round(mx.y - mn.y, 3)],
"footprint": [round(mx.x - mn.x, 2), round(mx.y - mn.y, 2)], "thumb": thumb, "out": out}
def main():
specs = json.load(open(BATCH))
results, errors = [], []
for spec in specs:
try:
r = bake_one(spec)
results.append(r)
print(f"OK {r['file']:36} {r['tri_before']:>6}{r['tri_after']:>5} tris "
f"footprint {r['footprint']} h {r['size_m'][1]}")
except Exception as e:
import traceback; traceback.print_exc()
errors.append({"id": spec.get("id"), "error": str(e)})
print(f"ERR {spec.get('id')}: {e}")
json.dump({"results": results, "errors": errors},
open(os.path.join(OUT_DIR, "_bake_results.json"), "w"), indent=2)
print(f"\nBAKE_DONE ok={len(results)} err={len(errors)}{OUT_DIR}")
main()

50
pipeline/batch_bake.json Normal file
View File

@ -0,0 +1,50 @@
[
{
"src": "pipeline/_normalized/procity_fit_drinks_fridge_01.glb",
"id": "drinks_fridge",
"category": "fit",
"voxel": 0.03,
"tris": 8000,
"img": 1024
},
{
"src": "pipeline/_normalized/procity_fit_milkshake_mixer_01.glb",
"id": "milkshake_mixer",
"category": "fit",
"voxel": 0.005,
"tris": 8000,
"img": 1024
},
{
"src": "pipeline/_normalized/procity_fit_arcade_cabinet_01.glb",
"id": "arcade_cabinet",
"category": "fit",
"voxel": 0.012,
"tris": 8000,
"img": 1024
},
{
"src": "pipeline/_normalized/procity_fit_listening_booth_01.glb",
"id": "listening_booth",
"category": "fit",
"voxel": 0.012,
"tris": 8000,
"img": 1024
},
{
"src": "pipeline/_normalized/procity_street_novelty_record_01.glb",
"id": "novelty_record",
"category": "street",
"voxel": 0.014,
"tris": 8000,
"img": 1024
},
{
"src": "pipeline/_normalized/procity_fit_counter_till_01.glb",
"id": "counter_till",
"category": "fit",
"voxel": 0.012,
"tris": 8000,
"img": 1024
}
]

View File

@ -652,18 +652,18 @@
"file": "procity_fit_listening_booth_01.glb",
"footprint": [
0.88,
0.71
0.7
],
"height": 1.6,
"height": 1.595,
"thumb": "thumbs/procity_fit_listening_booth_01.png"
},
"drinks_fridge": {
"file": "procity_fit_drinks_fridge_01.glb",
"footprint": [
0.85,
0.96
0.84,
0.93
],
"height": 1.6,
"height": 1.591,
"thumb": "thumbs/procity_fit_drinks_fridge_01.png"
},
"arcade_cabinet": {
@ -672,7 +672,7 @@
0.75,
0.85
],
"height": 1.7,
"height": 1.692,
"thumb": "thumbs/procity_fit_arcade_cabinet_01.png"
},
"milkshake_mixer": {
@ -681,16 +681,16 @@
0.38,
0.27
],
"height": 0.5,
"height": 0.498,
"thumb": "thumbs/procity_fit_milkshake_mixer_01.png"
},
"counter_till": {
"file": "procity_fit_counter_till_01.glb",
"footprint": [
1.61,
0.64
0.61
],
"height": 1.15,
"height": 1.146,
"thumb": "thumbs/procity_fit_counter_till_01.png"
},
"cash_register": {
@ -769,10 +769,10 @@
"novelty_record": {
"file": "procity_street_novelty_record_01.glb",
"footprint": [
1.91,
1.9,
0.93
],
"height": 2.0,
"height": 1.992,
"thumb": "thumbs/procity_street_novelty_record_01.png"
}
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB