Files
Aquarium/scripts/fix-model-scales.mjs
T
kamilkrzywda 8c4a7d45a5 fix: normalize GLB model scales to prevent oversized fish
The three Poly Pizza models (betta, blue_tang, flower_horn) rendered
giant (filling the tank) because each GLB had two 100x-scaled nodes
(Fish_Armature bone-parent + the skinned mesh node). three.js binds
skinned meshes with an identity bindMatrix and uses the GLB's
inverseBindMatrices verbatim, so the 100x node scales inflated the
rendered skinned extent ~100x relative to what Box3.setFromObject
reports. GLTFish's 'scale = targetLength / longest' therefore scaled
~100x too big.

Fix: pure GLB JSON-chunk edit (BIN chunk + all 6 animations preserved)
setting the bone-parent node scale to S=1/base and the mesh node scale
to 1, so Box3 == rendered extent == 1.0 and targetLength directly
controls fish size, matching the working models.
2026-10-07 14:49:55 +00:00

161 lines
8.1 KiB
JavaScript

// Fix for the "giant fish" bug in the Poly Pizza GLB models (betta, blue_tang, flower_horn).
//
// ROOT CAUSE
// Each poly-pizza GLB has two 100x-scaled node hierarchies under RootNode:
// - Fish_Armature (scale=100) -> parent of the skeleton bones
// - <Fish> (scale=100) -> the node that owns the mesh + skin
// three.js binds skinned meshes with an identity bindMatrix and uses the GLB's
// inverseBindMatrices verbatim (Skeleton.init() only recomputes them if absent).
// Those inverses were authored for a UNIT-scale bind, but the 100x node scales
// inflate every bone's matrixWorld by 100x, so the shader product
// (boneMatrixWorld * inverseBindMatrix) inflates skinned vertices ~100x relative
// to the rest geometry. Box3.setFromObject (raw geometry) does NOT include that,
// so GLTFish's `scale = targetLength / Box3` ends up rendering ~100x too big.
//
// FIX (pure JSON-chunk edit; BIN chunk + all 6 animations preserved untouched)
// - Fish_Armature scale 100 -> S (bone parent: scales BOTH Box3 and rendered)
// - <mesh node> scale 100 -> 1 (mesh node: only affects rendered; keep 1 so Box3==rendered)
//
// Measured relationship (two 100x nodes: A=Fish_Armature, B=<mesh node>):
// M (Box3.setFromObject) = base * A
// R (true rendered) = base * A * B
// So setting B=1 and A=S=1/base gives M = R = 1.0, i.e. R/M = 1 and longest dim = 1.
// After this, `targetLength` directly controls fish size, matching the working models.
//
// Re-run-safe note: run once. Backups of the originals are in /tmp/opencode/backup-poly-pizza.
import fs from "node:fs";
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const MODELS = {
betta: "public/models/poly-pizza/betta.glb",
blue_tang: "public/models/poly-pizza/blue_tang.glb",
flower_horn: "public/models/poly-pizza/flower_horn.glb",
};
const root = new URL("..", import.meta.url).pathname;
// ---------------- GLB binary helpers ----------------
function readGLB(path) {
const bytes = new Uint8Array(fs.readFileSync(path));
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (dv.getUint32(0, true) !== 0x46546c67) throw new Error("not a GLB (bad magic): " + path);
const jsonLen = dv.getUint32(12, true);
if (String.fromCharCode(...bytes.slice(16, 20)) !== "JSON") throw new Error("bad JSON chunk: " + path);
const jsonBytes = bytes.slice(20, 20 + jsonLen);
const binStart = 20 + jsonLen;
const binLen = dv.getUint32(binStart, true);
if (String.fromCharCode(...bytes.slice(binStart + 4, binStart + 8)) !== "BIN\0") throw new Error("bad BIN chunk: " + path);
const binBytes = bytes.slice(binStart + 8, binStart + 8 + binLen);
return { json: JSON.parse(new TextDecoder().decode(jsonBytes)), binBytes, binLen };
}
function buildGLB(jsonObj, binBytes, binLen) {
const jsonRaw = new TextEncoder().encode(JSON.stringify(jsonObj));
const paddedJsonLen = (jsonRaw.length + 3) & ~3;
const jsonPadded = new Uint8Array(paddedJsonLen).fill(0x20);
jsonPadded.set(jsonRaw, 0);
const total = 12 + 8 + paddedJsonLen + 8 + binLen;
const out = new Uint8Array(total);
const dv = new DataView(out.buffer);
dv.setUint32(0, 0x46546c67, true); // "glTF"
dv.setUint32(4, 2, true); // version 2
dv.setUint32(8, total, true); // total length
dv.setUint32(12, paddedJsonLen, true);
out.set(new TextEncoder().encode("JSON"), 16);
out.set(jsonPadded, 20);
const binStart = 20 + paddedJsonLen;
dv.setUint32(binStart, binLen, true);
out.set(new TextEncoder().encode("BIN"), binStart + 4); // 4th byte stays 0x00 -> "BIN\0"
out.set(binBytes, binStart + 8);
return out;
}
// ---------------- measurement (exact three.js skinning formula) ----------------
function toArrayBuffer(buf) { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); }
function loadGLB(file) { return new Promise((res, rej) => { new GLTFLoader().parse(toArrayBuffer(fs.readFileSync(file)), "", res, (e) => rej(e)); }); }
function pickClip(clips) { if (!clips.length) return null; return clips.find((x) => /swim/i.test(x.name)) || clips[0]; }
function renderedLongest(model, mixer, frames) {
const skinned = []; model.traverse((o) => { if (o.isSkinnedMesh) skinned.push(o); });
const box = new THREE.Box3(); const tmp = new THREE.Vector3(); let maxL = 0;
for (let f = 0; f <= frames; f++) {
if (mixer) mixer.update(1 / 30);
model.updateMatrixWorld(true); box.makeEmpty();
for (const sm of skinned) {
const pos = sm.geometry.attributes.position, joints = sm.geometry.attributes.skinIndex, weights = sm.geometry.attributes.skinWeight;
const bones = sm.skeleton.bones, inverses = sm.skeleton.boneInverses;
if (!bones || !inverses || !pos) continue;
const bm = bones.map((b) => new THREE.Matrix4().multiplyMatrices(b.matrixWorld, inverses[bones.indexOf(b)]));
for (let i = 0; i < pos.count; i++) {
let sx = 0, sy = 0, sz = 0;
for (let k = 0; k < 4; k++) {
const w = weights.array[4 * i + k]; if (!w) continue;
const j = joints.array[4 * i + k]; if (j < 0 || j >= bm.length) continue;
tmp.set(pos.getX(i), pos.getY(i), pos.getZ(i)).applyMatrix4(bm[j]);
sx += tmp.x * w; sy += tmp.y * w; sz += tmp.z * w;
}
tmp.set(sx, sy, sz);
if (sm.bindMatrix) tmp.applyMatrix4(sm.bindMatrix);
if (sm.matrixWorld) tmp.applyMatrix4(sm.matrixWorld);
box.expandByPoint(tmp);
}
}
const s = box.getSize(new THREE.Vector3());
maxL = Math.max(maxL, Math.max(s.x, s.y, s.z));
}
return maxL;
}
// ---------------- per-model fix ----------------
async function fixOne(name, rel) {
const path = root + rel;
// 1) Compute S: load original, set all 100x nodes to 1, measure the natural rest longest dim.
const gltf = await loadGLB(path);
const model = gltf.scene ?? gltf.scenes[0];
model.traverse((o) => { if (o.scale && o.scale.x === 100 && o.scale.y === 100 && o.scale.z === 100) o.scale.set(1, 1, 1); });
model.updateMatrixWorld(true);
const l1 = new THREE.Box3().setFromObject(model).getSize(new THREE.Vector3());
const l1l = Math.max(l1.x, l1.y, l1.z);
const S = 1 / l1l;
// 2) Edit the raw GLB JSON chunk.
const { json, binBytes, binLen } = readGLB(path);
let meshNode = null, armatureNode = null;
for (const n of json.nodes || []) {
if (n.scale && n.scale[0] === 100 && n.scale[1] === 100 && n.scale[2] === 100) {
if (n.mesh !== undefined) { n.scale = [1, 1, 1]; meshNode = n; } // mesh node -> 1
else { n.scale = [S, S, S]; armatureNode = n; } // bone parent -> S
}
}
if (!meshNode || !armatureNode) throw new Error(`expected one mesh-node + one armature-node at scale 100; got mesh=${!!meshNode} armature=${!!armatureNode}`);
// 3) Write the corrected GLB.
fs.writeFileSync(path, buildGLB(json, binBytes, binLen));
// 4) Verify: reload and confirm Box3 ~ 1, rendered ~ 1 (R/M ~ 1), animations intact.
const g2 = await loadGLB(path);
const m2 = g2.scene ?? g2.scenes[0];
const M2 = new THREE.Box3().setFromObject(m2).getSize(new THREE.Vector3());
const M2l = Math.max(M2.x, M2.y, M2.z);
const clip2 = pickClip(g2.animations);
const mixer2 = new THREE.AnimationMixer(m2);
let R2 = 0; if (clip2) { mixer2.clipAction(clip2).play(); R2 = renderedLongest(m2, mixer2, 30); }
const clips = (g2.animations || []).length;
const ok = M2l > 0.95 && M2l < 1.05 && R2 / M2l > 0.95 && R2 / M2l < 1.1 && clips > 0;
console.log(
`${name}: rest-longest@1x=${l1l.toFixed(5)} -> S=${S.toFixed(4)} | ` +
`${armatureNode.name}(bones)=${S.toFixed(3)}, ${meshNode.name}(mesh)=1 | ` +
`Box3=${M2l.toFixed(4)} R(swim)=${R2.toFixed(4)} R/M=${(R2 / M2l).toFixed(3)} clips=${clips} ${ok ? "OK" : "** CHECK **"} | ` +
`targetLength 0.5 renders ${((0.5 / M2l) * R2).toFixed(3)}`
);
return ok;
}
const results = [];
for (const [name, rel] of Object.entries(MODELS)) {
try { results.push(await fixOne(name, rel)); }
catch (e) { console.log(`${name}: ERROR ${e.message}`); results.push(false); }
}
console.log(results.every(Boolean) ? "\nALL 3 FIXED & VERIFIED" : "\nSOME FAILED");
process.exit(results.every(Boolean) ? 0 : 1);