PROCITY/pipeline/render_views.py
m3ultra a569f08b14 Lane E R39 (1/n): THE MAGPIE, SETTLED ON A PICTURE — B's own birdGeometry() dumped vertex-for-vertex (182 tris, not 154), rendered against E's tinted GLB at 64/48/32 px, still and under motion blur
- pipeline/dump_bird.mjs: imports web/js/world/magpie.js UNMODIFIED (bare 'three' resolved to the
  repo's vendored build through a node resolve hook) and dumps birdGeometry() — so the A/B is
  against Lane B's actual bird, not a port. MEASURED 182 tris / 216 verts. --sim walks a player
  past a territory on magpie.js's own clock: drawn 54.1% of frames, and OF THOSE perched 80.5% /
  swooping 7.7% / returning 11.8%.
- pipeline/bird_to_glb.py: wraps the dump as a GLB in E's frame (head +Z, +Y up) so the identical
  render_views.py rig shoots both; --fold applies the perch pose (x x 0.42). glb_stat re-measures 182.
- pipeline/render_views.py --noemit: strips emission from BOTH candidates. normalize.py's
  emissiveFactor 0.28 is albedo-MODULATED; a vertex-coloured mesh cannot express that in glTF and
  Blender writes a flat 0.28 that lifts a black bird to grey. Off both sides or the picture lies.
- pipeline/view_sheet.py --ab: the strip as a GRID (one line per candidate, stacked) plus two
  linear motion-blur blocks at 25% and 100% of the bird's width, and a wrapped notes footer.
- docs/shots/laneE/r39_magpie_ab.png — the sheet Fable rules on.

Recommendation in LANE_E_NOTES; picture first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:35:00 +10:00

149 lines
6.8 KiB
Python

"""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()