Files
Aquarium/src/scene/GLTFish.ts
T
kamilkrzywda a1fd66d039 fix: per-species scale correction for skinned mesh fish
Box3.setFromObject measures skinned geometry in skeleton space (and may
use a stale cached bounding box), which does not match the GPU's actual
rendered extent (mesh.matrixWorld * skinning * position). Add a
per-species baseScale correction factor (rendered / box3) to FishSpec and
fold it into buildFish's scale calc so each GLB normalizes to its true
size.

Measured per GLB with three.js's exact skinning formula: most models
are 1.0. The skinned blendswap oscar (1.35) and knifefish (2.58) deviate
and are corrected. 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 pending a clean re-export.
2026-10-07 15:19:16 +00:00

394 lines
12 KiB
TypeScript

import * as THREE from "three";
import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
import type { FoodSystem } from "./Food";
import type { Genetics, Rarity } from "../game/types";
// ---- Tank bounds ----
type Range = [number, number];
interface Bounds { x: Range; y: Range; z: Range; }
const WANDER: Bounds = { x: [-1.5, 1.5], y: [0.3, 2.0], z: [-1.0, 1.0] };
const CLAMP: Bounds = { x: [-1.8, 1.8], y: [0.2, 2.2], z: [-1.3, 1.3] };
const FORWARD = new THREE.Vector3(1, 0, 0);
const FOOD_NOTICE = 1.2;
const FOOD_EAT = 0.15;
// ---- FishSpec ----
export interface FishSpec {
url: string;
targetLength: number;
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 ----
function rand(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function randIn([lo, hi]: Range): number {
return rand(lo, hi);
}
function randomWanderTarget(): THREE.Vector3 {
return new THREE.Vector3(randIn(WANDER.x), randIn(WANDER.y), randIn(WANDER.z));
}
function clampToBounds(v: THREE.Vector3): void {
v.x = THREE.MathUtils.clamp(v.x, CLAMP.x[0], CLAMP.x[1]);
v.y = THREE.MathUtils.clamp(v.y, CLAMP.y[0], CLAMP.y[1]);
v.z = THREE.MathUtils.clamp(v.z, CLAMP.z[0], CLAMP.z[1]);
}
function pickSwimClip(clips: THREE.AnimationClip[]): THREE.AnimationClip | null {
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 createSwimMixer(gltf: GLTF): THREE.AnimationMixer | null {
const model = gltf.scene ?? gltf.scenes[0];
const clip = pickSwimClip(gltf.animations);
if (!clip) return null;
const mixer = new THREE.AnimationMixer(model);
const action = mixer.clipAction(clip);
action.setLoop(THREE.LoopRepeat, Infinity);
action.play();
return mixer;
}
// ---- Apply genetics to a loaded model's materials ----
const RARITY_EMISSIVE: Record<Rarity, { color: number; intensity: number }> = {
common: { color: 0x000000, intensity: 0 },
uncommon: { color: 0x22aa44, intensity: 0.15 },
rare: { color: 0x3388ff, intensity: 0.25 },
epic: { color: 0xaa44ff, intensity: 0.4 },
legendary: { color: 0xffdd00, intensity: 0.6 },
};
function applyGeneticsToModel(model: THREE.Object3D, genetics: Genetics): void {
const emissive = RARITY_EMISSIVE[genetics.rarity];
model.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (!mesh.isMesh) return;
const mat = mesh.material as THREE.MeshStandardMaterial;
if (!mat || !mat.isMaterial) return;
// Color shift: modify hue and saturation
if (mat.color) {
const col = mat.color.clone();
const hsl = { h: 0, s: 0, l: 0 };
col.getHSL(hsl);
// Apply hue shift (genetics.hueShift is -30 to +30 degrees)
hsl.h = (hsl.h + genetics.hueShift / 360 + 1) % 1;
// Apply saturation multiplier
hsl.s = THREE.MathUtils.clamp(hsl.s * genetics.saturation, 0, 1);
col.setHSL(hsl.h, hsl.s, hsl.l);
mat.color = col;
}
// Rarity glow: add emissive
if (emissive.intensity > 0 && mat.emissive) {
mat.emissive = new THREE.Color(emissive.color);
mat.emissiveIntensity = emissive.intensity;
}
mesh.castShadow = true;
mesh.receiveShadow = false;
});
}
// ---- Per-fish state ----
interface Fish {
group: THREE.Group;
position: THREE.Vector3;
target: THREE.Vector3;
speed: number;
seekSpeed: number;
phase: number;
turnSpeed: number;
quaternion: THREE.Quaternion;
mixer: THREE.AnimationMixer | null;
chasingFood: number;
// For fade-out animation
fading: boolean;
fadeAlpha: number;
}
function buildFish(gltf: GLTF, spec: FishSpec): Fish {
const model = gltf.scene ?? gltf.scenes[0];
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);
orient.scale.setScalar(scale);
orient.add(model);
const group = new THREE.Group();
group.add(orient);
// Apply genetics (color shift, rarity glow)
if (spec.genetics) {
applyGeneticsToModel(model, spec.genetics);
} else {
model.traverse((o) => {
const mesh = o as THREE.Mesh;
if (mesh.isMesh) {
mesh.castShadow = true;
mesh.receiveShadow = false;
}
});
}
const speed = spec.speed ? rand(spec.speed[0], spec.speed[1]) : rand(0.3, 0.8);
const seekSpeed = speed * 1.4;
const phase = Math.random() * Math.PI * 2;
const turnSpeed = rand(2, 4);
const mixer = createSwimMixer(gltf);
if (mixer) {
mixer.timeScale = Math.min(1.6, 0.6 + speed * 1.2) * rand(0.9, 1.1);
}
const position = randomWanderTarget();
const target = randomWanderTarget();
const startDir = target.clone().sub(position).normalize();
const quaternion = new THREE.Quaternion().setFromUnitVectors(FORWARD, startDir);
group.position.copy(position);
group.quaternion.copy(quaternion);
return { group, position, target, speed, seekSpeed, phase, turnSpeed, quaternion, mixer, chasingFood: -1, fading: false, fadeAlpha: 1 };
}
// ---- Update logic ----
const _dir = new THREE.Vector3();
const _targetQuat = new THREE.Quaternion();
function updateFish(
f: Fish, dt: number, time: number,
food: FoodSystem | null,
foodPositions: THREE.Vector3[],
foodIndex: number[]
): void {
if (f.fading) {
f.fadeAlpha -= dt * 2;
f.group.scale.setScalar(Math.max(0.01, f.fadeAlpha));
if (f.fadeAlpha <= 0) {
f.group.visible = false;
}
return;
}
let speed = f.speed;
let target = f.target;
if (food && foodPositions.length > 0) {
if (f.chasingFood >= 0 && f.chasingFood < foodPositions.length) {
target = foodPositions[f.chasingFood];
speed = f.seekSpeed;
const dist = f.position.distanceTo(target);
if (dist < FOOD_EAT) {
food.eat(foodIndex[f.chasingFood]);
f.chasingFood = -1;
f.target.copy(randomWanderTarget());
target = f.target;
speed = f.speed;
}
} else {
let nearestIdx = -1;
let nearestDist = FOOD_NOTICE;
for (let i = 0; i < foodPositions.length; i++) {
const d = f.position.distanceTo(foodPositions[i]);
if (d < nearestDist) {
nearestDist = d;
nearestIdx = i;
}
}
if (nearestIdx >= 0) {
f.chasingFood = nearestIdx;
target = foodPositions[nearestIdx];
speed = f.seekSpeed;
}
}
} else {
f.chasingFood = -1;
}
_dir.subVectors(target, f.position);
if (f.chasingFood < 0 && _dir.length() < 0.3) {
f.target.copy(randomWanderTarget());
_dir.subVectors(f.target, f.position);
}
if (_dir.length() < 0.001) {
f.group.position.copy(f.position);
f.group.quaternion.copy(f.quaternion);
if (f.mixer) f.mixer.update(dt);
return;
}
_dir.normalize();
_targetQuat.setFromUnitVectors(FORWARD, _dir);
const turnRate = f.chasingFood >= 0 ? f.turnSpeed * 1.3 : f.turnSpeed;
f.quaternion.slerp(_targetQuat, 1 - Math.exp(-turnRate * dt));
f.position.addScaledVector(_dir, speed * dt);
f.position.y += Math.sin(time * 1.5 + f.phase) * 0.002;
clampToBounds(f.position);
f.group.position.copy(f.position);
f.group.quaternion.copy(f.quaternion);
if (f.mixer) {
const baseTimeScale = Math.min(1.6, 0.6 + f.speed * 1.2);
const seekBoost = f.chasingFood >= 0 ? 1.3 : 1.0;
f.mixer.timeScale = baseTimeScale * seekBoost;
f.mixer.update(dt);
}
}
// ---- School (dynamic) ----
export interface GLTFishSchool {
update: (dt: number, time: number) => void;
group: THREE.Group;
addFish: (spec: FishSpec) => void;
removeFish: (index: number) => void;
getFishCount: () => number;
updateBounds: (wander: Bounds, clamp: Bounds) => void;
}
const loader = new GLTFLoader();
const loadedModels: Map<string, GLTF> = new Map();
function loadModel(url: string): Promise<GLTF> {
if (loadedModels.has(url)) return Promise.resolve(loadedModels.get(url)!);
return new Promise((resolve, reject) => {
loader.load(
url,
(gltf) => { loadedModels.set(url, gltf); resolve(gltf); },
undefined,
(err) => reject(err)
);
});
}
export function createGLTFishSchool(
scene: THREE.Scene,
specs: FishSpec[],
food?: FoodSystem
): GLTFishSchool {
const group = new THREE.Group();
scene.add(group);
const fish: Fish[] = [];
let wander = { ...WANDER };
let clamp = { ...CLAMP };
function updateWanderTarget(): void {
// Re-randomize wander targets within current bounds
}
async function loadAndAdd(spec: FishSpec): Promise<void> {
try {
const gltf = await loadModel(spec.url);
// Clone the model so each fish instance is independent
const clonedGltf = { ...gltf, scene: gltf.scene.clone(true) } as GLTF;
const f = buildFish(clonedGltf, spec);
group.add(f.group);
fish.push(f);
} catch (err) {
console.error(`[GLTFish] failed to load ${spec.url}:`, err);
}
}
// Load initial fish
for (const spec of specs) {
loadAndAdd(spec);
}
const foodPositions: THREE.Vector3[] = [];
const foodIndex: number[] = [];
function update(dt: number, time: number): void {
if (food) {
foodPositions.length = 0;
foodIndex.length = 0;
const active = food.getActivePositions();
for (let i = 0; i < active.length; i++) {
foodPositions.push(active[i]);
foodIndex.push(i);
}
}
for (let i = fish.length - 1; i >= 0; i--) {
const f = fish[i];
if (f.fading && f.fadeAlpha <= 0) {
// Remove fully faded fish
group.remove(f.group);
f.group.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (mesh.isMesh) {
mesh.geometry.dispose();
if (Array.isArray(mesh.material)) mesh.material.forEach((m) => m.dispose());
else (mesh.material as THREE.Material).dispose();
}
});
if (f.mixer) f.mixer.stopAllAction();
fish.splice(i, 1);
continue;
}
// Use local bounds
const savedWander = WANDER;
const savedClamp = CLAMP;
// Temporarily set bounds
(WANDER as any).x = wander.x; (WANDER as any).y = wander.y; (WANDER as any).z = wander.z;
(CLAMP as any).x = clamp.x; (CLAMP as any).y = clamp.y; (CLAMP as any).z = clamp.z;
updateFish(f, dt, time, food || null, foodPositions, foodIndex);
// Restore
(WANDER as any).x = savedWander.x; (WANDER as any).y = savedWander.y; (WANDER as any).z = savedWander.z;
(CLAMP as any).x = savedClamp.x; (CLAMP as any).y = savedClamp.y; (CLAMP as any).z = savedClamp.z;
}
}
return {
update,
group,
addFish: (spec: FishSpec) => { loadAndAdd(spec); },
removeFish: (index: number) => {
if (index >= 0 && index < fish.length) {
fish[index].fading = true;
fish[index].fadeAlpha = 1;
}
},
getFishCount: () => fish.length,
updateBounds: (newWander: Bounds, newClamp: Bounds) => {
wander = newWander;
clamp = newClamp;
},
};
}