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>
201 lines
8.5 KiB
Python
201 lines
8.5 KiB
Python
"""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 25–77k 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()
|