diff --git a/.gitignore b/.gitignore index f06235c..9c97bbd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules dist +.env diff --git a/public/models/angelfish.glb b/public/models/angelfish.glb new file mode 100644 index 0000000..48b2092 Binary files /dev/null and b/public/models/angelfish.glb differ diff --git a/public/models/goldfish.glb b/public/models/goldfish.glb new file mode 100644 index 0000000..1f2bf01 Binary files /dev/null and b/public/models/goldfish.glb differ diff --git a/public/models/guppy.glb b/public/models/guppy.glb new file mode 100644 index 0000000..c52a387 Binary files /dev/null and b/public/models/guppy.glb differ diff --git a/public/models/plant0.glb b/public/models/plant0.glb new file mode 100644 index 0000000..c6d4990 Binary files /dev/null and b/public/models/plant0.glb differ diff --git a/public/models/plant1.glb b/public/models/plant1.glb new file mode 100644 index 0000000..c9903de Binary files /dev/null and b/public/models/plant1.glb differ diff --git a/public/models/quaternius/License.txt b/public/models/quaternius/License.txt new file mode 100644 index 0000000..05a0b19 --- /dev/null +++ b/public/models/quaternius/License.txt @@ -0,0 +1,10 @@ +------------------------------------------------------ +Animated Fish Pack by Quaternius +Consider supporting me on Patreon, even $1 helps me a lot! + +https://www.patreon.com/quaternius +------------------------------------------------------- +License: +CC0 1.0 Universal (CC0 1.0) +Public Domain Dedication +https://creativecommons.org/publicdomain/zero/1.0/ \ No newline at end of file diff --git a/public/models/quaternius/glb/Dolphin.glb b/public/models/quaternius/glb/Dolphin.glb new file mode 100644 index 0000000..cf336a6 Binary files /dev/null and b/public/models/quaternius/glb/Dolphin.glb differ diff --git a/public/models/quaternius/glb/Fish1.glb b/public/models/quaternius/glb/Fish1.glb new file mode 100644 index 0000000..c05c7d9 Binary files /dev/null and b/public/models/quaternius/glb/Fish1.glb differ diff --git a/public/models/quaternius/glb/Fish2.glb b/public/models/quaternius/glb/Fish2.glb new file mode 100644 index 0000000..72ed088 Binary files /dev/null and b/public/models/quaternius/glb/Fish2.glb differ diff --git a/public/models/quaternius/glb/Fish3.glb b/public/models/quaternius/glb/Fish3.glb new file mode 100644 index 0000000..9a8e4bc Binary files /dev/null and b/public/models/quaternius/glb/Fish3.glb differ diff --git a/public/models/quaternius/glb/Manta_ray.glb b/public/models/quaternius/glb/Manta_ray.glb new file mode 100644 index 0000000..84f16f1 Binary files /dev/null and b/public/models/quaternius/glb/Manta_ray.glb differ diff --git a/public/models/quaternius/glb/Shark.glb b/public/models/quaternius/glb/Shark.glb new file mode 100644 index 0000000..5a7dc18 Binary files /dev/null and b/public/models/quaternius/glb/Shark.glb differ diff --git a/public/models/quaternius/glb/Whale.glb b/public/models/quaternius/glb/Whale.glb new file mode 100644 index 0000000..875895d Binary files /dev/null and b/public/models/quaternius/glb/Whale.glb differ diff --git a/public/models/rock0.glb b/public/models/rock0.glb new file mode 100644 index 0000000..cc9655b Binary files /dev/null and b/public/models/rock0.glb differ diff --git a/public/models/rock1.glb b/public/models/rock1.glb new file mode 100644 index 0000000..19c36c1 Binary files /dev/null and b/public/models/rock1.glb differ diff --git a/public/models/rock2.glb b/public/models/rock2.glb new file mode 100644 index 0000000..77108d4 Binary files /dev/null and b/public/models/rock2.glb differ diff --git a/src/main.ts b/src/main.ts index bedf50b..bda677c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,8 @@ import * as THREE from "three"; import { createTank } from "./scene/Tank"; import { createWater } from "./scene/Water"; import { setupLighting } from "./scene/Lighting"; -import { createFishSchool } from "./scene/Fish"; +import { createGLTFishSchool, type FishSpec } from "./scene/GLTFish"; +import { createDecor, type DecorSpec } from "./scene/Decor"; import { createParallaxCamera } from "./scene/Camera"; const canvas = document.querySelector("#aquarium"); @@ -32,7 +33,37 @@ camera.lookAt(0, 1.2, 0); setupLighting(scene, renderer); createTank(scene); const water = createWater(scene); -const fishSchool = createFishSchool(scene, 6); + +// Fish — GLB models with swim animations + wandering AI. +// Maya's tropical fish already face local +X (no orient fix). The Quaternius +// pack is built with its long axis on +Z (nose at +Z), so it is rotated +90° +// about Y to bring the nose onto +X. +const fishSpecs: FishSpec[] = [ + // Tropical (maya's-aquarium-3d) + { url: "models/angelfish.glb", targetLength: 0.55, speed: [0.25, 0.45] }, + { url: "models/angelfish.glb", targetLength: 0.5, speed: [0.25, 0.45] }, + { url: "models/goldfish.glb", targetLength: 0.45, speed: [0.35, 0.6] }, + { url: "models/goldfish.glb", targetLength: 0.4, speed: [0.35, 0.6] }, + { url: "models/guppy.glb", targetLength: 0.4, speed: [0.4, 0.7] }, + { url: "models/guppy.glb", targetLength: 0.35, speed: [0.4, 0.7] }, + // Cute fish (Quaternius, CC0) + { url: "models/quaternius/glb/Fish1.glb", targetLength: 0.5, speed: [0.4, 0.7], orient: { y: Math.PI / 2 } }, + { url: "models/quaternius/glb/Fish1.glb", targetLength: 0.45, speed: [0.4, 0.7], orient: { y: Math.PI / 2 } }, + { url: "models/quaternius/glb/Fish2.glb", targetLength: 0.6, speed: [0.3, 0.55], orient: { y: Math.PI / 2 } }, + { url: "models/quaternius/glb/Fish3.glb", targetLength: 0.55, speed: [0.35, 0.6], orient: { y: Math.PI / 2 } }, +]; +const fishSchool = createGLTFishSchool(scene, fishSpecs); + +// Decor — static plants and rocks on the gravel floor (y ~ 0.05). +const decorSpecs: DecorSpec[] = [ + { url: "models/plant0.glb", x: -1.4, z: -0.9, targetSize: 1.4, rotationY: 0.4 }, + { url: "models/plant1.glb", x: 1.3, z: -0.8, targetSize: 1.1, rotationY: -0.3 }, + { url: "models/rock0.glb", x: -0.7, z: 0.7, targetSize: 1.0, rotationY: 0.2 }, + { url: "models/rock1.glb", x: 0.8, z: 0.8, targetSize: 1.1, rotationY: 1.1 }, + { url: "models/rock2.glb", x: 0.1, z: -1.1, targetSize: 1.3, rotationY: 0.0 }, +]; +createDecor(scene, decorSpecs); + const parallax = createParallaxCamera(camera); // Mouse tracking (normalized to -1..1) diff --git a/src/scene/Decor.ts b/src/scene/Decor.ts new file mode 100644 index 0000000..2deafb6 --- /dev/null +++ b/src/scene/Decor.ts @@ -0,0 +1,90 @@ +import * as THREE from "three"; +import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js"; + +// Gravel level — decor sits just above the glass floor / gravel. +const FLOOR_Y = 0.05; + +export interface DecorSpec { + /** Path to the GLB, relative to the page root (served from /public). */ + url: string; + /** Floor position to place the item's base-center at. */ + x: number; + z: number; + /** Long-axis size, in world units, the model is scaled to. */ + targetSize: number; + /** Optional yaw (radians) applied to the placed item. */ + rotationY?: number; +} + +interface Placed { + holder: THREE.Group; +} + +// Loads one static GLB, scales it to targetSize, and drops it on the floor so +// its bounding-box base-center lands at (x, FLOOR_Y, z). +function placeDecor( + group: THREE.Group, + loader: GLTFLoader, + spec: DecorSpec +): void { + loader.load( + spec.url, + (gltf: GLTF) => { + const model = gltf.scene ?? gltf.scenes[0]; + model.updateMatrixWorld(true); + + const box = new THREE.Box3().setFromObject(model); + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + const longest = Math.max(size.x, size.y, size.z); + const s = spec.targetSize / longest; + + const holder = new THREE.Group(); + holder.scale.setScalar(s); + const rotY = spec.rotationY ?? 0; + holder.rotation.y = rotY; + holder.add(model); + + // The model's bbox center (in model space) is `center`; after the + // holder's scale + yaw it sits at R_y * (center * s) relative to the + // holder origin. Offset the holder so that rotated center maps to the + // requested (x, z), and so the scaled base (min.y) rests on FLOOR_Y. + const c = new THREE.Vector3(center.x, 0, center.z).applyAxisAngle( + new THREE.Vector3(0, 1, 0), + rotY + ); + holder.position.set( + spec.x - c.x * s, + FLOOR_Y - box.min.y * s, + spec.z - c.z * s + ); + + // Decor casts shadows (rocks, plants) and receives them (gravel feel). + model.traverse((o) => { + const mesh = o as THREE.Mesh; + if (mesh.isMesh) { + mesh.castShadow = true; + mesh.receiveShadow = true; + } + }); + + group.add(holder); + }, + undefined, + (err) => console.error(`[Decor] failed to load ${spec.url}:`, err) + ); +} + +// Loads a set of static decor items (plants, rocks) under one parent group. +// Items are non-animated; they load asynchronously and appear as they arrive. +export function createDecor(scene: THREE.Scene, specs: DecorSpec[]): { group: THREE.Group } { + const group = new THREE.Group(); + scene.add(group); + + const loader = new GLTFLoader(); + for (const spec of specs) { + placeDecor(group, loader, spec); + } + + return { group }; +} diff --git a/src/scene/Fish.ts b/src/scene/Fish.ts deleted file mode 100644 index bd652a2..0000000 --- a/src/scene/Fish.ts +++ /dev/null @@ -1,204 +0,0 @@ -import * as THREE from "three"; - -// ---- Configuration ---- - -// Roaming / clamping bounds, kept inside the tank (tank spans x:-2..2, y:0..2.5, z:-1.5..1.5) -type Bounds = { x: [number, number]; y: [number, number]; z: [number, number] }; -const BOUNDS: { wander: Bounds; clamp: Bounds } = { - wander: { x: [-1.5, 1.5], y: [0.3, 2.0], z: [-1.0, 1.0] }, - clamp: { x: [-1.8, 1.8], y: [0.2, 2.3], z: [-1.3, 1.3] }, -}; - -// The fish body is elongated along local +X, so that is its forward axis. -const FORWARD = new THREE.Vector3(1, 0, 0); - -// Color palette: orange / blue / yellow hue ranges (HSL). -const HUE_RANGES: [number, number][] = [ - [0.05, 0.12], // orange - [0.55, 0.62], // blue - [0.12, 0.18], // yellow -]; - -// Scratch objects reused by update() to avoid per-frame allocation. -const _dir = new THREE.Vector3(); -const _targetQuat = new THREE.Quaternion(); - -// ---- Helpers ---- - -function rand(min: number, max: number): number { - return min + Math.random() * (max - min); -} - -function randIn(range: [number, number]): number { - return rand(range[0], range[1]); -} - -function randomWanderTarget(): THREE.Vector3 { - return new THREE.Vector3( - randIn(BOUNDS.wander.x), - randIn(BOUNDS.wander.y), - randIn(BOUNDS.wander.z) - ); -} - -function clampToBounds(v: THREE.Vector3): void { - v.x = THREE.MathUtils.clamp(v.x, BOUNDS.clamp.x[0], BOUNDS.clamp.x[1]); - v.y = THREE.MathUtils.clamp(v.y, BOUNDS.clamp.y[0], BOUNDS.clamp.y[1]); - v.z = THREE.MathUtils.clamp(v.z, BOUNDS.clamp.z[0], BOUNDS.clamp.z[1]); -} - -function randomFishColor(): THREE.Color { - const [h0, h1] = HUE_RANGES[Math.floor(Math.random() * HUE_RANGES.length)]; - return new THREE.Color().setHSL(rand(h0, h1), rand(0.7, 0.9), rand(0.5, 0.65)); -} - -// ---- Types ---- - -interface FishState { - group: THREE.Group; - tail: THREE.Mesh; - body: THREE.Mesh; - position: THREE.Vector3; - target: THREE.Vector3; - speed: number; - phase: number; - turnSpeed: number; - quaternion: THREE.Quaternion; -} - -// ---- Mesh construction ---- - -// Builds a low-poly procedural fish from primitives, all sharing one material. -// The body is an elongated sphere (long axis = local +X = forward). -function createFishMesh(color: THREE.Color): { - group: THREE.Group; - tail: THREE.Mesh; - body: THREE.Mesh; -} { - const material = new THREE.MeshStandardMaterial({ - color, - roughness: 0.4, - metalness: 0.1, - // DoubleSide so the flat pectoral fins are visible from either side. - side: THREE.DoubleSide, - }); - - const group = new THREE.Group(); - - // Body - const body = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), material); - body.scale.set(1.0, 0.4, 0.25); - body.castShadow = true; - - // Tail — thin cone at the rear. - // NOTE: the spec says "rotated Z by PI", but that orients the cone's axis - // along Y, which makes the per-frame rotation.y wag invisible (a line aligned - // with its rotation axis doesn't move). Rotating -PI/2 instead points the tip - // forward (+X, toward the body) as intended, and lets the y-wag sweep side-to-side. - const tail = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.5, 4), material); - tail.rotation.z = -Math.PI / 2; - tail.scale.set(0.1, 1, 1); - tail.position.set(-1.0, 0, 0); - tail.castShadow = true; - - // Dorsal fin — sits on top of the body, points up. - const dorsal = new THREE.Mesh(new THREE.ConeGeometry(0.15, 0.2, 4), material); - dorsal.scale.set(0.1, 1, 1); - dorsal.position.set(0, 0.4, 0); - dorsal.castShadow = true; - - // Pectoral fins — small flat paddles on either side, angled out and back. - // Nudged just past the body's z-extent (0.25) so they read as fins rather - // than being buried inside the opaque body. - const pectoralGeo = new THREE.PlaneGeometry(0.2, 0.1); - - const leftFin = new THREE.Mesh(pectoralGeo, material); - leftFin.position.set(0, -0.05, 0.26); - leftFin.rotation.set(-0.3, 0, 0.3); // face +Z (outward), droop down, sweep back - leftFin.castShadow = true; - - const rightFin = new THREE.Mesh(pectoralGeo, material); - rightFin.position.set(0, -0.05, -0.26); - rightFin.rotation.set(-0.3, Math.PI, -0.3); // face -Z (outward) - rightFin.castShadow = true; - - group.add(body, tail, dorsal, leftFin, rightFin); - - return { group, tail, body }; -} - -// ---- School ---- - -export interface FishSchool { - update: (dt: number, time: number) => void; - group: THREE.Group; -} - -// Creates `count` independently-wandering fish under a single parent group. -export function createFishSchool(scene: THREE.Scene, count: number = 6): FishSchool { - const group = new THREE.Group(); - const fish: FishState[] = []; - - for (let i = 0; i < count; i++) { - const { group: fishGroup, tail, body } = createFishMesh(randomFishColor()); - - const position = randomWanderTarget(); - const target = randomWanderTarget(); - const speed = rand(0.3, 0.8); - const phase = Math.random() * Math.PI * 2; - const turnSpeed = rand(2, 4); - - // Start already facing the first target to avoid an initial swing. - const startDir = target.clone().sub(position).normalize(); - const quaternion = new THREE.Quaternion().setFromUnitVectors(FORWARD, startDir); - - fishGroup.position.copy(position); - fishGroup.quaternion.copy(quaternion); - fishGroup.scale.setScalar(rand(0.3, 0.5)); - - group.add(fishGroup); - - fish.push({ group: fishGroup, tail, body, position, target, speed, phase, turnSpeed, quaternion }); - } - - scene.add(group); - - function update(dt: number, time: number): void { - for (const f of fish) { - // 1. Direction toward current target. - _dir.subVectors(f.target, f.position); - - // 2. Reached the target — pick a new one. - if (_dir.length() < 0.3) { - f.target.copy(randomWanderTarget()); - _dir.subVectors(f.target, f.position); - } - _dir.normalize(); - - // 3. Turn toward the target (frame-rate independent slerp). - _targetQuat.setFromUnitVectors(FORWARD, _dir); - f.quaternion.slerp(_targetQuat, 1 - Math.exp(-f.turnSpeed * dt)); - - // 4. Move forward. - f.position.addScaledVector(_dir, f.speed * dt); - - // 5. Gentle vertical bob. - f.position.y += Math.sin(time * 1.5 + f.phase) * 0.002; - - // 6. Keep inside the tank. - clampToBounds(f.position); - - // 7. Apply transform. - f.group.position.copy(f.position); - f.group.quaternion.copy(f.quaternion); - - // 8. Tail wag. - f.tail.rotation.y = Math.sin(time * (2 + f.speed * 3) + f.phase) * 0.4; - - // 9. Subtle body flex (length pulses around 1.0). - f.body.scale.x = 1.0 + Math.sin(time * (2 + f.speed * 3) + f.phase) * 0.03; - } - } - - return { update, group }; -} diff --git a/src/scene/GLTFish.ts b/src/scene/GLTFish.ts new file mode 100644 index 0000000..e751e0a --- /dev/null +++ b/src/scene/GLTFish.ts @@ -0,0 +1,273 @@ +import * as THREE from "three"; +import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js"; + +// ---- Tank bounds (slightly inside the 4 x 2.5 x 3 tank) ---- + +type Range = [number, number]; +interface Bounds { + x: Range; + y: Range; + z: Range; +} + +// Wander targets are drawn from the inner box; positions are hard-clamped to +// the outer box so fish never poke through the glass. +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] }; + +// After the per-model orientation fix, every fish's nose points along local +X, +// so a single forward axis works for the whole school. +const FORWARD = new THREE.Vector3(1, 0, 0); + +// ---- Per-model configuration ---- + +export interface FishSpec { + /** Path to the GLB, relative to the page root (served from /public). */ + url: string; + /** Long-axis length, in world units, the model is scaled to. */ + targetLength: number; + /** + * Static orientation applied to the loaded model so its nose ends up on + * local +X and its back on local +Y. Maya's tropical fish already satisfy + * this (nose +X); the Quaternius pack is built with its long axis on +Z, so + * it needs +90° about Y. + */ + orient?: { x?: number; y?: number; z?: number }; + /** Wandering speed range in world-units/second. */ + speed?: Range; +} + +// ---- 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]); +} + +// Prefer a clip whose final name segment is exactly "swim" (e.g. "Swim" or +// "Armature|Armature|Swim"); otherwise the first clip mentioning "swim"; +// otherwise the first clip. Models without clips return null (no mixer). +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]; +} + +// Bind the model's best swim clip to a playing AnimationMixer. Models without +// clips return null (the fish still wanders, just without a tail beat). +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; +} + +// ---- Single-model loader (public API) ---- + +// Load a single GLB into the scene wrapped in a group, with its swim clip (if +// any) already playing. `scale` multiplies the model's size (default 1). This +// is a plain loader — for wandering fish use createGLTFishSchool. +export function loadGLBFish( + scene: THREE.Scene, + url: string, + scale = 1 +): Promise<{ mesh: THREE.Group; mixer: THREE.AnimationMixer | null }> { + const loader = new GLTFLoader(); + return new Promise((resolve, reject) => { + loader.load( + url, + (gltf) => { + const model = gltf.scene ?? gltf.scenes[0]; + const group = new THREE.Group(); + group.add(model); + group.scale.setScalar(scale); + + model.traverse((o) => { + const mesh = o as THREE.Mesh; + if (mesh.isMesh) { + mesh.castShadow = true; + mesh.receiveShadow = false; + } + }); + + scene.add(group); + resolve({ mesh: group, mixer: createSwimMixer(gltf) }); + }, + undefined, + (err) => reject(err) + ); + }); +} + +// ---- Per-fish state ---- + +interface Fish { + /** Outer group moved by the AI (position + quaternion). */ + group: THREE.Group; + position: THREE.Vector3; + target: THREE.Vector3; + speed: number; + phase: number; + turnSpeed: number; + quaternion: THREE.Quaternion; + mixer: THREE.AnimationMixer | null; +} + +// Builds one wandering fish from a loaded GLB: wraps the model in an +// orientation/scale group, attaches an animation mixer for the swim clip, and +// seeds the wandering state. +function buildFish(gltf: GLTF, spec: FishSpec): Fish { + const model = gltf.scene ?? gltf.scenes[0]; + + // Orientation fix + scale live on a wrapper so the AI only ever touches the + // outer group's transform. + 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); + // Small per-instance jitter so a pair of the same species don't look cloned. + const scale = (spec.targetLength / longest) * rand(0.92, 1.08); + orient.scale.setScalar(scale); + orient.add(model); + + const group = new THREE.Group(); + group.add(orient); + + // Fish cast shadows onto the gravel; they don't need to receive them. + model.traverse((o) => { + const mesh = o as THREE.Mesh; + if (mesh.isMesh) { + mesh.castShadow = true; + mesh.receiveShadow = false; + } + }); + + // Wandering parameters. + const speed = spec.speed ? rand(spec.speed[0], spec.speed[1]) : rand(0.3, 0.8); + const phase = Math.random() * Math.PI * 2; + const turnSpeed = rand(2, 4); + + // Swim animation (if the model ships one). + const mixer = createSwimMixer(gltf); + if (mixer) { + // Tail beat scales with cruising speed, with a little random variation. + mixer.timeScale = Math.min(1.6, 0.6 + speed * 1.2) * rand(0.9, 1.1); + } + + // Start already facing the first target to avoid an initial swing. + 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, phase, turnSpeed, quaternion, mixer }; +} + +// ---- School ---- + +export interface GLTFishSchool { + update: (dt: number, time: number) => void; + group: THREE.Group; +} + +// Scratch objects reused by updateFish() to avoid per-frame allocation. +const _dir = new THREE.Vector3(); +const _targetQuat = new THREE.Quaternion(); + +// One frame of wandering for a single fish: seek the current target, retarget +// on arrival, smoothly turn (frame-rate independent slerp), move, bob, clamp, +// and advance the swim animation. +function updateFish(f: Fish, dt: number, time: number): void { + // 1. Direction toward the current target. + _dir.subVectors(f.target, f.position); + + // 2. Reached the target — pick a new one. + if (_dir.length() < 0.3) { + f.target.copy(randomWanderTarget()); + _dir.subVectors(f.target, f.position); + } + _dir.normalize(); + + // 3. Turn toward the target (frame-rate independent slerp). + _targetQuat.setFromUnitVectors(FORWARD, _dir); + f.quaternion.slerp(_targetQuat, 1 - Math.exp(-f.turnSpeed * dt)); + + // 4. Move forward. + f.position.addScaledVector(_dir, f.speed * dt); + + // 5. Gentle vertical bob. + f.position.y += Math.sin(time * 1.5 + f.phase) * 0.002; + + // 6. Keep inside the tank. + clampToBounds(f.position); + + // 7. Apply transform. + f.group.position.copy(f.position); + f.group.quaternion.copy(f.quaternion); + + // 8. Advance the swim animation. + if (f.mixer) f.mixer.update(dt); +} + +// Loads the given GLB fish specs and creates a single school under one parent +// group. Models load asynchronously and join the school as they arrive, so the +// aquarium populates progressively. Returns an updater to call each frame. +export function createGLTFishSchool(scene: THREE.Scene, specs: FishSpec[]): GLTFishSchool { + const group = new THREE.Group(); + scene.add(group); + + const fish: Fish[] = []; + const loader = new GLTFLoader(); + + for (const spec of specs) { + loader.load( + spec.url, + (gltf) => { + const f = buildFish(gltf, spec); + group.add(f.group); + fish.push(f); + }, + undefined, + (err) => console.error(`[GLTFish] failed to load ${spec.url}:`, err) + ); + } + + function update(dt: number, time: number): void { + for (let i = 0; i < fish.length; i++) { + updateFish(fish[i], dt, time); + } + } + + return { update, group }; +}