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>
This commit is contained in:
parent
f628bf581a
commit
a569f08b14
BIN
docs/shots/laneE/r39_magpie_ab.png
Normal file
BIN
docs/shots/laneE/r39_magpie_ab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 436 KiB |
1
pipeline/_bird_b.json
Normal file
1
pipeline/_bird_b.json
Normal file
File diff suppressed because one or more lines are too long
96
pipeline/bird_to_glb.py
Normal file
96
pipeline/bird_to_glb.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""PROCITY Lane E — bird_to_glb.py (R39, the magpie A/B)
|
||||
|
||||
Turn `pipeline/dump_bird.mjs`'s dump of LANE B'S OWN `birdGeometry()` into a GLB, so the identical
|
||||
`render_views.py` camera rig that shot E's tinted 894-tri GLB can shoot B's 182-tri procedural bird
|
||||
from the identical angles under the identical light. Rendering the two through different pipelines
|
||||
would make the picture arguable; this makes it decisive.
|
||||
|
||||
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
||||
"$BL" --background --python pipeline/bird_to_glb.py -- IN.json OUT.glb [--fold]
|
||||
|
||||
`--fold` applies magpie.js's perch pose (`place(..., folded=true)` scales the instance X by 0.42),
|
||||
because B's bird has TWO silhouettes off one geometry and E's GLB has one.
|
||||
|
||||
Vertex colours ride as COLOR_0 and are wired straight into Base Color, matching
|
||||
`MeshStandardMaterial({ vertexColors: true, roughness: 0.62, metalness: 0, side: DoubleSide })`.
|
||||
Normals are the ones three.js computed, imported as custom split normals — a Blender-recomputed
|
||||
normal would shade the sphere differently from the browser.
|
||||
"""
|
||||
import bpy, sys, json
|
||||
|
||||
ARGV = sys.argv[sys.argv.index("--") + 1:]
|
||||
SRC, OUT = ARGV[0], ARGV[1]
|
||||
FOLD = "--fold" in ARGV
|
||||
FOLD_X = 0.42 # magpie.js: the perched instance is squashed in X, wings in
|
||||
|
||||
d = json.load(open(SRC))
|
||||
pos, nrm, col, idx = d["position"], d["normal"], d["color"], d["index"]
|
||||
nv = len(pos) // 3
|
||||
|
||||
# ── FRAME. three: +Y up, nose along −Z. E's published magpie GLB: +Y up, HEAD AT +Z (that is what
|
||||
# render_views.py's rig assumes — "glTF +Z head -> Blender -Y"). Blender's glTF exporter with
|
||||
# export_yup writes glTF = (Bx, Bz, −By). So to land B's bird in E's frame we want
|
||||
# glTF = (−xt, yt, −zt) (a 180° yaw, det = +1 ⇒ winding preserved)
|
||||
# which means the Blender vertex must be (−xt, zt, yt). Without this the two rows of the sheet
|
||||
# would be shot from opposite ends of the bird and the comparison would be worthless.
|
||||
def to_blender(x, y, z):
|
||||
return (-x, z, y)
|
||||
|
||||
fx = FOLD_X if FOLD else 1.0
|
||||
verts = [to_blender(pos[i * 3] * fx, pos[i * 3 + 1], pos[i * 3 + 2]) for i in range(nv)]
|
||||
faces = [tuple(idx[i:i + 3]) for i in range(0, len(idx), 3)] if idx else \
|
||||
[(i, i + 1, i + 2) for i in range(0, nv, 3)]
|
||||
|
||||
for o in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
me = bpy.data.meshes.new("bird")
|
||||
me.from_pydata(verts, [], faces)
|
||||
me.update()
|
||||
ob = bpy.data.objects.new("bird", me)
|
||||
bpy.context.collection.objects.link(ob)
|
||||
|
||||
# three's own normals, per corner (folding in X inverts nothing: 0.42 > 0, so only a rescale)
|
||||
if nrm:
|
||||
loops = []
|
||||
for p in me.polygons:
|
||||
for vi in p.vertices:
|
||||
loops.append(to_blender(nrm[vi * 3], nrm[vi * 3 + 1], nrm[vi * 3 + 2]))
|
||||
try:
|
||||
me.normals_split_custom_set(loops)
|
||||
except Exception as e: # never let shading cosmetics kill the export
|
||||
print("custom normals skipped:", e)
|
||||
|
||||
# COLOR_0 — the whole argument for B's bird is that the white is IN the vertices
|
||||
if col:
|
||||
ca = me.color_attributes.new(name="Col", type='FLOAT_COLOR', domain='POINT')
|
||||
for i in range(nv):
|
||||
ca.data[i].color = (col[i * 3], col[i * 3 + 1], col[i * 3 + 2], 1.0)
|
||||
me.color_attributes.active_color = ca
|
||||
me.attributes.active_color = ca
|
||||
|
||||
mat = bpy.data.materials.new("birdMat")
|
||||
mat.use_nodes = True
|
||||
mat.use_backface_culling = False # three: side = DoubleSide
|
||||
nt = mat.node_tree
|
||||
bsdf = nt.nodes["Principled BSDF"]
|
||||
bsdf.inputs["Roughness"].default_value = 0.62
|
||||
bsdf.inputs["Metallic"].default_value = 0.0
|
||||
if col:
|
||||
cattr = nt.nodes.new("ShaderNodeVertexColor")
|
||||
cattr.layer_name = "Col"
|
||||
nt.links.new(cattr.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
# FAIRNESS. E's published magpie carries normalize.py's emissiveFactor 0.28 WITH an
|
||||
# emissiveTexture, i.e. 0.28 × its own albedo — a self-lit copy of its own markings. Handing B's
|
||||
# bird the identical treatment (0.28 × its vertex colour) is the only way the two rows of the
|
||||
# sheet are lit the same; without it the render flatters E by construction and the ruling is
|
||||
# worthless. `--noemit` drops it, for the ?noassets-honest version of B's material.
|
||||
if "--noemit" not in ARGV:
|
||||
nt.links.new(cattr.outputs["Color"], bsdf.inputs["Emission Color"])
|
||||
bsdf.inputs["Emission Strength"].default_value = 0.28
|
||||
me.materials.append(mat)
|
||||
|
||||
bpy.ops.export_scene.gltf(filepath=OUT, export_format='GLB',
|
||||
export_apply=True, export_yup=True,
|
||||
export_normals=True, export_vertex_color='ACTIVE')
|
||||
print(f"WROTE {OUT} tris={len(faces)} verts={nv} folded={FOLD}")
|
||||
95
pipeline/dump_bird.mjs
Normal file
95
pipeline/dump_bird.mjs
Normal file
@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
// PROCITY Lane E — dump_bird.mjs (R39, the magpie A/B)
|
||||
//
|
||||
// Dump Lane B's PROCEDURAL magpie geometry, by RUNNING LANE B'S OWN MODULE — not by re-implementing
|
||||
// it. `web/js/world/magpie.js` is imported unmodified; the bare `three` / `three/addons/` specifiers
|
||||
// its imports use are resolved to the repo's OWN vendored build through a node resolve hook, so the
|
||||
// vertices dumped here are byte-for-byte the vertices the browser gets. That matters: an A/B render
|
||||
// against a hand-ported strawman proves nothing, and the whole point of R39 item 1 is that Fable
|
||||
// rules on the picture.
|
||||
//
|
||||
// node pipeline/dump_bird.mjs [OUT.json]
|
||||
//
|
||||
// Writes { tris, verts, position[], normal[], color[], index[]|null } in the mesh's own metric frame
|
||||
// (metres, three's +Y up, nose along −Z). `pipeline/bird_to_glb.py` turns it into a GLB so the same
|
||||
// `render_views.py` rig that shot E's tinted GLB can shoot B's bird from the same cameras.
|
||||
import { registerHooks } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const VENDOR = pathToFileURL(resolve(ROOT, 'web/vendor/three.module.js')).href;
|
||||
const ADDONS = pathToFileURL(resolve(ROOT, 'web/vendor/addons')).href + '/';
|
||||
|
||||
// The repo's importmap, as a node resolver: "three" and "three/addons/*" only.
|
||||
registerHooks({
|
||||
resolve(spec, ctx, next) {
|
||||
if (spec === 'three') return { url: VENDOR, shortCircuit: true };
|
||||
if (spec.startsWith('three/addons/')) {
|
||||
return { url: ADDONS + spec.slice('three/addons/'.length), shortCircuit: true };
|
||||
}
|
||||
return next(spec, ctx);
|
||||
},
|
||||
});
|
||||
|
||||
const { generatePlan } = await import(pathToFileURL(resolve(ROOT, 'web/js/citygen/plan.js')).href);
|
||||
const { createMagpie } = await import(pathToFileURL(resolve(ROOT, 'web/js/world/magpie.js')).href);
|
||||
|
||||
const plan = generatePlan(20261990);
|
||||
const scene = { add() {}, remove() {} };
|
||||
const camera = { position: { x: 0, y: 1.6, z: 0 } };
|
||||
const m = createMagpie({ scene, plan, citySeed: 20261990, townKey: null, camera, chunks: null, lighting: null, force: true });
|
||||
const mesh = m.group.children.find((c) => c.isInstancedMesh);
|
||||
const g = mesh.geometry;
|
||||
|
||||
const idx = g.index ? Array.from(g.index.array) : null;
|
||||
const pos = Array.from(g.attributes.position.array);
|
||||
const nrm = g.attributes.normal ? Array.from(g.attributes.normal.array) : null;
|
||||
const col = g.attributes.color ? Array.from(g.attributes.color.array) : null;
|
||||
const tris = idx ? idx.length / 3 : pos.length / 9;
|
||||
|
||||
const out = {
|
||||
source: 'web/js/world/magpie.js :: birdGeometry() via createMagpie()',
|
||||
tris, verts: pos.length / 3,
|
||||
material: { vertexColors: true, roughness: 0.62, metalness: 0, side: 'DoubleSide', wind: 'wing' },
|
||||
bbox: (() => {
|
||||
g.computeBoundingBox();
|
||||
const b = g.boundingBox;
|
||||
return { min: [b.min.x, b.min.y, b.min.z], max: [b.max.x, b.max.y, b.max.z],
|
||||
size: [b.max.x - b.min.x, b.max.y - b.min.y, b.max.z - b.min.z] };
|
||||
})(),
|
||||
// the perch pose is the same geometry squashed in X (magpie.js `place(..., folded)`)
|
||||
foldedScaleX: 0.42,
|
||||
index: idx, position: pos, normal: nrm, color: col,
|
||||
};
|
||||
const dst = process.argv.find((a) => a.endsWith('.json')) || resolve(HERE, '_bird_b.json');
|
||||
writeFileSync(dst, JSON.stringify(out));
|
||||
console.log(`bird: ${tris} triangles, ${out.verts} verts, bbox size ${out.bbox.size.map((v) => v.toFixed(3)).join(' × ')} m → ${dst}`);
|
||||
|
||||
// ── --sim: WHICH POSE IS THE PLAYER ACTUALLY LOOKING AT? ────────────────────────────────────────
|
||||
// The A/B is usually argued as "the swoop is the whole point", but magpie.js's own clock says the
|
||||
// swoop is the minority state: COOLDOWN 5.5 s perched against SWOOP_T 1.25 + RETURN_T 1.9 = 3.15 s
|
||||
// in the air, and the mesh is drawn out to DEFEND_R × 2.2 = 74.8 m where it can only be perched.
|
||||
// So walk a player down the street past a territory at WALK speed and COUNT the frames.
|
||||
if (process.argv.includes('--sim')) {
|
||||
const t = m.territories[0] || { x: 0, z: 0 };
|
||||
const DT = 1 / 60, SPEED = 4.6, OFFSET = 4.0; // WALK m/s, and how far off the perch you pass
|
||||
globalThis.window = { PROCITY: { game: { day: 1 } } };
|
||||
const tally = {};
|
||||
let frames = 0;
|
||||
for (let i = 0; i < 60 * 60; i++) { // 60 s of walking
|
||||
const s = -140 + i * DT * SPEED; // straight past the perch, 140 m either side
|
||||
camera.position.x = t.x + s; camera.position.z = t.z + OFFSET;
|
||||
m.update(DT);
|
||||
tally[m.state.mode] = (tally[m.state.mode] || 0) + 1;
|
||||
if (m.count > 0) frames++;
|
||||
}
|
||||
const drawn = Object.entries(tally).filter(([k]) => k === 'perched' || k === 'swooping' || k === 'returning');
|
||||
const total = drawn.reduce((a, [, v]) => a + v, 0);
|
||||
console.log(`sim: 60 s walk at ${SPEED} m/s, ${OFFSET} m off the perch — modes ${JSON.stringify(tally)}`);
|
||||
console.log(`sim: bird DRAWN in ${frames} of 3600 frames (${(frames / 36).toFixed(1)}%)`);
|
||||
for (const [k, v] of drawn) console.log(`sim: ${k.padEnd(10)} ${v} frames = ${(100 * v / total).toFixed(1)}% of the frames it is on screen`);
|
||||
}
|
||||
@ -34,9 +34,33 @@ def bounds(o):
|
||||
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)
|
||||
|
||||
@ -8,8 +8,16 @@ One row of views per input (each composited over sky — bird-against-sky is the
|
||||
matters), then a strip that re-samples the swoop / flank / rear views to 64 / 48 / 32 px and
|
||||
magnifies them NEAREST: 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 of whether a marking reads and 32 px is the pessimistic one.
|
||||
|
||||
`--ab` (R39, the two magpies) lays the distance strip out as a GRID — one line per candidate, the
|
||||
same view and the same pixel size stacked vertically — because a single 27-cell line is a list and
|
||||
what a ruling needs is a column you can look down. It then repeats that grid under linear MOTION
|
||||
BLUR, since the bird is seen for ~1.2 s crossing peripheral vision and a marking that only reads on
|
||||
a still is not a marking the player ever sees.
|
||||
"""
|
||||
import sys
|
||||
import textwrap
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SKY = (158, 184, 219)
|
||||
@ -17,12 +25,16 @@ PAD, LBL = 10, 16
|
||||
STRIP = ("swoop", "flank", "rear34")
|
||||
SIZES = (64, 48, 32)
|
||||
CELL = 112
|
||||
GUTTER = 150
|
||||
|
||||
|
||||
def over_sky(p, bg=SKY):
|
||||
im = Image.open(p).convert("RGBA")
|
||||
out = Image.new("RGB", im.size, bg)
|
||||
out.paste(im, (0, 0), im)
|
||||
a = im.getchannel("A")
|
||||
bb = a.getbbox() # the SUBJECT's extent — motion blur is scaled to the bird
|
||||
out.info["subject"] = bb or (0, 0, im.width, im.height)
|
||||
return out
|
||||
|
||||
|
||||
@ -35,11 +47,95 @@ def load(path):
|
||||
return views
|
||||
|
||||
|
||||
def motion_blur(im, frac):
|
||||
"""Linear horizontal smear whose length is `frac` × the SUBJECT's on-screen width.
|
||||
|
||||
A true average of N sub-frame positions, not a gaussian: that is what a shutter integrates and
|
||||
it is what decides whether a 5 cm white wing bar survives being dragged across its own body.
|
||||
"""
|
||||
bb = im.info.get("subject", (0, 0, im.width, im.height))
|
||||
span = max(1, int(round(frac * (bb[2] - bb[0]))))
|
||||
if span < 2:
|
||||
return im
|
||||
pad = span // 2 + 2
|
||||
a = np.asarray(Image.new("RGB", (im.width + 2 * pad, im.height), SKY), dtype=np.float32).copy()
|
||||
a[:, pad:pad + im.width] = np.asarray(im, dtype=np.float32)
|
||||
n = min(96, span)
|
||||
acc = np.zeros_like(a)
|
||||
for i in range(n):
|
||||
off = int(round((i / (n - 1) - 0.5) * span))
|
||||
acc += np.roll(a, off, axis=1)
|
||||
acc /= n
|
||||
out = Image.fromarray(acc[:, pad:pad + im.width].astype("uint8"))
|
||||
out.info["subject"] = bb
|
||||
return out
|
||||
|
||||
|
||||
def ab_sheet(out_path, title, rows, notes):
|
||||
"""One line per candidate, stacked, still and then moving. Built for a ruling, not a gallery."""
|
||||
w, h = next(iter(rows[0][1].values())).size
|
||||
ncol = max(len(v) for _, v in rows)
|
||||
cols = [(n, s) for n in STRIP for s in SIZES]
|
||||
blocks = [("AT THE SIZE IT IS ACTUALLY SEEN — still (rendered px, magnified NEAREST)", 0.0),
|
||||
("MOVING — smear = 25% of the bird's width (the approach: ~0.11 m/frame at 60 fps)", 0.25),
|
||||
("MOVING — smear = 100% of the bird's width (the pass: swoop covers 34 m in 1.25 s "
|
||||
"⇒ 0.45 m/frame, ~1 body length)", 1.0)]
|
||||
W = max(PAD + ncol * (w + PAD), GUTTER + len(cols) * (CELL + PAD) + PAD)
|
||||
H = (24 + len(rows) * (h + LBL + PAD)
|
||||
+ len(blocks) * (LBL + 14 + len(rows) * (CELL + LBL + 4)) + PAD
|
||||
+ LBL * (sum(max(1, len(l) // 300 + 1) for l in notes) * 3 + 2))
|
||||
sheet = Image.new("RGB", (W, H), (24, 24, 26))
|
||||
d = ImageDraw.Draw(sheet)
|
||||
d.text((PAD, 5), title, fill=(240, 240, 240))
|
||||
y = 24
|
||||
for label, views in rows:
|
||||
d.text((PAD, y), label, fill=(250, 210, 120))
|
||||
y += LBL
|
||||
for i, (n, im) in enumerate(views.items()):
|
||||
x = PAD + i * (w + PAD)
|
||||
sheet.paste(im, (x, y))
|
||||
d.text((x + 4, y + 2), n, fill=(40, 40, 40))
|
||||
y += h + PAD
|
||||
for head, frac in blocks:
|
||||
d.text((PAD, y), head, fill=(250, 210, 120))
|
||||
y += LBL
|
||||
for i, (n, s) in enumerate(cols):
|
||||
d.text((GUTTER + i * (CELL + PAD) + 3, y), f"{n} {s}px", fill=(175, 175, 175))
|
||||
y += 14
|
||||
for label, views in rows:
|
||||
d.text((PAD, y + CELL // 2 - 6), label[:22], fill=(225, 225, 225))
|
||||
for i, (n, s) in enumerate(cols):
|
||||
if n not in views:
|
||||
continue
|
||||
src = motion_blur(views[n], frac) if frac else views[n]
|
||||
sheet.paste(src.resize((s, s), Image.LANCZOS).resize((CELL, CELL), Image.NEAREST),
|
||||
(GUTTER + i * (CELL + PAD), y))
|
||||
y += CELL + LBL + 4
|
||||
y += 4
|
||||
for line in notes:
|
||||
for part in textwrap.wrap(line, width=max(60, (W - 2 * PAD) // 6)) or [""]:
|
||||
d.text((PAD, y), part, fill=(190, 190, 190))
|
||||
y += LBL
|
||||
sheet = sheet.crop((0, 0, W, min(sheet.height, y + PAD))) # the height estimate over-reserves
|
||||
sheet.save(out_path)
|
||||
print(f"ab sheet -> {out_path} {sheet.size}")
|
||||
|
||||
|
||||
def main():
|
||||
out_path, title = sys.argv[1], sys.argv[2]
|
||||
rows = []
|
||||
args = sys.argv[3:]
|
||||
extra = None
|
||||
notes = []
|
||||
if "--note" in args: # --note "line" ["line" ...] : footer, always last
|
||||
i = args.index("--note")
|
||||
notes = args[i + 1:]
|
||||
args = args[:i]
|
||||
if "--ab" in args:
|
||||
args.remove("--ab")
|
||||
for i in range(0, len(args), 2):
|
||||
rows.append((args[i], load(args[i + 1])))
|
||||
return ab_sheet(out_path, title, rows, notes)
|
||||
if "--extra" in args: # --extra IMG.png "caption": pasted under the sheet
|
||||
i = args.index("--extra")
|
||||
extra = (args[i + 1], args[i + 2])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user