feat: replace procedural fish with GLB 3D models + tank decor
- 6 tropical fish (angelfish x2, goldfish x2, guppy x2) from maya's-aquarium-3d - 4 Quaternius cute fish (Fish1 x2, Fish2, Fish3) with swim animations - 2 plants + 3 rocks on the gravel floor - Wandering AI with frame-rate-independent smoothing - Quaternius fish rotated to match local +X forward axis - .gitignore: add .env
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user