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>
125 lines
5.5 KiB
Python
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()
|