Lane C measured the bookshelf's transmission halving interior draws (opshop/hall 191->104, book/hall
101->51, dept/hall 116->116 control). The cause, with vendored line numbers: three.module.js:16209
enters renderTransmissionPass(opaqueObjects, ...) whenever currentRenderList.transmissive is
non-empty, and :16433 re-renders the WHOLE OPAQUE LIST into a render target — every opaque draw
issued twice. Trigger is material.transmission > 0 (:6890), set from the glTF at GLTFLoader:1202.
And at 1.0 it is not 'a bit glassy': :494 does totalDiffuse = mix(totalDiffuse, transmitted.rgb,
material.transmission) — the diffuse term is REPLACED by the backdrop.
I SCANNED ALL 103 GLBs. Three carried it:
procity_fit_bookshelf_01 mtl_10218_Bookshelves_v1 shipped and hurting (C's)
procity_street_longbench_01 lambert3SG NOT WIRED — the 5.86 m arcade bench
the v9 charter names, on my R38
recommend list for Lane B
procity_street_streetlight_01 Streetlight_HighResSG3 NOT WIRED — one per ~18 m of street
The two unwired ones are the worse finding: the street budget is 292/300 with EIGHT draws of margin,
and a pre-pass there costs another ~292, not eight. Armed, not fired.
FIXED SURGICALLY. pipeline/strip_transmission.py rewrites only the JSON chunk and copies BIN through,
printing sha1(BIN) before and after: BIN IDENTICAL on all three, tris/dims/mtl/img/tex unchanged.
KHR_materials_ior deliberately left (ior does not trigger the pre-pass).
PUBLISHED + VERIFIED under John's R22 standing authorization via the passwordless tailnet ingress:
6/6 HTTP 200 off the PUBLIC depot, byte counts exact, sha1 identical, depot copies re-scanned clean.
_published.json 56 -> 59.
GATED, which matters more than the fix: validate_manifest.py now HARD FAILS on transmissionFactor>0
with the three.js line numbers and the fix command in the message; glb_stat.py grew a transmission
column and a loud footer. Nobody looked for three epochs because a bookshelf is obviously not glass.
ALSO: the arcade's three meshes, published and catalogued the same round they were generated —
aframe_board 366 tris (<=16 inst), blade_sign 299 (17 inst), keycutter_sign 498 (1 inst). manifest
furniture 13->16, GLBs 36->39, validate 0 errors 0 warnings. blade_sign ships FLAGGED: its back face
carries TRELLIS-hallucinated art where the concept had no data, which is one more reason the
+0-draw perpendicular-quad answer is the recommendation and this GLB is the fallback.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
250 lines
10 KiB
Python
250 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""Dependency-free GLB inspector for the PROCITY asset audit.
|
||
|
||
Parses .glb binary containers (no Blender, no pygltflib) and reports, per file:
|
||
tri count, bounding box in metres (scale sanity), node/mesh/material/image counts,
|
||
and embedded texture pixel dimensions. Also flags likely house-GLB-law violations.
|
||
|
||
Usage:
|
||
python3 glb_stat.py FILE.glb [FILE2.glb ...] # human table
|
||
python3 glb_stat.py --json DIR # JSON, recurse *.glb in DIR
|
||
python3 glb_stat.py --json FILE.glb # JSON for one file
|
||
|
||
House GLB law (CITY_SPEC / LANE_E): metres, +Y up, origin at base, facing -Z,
|
||
props <=5k tris, textures <=1024, no Draco.
|
||
"""
|
||
import json, struct, sys, os, glob
|
||
|
||
COMPONENT_SIZE = {5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4}
|
||
TYPE_COUNT = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4,
|
||
"MAT2": 4, "MAT3": 9, "MAT4": 16}
|
||
|
||
|
||
def _read_glb(path):
|
||
with open(path, "rb") as f:
|
||
data = f.read()
|
||
if data[:4] != b"glTF":
|
||
# maybe a .gltf JSON, or non-glb; try JSON parse
|
||
try:
|
||
return json.loads(data.decode("utf-8")), b""
|
||
except Exception:
|
||
raise ValueError("not a GLB/glTF")
|
||
ver, length = struct.unpack("<II", data[4:12])
|
||
off, gltf, bin_chunk = 12, None, b""
|
||
while off < len(data):
|
||
clen, ctype = struct.unpack("<II", data[off:off + 8])
|
||
chunk = data[off + 8: off + 8 + clen]
|
||
if ctype == 0x4E4F534A: # 'JSON'
|
||
gltf = json.loads(chunk.decode("utf-8"))
|
||
elif ctype == 0x004E4942: # 'BIN\0'
|
||
bin_chunk = chunk
|
||
off += 8 + clen
|
||
return gltf, bin_chunk
|
||
|
||
|
||
def _prim_tris(gltf, prim):
|
||
"""Triangle count for one primitive (mode 4 / default = TRIANGLES)."""
|
||
mode = prim.get("mode", 4)
|
||
accessors = gltf.get("accessors", [])
|
||
if "indices" in prim:
|
||
count = accessors[prim["indices"]]["count"]
|
||
else:
|
||
pos = prim.get("attributes", {}).get("POSITION")
|
||
if pos is None:
|
||
return 0
|
||
count = accessors[pos]["count"]
|
||
if mode == 4:
|
||
return count // 3
|
||
if mode in (5, 6): # triangle strip / fan
|
||
return max(0, count - 2)
|
||
return 0 # points/lines contribute no tris
|
||
|
||
|
||
def _mat_mul(A, B):
|
||
return [[sum(A[i][k] * B[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
|
||
|
||
|
||
def _node_matrix(n):
|
||
if "matrix" in n: # column-major → row-major
|
||
m = n["matrix"]
|
||
return [[m[c * 4 + r] for c in range(4)] for r in range(4)]
|
||
t = n.get("translation", [0, 0, 0]); q = n.get("rotation", [0, 0, 0, 1]); s = n.get("scale", [1, 1, 1])
|
||
x, y, z, w = q
|
||
R = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w), 0],
|
||
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w), 0],
|
||
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y), 0],
|
||
[0, 0, 0, 1]]
|
||
S = [[s[0], 0, 0, 0], [0, s[1], 0, 0], [0, 0, s[2], 0], [0, 0, 0, 1]]
|
||
T = [[1, 0, 0, t[0]], [0, 1, 0, t[1]], [0, 0, 1, t[2]], [0, 0, 0, 1]]
|
||
return _mat_mul(_mat_mul(T, R), S)
|
||
|
||
|
||
def _xform(M, p):
|
||
return [sum(M[i][j] * ([p[0], p[1], p[2], 1][j]) for j in range(4)) for i in range(3)]
|
||
|
||
|
||
def stat(path):
|
||
gltf, _ = _read_glb(path)
|
||
accessors = gltf.get("accessors", [])
|
||
meshes = gltf.get("meshes", [])
|
||
nodes = gltf.get("nodes", [])
|
||
tris = 0
|
||
for m in meshes: # tri count is transform-independent
|
||
for p in m.get("primitives", []):
|
||
tris += _prim_tris(gltf, p)
|
||
# WORLD-space bbox: walk the scene graph so node TRS (unit scale / pivot offset) is respected —
|
||
# local accessor min/max alone lies for multi-node assets (missed a 1km off-origin bug once).
|
||
mn = [float("inf")] * 3
|
||
mx = [float("-inf")] * 3
|
||
scenes = gltf.get("scenes") or [{"nodes": list(range(len(nodes)))}]
|
||
roots = scenes[gltf.get("scene", 0)].get("nodes", [])
|
||
|
||
def walk(ni, M):
|
||
n = nodes[ni]
|
||
W = _mat_mul(M, _node_matrix(n))
|
||
if "mesh" in n:
|
||
for p in meshes[n["mesh"]].get("primitives", []):
|
||
pos = p.get("attributes", {}).get("POSITION")
|
||
if pos is None:
|
||
continue
|
||
a = accessors[pos]
|
||
if "min" not in a or "max" not in a:
|
||
continue
|
||
lo, hi = a["min"], a["max"]
|
||
for cx in (lo[0], hi[0]):
|
||
for cy in (lo[1], hi[1]):
|
||
for cz in (lo[2], hi[2]):
|
||
w = _xform(W, [cx, cy, cz])
|
||
for k in range(3):
|
||
mn[k] = min(mn[k], w[k]); mx[k] = max(mx[k], w[k])
|
||
for c in n.get("children", []):
|
||
walk(c, W)
|
||
|
||
I = [[1 if i == j else 0 for j in range(4)] for i in range(4)]
|
||
for r in roots:
|
||
walk(r, I)
|
||
dims = [round(mx[i] - mn[i], 3) if mx[i] > mn[i] else 0.0 for i in range(3)]
|
||
# texture pixel sizes from embedded images (PNG/JPEG header sniff)
|
||
tex_sizes = []
|
||
images = gltf.get("images", [])
|
||
bufviews = gltf.get("bufferViews", [])
|
||
_, binc = _read_glb(path)
|
||
for im in images:
|
||
wh = None
|
||
if "bufferView" in im and binc:
|
||
bv = bufviews[im["bufferView"]]
|
||
start = bv.get("byteOffset", 0)
|
||
blob = binc[start:start + bv.get("byteLength", 0)]
|
||
wh = _img_size(blob)
|
||
if wh:
|
||
tex_sizes.append(wh)
|
||
exts = gltf.get("extensionsUsed", []) or []
|
||
draco = any("draco" in e.lower() for e in exts)
|
||
# [R39] TRANSMISSION IS A DRAW-BUDGET PROPERTY, NOT A LOOK. three.js runs a transmission
|
||
# PRE-PASS — `renderTransmissionPass(opaqueObjects, …)` at three.module.js:16433, entered from
|
||
# :16209 whenever `currentRenderList.transmissive.length > 0` — which re-renders the whole
|
||
# OPAQUE list into a render target, so every draw in that scene is issued TWICE. Three PROCITY
|
||
# GLBs shipped with `transmissionFactor: 1` and nobody looked, because a bookshelf, a bench and
|
||
# a streetlight are not glass. Lane C measured the bookshelf's at −46%/−50% of interior draws.
|
||
# It is reported here so it can never hide in a material block again.
|
||
transmission = []
|
||
for i, m in enumerate(gltf.get("materials", [])):
|
||
ex = m.get("extensions") or {}
|
||
t = ex.get("KHR_materials_transmission")
|
||
if t and t.get("transmissionFactor", 0) > 0:
|
||
transmission.append({"material": i, "name": m.get("name"),
|
||
"factor": t.get("transmissionFactor")})
|
||
return {
|
||
"file": os.path.basename(path),
|
||
"tris": tris,
|
||
"transmission": transmission,
|
||
"dims_m": dims, # x,y,z extent in file units (hopefully metres)
|
||
"nodes": len(gltf.get("nodes", [])),
|
||
"meshes": len(meshes),
|
||
"materials": len(gltf.get("materials", [])),
|
||
"images": len(images),
|
||
"tex_sizes": tex_sizes,
|
||
"max_tex": max((max(w, h) for w, h in tex_sizes), default=0),
|
||
"draco": draco,
|
||
"exts": exts,
|
||
}
|
||
|
||
|
||
def _img_size(blob):
|
||
if blob[:8] == b"\x89PNG\r\n\x1a\n":
|
||
w, h = struct.unpack(">II", blob[16:24])
|
||
return (w, h)
|
||
if blob[:2] == b"\xff\xd8": # JPEG: scan SOF markers
|
||
i = 2
|
||
while i < len(blob) - 9:
|
||
if blob[i] != 0xFF:
|
||
i += 1
|
||
continue
|
||
marker = blob[i + 1]
|
||
if marker in (0xC0, 0xC1, 0xC2, 0xC3):
|
||
h, w = struct.unpack(">HH", blob[i + 5:i + 9])
|
||
return (w, h)
|
||
seglen = struct.unpack(">H", blob[i + 2:i + 4])[0]
|
||
i += 2 + seglen
|
||
if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP": # WebP (normalize.py exports these)
|
||
fmt = blob[12:16]
|
||
if fmt == b"VP8 ": # lossy: 16-bit dims at offset 26 (14-bit each)
|
||
w = struct.unpack("<H", blob[26:28])[0] & 0x3FFF
|
||
h = struct.unpack("<H", blob[28:30])[0] & 0x3FFF
|
||
return (w, h)
|
||
if fmt == b"VP8L": # lossless: 14-bit dims packed after the 0x2F sig
|
||
b0, b1, b2, b3 = blob[21], blob[22], blob[23], blob[24]
|
||
w = ((b1 & 0x3F) << 8 | b0) + 1
|
||
h = ((b3 & 0x0F) << 10 | b2 << 2 | (b1 & 0xC0) >> 6) + 1
|
||
return (w, h)
|
||
if fmt == b"VP8X": # extended: 24-bit dims at offset 24
|
||
w = (blob[24] | blob[25] << 8 | blob[26] << 16) + 1
|
||
h = (blob[27] | blob[28] << 8 | blob[29] << 16) + 1
|
||
return (w, h)
|
||
return None
|
||
|
||
|
||
def main():
|
||
args = sys.argv[1:]
|
||
as_json = "--json" in args
|
||
args = [a for a in args if a != "--json"]
|
||
files = []
|
||
for a in args:
|
||
if os.path.isdir(a):
|
||
files += sorted(glob.glob(os.path.join(a, "**", "*.glb"), recursive=True))
|
||
else:
|
||
files.append(a)
|
||
rows = []
|
||
for f in files:
|
||
try:
|
||
rows.append(stat(f))
|
||
except Exception as e:
|
||
rows.append({"file": os.path.basename(f), "error": str(e)})
|
||
if as_json:
|
||
print(json.dumps(rows, indent=2))
|
||
return
|
||
hdr = (f"{'file':38} {'tris':>7} {'dims (m) x,y,z':>22} {'mtl':>3} {'img':>3} {'maxtex':>6} "
|
||
f"draco transmission")
|
||
print(hdr)
|
||
print("-" * len(hdr))
|
||
bad = 0
|
||
for r in rows:
|
||
if "error" in r:
|
||
print(f"{r['file']:38} ERROR: {r['error']}")
|
||
continue
|
||
d = r["dims_m"]
|
||
dstr = f"{d[0]:.2f},{d[1]:.2f},{d[2]:.2f}"
|
||
tx = r.get("transmission") or []
|
||
bad += len(tx)
|
||
txs = ("*** " + ",".join(f"{t['name']}={t['factor']}" for t in tx) + " ***") if tx else "-"
|
||
print(f"{r['file']:38} {r['tris']:>7} {dstr:>22} {r['materials']:>3} "
|
||
f"{r['images']:>3} {r['max_tex']:>6} {'YES' if r['draco'] else '-':5} {txs}")
|
||
if bad:
|
||
print(f"\n*** {bad} material(s) carry KHR_materials_transmission > 0. three.js will run a "
|
||
f"transmission PRE-PASS (three.module.js:16209/:16433) and issue EVERY opaque draw in "
|
||
f"that scene TWICE. Fix with: python3 pipeline/strip_transmission.py <file> ***")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|