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:
m3ultra 2026-08-03 20:35:00 +10:00
parent f628bf581a
commit a569f08b14
6 changed files with 312 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

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
View 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
View 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`);
}

View File

@ -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)))) 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(): def main():
wipe() wipe()
bpy.ops.import_scene.gltf(filepath=SRC) 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'] objs = [o for o in bpy.data.objects if o.type == 'MESH']
ob = objs[0] ob = objs[0]
mn, mx = bounds(ob) mn, mx = bounds(ob)

View File

@ -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 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 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. 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 sys
import textwrap
import numpy as np
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
SKY = (158, 184, 219) SKY = (158, 184, 219)
@ -17,12 +25,16 @@ PAD, LBL = 10, 16
STRIP = ("swoop", "flank", "rear34") STRIP = ("swoop", "flank", "rear34")
SIZES = (64, 48, 32) SIZES = (64, 48, 32)
CELL = 112 CELL = 112
GUTTER = 150
def over_sky(p, bg=SKY): def over_sky(p, bg=SKY):
im = Image.open(p).convert("RGBA") im = Image.open(p).convert("RGBA")
out = Image.new("RGB", im.size, bg) out = Image.new("RGB", im.size, bg)
out.paste(im, (0, 0), im) 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 return out
@ -35,11 +47,95 @@ def load(path):
return views 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(): def main():
out_path, title = sys.argv[1], sys.argv[2] out_path, title = sys.argv[1], sys.argv[2]
rows = [] rows = []
args = sys.argv[3:] args = sys.argv[3:]
extra = None 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 if "--extra" in args: # --extra IMG.png "caption": pasted under the sheet
i = args.index("--extra") i = args.index("--extra")
extra = (args[i + 1], args[i + 2]) extra = (args[i + 1], args[i + 2])