PROCITY/pipeline/render_views.py
m3ultra 74d6257940 Lane E R38 s2: the three GLBs PUBLISHED + sha1-verified off the public depot; the magpie is a magpie
A. DEPOT — closed, under John's R22 standing Lane-E authorization (the authority that
   shipped sit.glb in R28 and the nine R36 sweep assets). Pushed via the passwordless
   tailnet ingress: _published.json 53 -> 56, drift check clean. Then verified the way
   this lane verifies — FETCHED BACK OFF THE PUBLIC DEPOT and hashed, not "the upload
   said 200": magpie/paling_fence/water_tank all HTTP 200, byte counts exact, sha1
   identical to local, 3/3. _r38_results.PENDING_PUBLISH.json renamed, build_manifest
   re-run (GLBs 33 -> 36), validate_manifest 0 errors / 0 warnings.

B. THE MAGPIE TINT — done, not offered. The bird shipped as an all-black crow (R38 4d
   traced the loss to trellis2_mlx's PBR bake). "Hand-tint the atlas" is not a paint job:
   the bake is a smart-UV unwrap of ~100 islands and none of them announces itself as a
   nape. So pipeline/tint_atlas.py defines the tint in the MESH's frame and projects it
   through the UVs — rasterize every UV triangle to texel->(position,normal), evaluate
   soft anatomical windows on the positions (white nape behind a black crown, shoulder
   bar on the flanks only so the dorsal midline stays black, rump + tail base stopping
   short of the black terminal band), paint keeping the bake's own luminance as shading,
   then dilate 5 texels into the UV padding so no black fringe survives bilinear/mip.
   3-D masks give one consistent soft edge across every island at once; a 2-D blur could
   not (it would bleed island-to-island through the black background).
   GEOMETRY UNTOUCHED byte for byte: still 894 tris / 1 mtl / 1 img / 512^2 WebP / no
   Draco / 0.155x0.358x0.404 m. Painted luma median 0.84 (p90 0.95) vs the bake's 0.13.
   VERIFIED IN THE GAME'S OWN RENDERER: the PUBLISHED file loaded off digalot.fyi through
   the vendored GLTFLoader + three.js — 1 mesh, 894 tris, 512x512 texture decoded (which
   also proves the PIL-written WebP decodes in a browser).
   VERDICT: it reads. Pied and unmistakable at 64 and 48 px, still black-and-white at 32.
   Honest caveat: from directly below-and-in-front it stays dark — correct, a real
   magpie's bib and belly ARE black. Render: docs/shots/laneE/r38_magpie_tint.png.
   Standing after B's 4514cb9: an UPGRADE PATH, not a dependency — B's 182-tri procedural
   bird is 4.9x lighter and the right call. Nothing probes for the GLB and nothing should.

C. CORRECTIONS FOLDED INTO THE RECORD so the next asset round does not re-learn them:
   skins are 9.7 s not 5.7 s (1024^2, 1.78x the pixels, per-pixel identical) - the reuse
   list's "one line each" holds for 5 of 9 (extractGLB keeps only the FIRST material) -
   normalize.py's collapse decimator floors at the shell count on TRELLIS output while
   bake_lowpoly.py clears it every time - the 489 s outlier at 3.8x median means
   throughput plans on a tail, not a mean - furniture.js:30's stale comment is fixed by B.
   NEW, and the one worth keeping: an asset's tri budget is tris x THE INSTANCE COUNT THE
   PLACEMENT RULE IMPLIES. paling_fence is a good 1,121-tri panel and a rejected asset —
   905 instances = 1.01M tris, ~47x over. Ask the consuming lane for the instance count
   BEFORE generating anything that tiles, edges or repeats per-lot.

New tools, all on-device and $0: tint_atlas.py, render_views.py (view sheet + --eevee +
roster-consistent --thumb), view_sheet.py (the distance strip no 256px thumbnail can make).

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

125 lines
5.5 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 main():
wipe()
bpy.ops.import_scene.gltf(filepath=SRC)
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()