feat: procedural fish with wandering AI & parallax camera (#7, #5)

This commit is contained in:
2026-10-06 19:34:56 +00:00
parent b7489af394
commit c803ca97d3
3 changed files with 289 additions and 1 deletions
+18 -1
View File
@@ -2,6 +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 { createParallaxCamera } from "./scene/Camera";
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
if (!canvas) {
@@ -30,6 +32,15 @@ camera.lookAt(0, 1.2, 0);
setupLighting(scene, renderer);
createTank(scene);
const water = createWater(scene);
const fishSchool = createFishSchool(scene, 6);
const parallax = createParallaxCamera(camera);
// Mouse tracking (normalized to -1..1)
let mouseX = 0, mouseY = 0;
window.addEventListener("mousemove", (e) => {
mouseX = (e.clientX / window.innerWidth) * 2 - 1;
mouseY = (e.clientY / window.innerHeight) * 2 - 1;
});
// Resize handling
function resize(): void {
@@ -45,10 +56,16 @@ window.addEventListener("resize", resize);
// Render loop
const startTime = performance.now();
let lastFrameTime = performance.now();
function animate(): void {
requestAnimationFrame(animate);
const time = (performance.now() - startTime) / 1000;
const now = performance.now();
const time = (now - startTime) / 1000;
const dt = Math.min((now - lastFrameTime) / 1000, 0.1);
lastFrameTime = now;
water.update(time);
fishSchool.update(dt, time);
parallax.update(mouseX, mouseY, time, dt);
renderer.render(scene, camera);
}
animate();
+67
View File
@@ -0,0 +1,67 @@
import * as THREE from "three";
export interface ParallaxCamera {
update: (mouseX: number, mouseY: number, time: number, dt: number) => void;
}
// Creates a parallax camera controller: the camera sways gently toward the
// mouse and drifts slowly on its own while the mouse is idle.
export function createParallaxCamera(camera: THREE.PerspectiveCamera): ParallaxCamera {
// Baseline captured at creation time. `basePosition` is the full snapshot;
// `baseZ` is the starting depth used to offset the camera along its axis.
const basePosition = camera.position.clone();
const baseZ = camera.position.z;
// Smoothed state (radians for rotations, world units for the z offset).
let currentY = 0;
let currentX = 0;
let currentZ = 0;
// Last known mouse position and when it most recently changed (idle detect).
let lastMouseX = 0;
let lastMouseY = 0;
let lastChangeTime = -Infinity;
// Easing rate: how fast values chase their target (frame-rate independent).
const EASE = 5;
function update(mouseX: number, mouseY: number, time: number, dt: number): void {
// 1. Mouse moved? Refresh the "last change" timestamp.
if (mouseX !== lastMouseX || mouseY !== lastMouseY) {
lastChangeTime = time;
lastMouseX = mouseX;
lastMouseY = mouseY;
}
// 2. Target rotation about Y (≈ ±5°).
let targetY = mouseX * 0.087;
// 3. Target rotation about X (≈ ±1°).
const targetX = mouseY * 0.02;
// 4. Target z offset (parallax push away/toward the tank).
const targetZ = mouseX * 0.1;
// 5. Idle sway — a slow drift once the mouse has been still for >2s.
if (time - lastChangeTime > 2) {
targetY += Math.sin(time * 0.4) * 0.03;
}
// 6. Ease each value toward its target.
const ease = 1 - Math.exp(-EASE * dt);
currentY += (targetY - currentY) * ease;
currentX += (targetX - currentX) * ease;
currentZ += (targetZ - currentZ) * ease;
// 7. Apply to the camera (position offset from the original baseline).
camera.rotation.y = currentY;
camera.rotation.x = currentX;
camera.position.z = baseZ + currentZ;
}
// basePosition is the full baseline snapshot kept for future use (e.g.
// restoring x/y or clamping); the current parallax only offsets z.
void basePosition;
return { update };
}
+204
View File
@@ -0,0 +1,204 @@
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 };
}