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.
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,160 @@
|
||||
// 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);
|
||||
Reference in New Issue
Block a user