- 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>
149 lines
6.8 KiB
Python
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()
|