"""PROCITY view sheet — render a normalized GLB from the angles it is actually SEEN from, plus a distance strip at the pixel sizes it actually occupies. Written for R38's magpie tint verdict: "does the marking read at the distance and speed the bird is seen at" is a question no 256 px thumbnail can answer. BL=/Applications/Blender.app/Contents/MacOS/Blender "$BL" --background --python pipeline/render_views.py -- IN.glb OUT.png [LABEL] Views (Blender frame after glTF import: glTF +Z head -> Blender -Y): flank · rear-quarter high (the nape read) · under-front (the swoop, bird above the player) · top-down · straight rear. Each is composited over sky, not over transparency, because the contrast that matters is bird-against-sky. The bottom strip re-samples the flank + swoop view to 96 / 64 / 48 / 32 px — a 0.36 m bird at 3-10 m on a 1080-high screen is 50-100 px, so 64 px is the honest test and 32 px is the pessimistic one. """ import bpy, sys, os, math from mathutils import Vector ARGV = sys.argv[sys.argv.index("--") + 1:] SRC, OUT = ARGV[0], ARGV[1] LABEL = ARGV[2] if len(ARGV) > 2 else "" RES = 384 SKY = (0.62, 0.72, 0.86) def wipe(): for o in list(bpy.data.objects): bpy.data.objects.remove(o, do_unlink=True) def bounds(o): cs = [o.matrix_world @ Vector(c) for c in o.bound_box] return (Vector((min(c.x for c in cs), min(c.y for c in cs), min(c.z for c in cs))), Vector((max(c.x for c in cs), max(c.y for c in cs), max(c.z for c in cs)))) def kill_emission(): """Zero every imported material's emission. [R39] Needed for an A/B that is allowed to decide anything. `normalize.py` gives every baked GLB `emissiveFactor 0.28` WITH an `emissiveTexture`, i.e. 0.28 x its own albedo — blacks stay black, whites get brighter. A vertex-coloured mesh cannot express that in glTF at all (COLOR_0 multiplies base colour only), and Blender's exporter silently writes a FLAT 0.28, which lifts the black bird to grey and renders the comparison meaningless. So the fair move is to strip it from BOTH sides and light the two candidates identically off sky + sun. Stated on the sheet: a baked GLB reads slightly BETTER in game than it does here, which is the safe direction to be wrong in. """ for m in bpy.data.materials: if not m.use_nodes: continue for n in m.node_tree.nodes: for sock in ("Emission Strength", "Emission Color"): if sock in n.inputs: for lk in list(n.inputs[sock].links): m.node_tree.links.remove(lk) n.inputs[sock].default_value = 0.0 if sock == "Emission Strength" else (0, 0, 0, 1) def main(): wipe() bpy.ops.import_scene.gltf(filepath=SRC) if "--noemit" in ARGV: kill_emission() objs = [o for o in bpy.data.objects if o.type == 'MESH'] ob = objs[0] mn, mx = bounds(ob) ctr = (mn + mx) / 2 size = max((mx - mn).x, (mx - mn).y, (mx - mn).z) or 1.0 scn = bpy.context.scene if "--eevee" in ARGV: # closest cheap proxy for the in-game look: the GLB's OWN material (base colour + the # 0.28 emissive normalize.py adds) under a bright sky-coloured world + a sun. eng = [e.identifier for e in type(scn).bl_rna.properties['render'].fixed_type.properties['engine'].enum_items] scn.render.engine = 'BLENDER_EEVEE_NEXT' if 'BLENDER_EEVEE_NEXT' in eng else 'BLENDER_EEVEE' w = bpy.data.worlds.get("sky") or bpy.data.worlds.new("sky") scn.world = w w.use_nodes = True bg = w.node_tree.nodes["Background"] bg.inputs[0].default_value = (SKY[0], SKY[1], SKY[2], 1.0) bg.inputs[1].default_value = 1.6 bpy.ops.object.light_add(type='SUN', location=(size * 3, -size * 3, size * 4)) bpy.context.object.data.energy = 3.0 bpy.context.object.rotation_euler = (math.radians(50), 0, math.radians(35)) else: scn.render.engine = 'BLENDER_WORKBENCH' scn.display.shading.light = 'STUDIO' scn.display.shading.color_type = 'TEXTURE' scn.display.shading.show_cavity = False scn.view_settings.view_transform = 'Standard' scn.render.film_transparent = True scn.render.resolution_x = scn.render.resolution_y = RES bpy.ops.object.camera_add() cam = bpy.context.object scn.camera = cam cam.data.lens = 50 if "--thumb" in ARGV: # byte-for-byte the same camera/lighting as bake_lowpoly.render_thumb, so a re-tinted asset # gets a thumbnail that matches the rest of the roster instead of a new angle. scn.render.engine = 'BLENDER_WORKBENCH' scn.display.shading.light = 'STUDIO' scn.display.shading.color_type = 'TEXTURE' scn.display.shading.show_cavity = 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 r = size * 1.9 # --rear swings the same rig to the other quarter. Needed for the magpie: the roster's # standard front-quarter looks straight at a magpie's black face and bib, so it records # none of the plumage — the one thing this asset's thumb has to show. cam.location = ctr + Vector((r * 0.8, r if "--rear" in ARGV else -r, size * 0.6)) cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler() scn.render.resolution_x = scn.render.resolution_y = 256 scn.render.filepath = OUT bpy.ops.render.render(write_still=True) print("RENDERED thumb -> " + OUT) return r = size * 2.2 # head is -Y after import; +Y is behind the bird views = { "flank": Vector((r, -r * 0.15, size * 0.15)), "rear34": Vector((r * 0.55, r * 0.85, size * 0.85)), # over the shoulder: the nape "swoop": Vector((r * 0.35, -r * 0.80, -size * 0.75)), # bird overhead, coming at you "top": Vector((0.001, 0.001, r * 1.15)), "rear": Vector((0.0, r * 1.1, size * 0.2)), } tmp = os.path.join(os.path.dirname(OUT), "_v_") paths = [] for name, loc in views.items(): cam.location = ctr + loc cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler() p = tmp + name + ".png" scn.render.filepath = p bpy.ops.render.render(write_still=True) paths.append((name, p)) print("RENDERED " + " ".join(n for n, _ in paths)) with open(OUT + ".views.txt", "w") as f: f.write("\n".join(f"{n}\t{p}" for n, p in paths) + f"\nLABEL\t{LABEL}\n") main()