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:
2026-10-07 11:45:39 +00:00
parent 3bfef18767
commit 4893ee2f8c
21 changed files with 407 additions and 206 deletions
+1
View File
@@ -1,2 +1,3 @@
node_modules node_modules
dist dist
.env
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10
View File
@@ -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/
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+33 -2
View File
@@ -2,7 +2,8 @@ import * as THREE from "three";
import { createTank } from "./scene/Tank"; import { createTank } from "./scene/Tank";
import { createWater } from "./scene/Water"; import { createWater } from "./scene/Water";
import { setupLighting } from "./scene/Lighting"; 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"; import { createParallaxCamera } from "./scene/Camera";
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium"); const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
@@ -32,7 +33,37 @@ camera.lookAt(0, 1.2, 0);
setupLighting(scene, renderer); setupLighting(scene, renderer);
createTank(scene); createTank(scene);
const water = createWater(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); const parallax = createParallaxCamera(camera);
// Mouse tracking (normalized to -1..1) // Mouse tracking (normalized to -1..1)
+90
View File
@@ -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 };
}
-204
View File
@@ -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 };
}
+273
View File
@@ -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 };
}