PROCITY/pipeline/bake_lowpoly.py
m3ultra 8644d3f7ac Lane E round 5 (E3): fal Hunyuan glass_case + bus_shelter + magazine_rack (~$0.64)
The 2 R3 TRELLIS glass rejects + optional magazine-rack, generated on fal.ai
Hunyuan3D v2 (John-authorized, $22 balance). pipeline/gen_props_fal.py drives the
fal operator using the FAL_KEY from MODELBEAST settings (never hardcoded). Frosted/
opaque flux_local concepts gave the reconstructor a surface (clear glass defeated
TRELLIS). glass-case 1 att, bus-shelter 2 att (opaque back wall), magazine-rack 1 att
= ~$0.64 total, guardrail held. All baked to <=8k via bake_lowpoly.py (switched to
EMIT albedo bake — Hunyuan materials bake near-black under DIFFUSE). Verified loading
in vendored GLTFLoader, published, validate --depot 0 err. Manifest 17 fittings + 9
furniture; aliases wire glass_case/glass_cabinets/magazine_rack/fridge/listening_corner.
Costs logged in AUDIT R5. Last 2 primitive-only fittings retired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:20:44 +10:00

225 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 _base_color_to_emission(hp):
"""Route every HP material's base-colour (texture or flat value) into its Emission and mute the
BSDF, so a bake_type='EMIT' pass reads PURE ALBEDO regardless of the source material setup
(TRELLIS and fal-Hunyuan wire their materials differently; DIFFUSE(COLOR) mis-reads fal's)."""
for m in hp.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
bc, ec = b.inputs.get("Base Color"), (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 bake_color(hp, lp, img_size):
_base_color_to_emission(hp)
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='EMIT', use_clear=True) # HP base-colour routed to emission = albedo
# 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()