fix: replace Box3 auto-scale with measured modelLength per species
- Remove unreliable Box3.setFromObject scaling (broke for skinned meshes, control rigs, and scene-containing GLBs) - Use measured vertex extents (scripts/measure-models.mjs) as modelLength - Clownfish: hide Surface/TopLight/Bubbles/Mirrored nodes (GLB is a full scene, not just a fish) — eliminates the white box + giant fish bug - All 10 species now have accurate scale factors
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
// Measure the actual vertex extents of the fish GLB models.
|
||||
//
|
||||
// Part A parses the raw GLB binary directly (JSON + BIN chunks) and reads
|
||||
// each mesh primitive's POSITION attribute, so the numbers are independent
|
||||
// of three.js/GLTFLoader quirks (skinned-mesh bind-pose bounds, stale
|
||||
// cached bboxes, degenerate control rigs, scene extras like water planes).
|
||||
//
|
||||
// Part B loads each GLB through three.js and evaluates the actual skinned
|
||||
// vertex positions (rest pose + swim animation frames), so we can compare
|
||||
// "measured raw" vs "actually rendered" per species and calibrate
|
||||
// FishSpec.modelLength (see src/scene/GLTFish.ts). The summary at the end
|
||||
// prints the hardcoded `modelLength` value per species.
|
||||
//
|
||||
// GLB format: [magic "glTF"][version][length] then chunks of
|
||||
// [chunkLength: u32 LE][chunkType: u32 LE][chunkData]:
|
||||
// JSON chunk type = 0x4e4f534a ("JSON"), BIN chunk type = 0x004e4942 ("BIN\0")
|
||||
//
|
||||
// Usage: node scripts/measure-models.mjs
|
||||
|
||||
import fs from "node:fs";
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
|
||||
// ---- Node polyfills so three's GLTFLoader can decode embedded textures.
|
||||
// Part B only needs geometry/skinning math; image sources are stubbed as
|
||||
// 1x1 bitmaps. (three r182's GLTFParser.loadImageSource references the bare
|
||||
// global `self` and URL.createObjectURL, which Node does not provide.)
|
||||
if (typeof globalThis.self === "undefined") globalThis.self = globalThis;
|
||||
if (typeof globalThis.createImageBitmap !== "function") {
|
||||
globalThis.createImageBitmap = () => Promise.resolve({ width: 1, height: 1, close() {} });
|
||||
}
|
||||
if (typeof URL.createObjectURL !== "function") {
|
||||
const RealBlob = globalThis.Blob;
|
||||
class DataUrlBlob extends RealBlob {
|
||||
constructor(parts, options) {
|
||||
super(parts, options);
|
||||
const chunk = parts && parts[0];
|
||||
if (chunk && chunk.byteLength !== undefined) this._bytes = new Uint8Array(chunk);
|
||||
}
|
||||
}
|
||||
globalThis.Blob = DataUrlBlob;
|
||||
URL.createObjectURL = (b) =>
|
||||
b && b._bytes
|
||||
? "data:" + (b.type || "application/octet-stream") + ";base64," + Buffer.from(b._bytes).toString("base64")
|
||||
: "data:;";
|
||||
URL.revokeObjectURL = () => {};
|
||||
}
|
||||
|
||||
const ROOT = new URL("..", import.meta.url).pathname;
|
||||
|
||||
const MODELS = {
|
||||
angelfish: "public/models/blendswap/blueface_angelfish.glb",
|
||||
goldfish: "public/models/goldfish.glb",
|
||||
guppy: "public/models/guppy.glb",
|
||||
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",
|
||||
neon_tetra: "public/models/blendswap/neon_tetra.glb",
|
||||
oscar: "public/models/blendswap/oscar_cichlid.glb",
|
||||
knifefish: "public/models/blendswap/ghost_knifefish.glb",
|
||||
clownfish: "public/models/blendswap/clownfish.glb",
|
||||
};
|
||||
|
||||
// Node names excluded from the *rendered* measurement: scene extras shipped
|
||||
// inside the GLB (clownfish water/mirror/light/bubbles), not the fish.
|
||||
const HIDDEN_NODES = {
|
||||
clownfish: ["Surface", "Original-Mirrored", "TopLight", "Bubbles"],
|
||||
};
|
||||
|
||||
const JSON_CHUNK = 0x4e4f534a; // "JSON" as u32 LE
|
||||
const BIN_CHUNK = 0x004e4942; // "BIN\0" as u32 LE
|
||||
|
||||
// ---------------- mat4 helpers (column-major, glTF layout) ----------------
|
||||
function m4identity() {
|
||||
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
}
|
||||
|
||||
function m4mul(a, b) {
|
||||
const out = new Array(16);
|
||||
for (let c = 0; c < 4; c++) {
|
||||
for (let r = 0; r < 4; r++) {
|
||||
let s = 0;
|
||||
for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
|
||||
out[c * 4 + r] = s;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function m4trs(t, r, s) {
|
||||
const x = r[0], y = r[1], z = r[2], w = r[3];
|
||||
const xx = x * x, yy = y * y, zz = z * z;
|
||||
const xy = x * y, xz = x * z, yz = y * z;
|
||||
const wx = w * x, wy = w * y, wz = w * z;
|
||||
return [
|
||||
(1 - 2 * (yy + zz)) * s[0], 2 * (xy + wz) * s[0], 2 * (xz - wy) * s[0], 0,
|
||||
2 * (xy - wz) * s[1], (1 - 2 * (xx + zz)) * s[1], 2 * (yz + wx) * s[1], 0,
|
||||
2 * (xz + wy) * s[2], 2 * (yz - wx) * s[2], (1 - 2 * (xx + yy)) * s[2], 0,
|
||||
t[0], t[1], t[2], 1,
|
||||
];
|
||||
}
|
||||
|
||||
function m4transform(m, p) {
|
||||
return [
|
||||
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
|
||||
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
|
||||
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------- GLB binary parsing ----------------
|
||||
function readGLB(file) {
|
||||
const bytes = new Uint8Array(fs.readFileSync(file));
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const magic = dv.getUint32(0, true);
|
||||
if (magic !== 0x46546c67) throw new Error("not a GLB (bad magic): " + file);
|
||||
const version = dv.getUint32(4, true);
|
||||
const totalLen = dv.getUint32(8, true);
|
||||
if (version !== 2) throw new Error("unsupported glTF version " + version + ": " + file);
|
||||
|
||||
let jsonBytes = null;
|
||||
let binBytes = null;
|
||||
let off = 12;
|
||||
while (off + 8 <= totalLen) {
|
||||
const chunkLen = dv.getUint32(off, true);
|
||||
const chunkType = dv.getUint32(off + 4, true);
|
||||
const data = bytes.slice(off + 8, off + 8 + chunkLen);
|
||||
if (chunkType === JSON_CHUNK) jsonBytes = data;
|
||||
else if (chunkType === BIN_CHUNK) binBytes = data;
|
||||
off += 8 + chunkLen;
|
||||
if (jsonBytes && binBytes) break;
|
||||
}
|
||||
if (!jsonBytes) throw new Error("no JSON chunk: " + file);
|
||||
const json = JSON.parse(new TextDecoder().decode(jsonBytes));
|
||||
const binBufferIdx = (json.buffers || []).findIndex((b) => !b.uri);
|
||||
if (binBufferIdx >= 0 && !binBytes) throw new Error("buffer declares BIN but no BIN chunk found: " + file);
|
||||
return { json, binBytes, binBufferIdx };
|
||||
}
|
||||
|
||||
// ---------------- POSITION attribute reading ----------------
|
||||
function readPositionBounds(json, dv, binBufferIdx, accIdx) {
|
||||
if (accIdx === undefined || accIdx === null) return null;
|
||||
const acc = json.accessors[accIdx];
|
||||
if (!acc) return null;
|
||||
if (acc.componentType !== 5126 || acc.type !== "VEC3") return null; // float32 VEC3 only
|
||||
const bv = json.bufferViews[acc.bufferView];
|
||||
if (!bv || bv.buffer !== binBufferIdx) return null; // external buffer: not readable inline
|
||||
// NOTE: bufferView.byteOffset is relative to the buffer (BIN chunk), and
|
||||
// `dv` is positioned at the start of that chunk, so it can be used directly.
|
||||
const base = (bv.byteOffset || 0) + (acc.byteOffset || 0);
|
||||
const stride = bv.byteStride || 12;
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
for (let i = 0; i < acc.count; i++) {
|
||||
const o = base + i * stride;
|
||||
const x = dv.getFloat32(o, true);
|
||||
const y = dv.getFloat32(o + 4, true);
|
||||
const z = dv.getFloat32(o + 8, true);
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
if (z < minZ) minZ = z;
|
||||
if (z > maxZ) maxZ = z;
|
||||
}
|
||||
return { count: acc.count, min: [minX, minY, minZ], max: [maxX, maxY, maxZ] };
|
||||
}
|
||||
|
||||
// ---------------- node world matrices (from JSON hierarchy) ----------------
|
||||
function buildWorldMats(json) {
|
||||
const mats = new Map();
|
||||
const scene = json.scenes[json.scene || 0];
|
||||
if (!scene) return mats;
|
||||
const queue = scene.nodes.map((i) => [i, m4identity()]);
|
||||
while (queue.length) {
|
||||
const [idx, parentWorld] = queue.pop();
|
||||
const n = json.nodes[idx];
|
||||
if (!n) continue;
|
||||
const local = n.matrix ? n.matrix.slice(0, 16) : m4trs(
|
||||
n.translation || [0, 0, 0],
|
||||
n.rotation || [0, 0, 0, 1],
|
||||
n.scale || [1, 1, 1]
|
||||
);
|
||||
const world = m4mul(parentWorld, local);
|
||||
mats.set(idx, world);
|
||||
for (const c of n.children || []) queue.push([c, world]);
|
||||
}
|
||||
return mats;
|
||||
}
|
||||
|
||||
// ---------------- bbox helpers ----------------
|
||||
function boxUnion(a, b) {
|
||||
return {
|
||||
min: [Math.min(a.min[0], b.min[0]), Math.min(a.min[1], b.min[1]), Math.min(a.min[2], b.min[2])],
|
||||
max: [Math.max(a.max[0], b.max[0]), Math.max(a.max[1], b.max[1]), Math.max(a.max[2], b.max[2])],
|
||||
};
|
||||
}
|
||||
function boxSize(b) {
|
||||
return [b.max[0] - b.min[0], b.max[1] - b.min[1], b.max[2] - b.min[2]];
|
||||
}
|
||||
function boxLongest(b) {
|
||||
return b ? Math.max(...boxSize(b)) : null;
|
||||
}
|
||||
function worldBox(rawBox, world) {
|
||||
const min = [Infinity, Infinity, Infinity];
|
||||
const max = [-Infinity, -Infinity, -Infinity];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const corner = [
|
||||
(i & 1) ? rawBox.max[0] : rawBox.min[0],
|
||||
(i & 2) ? rawBox.max[1] : rawBox.min[1],
|
||||
(i & 4) ? rawBox.max[2] : rawBox.min[2],
|
||||
];
|
||||
const p = m4transform(world, corner);
|
||||
for (let a = 0; a < 3; a++) {
|
||||
if (p[a] < min[a]) min[a] = p[a];
|
||||
if (p[a] > max[a]) max[a] = p[a];
|
||||
}
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
function fmtBox(b) {
|
||||
if (!b) return "(no POSITION data)";
|
||||
const s = boxSize(b);
|
||||
return [
|
||||
`min(${b.min[0].toFixed(3)}, ${b.min[1].toFixed(3)}, ${b.min[2].toFixed(3)})`,
|
||||
`max(${b.max[0].toFixed(3)}, ${b.max[1].toFixed(3)}, ${b.max[2].toFixed(3)})`,
|
||||
`size(${s[0].toFixed(3)}, ${s[1].toFixed(3)}, ${s[2].toFixed(3)})`,
|
||||
`longest=${boxLongest(b).toFixed(4)}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// ---------------- Part A: raw GLB measurement ----------------
|
||||
function measureRaw(name, rel) {
|
||||
const file = ROOT + rel;
|
||||
const { json, binBytes, binBufferIdx } = readGLB(file);
|
||||
if (!binBytes) throw new Error("no BIN chunk but bufferViews reference buffer 0: " + rel);
|
||||
// dv positioned at the start of the BIN chunk: bufferView offsets are relative to it.
|
||||
const dv = new DataView(binBytes.buffer, binBytes.byteOffset, binBytes.byteLength);
|
||||
const worldMats = buildWorldMats(json);
|
||||
|
||||
const meshes = [];
|
||||
let overallRaw = null;
|
||||
let overallWorld = null;
|
||||
for (let mi = 0; mi < (json.meshes || []).length; mi++) {
|
||||
const mesh = json.meshes[mi];
|
||||
let raw = null;
|
||||
let verts = 0;
|
||||
const primDetails = [];
|
||||
for (const prim of mesh.primitives || []) {
|
||||
const pb = readPositionBounds(json, dv, binBufferIdx, prim.attributes && prim.attributes.POSITION);
|
||||
if (!pb) {
|
||||
primDetails.push({ material: prim.material !== undefined ? prim.material : null, pos: false });
|
||||
continue;
|
||||
}
|
||||
verts += pb.count;
|
||||
primDetails.push({
|
||||
material: prim.material !== undefined ? prim.material : null,
|
||||
count: pb.count,
|
||||
min: pb.min,
|
||||
max: pb.max,
|
||||
});
|
||||
raw = raw ? boxUnion(raw, { min: pb.min.slice(), max: pb.max.slice() }) : { min: pb.min.slice(), max: pb.max.slice() };
|
||||
}
|
||||
|
||||
const ownerNodes = (json.nodes || [])
|
||||
.map((n, i) => ({ n, i }))
|
||||
.filter((x) => x.n.mesh === mi);
|
||||
let world = null;
|
||||
const nodeInfo = ownerNodes.map(({ n, i }) => {
|
||||
const w = worldMats.get(i);
|
||||
if (raw && w) {
|
||||
const wb = worldBox(raw, w);
|
||||
world = world ? boxUnion(world, wb) : wb;
|
||||
}
|
||||
return {
|
||||
name: n.name || `(node#${i})`,
|
||||
scale: n.scale ? n.scale.map((v) => +v.toFixed(5)) : [1, 1, 1],
|
||||
};
|
||||
});
|
||||
|
||||
meshes.push({
|
||||
name: mesh.name || `(mesh#${mi})`,
|
||||
verts,
|
||||
raw,
|
||||
world,
|
||||
nodes: nodeInfo,
|
||||
prims: primDetails,
|
||||
});
|
||||
if (raw) overallRaw = overallRaw ? boxUnion(overallRaw, raw) : raw;
|
||||
if (world) overallWorld = overallWorld ? boxUnion(overallWorld, world) : world;
|
||||
}
|
||||
|
||||
return { json, meshes, overallRaw, overallWorld };
|
||||
}
|
||||
|
||||
function dumpMaterials(json) {
|
||||
const ext = {
|
||||
used: json.extensionsUsed || [],
|
||||
required: json.extensionsRequired || [],
|
||||
};
|
||||
const images = (json.images || []).map((im) => {
|
||||
const bv = im.bufferView !== undefined ? json.bufferViews[im.bufferView] : null;
|
||||
return {
|
||||
name: im.name || null,
|
||||
mime: im.mimeType || null,
|
||||
bytes: bv ? bv.byteLength : (im.uri ? "uri:" + im.uri : "?"),
|
||||
};
|
||||
});
|
||||
const mats = (json.materials || []).map((m, i) => {
|
||||
const pbr = m.pbrMetallicRoughness || {};
|
||||
const tex = pbr.baseColorTexture;
|
||||
const img = tex !== undefined ? images[tex.index] : null;
|
||||
return {
|
||||
i,
|
||||
name: m.name || null,
|
||||
baseColorFactor: pbr.baseColorFactor || [1, 1, 1, 1],
|
||||
baseColorTexture: tex !== undefined ? `img${tex.index}(${img ? img.name || img.mime : "?"})` : null,
|
||||
metallic: pbr.metallicFactor !== undefined ? pbr.metallicFactor : 1,
|
||||
roughness: pbr.roughnessFactor !== undefined ? pbr.roughnessFactor : 1,
|
||||
alphaMode: m.alphaMode || "OPAQUE",
|
||||
emissive: m.emissiveFactor || null,
|
||||
doubleSided: m.doubleSided || false,
|
||||
extraKeys: Object.keys(m).filter(
|
||||
(k) => !["name", "pbrMetallicRoughness", "alphaMode", "emissiveFactor", "normalTexture", "occlusionTexture", "image", "extensions", "extras", "doubleSided", "alphaCutoff", "ior", "specularColorFactor"].includes(k)
|
||||
),
|
||||
};
|
||||
});
|
||||
return { ext, mats, images };
|
||||
}
|
||||
|
||||
// ---------------- Part B: three.js rendered measurement ----------------
|
||||
// IMPORTANT: attributes in these GLBs may be INTERLEAVED (byteStride > 0).
|
||||
// attribute.array[4*i+k] is WRONG for interleaved attributes; the
|
||||
// getX/getY/getZ/getW getters (and .getX etc. on InterleavedBufferAttribute)
|
||||
// apply the stride+offset. All per-component reads below go through getters.
|
||||
function pickSwimClip(clips) {
|
||||
if (clips.length === 0) return null;
|
||||
for (const c of clips) {
|
||||
const last = (c.name.split("|").pop() || c.name).trim().toLowerCase();
|
||||
if (last === "swim") return c;
|
||||
}
|
||||
for (const c of clips) {
|
||||
if (c.name.toLowerCase().includes("swim")) return c;
|
||||
}
|
||||
return clips[0];
|
||||
}
|
||||
|
||||
function isUnderHidden(obj, hideSet) {
|
||||
let cur = obj;
|
||||
while (cur) {
|
||||
if (hideSet.has(cur.name)) return true;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function threeBoxUnion(a, b) {
|
||||
a.min.min(b.min);
|
||||
a.max.max(b.max);
|
||||
return a;
|
||||
}
|
||||
|
||||
// Describe an attribute's layout (to document interleaving per model).
|
||||
function attrDesc(a) {
|
||||
if (!a) return "missing";
|
||||
if (a.isInterleavedBufferAttribute) {
|
||||
return `interleaved(stride=${a.data.stride}B, off=${a.offset}B, itemSize=${a.itemSize}, ${a.data.array.constructor.name})`;
|
||||
}
|
||||
return `packed(itemSize=${a.itemSize}, ${a.array.constructor.name})`;
|
||||
}
|
||||
|
||||
async function measureRendered(name, rel) {
|
||||
const file = ROOT + rel;
|
||||
const bytes = new Uint8Array(fs.readFileSync(file));
|
||||
const gltf = await new Promise((resolve, reject) => {
|
||||
new GLTFLoader().parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "", resolve, reject);
|
||||
});
|
||||
const model = gltf.scene || gltf.scenes[0];
|
||||
model.updateMatrixWorld(true);
|
||||
|
||||
const hideSet = new Set(HIDDEN_NODES[name] || []);
|
||||
const plain = [];
|
||||
const skinned = [];
|
||||
const layoutNotes = [];
|
||||
model.traverse((o) => {
|
||||
if (!o.isMesh) return;
|
||||
if (o.isSkinnedMesh && o.geometry.attributes.skinIndex) {
|
||||
layoutNotes.push(
|
||||
` layout "${o.name}": pos=${attrDesc(o.geometry.attributes.position)} ` +
|
||||
`joints=${attrDesc(o.geometry.attributes.skinIndex)} weights=${attrDesc(o.geometry.attributes.skinWeight)}`
|
||||
);
|
||||
}
|
||||
if (hideSet.size && isUnderHidden(o, hideSet)) return;
|
||||
(o.isSkinnedMesh ? skinned : plain).push(o);
|
||||
});
|
||||
|
||||
// The value the current app code (Box3.setFromObject on the whole scene)
|
||||
// computes — includes any scene extras (e.g. clownfish water surface).
|
||||
const box3 = new THREE.Box3().setFromObject(model);
|
||||
const s3 = box3.getSize(new THREE.Vector3());
|
||||
// Fish-only box3 (scene extras excluded) — reference for the new hardcoded scheme.
|
||||
let box3Fish = null;
|
||||
if (hideSet.size) {
|
||||
box3Fish = new THREE.Box3();
|
||||
model.traverse((o) => {
|
||||
if (!o.isMesh) return;
|
||||
if (isUnderHidden(o, hideSet)) return;
|
||||
if (o.geometry.boundingBox === null) o.geometry.computeBoundingBox();
|
||||
if (o.geometry.boundingBox) {
|
||||
const b = o.geometry.boundingBox.clone().applyMatrix4(o.matrixWorld);
|
||||
box3Fish.union(b);
|
||||
}
|
||||
});
|
||||
if (box3Fish.isEmpty()) box3Fish = null;
|
||||
}
|
||||
|
||||
const tmp = new THREE.Vector3();
|
||||
function evalBox() {
|
||||
const box = new THREE.Box3();
|
||||
for (const m of plain) {
|
||||
if (m.geometry.boundingBox === null) m.geometry.computeBoundingBox();
|
||||
const bb = m.geometry.boundingBox;
|
||||
if (!bb) continue;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
tmp.set(
|
||||
(i & 1) ? bb.max.x : bb.min.x,
|
||||
(i & 2) ? bb.max.y : bb.min.y,
|
||||
(i & 4) ? bb.max.z : bb.min.z
|
||||
);
|
||||
tmp.applyMatrix4(m.matrixWorld);
|
||||
box.expandByPoint(tmp);
|
||||
}
|
||||
}
|
||||
for (const sm of skinned) {
|
||||
const pos = sm.geometry.attributes.position;
|
||||
const joints = sm.geometry.attributes.skinIndex;
|
||||
const weights = sm.geometry.attributes.skinWeight;
|
||||
const bones = sm.skeleton ? sm.skeleton.bones : null;
|
||||
const inverses = sm.skeleton ? sm.skeleton.boneInverses : null;
|
||||
if (!pos || !joints || !weights || !bones || !inverses) continue;
|
||||
const bm = bones.map((b, bi) => new THREE.Matrix4().multiplyMatrices(b.matrixWorld, inverses[bi]));
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
let sx = 0, sy = 0, sz = 0;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
// STRIDE-SAFE component reads (work for packed AND interleaved attrs)
|
||||
const w = k === 0 ? weights.getX(i) : k === 1 ? weights.getY(i) : k === 2 ? weights.getZ(i) : weights.getW(i);
|
||||
if (!w) continue;
|
||||
const j = k === 0 ? joints.getX(i) : k === 1 ? joints.getY(i) : k === 2 ? joints.getZ(i) : joints.getW(i);
|
||||
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);
|
||||
}
|
||||
}
|
||||
return box.isEmpty() ? null : box;
|
||||
}
|
||||
|
||||
const rest = evalBox();
|
||||
const restSize = rest ? rest.getSize(new THREE.Vector3()) : null;
|
||||
|
||||
// Swim animation: track max extent over 60 frames (at 30fps = 2s).
|
||||
let swimMax = null;
|
||||
const clip = pickSwimClip(gltf.animations || []);
|
||||
if (clip) {
|
||||
const mixer = new THREE.AnimationMixer(model);
|
||||
mixer.clipAction(clip).setLoop(THREE.LoopRepeat, Infinity).play();
|
||||
for (let f = 0; f < 60; f++) {
|
||||
mixer.update(1 / 30);
|
||||
model.updateMatrixWorld(true);
|
||||
const b = evalBox();
|
||||
if (!b) continue;
|
||||
swimMax = swimMax ? threeBoxUnion(swimMax, b) : b.clone();
|
||||
}
|
||||
}
|
||||
const swimSize = swimMax ? swimMax.getSize(new THREE.Vector3()) : null;
|
||||
|
||||
return {
|
||||
box3Longest: box3.isEmpty() ? null : Math.max(s3.x, s3.y, s3.z),
|
||||
box3Size: { x: +s3.x.toFixed(4), y: +s3.y.toFixed(4), z: +s3.z.toFixed(4) },
|
||||
box3FishLongest: box3Fish ? +Math.max(...box3Fish.getSize(new THREE.Vector3()).toArray()).toFixed(4) : null,
|
||||
restLongest: restSize ? Math.max(restSize.x, restSize.y, restSize.z) : null,
|
||||
restSize: restSize ? { x: +restSize.x.toFixed(4), y: +restSize.y.toFixed(4), z: +restSize.z.toFixed(4) } : null,
|
||||
swimLongest: swimSize ? Math.max(swimSize.x, swimSize.y, swimSize.z) : null,
|
||||
swimSize: swimSize ? { x: +swimSize.x.toFixed(4), y: +swimSize.y.toFixed(4), z: +swimSize.z.toFixed(4) } : null,
|
||||
clipCount: (gltf.animations || []).length,
|
||||
layoutNotes,
|
||||
};
|
||||
}
|
||||
|
||||
const fmtNum = (n, d = 4) => (n === null || n === undefined ? "-" : n.toFixed(d));
|
||||
|
||||
// ---------------- main ----------------
|
||||
const summary = [];
|
||||
const summaryRendered = {};
|
||||
|
||||
for (const [name, rel] of Object.entries(MODELS)) {
|
||||
const file = ROOT + rel;
|
||||
console.log(`\n=== ${name} (${rel}) ===`);
|
||||
let measured = null;
|
||||
let rest = null;
|
||||
let box3 = null;
|
||||
let swim = null;
|
||||
try {
|
||||
const raw = measureRaw(name, rel);
|
||||
const { json } = raw;
|
||||
|
||||
console.log(` file size: ${(fs.statSync(file).size / 1024).toFixed(1)} kB`);
|
||||
console.log(` nodes: ${json.nodes ? json.nodes.length : 0}, meshes: ${json.meshes ? json.meshes.length : 0}, animations: ${(json.animations || []).length}`);
|
||||
const mat = dumpMaterials(json);
|
||||
console.log(` extensions used: [${mat.ext.used.join(", ")}] required: [${mat.ext.required.join(", ")}]`);
|
||||
console.log(` images: ${mat.images.length ? mat.images.map((im) => `${im.name}(${im.mime}, ${im.bytes}B)`).join(", ") : "(none)"}`);
|
||||
for (const m of mat.mats) {
|
||||
const parts = [
|
||||
`[${m.i}] "${m.name}"`,
|
||||
`baseColor=${JSON.stringify(m.baseColorFactor)}`,
|
||||
m.baseColorTexture ? `tex=${m.baseColorTexture}` : "noTex",
|
||||
`metal=${typeof m.metallic === "number" ? +m.metallic.toFixed(3) : m.metallic}`,
|
||||
`rough=${typeof m.roughness === "number" ? +m.roughness.toFixed(3) : m.roughness}`,
|
||||
m.alphaMode !== "OPAQUE" ? `alpha=${m.alphaMode}` : "",
|
||||
m.emissive ? `emissive=${JSON.stringify(m.emissive)}` : "",
|
||||
m.doubleSided ? "doubleSided" : "",
|
||||
];
|
||||
if (m.extraKeys.length) parts.push(`extra=[${m.extraKeys.join(",")}]`);
|
||||
console.log(` material ${parts.filter(Boolean).join(" ")}`);
|
||||
}
|
||||
|
||||
for (const m of raw.meshes) {
|
||||
const nodeDesc = m.nodes.length ? m.nodes.map((n) => `${n.name}(scale ${n.scale.join(",")})`).join(" ") : "(no node)";
|
||||
const matIdx = m.prims.map((p) => (p.material === null || p.material === undefined ? "?" : p.material)).join(",");
|
||||
console.log(
|
||||
` mesh "${m.name}" verts=${m.verts} mat=[${matIdx}] node=${nodeDesc}\n` +
|
||||
` raw ${fmtBox(m.raw)}\n` +
|
||||
` world ${fmtBox(m.world)}`
|
||||
);
|
||||
}
|
||||
console.log(` OVERALL raw ${fmtBox(raw.overallRaw)}`);
|
||||
console.log(` OVERALL world ${fmtBox(raw.overallWorld)}`);
|
||||
|
||||
// Part B: what does three.js actually render?
|
||||
try {
|
||||
const r = await measureRendered(name, rel);
|
||||
for (const note of r.layoutNotes) console.log(note);
|
||||
console.log(
|
||||
` [three] box3(whole scene, app's value) longest=${fmtNum(r.box3Longest)} ${JSON.stringify(r.box3Size)}` +
|
||||
(r.box3FishLongest !== null ? ` | box3(fish only) longest=${fmtNum(r.box3FishLongest)}` : "") +
|
||||
` | ` +
|
||||
`rest longest=${fmtNum(r.restLongest)} ${JSON.stringify(r.restSize)} | ` +
|
||||
`swimMax longest=${fmtNum(r.swimLongest)} ${JSON.stringify(r.swimSize)} | clips=${r.clipCount}`
|
||||
);
|
||||
const rendered = Math.max(r.restLongest || 0, r.swimLongest || 0) || null;
|
||||
measured = boxLongest(raw.overallWorld);
|
||||
rest = r.restLongest;
|
||||
box3 = r.box3Longest;
|
||||
swim = r.swimLongest;
|
||||
summaryRendered[name] = rendered;
|
||||
if (measured && rendered) {
|
||||
console.log(` [three] rendered/measured(raw world) = ${(rendered / measured).toFixed(4)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` [three] measurement failed: ${e.message}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` ERROR: ${e.message}`);
|
||||
}
|
||||
summary.push({ name, measured, box3, rest, swim });
|
||||
}
|
||||
|
||||
console.log("\n================ SUMMARY (candidates for src/main.ts) ================");
|
||||
console.log(" hardcoded `longest` should be the model's TRUE rendered extent (max of rest & swimMax),");
|
||||
console.log(" measured stride-safe. For clownfish this is the fish body only (scene extras excluded).");
|
||||
for (const s of summary) {
|
||||
const rendered = summaryRendered[s.name];
|
||||
console.log(
|
||||
` ${s.name.padEnd(12)} rawWorld=${fmtNum(s.measured)} box3(app)=${fmtNum(s.box3)} ` +
|
||||
`restRendered=${fmtNum(s.rest)} swimMax=${fmtNum(s.swim)} ` +
|
||||
`-> hardcodedLongest=${rendered === undefined ? "?" : fmtNum(rendered)}`
|
||||
);
|
||||
}
|
||||
+26
-25
@@ -60,39 +60,40 @@ function getSpeciesUrl(sp: FishSpecies): string {
|
||||
};
|
||||
return urls[sp];
|
||||
}
|
||||
// Per-species correction factor between Box3.setFromObject's measured extent
|
||||
// and the model's true rendered extent (see FishSpec.baseScale). Box3 measures
|
||||
// skinned geometry in skeleton space (and may use a stale cached bounding box),
|
||||
// which does not match the GPU's actual output (mesh.matrixWorld * skinning *
|
||||
// position). baseScale = rendered / box3, so `targetLength / (box3 * baseScale)`
|
||||
// normalizes each fish to its real rendered size.
|
||||
//
|
||||
// Measured with three.js's exact skinning formula per GLB. Most models are 1.0.
|
||||
// The skinned blendswap oscar/knifefish deviate and are corrected here. The
|
||||
// clownfish GLB is a full *scene* (water "Surface" 45.3, "TopLight" 15, "Bubbles"
|
||||
// 3.4) plus a small skinned body (~8.3); box3 is dominated by the water plane, so
|
||||
// a baseScale can't fix it without blowing up the scene geometry — left at 1.0
|
||||
// until the GLB is re-exported as a clean fish.
|
||||
const SPECIES_SCALE: Record<FishSpecies, number> = {
|
||||
angelfish: 1.0,
|
||||
goldfish: 1.0,
|
||||
guppy: 1.0,
|
||||
betta: 1.0,
|
||||
blue_tang: 1.0,
|
||||
flower_horn: 1.0,
|
||||
neon_tetra: 1.0,
|
||||
oscar: 1.35,
|
||||
knifefish: 2.58,
|
||||
clownfish: 1.0,
|
||||
// Per-species true longest rendered extent (model units), measured
|
||||
// offline with scripts/measure-models.mjs (stride-safe rest-pose +
|
||||
// swim-clip evaluation of the actual GLB data). Fish are scaled by
|
||||
// targetLength / modelLength. This replaces the old
|
||||
// Box3.setFromObject auto-measure, which under-reports armature-scaled
|
||||
// models (oscar, knifefish) and for the clownfish is dominated by its
|
||||
// 45.3-unit water plane (see FishSpec.modelLength).
|
||||
const SPECIES_LENGTH: Record<FishSpecies, number> = {
|
||||
angelfish: 4.2139,
|
||||
goldfish: 1.7386,
|
||||
guppy: 2.8070,
|
||||
betta: 1.0228,
|
||||
blue_tang: 1.0192,
|
||||
flower_horn: 1.0516,
|
||||
neon_tetra: 3.6548,
|
||||
oscar: 8.8411,
|
||||
knifefish: 23.0140,
|
||||
clownfish: 8.2773,
|
||||
};
|
||||
// The clownfish GLB bundles a whole aquarium scene: an untextured white
|
||||
// "Surface" water plane (45.3 units — the "white box"), a "TopLight"
|
||||
// emissive slab, a "Bubbles" prop, and "Original-Mirrored", a mirrored
|
||||
// duplicate of the body that overlaps the real "ClownFish" node. Hide
|
||||
// everything except the fish itself.
|
||||
const HIDDEN_CLOWNFISH_NODES = ["Surface", "TopLight", "Bubbles", "Original-Mirrored"];
|
||||
function fishToSpec(f: FishData): FishSpec {
|
||||
return {
|
||||
url: getSpeciesUrl(f.species),
|
||||
targetLength: f.genetics.sizeMult * 0.5,
|
||||
modelLength: SPECIES_LENGTH[f.species],
|
||||
speed: [0.3 * f.genetics.speedMult, 0.7 * f.genetics.speedMult],
|
||||
orient: isQuaternius(f.species) ? { y: Math.PI / 2 } : undefined,
|
||||
genetics: f.genetics,
|
||||
baseScale: SPECIES_SCALE[f.species],
|
||||
hideNodes: f.species === "clownfish" ? HIDDEN_CLOWNFISH_NODES : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+32
-16
@@ -17,19 +17,31 @@ const FOOD_EAT = 0.15;
|
||||
export interface FishSpec {
|
||||
url: string;
|
||||
targetLength: number;
|
||||
/**
|
||||
* The model's true longest rendered extent in model units, measured
|
||||
* offline with `scripts/measure-models.mjs` (stride-safe evaluation of
|
||||
* rest pose + swim-clip extremes). The fish is scaled by
|
||||
* `targetLength / modelLength`.
|
||||
*
|
||||
* This replaces the old `Box3.setFromObject` auto-measure, which is
|
||||
* unreliable: it measures skinned geometry in skeleton space (an
|
||||
* armature's scale/rotation is not in the mesh's matrixWorld and a
|
||||
* stale cached bounding box may be used), so it under-reports
|
||||
* armature-scaled models (e.g. knifefish 8.89 vs true 22.91, oscar
|
||||
* 6.49 vs true 8.84), and for the clownfish GLB it is dominated by
|
||||
* the model's 45.3-unit water plane instead of the 8.28-unit fish.
|
||||
*/
|
||||
modelLength: number;
|
||||
/**
|
||||
* Node names to hide after loading. The clownfish GLB is a whole
|
||||
* aquarium scene: a 45.3-unit untextured white "Surface" water plane,
|
||||
* a "TopLight" slab, a "Bubbles" prop, and a mirrored duplicate of the
|
||||
* body ("Original-Mirrored") — only "ClownFish" is the fish itself.
|
||||
*/
|
||||
hideNodes?: string[];
|
||||
orient?: { x?: number; y?: number; z?: number };
|
||||
speed?: Range;
|
||||
genetics?: Genetics;
|
||||
/**
|
||||
* Correction factor between the extent reported by `Box3.setFromObject`
|
||||
* and the fish's true rendered extent. `Box3.setFromObject` measures
|
||||
* skinned geometry in skeleton space (and may use a stale cached
|
||||
* bounding box), which does not match what the GPU actually renders
|
||||
* (`mesh.matrixWorld * skinning * position`). baseScale = rendered / box3,
|
||||
* so the final scale becomes `targetLength / (box3 * baseScale) =
|
||||
* targetLength / rendered`, normalizing every fish to its real size.
|
||||
*/
|
||||
baseScale?: number;
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
@@ -133,17 +145,21 @@ interface Fish {
|
||||
function buildFish(gltf: GLTF, spec: FishSpec): Fish {
|
||||
const model = gltf.scene ?? gltf.scenes[0];
|
||||
|
||||
// Hide non-fish scene props before anything else (clownfish GLB bundles
|
||||
// a full scene around the fish body).
|
||||
if (spec.hideNodes) {
|
||||
for (const name of spec.hideNodes) {
|
||||
const node = model.getObjectByName(name);
|
||||
if (node) node.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
const orient = new THREE.Group();
|
||||
if (spec.orient) {
|
||||
orient.rotation.set(spec.orient.x ?? 0, spec.orient.y ?? 0, spec.orient.z ?? 0);
|
||||
}
|
||||
|
||||
model.updateMatrixWorld(true);
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const longest = Math.max(size.x, size.y, size.z);
|
||||
const baseScale = spec.baseScale ?? 1.0;
|
||||
const scale = (spec.targetLength / (longest * baseScale)) * rand(0.92, 1.08);
|
||||
const scale = (spec.targetLength / spec.modelLength) * rand(0.92, 1.08);
|
||||
orient.scale.setScalar(scale);
|
||||
orient.add(model);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user